作者:闲鱼技术-福居
闲鱼Flutter技术的基础设施在2018年的沉淀下已经基本趋于稳定。就在我们准备为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复杂的引擎深部。好在有热心用户愿意协助我们排查问题为我们提供精确的问题现场,才得以最终成功将其确认并解决。
对于疑难杂症的排查,我们应该结合技术手段,详细跟用户交流,秉承用户第一的理念真实还原用户的场景才能更快更好地定位问题并将其解决。