# 0x1 为什么要做 Crash 防护

在产品开发过程中 Crash 率是一个很重要的指标,也是一个团队中几乎所有的部门都应该关注或者去参与提升的一个指标,他不仅代表着整个产品的质量,也是一个团队整体技术能力的体现。更低的 Crash 率不但能让产品获得更好的用户口碑,在整个流程中也能让团队成员获得更多的成长,加深对 iOS 系统底层的理解,为今后的开发带了更大的帮助。

# 0x2 为什么要写这篇文章

起因也是因为自己的项目踩了 FB 的 SDK 的坑:2020.7.10,FB 后台下发数据错误,导致大量使用 FB SDK 的 App 发生启动 Crash,影响用户之多,范围之大,再加上当时包括我们的大部分 App 也缺乏相关的防护或者是容错处理,Crash 率瞬间飙升,重新发版又要走发布流程,只能依赖 FB 后台的修复,当时束手无策十分被动,所以决定自己做一套较为完整的 Crash 防护体系,来避免这样的场景再次发生。第二个目的就是,发生问题后我也第一之间查阅了网上的一些资料和其他团队的做法,发现大家的方式各有千秋,方法不同,效果不同,所以我也决定把市面上能找到的好的思路和方法再结合自己的一些想法和经验记录下来。最后也是因为知识是要沉淀、积累和分享的,也算是巩固和加深自己的理解吧。

# 0x3 怎么做

其实当时 Crash 的场景很简单,本来一个 Dictionary 参数 FB 后台却下发了个 String 类型的数据,这样一来解析时候必然会 Crash,解决的话其实只要做一层参数安全校验即可。

但是这么简单的问题,大部分 App 都没处理好,证明在流程上一定有大家注意不到的地方,暴露出来的只是冰山一角,我们机制一定存在着某种问题,或者存在可以优化的地方。

要想避免这种情况,就要先梳理出处理 Crash 的流程:

# I:Crash 处理流程

在 iOS 系统中基本可以总结出这四个步骤,

  • Crash 防护 - 通过 Hook 等手段,对一些类似容器类进行入参校验等措施,来进来避免 Crash 的发生

  • Crash 拦截 - 如果第一步防护失败,那么在 Crash 走到这一步就要进行拦截,要让我们发现异常

  • Crash 上报 - 对防护的、捕获的 Crash 进行防护,生成有效的日志进行上报,尽可能的还原堆栈。

  • Crash 后续流程 - Crash 发生后如何做才能最大限度的保护用户体验,如何优雅的 Crash

# II:Crash 防护

Crash 防护方式主要分两种:针对非内存问题通常采用 AOP 方式,内存问题采用 zombie 对象的方式,


# AOP:<br>

iOS 中 AOP 的相关知识网上线程的代码也很多,这里就不在赘述,但是在 AOP 这种频繁调用的场景中就需要注意的地方和坑点比较多。

  • AOP 的影响范围问题:当时用了普通的方式对数组相关的方法进行了 Hook,结果上线后发现大量的类似 Crash。 [UIKeyboardLayoutStar release]: message sent to deallocated instance UIKeyboardLayoutStar


    在通过一些其他场景可以判断出是因为 HookNSMutableArr 的相关方法,导致系统类的调用受到了影响。

    通过 Xcode 调试发现,因为 Hook 的本质就是在原有的系统调用前插入一个用户自定义的函数进行方法交换,那么在某种极端情况下(比如多线程),传入该函数的变量被释放,这样一来再走到原本系统调用的时候正常释放时就会出现重复释放的情况。大概的流程为

    该场景在测试过程中很难复现,但是一旦到了线上,用户量覆盖够大后该问题就会显现出来。解决方式很简单,Hook 尽量在 MRC 下进行,使用 autorelease pool 进行包装。保证内部变量在当前的 runloop 结束时候进行释放。

  • AOP 的性能问题:上面说了 AOP 的原理是会多一层方法调用,那么再结合 iOS 的方法转发流程可想而知,AOP 必定会造成性能的损耗,而且在 Crash 防护场景下频繁调用,性能问题一定不能忽略。

    通过上图看出,方法调用流程最终会返回出对应的 IMP 指针供外部调用,作为动态语言,OC 无法确定开发者会再什么时候插入或者交换哪个函数,所以必须通过这一套流程进行类似校验的逻辑。

    使用过 AOP 的同学一定知道在 AOP 前会先做一层校验

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    +(void)hookClass:(Class)classObject isClassMetohd:(BOOL)classMethod fromSelector:(SEL)fromSelector toSelector:(SEL)toSelector 
    {
    Class class = classObject;
    Method fromMethod = class_getInstanceMethod(class, fromSelector);
    Method toMethod = class_getInstanceMethod(class, toSelector);
    // 添加前进行检测
    if (classMethod) {
    class = object_getClass(classObject);
    fromMethod = class_getClassMethod(class, fromSelector);
    toMethod = class_getClassMethod(class, toSelector);
    }
    if(class_addMethod(class, fromSelector, method_getImplementation(toMethod), method_getTypeEncoding(toMethod))) {
    class_replaceMethod(class, toSelector, method_getImplementation(fromMethod), method_getTypeEncoding(fromMethod));
    } else {
    method_exchangeImplementations(fromMethod, toMethod);
    }
    }

    所以在方法我们在上面代码中的 toSelector 中 当我们需要调用回原方法时直接调用对应的函数指针即可

    最终我对直接调用 IMP 的方法做了测试,分别是 Demo 中和 App 中的某一个场景,测试数据如下,对比结果还是较为明显。这也就是为什么 Swift 或者一些其他静态语言比 OC 快的原因。

# Zombie:<br>

使用僵尸对象来解决内存问题一直是苹果主推的方式,Xcode 也有相关设置,在 Debug 下打开相应开关,但是一旦把该功能放到线上做防护或监控就要考虑很多的问题。

  • zombie 入口问题:换句话说就是在哪个地方生成 zombie 对象,看了一些相关的 SDK 都是采用 Dealloc 作为入口函数,不是不行,只是不是最优。原因有两点:

    • 1: 苹果已经不建议在 ARC 下主动调用 dealloc,目前只能采用 performSelector 或者其他动态调用的方式。

    • 2: 容易漏掉 Objc_destructInstance ,所有的成员变量、属性都会在这个函数中释放,如果漏掉这个函数就会生成一个并不干净的僵尸对象,内存占用过高,白白浪费内存空间。

    综上两点我最终选择在 Free 函数中生成僵尸对象

  • zombie 内存阈值问题:僵尸对象会占用内存空间,然而在线上环境操作内存一定要小心且一定要有一套完整的逻辑,当超过某一个内存阈值后需要及时清空僵尸对象。内存阈值的确定便成了关键,这里会遇到两个问题:

    • <font color='red'> 1:内存问题一定会和机型强相关,如何根据不同的机型调整不同的阈值? </font>

    • <font color='red'> 2:如何做到根据线上情况灵活动态调整?</font>

    我们的底线是在加入 zombie 后不能触发 memorywarning,所以我先对大部分机型做了 memorywarning 阈值测试:

    从上图可以看出当 App 占用内存达到总内存的 57%~69% 时候会触发内存警告,而且由于 iphone 中有一部分内存是系统保留内存并不会给到开发者,所以我们可用的也就 50% 左右,我总结出如下公式:

    公式 1:不能触发内存警告 Y = 0.5 * deviceMem – currentAppMem

    公式 2: 僵尸对象的内存占用再大也不会超过 App 本身的内存 Y = min ( ( 0.5 * deviceMem – currentAppMem ) , currentAppMem)

    上面两个公式看似完美,但是还是有优化的地方,因为并不是 APP 中所有的变量都有可能成为僵尸对象,可能只是其中的某一部分需要被监控,所以得到最终的内存阈值计算公式:

    <font color=red size=5 face="黑体">Y = min ( ( 0.5 * deviceMem – currentAppMem ) , currentAppMem / N )</font>

    因为 app 占用内存随时在变,所以可以加一个定时器每隔一定时间去更新该值。

    上面公式的 N 还有一个好处就是我们可以后台动态下发,根据线上内存引起 Crash 量,如果 Crash 量大,那可能就需要更大的内存阈值去保存僵尸对象,就可以把 N 调小,反正调大,这样就可以无视机型的差异根据 Crash 的情况进行远程配置。

    通过如图的线上数据可以看出 随着 N 的减小,zombie 的内存阈值在增加,但是并不会超过内存警告阈值,确保了内存健康。

    下图表示了不同的 N 值对应不同的捕获野指针问题的数量,各自 App 可以根据自己的业务情况进行调整。

  • zombie 更新策略问题:目前大家的做法都是在加入新的 zombie 对象时候检查是否超过阈值,达到阈值后删掉之前的 zombie 对象再加入新的对象,这样的清理逻辑是依赖于新 zombie 对象的加入,如果没有新对象的加入那么缓存空间也不会有变化,zombie 空间一旦生成就无法删掉,无法做到缓存的自清理,等于 App 无故增大了内存占用。

    同样借鉴 LRU 最近最久未使用的逻辑,每隔 30s 会检测下缓存情况,超过 30s 还未被使用的 zombie 对象将被删除,30s 是一个经验值,通过大量测试发现,内存问题一般会发生在对象被销毁的 30s 内,超过 30s 再出现的概率及小。这样可以做到缓存自清理的逻辑。

    通过 Instrument 测试发现该 zombie 逻辑并不会对 App 本身的内存造成太大的影响。