利用Objective-C语言的动态特性,采用AOP(Aspect Oriented Programming) 面向切面编程的设计思想,做到无痕植入。能够自动在app运行时实时捕获导致app崩溃的破环因子,然后通过特定的技术手段去化解这些破坏因子,使app免于崩溃,照样可以继续正常运行,为app的持续运转保驾护航。当然我们不可能强大到把所有类型的crash都处理掉,但是我们会对一些高频的crash进行一一的处理,我们的目的就是降低crash率。
我们常见的crash有哪些呢?
- unrecognized selector crash (没找到对应的函数)
- KVO crash :(KVO的被观察者dealloc时仍然注册着KVO导致的crash,添加KVO重复添加观察者或重复移除观察者)
- NSNotification crash:(当一个对象添加了notification之后,如果dealloc的时候,仍然持有notification)
- NSTimer类型crash:(需要在合适的时机invalidate 定时器,否则就会由于定时器timer强引用target的关系导致 target不能被释放,造成内存泄露,甚至在定时任务触发时导致crash)
- Container类型crash:(数组,字典,常见的越界,插入,nil)
- 野指针类型的crash
- 非主线程刷UI类型:(在非主线程刷UI将会导致app运行crash)
1、Unrecognized Selector类型crash防护
unrecognized selector类型的crash在app众多的crash类型中占着比较大的成分,通常是因为一个对象调用了一个不属于它方法的方法导致的。
方法调用流程:
- 首先,在相应操作的对象中的缓存方法列表中找调用的方法,如果找到,转向相应的函数
- 如果没找到在对象的类方法列表中找调用的方法,如果找到,转向相应的实现执行
- 如果没有找到,去父类指针指向的对象中执行1,2
- 以此类推,如果一直到根类还没有找到,转向拦截调用,走消息转发
- 如果没有重写拦截调用的方法,程序报错。
拦截调用:
拦截调用就是,在找不到调用的方法程序崩溃之前,你有机会通过重写NSObject的四个方法来处理:
+ (BOOL)resolveClassMethod:(SEL)sel; //动态类方法决议机制,决议类方法
+ (BOOL)resolveInstanceMethod:(SEL)sel;//动态的对象方法决议,决议对象方法
//后两个方法需要转发到其他的类处理
- (id)forwardingTargetForSelector:(SEL)aSelector;//转发给其他的一个对象处理函数
- (void)forwardInvocation:(NSInvocation *)anInvocation;//灵活的将目标函数以其他形式执行
拦截调用的整个流程即Objective-C的消息转发机制,我们不难发现,runtime提供了3种方式去补救:
- 调用resolveInstanceMethod给个机会让类添加这个实现这个函数
- 调用forwardingTargetForSelector让别的对象去执行这个函数
- 调用forwardInvocation(函数执行器)灵活的将目标函数以及其他形式执行。
如果都不行,系统才会调用doesNotRecognizeSelector抛出异常。
既然可以补救,我们完全也可以利用消息转发机制来做文章,但是我们选择哪一步比较合适呢?
- resolveInstanceMethod需要在类的本身动态的添加它本身不存在的方法,这些方法对于该类本身来说是冗余的
- forwardInvocation可以通过NSInvocation的形式将消息转发给多个对象,但是其开销比较大,需要创建新的NSInvocation对象,并且forwardInvocation的函数经常被使用者调用来做消息的转发选择机制,不适合多次重写
- forwardingTargetForSelector可以将消息转发给一个对象,开销较小,并且被重写的概率较低,适合重写
我们可以重写NSObject的该方法,可以做以下几步的处理:
- 第一步:为类动态的创建一个桩类
- 第二步:为类动态为桩类添加对应的Selector,用一个通用的返回0的函数来实现该SEL的IMP
- 第三步:将消息直接转发到这个桩类对象上。
具体的代码实现如下:
- (id)yh_forwardingTargetForSelector:(SEL)aSelector {
if(class_respondsToSelector([self class], @selector(forwardInvocation:))) {
IMP impOfNSObject = class_getMethodImplementation([NSObject class], @selector(forwardInvocation:));
IMP imp = class_getMethodImplementation([self class], @selector(forwardInvocation:));
if (imp != impOfNSObject) {
NSLog(@"class has implemented invocation");
return nil;
}
}
YHUnrecognizedSelectorSolveObject * solveObject = [YHUnrecognizedSelectorSolveObject new];
solveObject.objc = self;
return solveObject;
}
注意如果对象的类本身如果重写了forwardInvocation方法的话,就不应该对forwardingTargetForSelector进行重写了,否则会影响到该类型的对象原本的消息转发流程。
2、KVO类型crash防护
KVO crash 产生的原因大致有2种:
- KVO的被观察者dealloc时仍然注册着KVO导致的crash
- 添加KVO重复添加观察者或重复移除观察者(KVO注册观察者与移除观察者不匹配)导致的crash
一个被观察的对象上有若干个观察者,每个观察者又有若干条keypath,如果观察者和keypathx的数量一多,很容易不清楚被观察的对象整个KVO关系,导致被观察者在dealloc的时候,仍然残存着一些关系没有被注销,同时还会导致KVO注册者和移除观察者不匹配的情况发生,尤其是多线程的情况下,导致KVO重复添加观察者或者移除观察者的情况,这种类似的情况通常发生的比较隐蔽,很难从代码的层面上排查
KVO crash 防护方案
如何管理混乱的KVO关系呢:
可以让观察对象持有一个KVO的delegate,所有和KVO相关的操作均通过delegate来进行管理,delegate通过建立一张MAP表来维护KVO的整个关系,如下图:
这样做的好处有2个:
- 如果出现KVO重复添加观察或者移除观察者(KVO注册者不匹配的)情况,delegate,可以直接阻止这些非正常的操作。
- 被观察对象dealloc之前,可以通过delegate自动将与自己有关的KVO关系都注销掉,避免了KVO的被观察者dealloc时仍然注册着KVO导致的crash
3、NSNotification类型crash防护
当一个对象添加了notification之后,如果dealloc的时候,仍然持有notification,就会出现NSNotification类型的crash
NSNotification类型的crash多产生于程序员写代码时候犯疏忽,在NSNotificationCenter添加一个对象为observer之后,忘记了在对象dealloc的时候移除它。
所幸的是,苹果在iOS9之后专门针对于这种情况做了处理,所以在iOS9之后,即使开发者没有移除observer,Notification crash也不会再产生了。
不过针对于iOS9之前的用户,我们还是有必要做一下NSNotification Crash的防护的。
NSNotification Crash的防护原理很简单,利用method swizzling hook NSObject的dealloc函数,在对象真正dealloc之前先调用一下:[[NSNotificationCenter defaultCenter] removeObserver:self],即可。
注意:并不是所有的对象都需要做以上的操作,如果一个对象从来没有被NSNotificationCenter 添加为observer的话,在其dealloc之前调用removeObserver完全是多此一举。
4、NSTimer类型crash防护
NSTimer crash 产生原因:
在程序开发过程中,大家会经常使用定时任务,但使用NSTimer的 scheduledTimerWithTimeInterval:target:selector:userInfo:repeats: 接口做重复性的定时任务时存在一个问题:NSTimer会 强引用 target实例,所以需要在合适的时机invalidate 定时器,否则就会由于定时器timer强引用target的关系导致 target不能被释放,造成内存泄露,甚至在定时任务触发时导致crash。crash的展现形式和具体的target执行的selector有关。与此同时,如果NSTimer是无限重复的执行一个任务的话,也有可能导致target的selector一直被重复调用且处于无效状态,对app的CPU,内存等性能方面均是没有必要的浪费。所以,很有必要设计出一种方案,可以有效的防护NSTimer的滥用问题。
NSTimer crash 防护方案:
NSTimer所产生的问题的主要原因是因为其没有再一个合适的时机invalidate,同时还有NSTimer对target的强引用导致的内存泄漏问题。
那么解决NSTimer的问题的关键点在于以下两点:
- NSTimer对其target是否可以不强引用
- 是否找到一个合适的时机,在确定NSTimer已经失效的情况下,让NSTimer自动invalidate
关于第一个问题,target的强引用问题。可以用如下图的方案来解决:
在NSTimer和target之间加入一层stubTarget,stubTarget主要做为一个桥接层,负责NSTimer和target之间的通信。同时NSTimer强引用stubTarget,而stubTarget弱引用target,这样target和NSTimer之间的关系也就是弱引用了,意味着target可以自由的释放,从而解决了循环引用的问题。
上文提到了stubTarget负责NSTimer和target的通信,其具体的实现过程又细分为两大步:
- swizzle NSTimer中scheduledTimerWithTimeInterval:target:selector:userInfo:repeats: 相关的方法,在新方法中动态创建stubTarget对象,stubTarget对象弱引用持有原有的target,selector,timer,targetClass等properties。然后将原target分发stubTarget上,selector回调函数为stubTarget的fireProxyTimer
- 通过stubTarget的fireProxyTimer:来具体处理回调函数selector的处理和分发,当NSTimer的回调函数fireProxyTimer:被执行的时候,会自动判断原target是否已经被释放,如果释放了,意味着NSTimer已经无效,此时如果还继续调用原有target的selector很有可能会导致crash,而且是没有必要的。所以此时需要将NSTimer invalidate,然后统计上报错误数据。如此一来就做到了NSTimer在合适的时机自动invalidate
5、Container类型crash防护
Container crash 产生原因:
Container类型的crash 指的是容器类的crash,常见的有NSArray/NSMutableArray/NSDictionary/NSMutableDictionary/NSCache的crash。 一些常见的越界,插入nil,等错误操作均会导致此类crash发生。该类crash虽然比较容易排查,但是其在app crash概率总比还是挺高,所以有必要对其进行防护。
Container crash 防护方案:
Container crash 类型的防护方案也比较简单,针对于NSArray/NSMutableArray/NSDictionary/NSMutableDictionary/NSCache的一些常用的会导致崩溃的API进行method swizzling,然后在swizzle的新方法中加入一些条件限制和判断,从而让这些API变的安全。
6、野指针类型的crash
野指针类型crash产生的原因:
在App的所有Crash中,访问野指针导致的Crash占了很大一部分,野指针类型crash的表现为:Exception Type:SIGSEGV,Exception Codes: SEGV_ACCERR。
解决野指针导致的crash往往是一件棘手的事情,一来产生crash 的场景不好复现,二来crash之后console(控制台)的信息提供的帮助有限。XCode本身为了便于开放调试时发现野指针问题,提供了Zombie机制,能够在发生野指针时提示出现野指针的类,从而解决了开发阶段出现野指针的问题。然而针对于线上产生的野指针问题,依旧没有一个比较好的办法来定位问题。
所以,因为野指针出现概率高而且难定位问题,非常有必要针对于野指针专门做一层防护措施。
野指针crash 防护方案:
其实网上提出的方法都不完美,而且相当复杂。网上大多是在类init初始化的时候做一个标记,然后再dealloc再做一次标记,通过2次的标记来判断是否有内存,对于UIView UIImageview常用的类来讲多次分配释放内存消耗还是比较大的,并不是完美的解决方案。
可以使用腾讯的MLeaksFinder
MLeaksFinder 一开始从 UIViewController 入手。我们知道,当一个 UIViewController 被 pop 或 dismiss 后,该 UIViewController 包括它的 view,view 的 subviews 等等将很快被释放(除非你把它设计成单例,或者持有它的强引用,但一般很少这样做)。于是,我们只需在一个 ViewController 被 pop 或 dismiss 一小段时间后,看看该 UIViewController,它的 view,view 的 subviews 等等是否还存在。
具体的方法是,为基类 NSObject 添加一个方法 -willDealloc 方法,该方法的作用是,先用一个弱指针指向 self,并在一小段时间(2秒)后,通过这个弱指针调用 -assertNotDealloc,而 -assertNotDealloc 主要作用是直接中断言。这样,当我们认为某个对象应该要被释放了,在释放前调用这个方法,如果2秒后它被释放成功,weakSelf 就指向 nil,不会调用到 -assertNotDealloc 方法,也就不会中断言,如果它没被释放(泄露了),-assertNotDealloc 就会被调用中断言。这样,当一个 UIViewController 被 pop 或 dismiss 时(我们认为它应该要被释放了),我们遍历该 UIViewController 上的所有 view,依次调 -willDealloc,若2秒后没被释放,就会中断言。
总结起来一句话就是,当一个对象2秒之后还没释放,那么指向它的 weak 指针还是存在的,所以可以调用其 runtime 绑定的方法 willDealloc 从而提示内存泄漏。
7、非主线程刷UI类型crash防护
目前初步的处理方案是swizzle UIView类的以下三个方法:
- (void)setNeedsLayout;
- (void)setNeedsDisplay;
- (void)setNeedsDisplayInRect:(CGRect)rect;
在这三个方法调用的时候判断一下当前的线程,如果不是主线程的话,直接利用 dispatch_async(dispatch_get_main_queue(), ^{ //调用原本方法 });
来将对应的刷UI的操作转移到主线程上,同时统计错误信息。