在移动开发中,App 的闪退率是工程师十分关注且又头疼的事情。去年,网易杭州研究院曾经针对 crash 的防御有提出『大白健康系统--iOS APP 运行时 Crash 自动修复系统』方案,使得 crash 防御这个想法真正被落实,但至今该方案的具体实现并无被开源。通过一年的时间,圈子里也有一些开发朋友,基于这套方案设计并开源了本身的 “Baymax”,好比『老司机 iOS 周报第七期』中曾提到的 BayMaxProtector。本文将会针对网易 Baymax 这套方案,结合团队内的实践结果,总结其在生产环境中可能遇到的问题及其解决方案,并提出一些本身对这套方案的思考。友情提示,阅读本文前需对网易『大白健康系统--iOS APP 运行时 Crash 自动修复系统』一文有所了解,该文中已有的实现方案,本文不会再花更多笔墨进行赘述。css
在探讨 Crash 防御的方案以前,咱们有必要对计算机领域 Crash 这个概念进行从新认识。对于 Crash 的概念,维基百科中是这么定义的:html
In computing, a crash (or system crash) occurs when a computer program, such as a software application or an operating system, stops functioning properly and exits.ios
An application typically crashes when it performs an operation that is not allowed by the operating system. The operating system then triggers an exception or signal in the application. Unix applications traditionally responded to the signal by dumping core. Most Windows and Unix GUI applications respond by displaying a dialogue box (such as the one shown to the right) with the option to attach a debugger if one is installed. Some applications attempt to recover from the error and continue running instead of exiting.git
对于咱们 iOS 应用层的 App,可简单总结为应用执行了某些不被容许的操做触发了系统抛出异常信号但又没有处理这些异常信号从而被杀掉的现象,好比常见的闪退(crash to desktop)。在咱们开发领域从抛出异常的对象上来看,一共能够分为三类内核致使的异常、应用自身的异常或其余进程致使的异常:github
EXC_BAD_ACCESS
,这类异常若是没有被处理掉的话,会被转发到 SIGBUS
或 SIGSEGV
等类型的 BSD 信号;NSException
,这类异常苹果为了统一处理,最终会被转发为 SIGABRT
类的 BSD 信号;这里咱们主要谈最多见的前两种异常。面试
上面已经提到了 Crash 实际上咱们触发了异常,但又没有去处理这些异常而致使的结果。那么很天然的第一个防御方案即可以想到是去处理这些异常。编程
NSUncaughtExceptionHandler
来捕获并处理异常苹果的确提供有异常捕获的 API 以供开发者使用——NSSetUncaughtExceptionHandler,开发者只须要传入处理函数的指针,即可以处理掉应用中抛出的 NSException
类的异常。代码写起来就是:xcode
NSSetUncaughtExceptionHandler(&HandleException);
signal
来捕获并处理异常因为苹果将全部异常最终都转换成了 BSD 信号的发出,那么咱们就能够去捕获这个信号来处理这些异常,从而达到 Crash 防御的目的。系统也有提供相关 API 实现:缓存
void (*signal(int, void (*)(int)))(int);
前一个参数为异常类型,能够是 SIGSEGV
等这类,后一个参数为回调的函数,代码写起来就能够是:性能优化
signal(SIGABRT, SignalHandler); signal(SIGILL, SignalHandler); signal(SIGSEGV, SignalHandler); signal(SIGFPE, SignalHandler); signal(SIGBUS, SignalHandler); signal(SIGPIPE, SignalHandler);
注意:因为 Xcode 默认会开启
debug executable
,它会在咱们捕获这些异常信号以前拦截掉,所以作这个测试须要手动将debug executable
功能关闭,或者不在 Xcode 链接调试下进行测试。
image
至此,彷佛一切看起来都很顺利,然而实践过程当中你会发现程序并无在你处理完这些异常后就能继续进行。这与 iOS 的 Runloop 机制有关,在触发异常后,Main Runloop
将不会继续运行,这也就意味着 App 跑不起来了。固然,你可能会很天然地联想到,我本身再把 Main Runloop
继续挂起来跑不就好了吗?如如下相似代码:
//这里取到的是 Main Runloop CFRunLoopRef runLoop = CFRunLoopGetCurrent(); CFArrayRef allModes = CFRunLoopCopyAllModes(runLoop); while (YES) { for (NSString *mode in (NSArray *)allModes) { CFRunLoopRunInMode((CFStringRef)mode, 0.001, false); } } CFRelease(allModes);
这样一试,确实程序在捕获异常以后又可以继续运行了。但『经过 NSUncaughtExceptionHandler
来捕获并处理异常』和『经过 BSD 的 signal
来捕获并处理异常』这两种方式去作 Crash 防御并非一种靠谱的方式,缘由有如下几点:
这里附带下『App Architecture book』做者 Matt Gallagher 早年对于这部分研究后的一个 demo,因为是 MRC 时代的代码了,修改了部分配置使得可以正常编译且测试。
try-catch
的组合拳来捕获异常和其余编程语言同样,Objective-C 中也有万能的 try-catch 组合来捕获异常,这样处理不就能够了?这种方案确实是可行的,我也确实有见过一些人使用 try-catch 来作一些常见的 Crash 防御。但 Objective-C 的 try-catch 实际上有先天缺陷的,首先是效率并不高,甚至某些状况下会致使内存泄漏,不可控。
-fobjc-arc-exceptions
选项,更会由于生成低效代码而得不偿失,这也是苹果并不推荐的方式。但这不能彻底否认 try-catch 组合在咱们平常编程中的做用,在一些容易出现异常的操做上,好比文件读写或者须要配合使用 throw 的状况等。这里指的不适合,只是针对在大范围防御并不适合。
在综合分析了以上几个防御方案后,咱们再来看看 Baymax 中采用的方案。若是说上面三种方案都是在已经抛出了异常以后再去捕获处理,也就是“喝后悔药”的机制,那么 Baymax 的方案即是不让这些异常产生。不让错误异常产生能够经过多种作法,往项目管理上说提升代码质量,增长 Code Review 等,从编码角度来讲,咱们能够经过各类保护性代码进行。Baymax 中的大部分防御方案均可以理解为一种为你自动增长保护性代码的措施。好比,各类 Collection 类型,String 类型等。
Baymax 是基于 AOP 思想而设计的,方案中会充斥着各类 Hook 系统方法,这对于高频调用的方法,性能上的损耗是不可忽略的。为了将损耗尽可能下降,咱们能够经过只防御特定类来进行,好比只针对咱们的自定义类和部分在防御名单内的类,而对于系统的类,咱们不进行防御,这样就能在必定限度上下降性能损耗。对于判断自定义类能够经过如下方法进行:
若是只是判断 main bundle 的话能够经过如下代码进行:
+ (BOOL)isMainBundleClass:(Class)cls { return cls && [[NSBundle bundleForClass:cls] isEqual:[NSBundle mainBundle]] ; }
但在组件化开发中,咱们的代码会经过各类私有 pod 的形式导入,这样只判断 main bundle 的方式就不够用了,咱们能够经过如下代码进行:
+ (BOOL)isCustomClass:(Class)cls { ///var/containers/Bundle/Application/CB0D354B-DD08-4845-A084-A22FF01097FE/Example.app NSString *mainBundlePath = [NSBundle mainBundle].bundlePath; ///var/containers/Bundle/Application/CB0D354B-DD08-4845-A084-A22FF01097FE/Example.app/Frameworks/Baymax.framework NSString *clsBundlePath = [NSBundle bundleForClass:cls].bundlePath; return cls && mainBundlePath && clsBundlePath && [clsBundlePath hasPrefix:mainBundlePath]; }
另外,因为判断是否防御的条件会相对比较多,这里能够引入名单缓存来作进一步的效率优化,将本次判断结果存储到 NSCache 中,下回优先从 Cache 里读取防御状态,性能提高将会十分显著。大体代码以下:
//先从缓存中读取状态 NSNumber *status = [baymax needBaymaxStatusInProtectionCache:clsStr]; //若是有在缓存中 则直接返回缓存中的状态 若不在缓存中 则继续走判断逻辑 if (status != nil) return [status boolValue];
苹果在 KVO 的实现中,为每种类型都封装了一个特定的 set 方法,缘由未知(或许又是 Historical Reasons 吧),这里涵盖了 CoreFoundation 里的全部基础类型。
_NSSetBoolValueAndNotify、_NSSetCharValueAndNotify、_NSSetDoubleValueAndNotify、_NSSetFloatValueAndNotify、_NSSetIntValueAndNotify、_NSSetLongLongValueAndNotify、_NSSetLongValueAndNotify、_NSSetObjectValueAndNotify、_NSSetPointValueAndNotify、_NSSetRangeValueAndNotify、_NSSetRectValueAndNotify、_NSSetShortValueAndNotify、_NSSetSizeValueAndNotify、_NSSetUnsignedCharValueAndNotify、_NSSetUnsignedIntValueAndNotify、_NSSetUnsignedLongLongValueAndNotify、_NSSetUnsignedLongValueAndNotify、_NSSetUnsignedShortValueAndNotify
除这些类型外的其余类型(好比 UIKit 中的 struct 或者其余自定义的 struct)被做为 property 观察时,都会走如下的转发逻辑。这样的处理逻辑在特定的状况下就会影响防御,好比 UIEdgeInsets 类型的 property 被加入 KVO 检测,那么以后再 set 这个 property 的时候,set 方法就会进入转发逻辑,这样就会被误识别为一次UnrecognizedSelector 的 Crash,且致使原有的 KVO 逻辑失效。
<_NSCallStackArray 0x100700630>( 0 ??? 0x00000001001f3ecd 0x0 + 4297014989, 1 KVOAnalysisDemo 0x0000000100001850 main + 0, 2 Foundation 0x00007fff981fd67d NSKeyValueNotifyObserver + 350, 3 Foundation 0x00007fff981fcf14 NSKeyValueDidChange + 486, 4 Foundation 0x00007fff981cbdf6 -[NSObject(NSKeyValueObserverNotification) didChangeValueForKey:] + 118, 5 Foundation 0x00007fff9829cc11 NSKVOForwardInvocation + 325, 6 CoreFoundation 0x00007fff967c65fa ___forwarding___ + 538, 7 CoreFoundation 0x00007fff967c6358 _CF_forwarding_prep_0 + 120, 8 KVOAnalysisDemo 0x000000010000198b main + 315, 9 libdyld.dylib 0x00007fffabf2d235 start + 1 )
解决方案是经过判断是否重写相关转发方法决定是否须要防御,主要代码以下:
BOOL isMethodOverride = ([self isMethodOverride:cls selector:@selector(forwardInvocation:)] || [self isMethodOverride:cls selector:@selector(forwardingTargetForSelector:)]); if (!isMethodOverride) { return YES; }
+ (BOOL)isMethodOverride:(Class)cls selector:(SEL)sel { IMP selfIMP = class_getMethodImplementation(cls, sel); IMP superIMP = class_getMethodImplementation(class_getSuperclass(cls), sel); return selfIMP != superIMP; }
因为 iOS 系统的封闭性,系统 API 的实现咱们是没法直接看到的。而苹果有可能在更新系统版本的时候,出于各类缘由对一些 API 进行调整。在测试中已发现有如下几个系统类在 iOS8-iOS10 中被调整过:
po [@[] class] before iOS8:__NSArrayI later:__NSArray0 po [@[@1] class] before iOS9:__NSArrayI iOS10:__NSSingleObjectArrayI po [objc_getClass("NSTaggedPointerString") superclass] before iOS8:NSObject after iOS8:NSString
以上这些实现的调整,形成的影响均是 method-swizzling 的失败。但从实际测试状况来看,虽然以上类有作了调整,但其实并不影响防御。好比,__NSArray0 在 iOS8 中是__NSArrayI 代替,而 __NSArrayI 这个类在 iOS8 或者以后的系统都是会被防御的。
BadAccess 防御的核心原理是延迟内存释放,这里就须要在以后的某个合适的时机,手动去调用原有的释放方法来执行真正的内存释放。但在实际开发中,发现直接去调用保存的原 dealloc,并不能作到正确释放内存。排查搜索以后,发现这多是在 ARC 环境下,苹果对 dealloc 方法的特殊处理致使的,在 method-swizzling 后,原 dealloc 的 selector 实际上已经变成了转发后的 selector 了,而猜想目前 ARC 的对 dealloc 的处理只认 dealloc 这个 selector,因此惟一的方法处理即是仍是经过 imp(obj, NSSelectorFromString(@"dealloc")) 来调用。
目前的解决方法:直接用 c 函数传 imp 和 dealloc
调用,主要代码以下:
// Get Original Dealloc IMP. // See more in JSPatch:https://github.com/bang590/JSPatch/blob/master/JSPatch/JPEngine.m Class objCls = object_getClass(obj); Method deallocMethod = class_getInstanceMethod(objCls, NSSelectorFromString(@"wycd_dealloc")); void (*originalDealloc)(__unsafe_unretained id, SEL) = (__typeof__(originalDealloc))method_getImplementation(deallocMethod); originalDealloc(obj, NSSelectorFromString(@"dealloc"));
Hook 掉 objectAtIndex:
方法后,在这样一个场景下会出现意外的 crash:调出系统键盘再把 App 切到后台,就出现 [uikeyboardlayoutstar release] message sent to deallocated instance
crash。这实际上是 iOS 系统在 ARC 下的一个坑,ARC 致使了 over-released
的 crash,暂时没有其余更好的解决方案,只能把这部分防御改成 MRC 编写。
Baymax 方案涉及到不少的系统方法,那么怎么保证每一次更新迭代不会形成严重的线上问题呢?这最终仍是要落实到单元测试上,咱们能够给 Baymax 编写足够完善的单元测试用例,而后配置一个触发脚本,来自动地在咱们每次 push 到开发分支时跑这些测试用例。固然,必须值得注意的是,测试必须覆盖到你当前支持的全部 iOS 版本,若是是使用 GitLab Runner 能够按以下配置作:
test_job: only: - UnitTest stage: test script: - export LC_ALL='en_US.UTF-8' - xcodebuild clean -workspace Example/Baymax.xcworkspace -scheme Baymax-Example | xcpretty - pod install --project-directory=Example - xcodebuild test -workspace Example/Baymax.xcworkspace -scheme Baymax-Example -destination 'platform=iOS Simulator,name=iPhone 5s,OS=11.2' -destination 'platform=iOS Simulator,name=iPhone 5s,OS=9.3' -destination 'platform=iOS Simulator,name=iPhone 5s,OS=8.4' | xcpretty -s
大体的单元测试代码能够以下:
- (void)testCrashProtection { //given when Baymax *baymax = [Baymax sharedInstance]; [baymax configBaymaxType:BaymaxAll]; [baymax start]; //then for (int i = 0 ; i < kBaymaxType; i++) { NSUInteger type = 1 << i; Tester *tester = [Tester tester:type]; NSUInteger caseCount = [[tester testCaseSelectors] count]; for (int j = 0; j < caseCount; j++) { XCTAssertNoThrow([tester executeTestCase:j]); } } }
任何事物咱们都从正反两方面考虑,既然 Baymax 提供了防御功能,那其必然也存在着弊端。
首先,第一点就是上面提到的性能问题,在方案调研阶段,笔者曾经使用 XCTest 对 Collection 类型的防御作了部分的性能测试,结果大体以下:
不作 Hook
Test Case '-[PerformanceTests testPerformance_Collection]' measured [Time, seconds] average: 0.000, relative standard deviation: 151.327%, values: [0.000011, 0.000002, 0.000001, 0.000001, 0.000001, 0.000001, 0.000001, 0.000001, 0.000001, 0.000001]作了 Hook 可是不触发防御逻辑
Test Case '-[PerformanceTests testPerformance_Collection]' measured [Time, seconds] average: 0.000, relative standard deviation: 83.636%, values: [0.000021, 0.000005, 0.000005, 0.000009, 0.000003, 0.000003, 0.000003, 0.000003, 0.000009, 0.000003]作了 Hook 且触发了防御逻辑
Test Case '-[PerformanceTests testPerformance_Collection]' measured [Time, seconds] average: 0.000, relative standard deviation: 47.857%, values: [0.000026, 0.000010, 0.000009, 0.000009, 0.000008, 0.000009, 0.000009, 0.000008, 0.000009, 0.000009]
从上面数据能够很直观地看到,在不作任何优化的前提下性能降低十分明显,效率损失甚至高达 3 倍以上,因此若是要作防御,必须充分考虑到性能优化这些点。
其次,须要合理权衡开启的防御类型,目前咱们仅默认开启线上反馈的常见类型,而不是开启全部类型,其余类型能够配置为动态开启,根据用户设备的闪退日志开启防御。其中,Baymax 中提到的野指针防御,在实践中发现用处颇有限,由于只是作了延迟释放,而不是真正意义上对野指针这种 crash 进行防御,且因为对系统的释放时机进行了处理,与 Xcode 原来的 Zombie 机制有必定冲突,也会产生一些很奇葩的问题,不肯定性很高。
再次,各类Hook带来的未知性,Crash 自己是非正常状况下才产生的,若是一味地规避这种异常,可能会产生更多的异常状况,特别是业务逻辑上会出现不可控制的流程。
最后,这套防御方案的做用究竟有多大呢?根据笔者我的经验来讲,对于越成熟的团队,防御方案带来的效果会越小。由于成熟团队的代码质量相对更高,一些低级错误出现的几率极小。但对于小团队,或者历史比较久的项目而言,这套方案带来的帮助会比较大,毕竟坑老是防不胜防的。
面试题持续整理更新中,若是你想一块儿进阶,不妨添加一下交流群1012951431
面试题资料或者相关学习资料都在群文件中 进群便可下载!