题外话:近来工作闲暇之余把以前看的网易大神写的crash防护手动实现了。纸上得来终觉浅,绝知此事要躬行。记录一下思路,大部分还是参考大神的经验。框架内部可接入日志上报系统,结合服务端进行收集。
Baymax:网易iOS App运行时Crash自动防护实践
自己实现的OCShield
代码捕获crash
Crash一般产生自 iOS 的微内核 Mach,然后在 BSD 层转换成 UNIX SIGABRT 信号,以标准 POSIX 信号的形式提供给用户。NSException 是使用者在处理 App 逻辑时,用编程的方法抛出。
crash的捕获的方式
1.Mach 异常与 Unix 信号
Mach 异常捕获。基于Mach内核编程,需要对内核有一定了解。
Unix 信号捕获。对于Mach 异常,操作系统会将其转换为对应的 Unix信号,可以通过注册signalHandler的方式来做信号异常。
Exception Type: EXC_BAD_ACCESS (SIGSEGV)
Exception Subtype: KERN_INVALID_ADDRESS at 0x041a6f3
Mach 异常是什么?它又是如何与 Unix 信号建立联系的?
Mach 是一个 XNU 的微内核核心,Mach 异常是指最底层的内核级异常,被定义在 <mach/exception_types.h>下 。每个 thread,task,host 都有一个异常端口数组,Mach 的部分 API 暴露给了用户态,用户态的开发者可以直接通过 Mach API 设置 thread,task,host 的异常端口,来捕获 Mach 异常,抓取 Crash 事件。
所有 Mach 异常都在 host 层被ux_exception转换为相应的 Unix 信号,并通过threadsignal将信号投递到出错的线程。iOS 中的 POSIX API 就是通过 Mach 之上的 BSD 层实现的。
因此,EXC_BAD_ACCESS (SIGSEGV)表示的意思是:Mach 层的EXC_BAD_ACCESS异常,在 host 层被转换成 SIGSEGV 信号投递到出错的线程。既然最终以信号的方式投递到出错的线程,那么就可以通过注册 signalHandler 来捕获信号:
signal(SIGSEGV,signalHandler);
捕获 Mach 异常或者 Unix 信号都可以抓到 crash 事件,这两种方式哪个更好呢?优选 Mach 异常,因为 Mach 异常处理会先于 Unix 信号处理发生,如果 Mach 异常的 handler 让程序 exit 了,那么 Unix 信号就永远不会到达这个进程了。转换 Unix 信号是为了兼容更为流行的 POSIX 标准 (SUS 规范),这样不必了解 Mach 内核也可以通过 Unix 信号的方式来兼容开发。
因为硬件产生的信号 (通过 CPU 陷阱) 被 Mach 层捕获,然后才转换为对应的 Unix 信号;苹果为了统一机制,于是操作系统和用户产生的信号 (通过调用kill和pthread_kill) 也首先沉下来被转换为 Mach 异常,再转换为 Unix 信号。
signal(SIGABRT, SignalExceptionHandler)
2.NSException 捕获。
应用层,通过 NSUncaughtExceptionHandler
捕获,因为堆栈中不会有出错代码,所以需要获取NSException对象中的reason、name、callStackSymbols。然后把细节写入Crash日志,上传到后台做数据分析。
NSSetUncaughtExceptionHandler(UncaughtExceptionHandler) //程序启动代理方法
void UncaughtExceptionHandler(NSException *exception) {
NSArray *callStack = [exception callStackSymbols];
NSString *reason = [exception reason];
NSString *name = [exception name];
NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
[formatter setDateFormat:@"YYYY-MM-dd HH:mm:ss"];
NSString * dateStr = [formatter stringFromDate:[NSDate date]];
NSString * iOS_Version = [[UIDevice currentDevice] systemVersion];
NSString * PhoneSize = NSStringFromCGSize([[UIScreen mainScreen] bounds].size);
NSString * App_Version = [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleShortVersionString"];
NSString * iPhoneType = @"当前设备名字";
NSString *uploadString = @"所有拼接信息";
// 存储到本地沙盒.下次启动找寻
}
iOS的Crash分类
1.unrecognized selector crash【实现】
2.KVO/KVC crash【实现】
3.NSNotification crash
4.NSTimer crash【实现】
5.Container crash(数组越界,插nil等)【实现】
6.NSString crash (字符串操作的crash)【实现】
7.Bad Access crash (野指针)【实现】
8.UI not on Main Thread Crash (非主线程刷UI(机制待改善))
Unrecognized Selector
调用方法时会转换成objc_msgSend()函数调用。
1.首先,在相应操作的对象中的缓存方法列表中找调用的方法,如果找到,转向相应实现并执行。
2.如果没找到,在相应操作的对象中的方法列表中找调用的方法,如果找到,转向相应实现执行。
3.如果没找到,去父类指针所指向的对象中执行1,2。
4.以此类推,如果一直到根类还没找到,转向拦截调用,走消息转发机制。
5.如果没有重写拦截调用的方法,程序报错。
消息转发流程
1.调用resolveInstanceMethod给个机会让类添加这个实现这个函数。
2.调用forwardingTargetForSelector让别的对象去执行这个函数。
3.调用forwardInvocation(函数执行器)灵活的将目标函数以其他形式执行。
基于此,我选择2、3都去实现对比方案。
方案一:重写NSObject的forwardingTargetForSelector
方法。虽然不会造成NSInvocation对象的开销,但是会拦截到系统的其他方法,导致该方法调用多次问题。
1.动态创建一个桩类。
2.动态为桩类添加对应的Selector,用一个通用的返回0的函数来实现该SEL的IMP。
3.将消息直接转发到这个桩类对象上。
方案二:与方案一思路类似,hook NSObject的methodSignatureForSelector
和forwardInvocation
,虽然频繁创建NSInvocation对象,但是到了这里已经过滤掉系统的方法。
KVO类型
kvo一般crash原因是
1.KVO的被观察者dealloc时仍然注册着KVO。
2.添加KVO重复添加观察者或重复移除观察者(KVO注册观察者与移除观察者不匹配)。
基于管理混乱问题,可以让被观察对象持有一个KVO的delegate,所有和KVO相关的操作均通过delegate来进行管理,delegate通过建立一张map来维护KVO整个关系。
hook addObserver:方法
通过上面的流程,将observerd对象的所有kvo相关的observer信息全部转移到KVOdelegate上,并且避免了相同kvoinfo被重复添加多次的可能性。
hook 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而崩溃,所以需要做这一步来阻止该种情况的发生。
hook observeValueForKeyPath:方法
delegate对于
observeValueForKeyPath
方法的修改最主要的地方是,在于将对应的响应方法转移给真正的KVO Observer,通过keyInfoMap找到keypath对应的KVOInfo里面预先存储好的observer,然后调用observer原本的响应方法。同时在遍历InfoArray的时候,发现info.observerw == nil的时候,需要及时将其清除掉,避免KVO的观察者observer被释放后value变化导致的crash.
最后,针对 KVO的被观察者dealloc时仍然注册着KVO导致的crash 的情况,可以将NSObject的dealloc swizzle, 在object dealloc的时候自动将其对应的kvodelegate所有和kvo相关的数据清空,然后将kvodelegate也置空。避免出现KVO的被观察者dealloc时仍然注册着KVO而产生的crash。
KVC类型
hook常用的方法,用Try catch方式守护。
NSNotification类型
主要针对iOS9系统之前不移除通知。苹果在iOS9之后专门针对于这种情况做了处理,所以在iOS9之后,即使开发者没有移除observer,Notification crash也不会再产生了。
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,内存等性能方面均是没有必要的浪费。
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。
Container类型
针对于NSArray/NSMutableArray/NSDictionary/NSMutableDictionary/NSCache的一些常用的会导致崩溃的API进行method swizzling,然后在swizzle的新方法中加入一些条件限制和判断,从而让这些API变的安全。
NSString类型
NSString/NSMutableString 类型的crash的产生原因和防护方案与Container crash很相像。
野指针类型
method swizzling替换NSObject的allocWithZone
方法
在新的方法中判断该类型对象是否需要加入野指针防护,如果需要,则通过objc_setAssociatedObject为该对象设置flag标记,被标记的对象后续会进入zombie流程。
做flag标记是因为很多系统类,比如NSString,UIView等创建,释放非常频繁,而这些实例发生野指针概率非常低。基本都是我们自己写的类才会有野指针的相关问题,所以通过在创建时,设置一个标记用来过滤不必要做野指针防护的实例,提高方案的效率。
同时做判断是否要加入标记的条件里面,我们加入了黑名单机制,是因为一些特定的类是不适用于添加到zombie机制的,会发生崩溃(例如:NSBundle),而且所以和zombie机制相关的类也不能加入标记,否则会在释放过程中循环引用和调用,导致内存泄漏甚至栈溢出。
method swizzling替换NSObject的dealloc
方法
对flag标记的对象实例调用objc_destructInstance
,释放该实例引用的相关属性,然后将实例的isa修改为ShieldZombieObject。通过objc_setAssociatedObject 保存将原始类名保存在该实例中。
dealloc最后会调到objectdispose函数,在这个函数里面其实也做了三件事情。
1.调用objc_destructInstance释放该实例引用的相关实例。
2.将该实例的isa修改为stubClass,接受任意方法调用。
3.释放该内存。
在ShieldZombieSub 通过消息转发机制forwardingTargetForSelector
处理所有拦截的方法
根据selector动态添加能够处理方法的响应者ShieldZombieSub 实例,然后通过 objc_getAssociatedObject 获取之前保存该实例对应的原始类名,统计错误数据。当退到后台或者达到未释放实例的上限时,则调用free函数释被引用zombie化的实例。
注:
1.做了野指针防护,通过动态插入一个空实现的方法来防止出现Crash,但是业务层面的表现难以确定,可能会进入业务异常的状态。需要拟定一下如何展现该问题给用户的方案。
2.由于做了延时释放若干实例,对系统总内存会产生一定影响,目前将内存的缓冲区开到5M左右,所以应该没有很大的影响,但还是可能潜在一些风险。
3.延时释放实例是根据相关功能代码会聚焦在某一个时间段调用的假设前提下,所以野指针的zombie保护机制只能在其实例对象仍然缓存在zombie的缓存机制时才有效,若在实例真正释放之后,再调用野指针还是会出现crash,所以不能达到真正防止crash的目的。
据面试阿里的面试官说可以用计算内存堆栈信息的方式,作者表示不理解。
非主线程刷UI类型
- (void)setNeedsLayout;
- (void)setNeedsDisplay;
- (void)setNeedsDisplayInRect:(CGRect)rect;
在这三个方法调用的时候判断一下当前的线程,如果不是主线程的话,直接利用 dispatch_async(dispatch_get_main_queue(), ^{ //调用原本方法 });
来将对应的刷UI的操作转移到主线程上,同时统计错误信息。
但是真正实施了之后,发现这三个方法并不能完全覆盖UIView相关的所有刷UI到操作,但是如果要将全部到UIView的刷UI的方法统计起来并且swizzle,感觉略笨拙而且不高效。