runtime应用之crash防护机制

Posted by Renchao on April 20, 2020

崩溃问题一直是影响产品质量和用户体验的重要因素,如何解决代码问题导致的崩溃?除了开发过程中更加细心和专业,还有什么解决方法呢。我们可以利用runtime来对崩溃进行防护,减少崩溃率,同时通过上报崩溃信息,来及时修复bug。

常见的崩溃类型

  1. unrecognized selector
  2. KVO/KVC
  3. NSNotification
  4. NSTimer
  5. Container
  6. NSString
  7. Bad Access
  8. UI not on Main Thread

崩溃防护

unrecognized selector

这种崩溃其实就是找不到要执行的方法。缓存列表-方法列表-父类缓存列表/方法列表-根类–消息转发-都没有找到方法实现-崩溃。

其中消息转发的三个阶段:

1.调用resolveInstanceMethod/resolveClassMethod 给个机会让类添加这个实现这个函数。
2.调用forwardingTargetForSelector 让别的对象去执行这个函数。
3.调用forwardInvocation(函数执行器)灵活的将目标函数以其他形式执行。

既然可以补救,那么选择在哪个时机比较合适?选择第二步调用forwardingTargetForSelector比较合适,愿意如下:

  1. resolveInstanceMethod 需要在类的本身上动态添加它本身不存在的方法,这些方法对于该类本身来说冗余的。
  2. forwardInvocation可以通过NSInvocation的形式将消息转发给多个对象,但是其开销较大,需要创建新的NSInvocation对象,并且forwardInvocation的函数经常被使用者调用,来做多层消息转发选择机制,不适合多次重写。
  3. forwardingTargetForSelector可以将消息转发给一个对象,开销较小,并且被重写的概率较低,适合重写。

选择了forwardingTargetForSelector之后,可以将NSObject的该方法重写,做以下几步的处理:

  1. 动态创建一个桩类
  2. 动态为桩类添加对应的Selector,用一个通用的返回0的函数来实现该SEL的IMP
  3. 将消息直接转发到这个桩类对象上

注意如果对象的类本事如果重写了forwardInvocation方法的话,就不应该对forwardingTargetForSelector进行重写了,否则会影响到该类型的对象原本的消息转发流程。

通过重写NSObject的forwardingTargetForSelector方法,我们就可以将无法识别的方法进行拦截并且将消息转发到安全的桩类对象中,从而可以使app继续正常运行。

KVO

KVO,即:Key-Value Observing,它提供一种机制,当指定的对象的属性被修改后,则对象就会接受收到通知。简单的说就是每次指定的被观察的对象的属性被修改后,KVO就会自动通知相应的观察者了。

KVO机制在iOS的很多开发场景中都会被使用到。不过如果一不小心使用不当的话,会导致大量的crash问题。所以如果能找到一种方法能够自动抓取这些由于开发者粗心所导致的KVO Crash问题的话,是有一定的价值的。

导致KVO Crash的两种情形:

  1. VO的被观察者dealloc时仍然注册着KVO导致的crash
  2. 添加KVO重复添加观察者或重复移除观察者(KVO注册观察者与移除观察者不匹配)导致的crash

通常一个对象的KVO关系图如下:

一个被观察的对象(Observed Object)上有若干个观察者(Observer),每个观察者又观察若干条KeyPath。

如果观察者和keypath的数量一多,很容易理不清楚被观察对象整个KVO关系,导致被观察者在dealloc的时候,还残存着一些关系没有被注销。 同时还会导致KVO注册观察者与移除观察者不匹配的情况发生。

笔者曾经还遇到过在多线程的情况下,导致KVO重复添加观察者或移除观察者的情况。这类问题通常多数发生的比较隐蔽,不容易从代码的层面去排查。

由上可见多数由于KVO而导致的crash原因是由于被观察对象的KVO关系图混乱导致。那么如何来管理混乱的KVO关系呢。可以让被观察对象持有一个KVO的delegate,所有和KVO相关的操作均通过delegate来进行管理,delegate通过建立一张map来维护KVO整个关系。如下图:

这样做的两个好处:

  1. 如果出现KVO重复添加观察者或重复移除观察者(KVO注册观察者与移除观察者不匹配)的情况,delegate可以直接阻止这些非正常的操作。
  2. 被观察对象dealloc之前,可以通过delegate自动将与自己有关的KVO关系都注销掉,避免了KVO的被观察者dealloc时仍然注册着KVO导致的crash。

被swizzle的方法分别是:

- (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context;

- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;

- (void)observeValueForKeyPath:(nullable NSString *)keyPath ofObject:(nullable id)object change:(nullable NSDictionary<NSKeyValueChangeKey, id> *)change context:(nullable void *)context;

其中addObserver的改造流程为:

通过上面的流程,将observerd对象的所有kvo相关的observer信息全部转移到KVOdelegate上,并且避免了相同kvoinfo被重复添加多次的可能性。

removeObserver的改造流程为:

移除一个keypath的Observer时,当delegate的kvoInfoMap中找不到key为该keypath的时候,说明此时delegate并没有持有对应keypath的observer,即说明移除了一个不匹配的观察者,此时如果再继续操作会导致app崩溃,所以应该及时中断流程,然后统计异常信息。

当keypath对应的KVOInfo列表(infoArray)为空的时候,说明此时delegate已经不再持有任何和keypath相关的observer了。这时应该调用原有removeObserver的方法将delegate对应的观察者移除。

注意到在检查遍历infoArray的时侯,除了要删除对应的info信息,还多了一步检查info.observer == nil的过程,是因为如果observer为nil,那么此时如果keypath对应的值变化的话,也会因为找不到observer而崩溃,所以需要做这一步来阻止该种情况的发生。

observeValueForKeyPath的改造流程为:

delegate对于observeValueForKeyPath方法的修改最主要的法规,在于将对应的响应方法转移给真正的KVO Observer,通过keyInfoMap找到keypath对应的KVOInfo里面预先存储好的observer,然后调用observer原本的响应方法。

同时在遍历InfoArray的时候,发现info.observer == nil的时候,需要及时将其清除掉,避免KVO的观察者observer被释放后value变化导致的crash。

最后,针对 KVO的被观察者dealloc时仍然注册着KVO导致的crash 的情况

可以将NSObject的dealloc swizzle, 在object dealloc的时候自动将其对应的kvo delegate所有和kvo相关的数据清空,然后将kvo delegate也置空。避免出现KVO的被观察者dealloc时仍然注册着KVO而产生的crash。

NSNotification

当一个对象添加了notification之后,如果dealloc的时候,仍然持有notification,就会出现NSNotification类型的crash。 NSNotification类型的crash多产生于程序员写代码时候犯疏忽,在NSNotificationCenter添加一个对象为observer之后,忘记了在对象dealloc的时候移除它。

所幸的是,苹果在 iOS 9 之后专门针对于这种情况做了处理,所以在iOS9之后,即使开发者没有移除observer,Notification crash也不会再产生了。不过针对于iOS9之前的用户,我们还是有必要做一下NSNotification Crash的防护的。

防护方案:

NSNotification Crash的防护原理很简单, 利用method swizzling hook NSObject的dealloc函数,再对象真正dealloc之前先调用一下[[NSNotificationCenter defaultCenter] removeObserver:self] 即可。

注意到并不是所有的对象都需要做以上的操作,如果一个对象从来没有被NSNotificationCenter 添加为observer的话,在其dealloc之前调用removeObserver完全是多此一举。 所以我们hook了NSNotificationCenter的 addObserver:(id)observer selector:(SEL)aSelector name:(NSString \*)aName object:(id)anObject函数,在其添加observer的时候,对observer动态添加标记flag。这样在observer dealloc的时候,就可以通过flag标记来判断其是否有必要调用removeObserver函数了。

NSTimer

在程序开发过程中,大家会经常使用定时任务,但使用NSTimer的scheduledTimerWithTimeInterval:target:selector:userInfo:repeats: 接口做重复性的定时任务时存在一个问题:NSTimer会强引用target实例,所以需要在合适的时机invalidate定时器,否则就会由于定时器timer强引用target的关系导致target不能被释放,造成内存泄露,甚至在定时任务触发时导致crash。 crash的展现形式和具体的target执行的selector有关。

与此同时,如果NSTimer是无限重复的执行一个任务的话,也有可能导致target的selector一直被重复调用且处于无效状态,对app的CPU,内存等性能方面均是没有必要的浪费。

防护方案:

上面的分析可见,NSTimer所产生的问题的主要原因是因为其没有再一个合适的时机invalidate,同时还有NSTimer对target的强引用导致的内存泄漏问题。那么解决NSTimer的问题的关键点在于以下两点:

  1. NSTimer对其target是否可以不强引用
  2. 是否找到一个合适的时机,在确定NSTimer已经失效的情况下,让NSTimer自动invalidate

关于第一个问题,target的强引用问题。 可以用如下图的方案来解决:

在NSTimer和target之间加入一层stubTarget,stubTarget主要做为一个桥接层,负责NSTimer和target之间的通信。

同时NSTimer强引用stubTarget,而stubTarget弱引用target,这样target和NSTimer之间的关系也就是弱引用了,意味着target可以自由的释放,从而解决了循环引用的问题。

上文提到了stubTarget负责NSTimer和target的通信,其具体的实现过程又细分为两大步:

  1. swizzle NSTimer中scheduledTimerWithTimeInterval:target:selector:userInfo:repeats: 相关的方法,在新方法中动态创建stubTarget对象,stubTarget对象弱引用持有原有的target,selector,timer,targetClass等properties。然后将原target分发stubTarget上,selector回调函数为stubTarget的fireProxyTimer:,流程如下图:

  2. 通过stubTarget的fireProxyTimer:来具体处理回调函数selector的处理和分发,流程如下图:

    因为stubTarget的介入,原有的target已经可以不受NSTimer强引用的牵制,而自由的释放。

由上图流程可知,当NSTimer的回调函数fireProxyTimer:被执行的时候,会自动判断原target是否已经被释放,如果释放了,意味着NSTimer已经无效,此时如果还继续调用原有target的selector很有可能会导致crash,而且是没有必要的。所以此时需要将NSTimer invalidate,然后统计上报错误数据。如此一来就做到了NSTimer在合适的时机自动invalidate。

Container

Container 类型的crash 指的是容器类的crash,常见的有NSArray/NSMutableArray/NSDictionary/NSMutableDictionary/NSCache的crash。 一些常见的越界,插入nil,等错误操作均会导致此类crash发生。 由于产生的原因比较简单,就不展开来描述了。该类crash虽然比较容易排查,但是其在app crash概率总比还是挺高,所以有必要对其进行防护。

Container crash 类型的防护方案也比较简单,针对于NSArray/NSMutableArray/NSDictionary/NSMutableDictionary/NSCache的一些常用的会导致崩溃的API进行method swizzling,然后在swizzle的新方法中加入一些条件限制和判断,从而让这些API变的安全,这里就不展开来具体描述了。

NSString

NSString/NSMutableString 类型的crash的产生原因和防护方案与Container crash很相像,这里也不展开来描述了。

野指针类型(Bad Access)

在App的所有Crash中,访问野指针导致的Crash占了很大一部分,野指针类型crash的表现为:Exception Type:SIGSEGV,Exception Codes: SEGV_ACCERR。

解决野指针导致的crash往往是一件棘手的事情,一来产生crash 的场景不好复现,二来crash之后console的信息提供的帮助有限。 XCode本身为了便于开放调试时发现野指针问题,提供了Zombie机制,能够在发生野指针时提示出现野指针的类,从而解决了开发阶段出现野指针的问题。然而针对于线上产生的野指针问题,依旧没有一个比较好的办法来定位问题。

所以,因为野指针出现概率高而且难定位问题,非常有必要针对于野指针专门做一层防护措施。

防护方案:

野指针问题的解决思路方向其实很容易确定,Xcode提供了zombie的机制来排查野指针的问题,那么我们这边可以实现一个类似于zombie的机制,加上对zombie实例的全部方法拦截机制 和 消息转发机制,那么就可以做到在野指针访问时不Crash而只是crash时相关的信息。

同时还需要注意一点:因为zombie的机制需要在对象释放时保留其指针和相关内存占用,随着app的进行,越来越多的对象被创建和释放,这会导致内存占用越来越大,这样显然对于一个正常运行的app的性能有影响。所以需要一个合适的zombie对象释放机制,确定zombie机制对内存的影响是有限度的。

improve版的zombie机制的实现主要分为以下四个环节:

  1. method swizzling替换NSObject的allocWithZone方法,在新的方法中判断该类型对象是否需要加入野指针防护,如果需要,则通过objc_setAssociatedObject为该对象设置flag标记,被标记的对象后续会进入zombie流程:

    做flag标记是因为很多系统类,比如NSString,UIView等创建,释放非常频繁,而这些实例发生野指针概率非常低。基本都是我们自己写的类才会有野指针的相关问题,所以通过在创建时 设置一个标记用来过滤不必要做野指针防护的实例,提高方案的效率。

    同时做判断是否要加入标记的条件里面,我们加入了黑名单机制,是因为一些特定的类是不适用于添加到zombie机制的,会发生崩溃(例如:NSBundle),而且所以和zombie机制相关的类也不能加入标记,否则会在释放过程中循环引用和调用,导致内存泄漏甚至栈溢出。

  2. method swizzling替换NSObject的dealloc方法,对flag标记的对象实例调用objc_destructInstance,释放该实例引用的相关属性,然后将实例的isa修改为HTZombieObject。通过objc_setAssociatedObject 保存将原始类名保存在该实例中。

    调用objc_destructInstance的原因:

    这里参考了系统在Object-C Runtime 中NSZombies实现,dealloc最后会调到objectdispose函数,在这个函数里面 其实也做了三件事情,

    1)调用objc_destructInstance释放该实例引用的相关实例

    2)将该实例的isa修改为stubClass,接受任意方法调用

    3)释放该内存

    官方文档对objc_destructInstance的解释为:

    Destroys an instance of a class without freeing memory and removes any associated references this instance might have had.

    说明objc_destructInstance会释放与实例相关联的引用,但是并不释放该实例等内存。

  3. 在HTZombieObject 通过消息转发机制forwardingTargetForSelector处理所有拦截的方法,根据selector动态添加能够处理方法的响应者HTStubObject 实例,然后通过 objc_getAssociatedObject 获取之前保存该实例对应的原始类名,统计错误数据。

    HTZombieObject的处理和unrecognized selector crash的处理是一样,主要的目的就是拦截所有传给HTZombieObject的函数,用一个返回为空的函数来替换,从而达到程序不崩溃的目的。

  4. 当退到后台或者达到未释放实例的上限时,则在ht_freeSomeMemory方法中调用原有dealloc方法释放所有被zombie化的实例。

综上所述,可以用下图总结一下bad access类型crash的防护流程:

相关风险:

  1. 做了野指针防护,通过动态插入一个空实现的方法来防止出现Crash,但是业务层面的表现难以确定,可能会进入业务异常的状态。需要拟定一下如何展现该问题给用户的方案。

  2. 由于做了延时释放若干实例,对系统总内存会产生一定影响,目前将内存的缓冲区开到2M左右,所以应该没有很大的影响,但还是可能潜在一些风险。

  3. 延时释放实例是根据相关功能代码会聚焦在某一个时间段调用的假设前提下,所以野指针的zombie保护机制只能在其实例对象仍然缓存在zombie的缓存机制时才有效,若在实例真正释放之后,再调用野指针还是会出现Crash。

非主线程刷UI类型crash防护(UI not on Main Thread)

在非主线程刷UI将会导致app运行crash,有必要对其进行处理。

目前初步的处理方案是swizzle UIView类的以下三个方法:

- (void)setNeedsLayout;
- (void)setNeedsDisplay;
- (void)setNeedsDisplayInRect:(CGRect)rect;

在这三个方法调用的时候判断一下当前的线程,如果不是主线程的话,直接利用 dispatch_async(dispatch_get_main_queue(), ^{ //调用原本方法 });

来将对应的刷UI的操作转移到主线程上,同时统计错误信息。

但是真正实施了之后,发现这三个方法并不能完全覆盖UIView相关的所有刷UI到操作,但是如果要将全部到UIView的刷UI的方法统计起来并且swizzle,感觉略笨拙而且不高效。待更新。

参考博客

大白健康系统–iOS APP运行时Crash自动修复系统