闲鱼 Flutter 技术的基础设施已基本趋于稳定,就在我们准备松口气的时候,一个 Crash 却异军突起冲击着我们的稳定性防线!闲鱼技术火速成立侦探小组执行嫌犯侦查行动,经理重重磨难终于在一个隐蔽的角落将其绳之以法!
幽灵 Crash
问题要从闲鱼 Flutter 基础设施上一次大规模升级说起。2018 年我们对闲鱼的 Flutter 基建作了比较大的重构,目标在于提高基建的稳定性和可扩展性。这个过程当然是挑战重重,在上一次大规模的重构集成发版后,我们虽然没有发现非常明显的异常问题,但是 Crash 率却出现了一个比较明显的增长。虽然总体数值还在可控范围之内,但这一个 Crash 却占据了几乎一大半。这个问题引起了我们警觉,我们立刻成立专项小组重点进行排查。
一般 Crash Log 能够为我们定位 Crash 提供主要信息,我们一起看看这个 Crash 的 Log:
Thread 0 Crashed:
0 libobjc.A.dylib 0x00000001c1b42b00 objc_object::release() :16 (in libobjc.A.dylib)
1 libobjc.A.dylib 0x00000001c1b4338c (anonymous namespace)::AutoreleasePoolPage::pop(void*) :676 (in libobjc.A.dylib)
2 CoreFoundation 0x00000001c28e8804 __CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__ :28 (in CoreFoundation)
3 CoreFoundation 0x00000001c28e8534 __CFRunLoopDoTimer :864 (in CoreFoundation)
4 CoreFoundation 0x00000001c28e7d68 __CFRunLoopDoTimers :248 (in CoreFoundation)
5 CoreFoundation 0x00000001c28e2c44 __CFRunLoopRun :1880 (in CoreFoundation)
6 CoreFoundation 0x00000001c28e21cc _CFRunLoopRunSpecific :436 (in CoreFoundation)
7 GraphicsServices 0x00000001c4b59584 _GSEventRunModal :100 (in GraphicsServices)
8 UIKitCore 0x00000001efb59054 _UIApplicationMain :212 (in UIKitCore)
9 Runner 0x0000000102df4eb4 main main.m:49 (in Runner)
10 libdyld.dylib 0x00000001c23a2bb4 _start :4 (in libdyld.dylib)
这是一个很典型的野指针 Crash Log,是其中一种俗称的 Over released 问题。但是具体是哪个对象和方法,很难直接从 Log 上面得知,况且 ARC 下面的野指针更令人费解。
一些推测
Crash 理因由变更引入的,我们直觉地从最近发版引入的主要变更去推测。考虑到我们开始出现问题的版本有几个比较大的改造,我们让相关的同学重新 review 了一下自己的代码,主要关注内存方面的问题。虽然没有找到非常确切的问题,我们还是进行了一次可疑代码优化,进行技术灰度却没有任何效果。在庞大的代码库数不清的提交中去找寻毫无头绪的野指针问题看起来不是一件容易的事情,
机型 iOS 版本 闲鱼版本
我们详细的分析了 Crash 的数据以及用户操作日志,然后得出结论这个 Crash 与机型,系统版本都没明显联系。但是我们可以发现用户基本上都是在 Flutter 容器的详情页容易崩溃。Flutter 不可避免成为了被怀疑对象,包括我们自己实现的基础设施,以及 Flutter 底层的库。
但是 Flutter 已经在闲鱼应用比较长的一段时间,Flutter 底层我们几乎确定是稳定的,不然早就出问题了。这个时候主要怀疑点转移到了我们自己实现的组件,主要包括混合栈组件以及一些监控埋点设施。但是我们随后将这些怀疑对象通过技术灰度手段一一排除了嫌疑。
版本走势
从版本的 Crash 率的走势看,我们还发现这个问题有一个缓慢增长放量的过程,这不免让我们开始怀疑 App 是否存在类似的慢慢放量的功能需求。然而事实证明,这个方向没有任何收获。
无法复现的问题
不断有用户向我们反馈容易遇到闪退,但是我们自己的设备经过大量尝试却没有复现这个问题。这是最为头疼的,从用户的操作路径来看并无特殊的地方。无论是测试还是开发同学都无法在自己设备上面复现出来,无法复现的野指针问题非常难以定位。
线上监控技术
从变更和问题特征排除没有实质性的进展,我们开始尝试线上的一些监控方法来协助排查。希望可以拿到更加详细的相关信息。
GCD 线程跟踪技术
从 Crash Log 我们可以到这应该是一个 autorelease 对象野指针导致的问题,本来应该 autorelease 进行释放的对象,在其被 AutoReleasePool 释放前就因为某种原因提前释放。我们怀疑是否存在多线程导致的问题,所以我们采用 GCD 线程跟踪技术进行监控。
这个技术的基本原理是 hook 住 GCD 的 dispatch 方法,将 block 的返回地址通过 __builtin_return_address
函数拿到,然后编码写入到当前的线程名中,崩溃的时候,从线程名字中解码得出 dispather 的返回地址即可定位到是谁 dispatch 的这个 block,然后随同 Crash Log 的扩展字段将其上传到后台。
GCD 是一套 C 接口,所以我们采用 fishhook 去 hook,此类底层 hook 对性能会有一定影响,所以我们只在专门的技术验证灰度中采用此项技术。fishhook 的大致原理是重新绑定一些 C 的符号,因为很多共享的库的符号比如 GCD 在 iOS 中是动态绑定到 App 的可执行文件中的。而目前这部分符号表所在的内存没有签名,所以可以通过 MachO 提供的接口去进行重新绑定。感兴趣的同学可以参考 Facebook fishhook 项目。
我们准备了一个技术灰度版本来监控这个问题。可能由于样本比较小,我们收集到的返回地址数量非常有限。通过符号解析,得出来的都是一些 NSFoundation 对象,没有太多有价值的东西。之前怀疑这问题可能发生在 GCD 执行的 block 中,只是收集崩溃的时候 GCD 上一次调用的返回地址本身也缺乏针对性。
期望是美好的,现实是骨感觉,最终我们没有拿到有用的信息。
线上 Zombie 的野指针监控
在 Debug 模式下,Xcode 有用强大的工具去帮助你定位野指针。最为通用的野指针监控工具莫过于 NSZombie,如果我们能在线上开启 Zombie 应该能够很容易的抓到野指针对象。淘系基础设施里面有线上 Zombie 的实现。
线上的 Zombie 实现主要原理 hook 对象的 dealloc 方法在 dealloc 的时候通过 runtime 的动态性将其转变成一个 Zombie 类,当有其它消息发给 Zombie 对象的时候我们就可以根据存储下来的类型定位到 Zombie 的对象类型。详细可以参考 Mike Ash 的 Let’s build NSZombie。不过需要注意的是,这里面的实现是基于 MRC,ARC 实现上可能会有差异,基本原理是大致相同的。
我们在闲鱼 App 中根据基础提供的文档将线上 Zombie 打开进行灰度监控,所幸的是我们拿到了一些野指针对象。量也不是很多,只有个位数的类型。
可能是由于样本不够大,没有覆盖到典型的用户。或许是我们的监控组件无法抓到这个特定类型的 Crash。最终在排查完所有收集到的野指针对象后,依然没有解决这个 Crash。
线上监控似乎没能为我们打开突破口。
UI 自动化
我们还是期望与能够将问题重现出来,这样可以迅速通过 Xcode 定位到问题。从概率上确实不算太高,基于前面手动复现困难的问题,我们尝试利用自动化工具去做自动复现尝试。
SwiftMonkey + 引擎 DEBUG
SwiftMonkey 是一个比较好的 UI 自动化工具,集成简单,而且可以在 Debug 模式下面进行自动 UI 测试。也就是说我们可以在保持 Xcode 各种强大工具有效的前提下进行自动化测试。
我们采用 Local Debug Flutter 引擎进行测试以便拿到相关的符号,经过一段时间的自动化测试我们在模拟器上面抓到了一摸一样的 Crash Log!
这不得不说是一个令人振奋的消息,Xcode 抓到的 Zombie 对象是一个 NSMutableArray,这是一个通用对象,似乎也没有特别的地方。这个时候我们需要用到 Xcode 提供的 malloc log 和 Address sanitizer 去跟踪是谁创建的这个对象。
我们在模拟器上面打开 malloc log 以及 Address sanitizer 复现问题导出 MemGraph 然后使用
memory history 地址
malloc log MemGraph 地址
最终定位到问题出现在 Flutter 引擎内部文件 accessibility_bridge.mm 533 行:
NSMutableArray* newChildren =
[[[NSMutableArray alloc] initWithCapacity:newChildCount] autorelease];
for (NSUInteger i = 0; i < newChildCount; ++i) {SemanticsObject* child = GetOrCreateObject(node.childrenInTraversalOrder[i], nodes);
child.parent = object;
[newChildren addObject:child];
}
object.children = newChildren;
这个问题把我们带到了 Flutter 的 Accessibility(通用 -> 辅助功能)支持模块,我们跟用户经过了交流,并没有发现用户有打开相关的辅助功能。
虽然 Log 是一摸一样的,我们有点不相信我们追寻的 Crash 是由于这个原因导致的。这的确是 Flutter 在 Accessibility 的一个坑,但是跟我们用户交流的情形不一致。而且模拟器上面容易出现,我们将测试包装到手机上却无法在复现这问题。很显然,用户都是真机,模拟器或许不能说明问题。此时我们还没有信心确认这个问题,开辅助功能的人应该是不多的。
这感觉好像在黑暗中看到光亮,一瞬间又被黑暗淹没了,我们似乎又来到了一个死胡同。到底是哪里出问题了?
用户面对面
线上交流
在问题排查的过程中我们一直跟用户保持良好的交流。工程师们主动联系用户,很多用户也热心响应我们的访问,给我们录制了不少崩溃现场的视频。我们可以看到那些反馈问题的用户很容易出现,但是不出现的用户基本上没有这个问题。我们开始怀疑跟账号的关系,可能有一些 ABTest 的参数所有影响。线上的交流虽然给了我们不少有用的信息,但是依然没有实质性突破。
线下面对面
我们开始寻找愿意协助我们现场排查问题用户,我们重点找了几个非常容易出现问题的杭州用户打算上门现场 Debug。在和用户进行了深入交流以后,其中一个用户愿意已访问园区方式来现场协助工程师排查问题。
我们选了用户有时间的一个周末然后拿到用户的手机进行了调试,果然在用户的手机上非常容易复现。而且就是我们前面提到的 accessibility_bridge.mm 处的崩溃,为什么之前再模拟器上那么容易出现呢?
原来在引擎的代码中如果是模拟器的话是默认打开 Accessibility 的,而真机是取决于系统的设置。
#if TARGET_OS_SIMULATOR
// There doesn't appear to be any way to determine whether the accessibility
// inspector is enabled on the simulator. We conservatively always turn on the
// accessibility bridge in the simulator, but never assistive technology.
platformView->SetSemanticsEnabled(true);
platformView->SetAccessibilityFeatures(flags);
#else
bool enabled = UIAccessibilityIsVoiceOverRunning() || UIAccessibilityIsSwitchControlRunning();
if (enabled)
flags |= static_cast<int32_t>(blink::AccessibilityFeatureFlag::kAccessibleNavigation);
platformView->SetSemanticsEnabled(enabled || UIAccessibilityIsSpeakScreenEnabled());
platformView->SetAccessibilityFeatures(flags);
#endif
原来这名用户打开了 iOS 的阅读屏幕功能: UIAccessibilityIsSpeakScreenEnabled, 这导致 Flutter 辅助支持模块被打开。我们马上联系其它用户确认,基本上用户都打开了“阅读屏幕”功能。至此,我们基本确认就是这个问题所致。我们随后进行了一个小范围禁用 Accessibility 的灰度实验确认就是这问题导致的 Crash。
在经过止血修复以后,我们继续寻找野指针的源头。问题出在这个 autorelease 的 NSMutableArray 对象,这个代码看起来也没什么明显问题。FLutter 引擎的 iOS 使用 MRC 进行内存管理。我们继续 review 相关的代码,终于在 SemanticsObject 类发现了一段奇怪的代码:
- (void)dealloc {for (SemanticsObject* child in _children) {child.parent = nil;}
[_children removeAllObjects];
[_children dealloc];
_parent = nil;
[_container release];
_container = nil;
[super dealloc];
}
注意其中的 [_children dealloc];
,这里不应该直接调用 dealloc,而只需要 release,这或许就是 MRC 难以避免的误写吧。问题定位到,修复也就是分分钟钟的事情。
后来我们发现其实这个问题最近已经在 Flutter 官方 master 分支上修复了,只是我们自己维护的引擎尚未同步对应的代码。
至此,问题得到圆满解决,Crash 率恢复到正常水平。
总结
为了排查这个问题,我们从多个方向同时进行了不同的尝试。具体来说从代码变更跟踪,线上监控技术,UI 自动化以及深入阅读相关源码等方式同时去推进问题的解决。需要特别强调的是,跟用户的紧密交流也是解决问题的关键,俗话说知彼知己方能百战不殆,只有充分理解需要解决的问题才能更有效的将其解决。
问题的复现与否通常对于解决方案至关重要,一个能够复现的问题基本能够在现代的 IDE 提供的强大工具的帮助下方便定位到。一开始我们也是苦于没能找到复现的路径,原来这个 Crash 却被掩盖在一个并不常见的系统设置下面,同时深藏于 Flutter 复杂的引擎深部。好在有热心用户愿意协助我们排查问题为我们提供精确的问题现场,才得以最终成功将其确认并解决。
本文作者:闲鱼技术 - 福居
阅读原文
本文为云栖社区原创内容,未经允许不得转载。