这里没有汇编, 内存, 编译原理, 有的只是满满的套路.
在无法 review 代码的前提下, 崩溃率是业内衡量 App 稳定性的一个关键指标. 然而几乎每个 App 都会遇到那么几个数量居高不下而又难以下手的崩溃, 在崩溃榜前列落灰.
对于如何解决崩溃开发者们心得都不少, 简单的从调用栈就能定位问题, 复杂一些的就需要收集线程, 系统版本, 寄存器内容等等信息结合用户日志才能定位问题. 然而在面对那些遗留数十个版本却依然居高不下的崩溃时, 这些正统套路往往难以生效, 这个时候不妨尝试一些旁门左道.
最近一段时间接连啃下三块榜上前列的硬骨头, App 的崩溃率整体下降了5成左右, 在此记录一下.
理解调用栈
0 libdispatch.dylib 0x00000001823f13a0 _dispatch_source_get_data$VARIANT$mp
1 libdispatch.dylib 0x00000001823e0a60 __dispatch_client_callout + 16
2 libdispatch.dylib 0x00000001823e8e94 __dispatch_continuation_pop$VARIANT$mp + 424
3 libdispatch.dylib 0x00000001823f2784 __dispatch_source_invoke$VARIANT$mp + 1364
4 libdispatch.dylib 0x00000001823ea86c __dispatch_queue_serial_drain$VARIANT$mp + 280
5 libdispatch.dylib 0x00000001823eb2fc __dispatch_queue_invoke$VARIANT$mp + 336
6 libdispatch.dylib 0x00000001823ebcc8 __dispatch_root_queue_drain_deferred_wlh$VARIANT$mp + 340
7 libdispatch.dylib 0x00000001823f4098 __dispatch_workloop_worker_thread$VARIANT$mp + 668
8 libsystem_pthread.dylib 0x0000000182713e70 _pthread_wqthread + 860
7 libsystem_pthread.dylib 0x0000000182713b08 pthread_workqueue_addthreads_np + 120
该崩溃一直处于崩溃版前十并且已经十几个版本没有解决. 但是却是调查起来最没有技术含量的一个.
该崩溃的特点是调用栈显示全都是系统调用, 没有一处业务上的关联.
观察调用栈
但是, 只需要用 Xcode 稍微验证一下, 就能发现 dispatch_source_get_data
是公开接口, 而调用栈的其他函数是私有接口.
谷歌大法
搜索调用栈的第一行, 即 dispatch_source_get_data
+ crash
, 未收集到有效信息.
搜索调用栈的第一行 + 第二行, 即 dispatch_source_get_data
+ dispatch_client_callout
, 连搜索结果都寥寥无几.
搜索调用栈的第二行 + 第三行, 即 dispatch_client_callout
+ dispatch_continuation_pop
, 得到大量搜索结果, 且调用栈除了第一行外都能匹配上.
猜想
该调用栈为系统触发系统库内部函数的调用, 在 dispatch_client_callout
之后调用了业务层传入的 block, 而 dispatch_source_get_data
为业务层的函数调用. 这能够解释谷歌出来的结果显示 dispatch_client_callout
之后的调用五花八门的情况.
dispatch_source_get_data
并不是一个常见的调用, 全局搜索该函数名只有几处调用, 仔细观察代码就发现其中一处存在着多线程崩溃的风险.
结论
整个推理过程非常简单, 如果有同学熟悉 dispatch_source
的使用甚至可以一眼看出问题, 可为什么这个崩溃数位居前列却存依然在了十几个版本呢, 原因在于 dispatch_source_get_data
并不是一个常用的函数, 很容易被当成系统内部的崩溃.
通常在面对系统内部崩溃的时候, 开发者们会采取这些手段:
- 谷歌 or Stack Overflow. 由于该崩溃本身是由于自身业务代码的崩溃, 因此搜索无结果.
- 用户行为日志分析定位出错模块再结合版本回溯定位原因. 该崩溃的日志分析没能定位出错模块, 因此直接回溯版本需要面对巨大的代码变更, 想通过直接 review 变更代码找出出问题的代码无异于大海捞针.
- 阅读源码/汇编逆推上层调用出错原因. 在这个 Case 中, 阅读源码会发现,
dispatch_client_callout
调用了一个外界注入的 block, 而dispatch_source_get_data
是 block 中的代码, 且dispatch_source_get_data
接收到的参数是个野指针. 收集到的信息很细致, 却无法得出注入的 block 是由业务层注入的还是系统内其他模块注入.
因此, 总结一下这个 Case, 就是: 在网上搜索不到类似调用堆栈的崩溃十有八九是业务代码的崩溃. 倘若说是业务代码导致系统内部的崩溃, 在网上基本都能找到类似 Case.
构造调用栈
0 libobjc.A.dylib 0x000000018066c1a0 objc_retain + 16
1 live4iphone 0x000000010274cba0 -[NSArray(SafeArray) QLAddObject:] + 32
2 CoreFoundation 0x000000018137d7b4 -[NSInvocation invoke] + 392
3 Foundation 0x0000000181ebf308 -[NSInvocationOperation main] + 40
6 Foundation 0x0000000181dffcac -[__NSOperationInternal _start:] + 848
7 Foundation 0x0000000181ec076c ___NSOQSchedule_f + 404
8 libdispatch.dylib 0x0000000180d88a60 __dispatch_client_callout + 16
9 libdispatch.dylib 0x0000000180d90e94 __dispatch_continuation_pop$VARIANT$mp + 424
8 libdispatch.dylib 0x0000000180d8f7cc __dispatch_async_redirect_invoke$VARIANT$mp + 604
11 libdispatch.dylib 0x0000000180d95cac __dispatch_root_queue_drain + 588
12 libdispatch.dylib 0x0000000180d959fc __dispatch_worker_thread3 + 120
13 libsystem_pthread.dylib 0x00000001810bbfac _pthread_wqthread + 1164
1 libsystem_pthread.dylib 0x00000001810bbb08 pthread_workqueue_addthreads_np + 120
该崩溃也是数十个版本一直处于崩溃榜前列, 甚至一度进入前5. 与上一个崩溃不同的是, 该崩溃实打实的告诉你崩溃在哪个方法上:
- (void)QLAddObject:(id)anObject {
if (nil == anObject){
return;
}
@try {
[self QLAddObject:anObject];
} @catch (...) {
}
}
结合经验分析, 崩溃原因为传入的 anObject 为野指针导致.
问题在于 QLAddObject:
在工程中作为 MutableArray AddObject:
的替代函数存在, 也就是说任意数据的添加操作都会走到这个方法内部来. 因此无法确定究竟是哪个数组添加了野指针, 也就无法定位产生问题的模块.
开发者们解决这种崩溃的思路一般是增大崩溃概率, 通过复现崩溃来定位问题模块. 复现与否凭运气, 属于随缘 Debug 一类.
收集相关信息
- 实打实的系统调用, 没有任务业务层信息.
- 能收集到崩溃日志, 却无法收集到由于崩溃退出的用户行为日志, 所有日志在崩溃时间点显示的都是正常退出. 结合自身业务能够得出是在用户退出 App 的回调,
applicationWillTerminate
中崩溃的, 但是由于该回调中执行的逻辑过多, 依旧无法定位问题模块.
分析
调用栈内虽然没有业务信息, 能否通过构造该调用栈, 看看在什么情况下能够形成这样的调用栈.
由于该崩溃量巨大, 而野指针属于低概率偶发崩溃, 因此在 App 的运行周期内形成该种调用栈应该是一个非常普遍的现象.
构造调用栈
- (void)QLAddObject:(id)anObject
{
NSArray<NSString *> *stackArray = [NSThread callStackSymbols];
if (stackArray.count > 1) {
if ([stackArray[1] containsString:@"[NSInvocation invoke]"]) {
// breakpoint
}
}
// 原方法实现
}
在该方法运行之前获取一下当前调用栈, 并与崩溃调用栈比对, 在比对成功的时候断点, 获取 self 以及 anObject 信息.
由于 addObject:
调用非常频繁, 因此这样会验证影响运行速度, 看起来就跟 App 卡住了一样(这也是不能在线上版本通过添加日志来定位问题的原因).
结果很容易的就多次触发了该断点, 并且调用栈跟线上崩溃一模一样, 只是少了最终崩溃的 objc_retain + 16
. 从 参数 self 以及 anObject 中的信息得到该调用栈是由日志模块的逻辑触发的, 结合在 applicationWillTerminate
中崩溃这一特点, 最终定位到问题所在.
正常来讲, 解决崩溃的步骤是通过理解调用栈获取足够信息来判断为什么崩溃以及为什么会这样子崩溃. 而该崩溃的调查思路则是通过构造崩溃调用栈, 在即将崩溃的时候断点拦截获取运行时对象信息.
这也是建立在该崩溃类型为野指针且崩溃量巨大的前提下, 这才能保证一定能构造出崩溃时的调用栈.
符号断点
0 WebKitLegacy 0x000000019395ec5c std::__1::unique_ptr<WTF::Function<void ()>, std::__1::default_delete<WTF::Function<void ()> > > WTF::MessageQueue<WTF::Function<void ()> >::waitForMessageFilteredWithTimeout<WTF::MessageQueue<WTF::Function<void ()> >::waitForMessage()::'lambda'(WTF::Function<void ()> const&)>(WTF::MessageQueueWaitResult&, WTF::MessageQueue<WTF::Function<void ()> >::waitForMessage()::'lambda'(WTF::Function<void ()> const&)&&, double) + 192
1 WebKitLegacy 0x000000019395e240 WebCore::StorageThread::threadEntryPoint() + 68
5 JavaScriptCore 0x0000000191ca700c WTF::threadEntryPoint(void*) + 212
6 JavaScriptCore 0x0000000191ca6f1c WTF::wtfThreadEntryPoint(void*) + 24
8 libsystem_pthread.dylib 0x000000018ce2d850 __pthread_body + 240
9 libsystem_pthread.dylib 0x000000018ce2d760 __pthread_body
10 libsystem_pthread.dylib 0x000000018ce2ad94 thread_start + 0
该崩溃常年位于崩溃量排行榜第一, 约占总崩溃量的3到4成.
收集相关信息
- 只在 iOS10上出现, 属于系统 bug. 跟 JS 的 LocalStorage 有关系.
- 崩溃经常伴随着 WebView 的释放.
- 通过阅读源代码, 崩溃函数为系统库 WebKit 内的一个消息循环, 完全无法收集到任何业务信息.
符号断点
联想到上一个崩溃的解决方法, 希望能够通过构造调用栈收集运行时信息.
上一个例子中, 由于崩溃的是业务代码, 因此可以直接加断点断住. 而该调用为系统调用, 则需要另外一种方式.
在 Xcode 中添加符号断点, 使调用 std::__1::unique_ptr<WTF::Function<void ()>, std::__1::default_delete<WTF::Function<void ()> > > WTF::MessageQueue<WTF::Function<void ()> >::waitForMessageFilteredWithTimeout<WTF::MessageQueue<WTF::Function<void ()> >::waitForMessage()::'lambda'(WTF::Function<void ()> const&)>(WTF::MessageQueueWaitResult&, WTF::MessageQueue<WTF::Function<void ()> >::waitForMessage()::'lambda'(WTF::Function<void ()> const&)&&, double)
的时候断住即可(没错, 这是个函数名).
调用栈信息
结果在一开始不管怎么操作, 都没能使断点断到该处. 联想到崩溃伴随着 WebView 的释放, 猜测是否跟特定 url 的WebView 才有关系.
在从线上的一份崩溃用户的行为日志中抓取到释放的 WebView 的 url 之后, 终于成功触发了该断点.
但是由于断点的函数其实是 WebKit 内的一个消息循环, 且断住的是汇编代码, 无法读取更多有效信息. 因此从运行时信息推断问题模块行不通.
在触发断点的时候, 可以看到线程的名称为 StorageThread, 结合网上的信息, 跟 JS 的 LocalStorage 有关系. 因此写了一个 Demo 验证. 发现:
- JS 任何对 LocalStorage 的操作, 比如
setItem
,getItem
, 都会触发该断点. - 在 WebView 释放的时候, 会几率性的触发该断点.
经过进一步的收集信息, 发现只有在最后一个操作过 LocalStorage 的 WebView 释放才会触发该断点.
结论
在获取到这些信息之后, 虽然还是无法确定崩溃原因, 但是却能从行为上杜绝这个崩溃, 只需要保证时时刻刻都存在一个使用 LocalStorage 的 WebView, 那么所有正常使用的 WebView 在释放的时候都不会触发该断点, 也就没有了崩溃的前提.
相对于其他崩溃的解决, 这个 Case 可以说是不求甚解了. 在并没有了解崩溃原因的情况下, 从行为上杜绝了该崩溃发生的可能性. 这就好像, 我不知道为什么崩溃, 但是这样做就不会崩溃. 尽管很扯, 但是有用.╮(╯_╰)╭
后记
以上3个崩溃的解决都显得有些旁门左道, 但是本质上还是 获取信息 —> 定位问题模块 这样的解决问题过程, 只是收集信息的过程略显奇葩罢了. 而第3个崩溃则为解决系统 bug 提供一种新的思路, 只要不在河边走, 就永远不会湿鞋.
这篇记录不谈汇编不谈内存不谈底层实现, 有的只是满满的套路, 希望能给读者一些启发.