再谈 iOS App Crash 防护

作者: iOS开发面试题技术合集 | 来源:发表于2020-06-08 15:14 被阅读0次

    在移动开发中,App 的闪退率是工程师十分关注且又头疼的事情。去年,网易杭州研究院曾经针对 crash 的防护有提出『大白健康系统--iOS APP 运行时 Crash 自动修复系统』方案,使得 crash 防护这个想法真正被落实,但至今该方案的具体实现并没有被开源。经过一年的时间,圈子里也有一些开发朋友,基于这套方案设计并开源了自己的 “Baymax”,比如『老司机 iOS 周报第七期』中曾提到的 BayMaxProtector。本文将会针对网易 Baymax 这套方案,结合团队内的实践结果,总结其在生产环境中可能遇到的问题及其解决方案,并提出一些自己对这套方案的思考。友情提示,阅读本文前需对网易『大白健康系统--iOS APP 运行时 Crash 自动修复系统』一文有所了解,该文中已有的实现方案,本文不会再花更多笔墨进行赘述。

    Crash 防护可选的方案

    Crash 是什么?

    在探讨 Crash 防护的方案之前,我们有必要对计算机领域 Crash 这个概念进行重新认识。对于 Crash 的概念,维基百科中是这么定义的:

    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.

    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.

    对于我们 iOS 应用层的 App,可简单总结为应用执行了某些不被允许的操作触发了系统抛出异常信号但又没有处理这些异常信号从而被杀掉的现象,比如常见的闪退(crash to desktop)。在我们开发领域从抛出异常的对象上来看,一共可以分为三类内核导致的异常应用自身的异常其他进程导致的异常

    • 由操作系统内核捕获硬件产生的异常信号,比如 EXC_BAD_ACCESS,这类异常如果没有被处理掉的话,会被转发到 SIGBUSSIGSEGV 等类型的 BSD 信号;
    • 由 SDK 开发者或上层应用开发者主动抛出的异常信号,比如各种常见的 NSException,这类异常苹果为了统一处理,最终会被转发为 SIGABRT 类的 BSD 信号;
    • 其他进程杀死你的应用;

    这里我们主要谈最常见的前两种异常。

    可选的 Crash 防护方案

    上面已经提到了 Crash 实际上我们触发了异常,但又没有去处理这些异常而导致的结果。那么很自然的第一个防护方案便可以想到是去处理这些异常

    通过 NSUncaughtExceptionHandler 来捕获并处理异常

    苹果的确提供有异常捕获的 API 以供开发者使用——NSSetUncaughtExceptionHandler,开发者只需要传入处理函数的指针,便可以处理掉应用中抛出的 NSException 类的异常。代码写起来就是:

    NSSetUncaughtExceptionHandler(&HandleException);
    
    

    通过 BSD 的 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 防护并不是一种靠谱的方式,原因有以下几点:

    • iOS/OSX 在被抛出异常后,被认为是不可恢复的,如果我们强行恢复 Runloop,整个 App 的不确定性将会更大,crash 的部分可能会再次发生;
    • 内核抛出的异常一般都是较严重的底层硬件问题,如果这类问题不及时停止程序运行,可能会进一步影响整个系统的运行,乃至损坏硬件;
    • 以上两种做法,通常是用于 Crash 日志收集上,如果我们防护层也通过这个方案去做的话,冲突的可能性会很大;

    这里附带下『App Architecture book』作者 Matt Gallagher 早年对于这部分研究后的一个 demo,由于是 MRC 时代的代码了,修改了部分配置使得能够正常编译且测试。

    通过 try-catch 的组合拳来捕获异常

    和其他编程语言一样,Objective-C 中也有万能的 try-catch 组合来捕获异常,这样处理不就可以了?这种方案确实是可行的,我也确实有见过一些人使用 try-catch 来做一些常见的 Crash 防护。但 Objective-C 的 try-catch 实际上有先天缺陷的,首先是效率并不高,甚至某些情况下会导致内存泄漏,不可控。

    • 效率不高是由于 try-catch 是基于 block 的处理方案,会多出额外的开销(不过苹果已经重写了 64 bit 机器上的 try-catch,而且声明是 zero-cost);
    • 可能会内存泄漏是由于 Xcode 默认并不会对 try-catch 中的代码进行 ARC 管理。try 在捕捉到 Exception 之后,会立即转到 catch 中执行,这样就导致了如果 release 代码是写在 try 中 throw 异常的代码之后的话,就会不被执行而导致内存泄漏。如果为了防止这个泄漏而去配置 -fobjc-arc-exceptions 选项,更会因为生成低效代码而得不偿失,这也是苹果并不推荐的方式。

    但这不能完全否定 try-catch 组合在我们日常编程中的作用,在一些容易出现异常的操作上,比如文件读写或者需要配合使用 throw 的情况等。这里指的不适合,只是针对在大范围防护并不适合。

    Baymax 的方案

    在综合分析了以上几个防护方案后,我们再来看看 Baymax 中采用的方案。如果说上面三种方案都是在已经抛出了异常之后再去捕获处理,也就是“喝后悔药”的机制,那么 Baymax 的方案便是不让这些异常产生。不让错误异常产生可以通过多种做法,往项目管理上说提高代码质量,增加 Code Review 等,从编码角度来说,我们可以通过各种保护性代码进行。Baymax 中的大部分防护方案都可以理解为一种为你自动增加保护性代码的措施。比如,各种 Collection 类型,String 类型等。

    实践 Baymax 方案中可能遇到的问题

    高频调用方法的性能问题

    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];
    
    

    UnrecognizedSelector 防护的坑

    苹果在 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 SDK 在不断调整

    由于 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 方法的延迟调用

    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"));
    
    

    NSArray 防护后出现的奇葩问题

    Hook 掉 objectAtIndex:方法后,在这样一个场景下会出现意外的 crash:调出系统键盘再把 App 切到后台,就出现 [uikeyboardlayoutstar release] message sent to deallocated instance crash。这其实是 iOS 系统在 ARC 下的一个坑,ARC 导致了 over-released 的 crash,暂时没有其他更好的解决方案,只能把这部分防护改为 MRC 编写。

    如何保证 SDK 更新的稳定性

    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

    面试题资料或者相关学习资料都在群文件中 进群即可下载!


    相关文章

      网友评论

        本文标题:再谈 iOS App Crash 防护

        本文链接:https://www.haomeiwen.com/subject/hoxbzhtx.html