思路清奇的解iOS崩溃

这里没有汇编, 内存, 编译原理, 有的只是满满的套路.

在无法 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 并不是一个常用的函数, 很容易被当成系统内部的崩溃.

通常在面对系统内部崩溃的时候, 开发者们会采取这些手段:

  1. 谷歌 or Stack Overflow. 由于该崩溃本身是由于自身业务代码的崩溃, 因此搜索无结果.
  2. 用户行为日志分析定位出错模块再结合版本回溯定位原因. 该崩溃的日志分析没能定位出错模块, 因此直接回溯版本需要面对巨大的代码变更, 想通过直接 review 变更代码找出出问题的代码无异于大海捞针.
  3. 阅读源码/汇编逆推上层调用出错原因. 在这个 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 一类.

收集相关信息

  1. 实打实的系统调用, 没有任务业务层信息.
  2. 能收集到崩溃日志, 却无法收集到由于崩溃退出的用户行为日志, 所有日志在崩溃时间点显示的都是正常退出. 结合自身业务能够得出是在用户退出 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&lt;WTF::Function&lt;void ()&gt;, std::__1::default_delete&lt;WTF::Function&lt;void ()&gt; &gt; &gt; WTF::MessageQueue&lt;WTF::Function&lt;void ()&gt; &gt;::waitForMessageFilteredWithTimeout&lt;WTF::MessageQueue&lt;WTF::Function&lt;void ()&gt; &gt;::waitForMessage()::'lambda'(WTF::Function&lt;void ()&gt; const&amp;)&gt;(WTF::MessageQueueWaitResult&amp;, WTF::MessageQueue&lt;WTF::Function&lt;void ()&gt; &gt;::waitForMessage()::'lambda'(WTF::Function&lt;void ()&gt; const&amp;)&amp;&amp;, 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成.

收集相关信息

  1. 只在 iOS10上出现, 属于系统 bug. 跟 JS 的 LocalStorage 有关系.
  2. 崩溃经常伴随着 WebView 的释放.
  3. 通过阅读源代码, 崩溃函数为系统库 WebKit 内的一个消息循环, 完全无法收集到任何业务信息.

符号断点

联想到上一个崩溃的解决方法, 希望能够通过构造调用栈收集运行时信息.

上一个例子中, 由于崩溃的是业务代码, 因此可以直接加断点断住. 而该调用为系统调用, 则需要另外一种方式.

在 Xcode 中添加符号断点, 使调用 std::__1::unique_ptr&lt;WTF::Function&lt;void ()&gt;, std::__1::default_delete&lt;WTF::Function&lt;void ()&gt; &gt; &gt; WTF::MessageQueue&lt;WTF::Function&lt;void ()&gt; &gt;::waitForMessageFilteredWithTimeout&lt;WTF::MessageQueue&lt;WTF::Function&lt;void ()&gt; &gt;::waitForMessage()::'lambda'(WTF::Function&lt;void ()&gt; const&amp;)&gt;(WTF::MessageQueueWaitResult&amp;, WTF::MessageQueue&lt;WTF::Function&lt;void ()&gt; &gt;::waitForMessage()::'lambda'(WTF::Function&lt;void ()&gt; const&amp;)&amp;&amp;, double) 的时候断住即可(没错, 这是个函数名).

调用栈信息

结果在一开始不管怎么操作, 都没能使断点断到该处. 联想到崩溃伴随着 WebView 的释放, 猜测是否跟特定 url 的WebView 才有关系.

在从线上的一份崩溃用户的行为日志中抓取到释放的 WebView 的 url 之后, 终于成功触发了该断点.

但是由于断点的函数其实是 WebKit 内的一个消息循环, 且断住的是汇编代码, 无法读取更多有效信息. 因此从运行时信息推断问题模块行不通.

在触发断点的时候, 可以看到线程的名称为 StorageThread, 结合网上的信息, 跟 JS 的 LocalStorage 有关系. 因此写了一个 Demo 验证. 发现:

  1. JS 任何对 LocalStorage 的操作, 比如 setItem, getItem, 都会触发该断点.
  2. 在 WebView 释放的时候, 会几率性的触发该断点.

经过进一步的收集信息, 发现只有在最后一个操作过 LocalStorage 的 WebView 释放才会触发该断点.

结论

在获取到这些信息之后, 虽然还是无法确定崩溃原因, 但是却能从行为上杜绝这个崩溃, 只需要保证时时刻刻都存在一个使用 LocalStorage 的 WebView, 那么所有正常使用的 WebView 在释放的时候都不会触发该断点, 也就没有了崩溃的前提.

相对于其他崩溃的解决, 这个 Case 可以说是不求甚解了. 在并没有了解崩溃原因的情况下, 从行为上杜绝了该崩溃发生的可能性. 这就好像, 我不知道为什么崩溃, 但是这样做就不会崩溃. 尽管很扯, 但是有用.╮(╯_╰)╭

后记

以上3个崩溃的解决都显得有些旁门左道, 但是本质上还是 获取信息 —> 定位问题模块 这样的解决问题过程, 只是收集信息的过程略显奇葩罢了. 而第3个崩溃则为解决系统 bug 提供一种新的思路, 只要不在河边走, 就永远不会湿鞋.

这篇记录不谈汇编不谈内存不谈底层实现, 有的只是满满的套路, 希望能给读者一些启发.

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,684评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,143评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,214评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,788评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,796评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,665评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,027评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,679评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,346评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,664评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,766评论 1 331
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,412评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,015评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,974评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,073评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,501评论 2 343

推荐阅读更多精彩内容

  • 1.ios高性能编程 (1).内层 最小的内层平均值和峰值(2).耗电量 高效的算法和数据结构(3).初始化时...
    欧辰_OSR阅读 29,300评论 8 265
  • Swift1> Swift和OC的区别1.1> Swift没有地址/指针的概念1.2> 泛型1.3> 类型严谨 对...
    cosWriter阅读 11,084评论 1 32
  • HTML表单HTML表单用于搜集用户输入HTML表单常用属性及说明:属性描述accept-charset规定在被提...
    Lv_0阅读 457评论 0 1
  • 又到小满,麦类值此已盈满,人生小满不缺憾,知足幸福现。 外环采杏微汗,浅品茶,清风拂面。农园风光,景色无限,神仙亦...
    小泥壶阅读 391评论 2 1
  • 一、水平滚动条 和 垂直滚动条(案例练习总结) 1.1 核心技术点 1)求滚动条的长度? 2)拖动滚动条,求内容要...
    落落izj阅读 369评论 0 1