漫谈iOS Crash收集框架
为了能够第一时间发现程序问题,应用程序需要实现自己的崩溃日志收集服务,成熟的开源项目很多,如 ,, 等。追求方便省心,对于保密性要求不高的程序来说,也可以选择各种一条龙Crash统计产品,如 , ,, 等等。
-
是否集成越多的Crash日志收集服务就越保险?
-
自己收集的Crash日志和系统生成的Crash日志有分歧,应该相信谁?
-
为什么有大量Crash日志显示崩在main函数里,但函数栈中却没有一行自己的代码?
-
野指针类的Crash难定位,有何妙招来应对?
想解释清这些问题,必须从Mach异常说起
Mach异常与Unix信号
iOS系统自带的 Apple’s Crash Reporter 记录在设备中的Crash日志,Exception Type项通常会包含两个元素: Mach异常 和 Unix信号。
1 2 | Exception Type: EXC_BAD_ACCESS (SIGSEGV) Exception Subtype: KERN_INVALID_ADDRESS at 0x041a6f3 |
Mach异常是什么?它又是如何与Unix信号建立联系的? Mach是一个XNU的微内核核心,Mach异常是指最底层的内核级异常,被定义在下 。每个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来捕获信号:
1 | 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信号。
Crash收集的实现思路
正如上述所说,可以通过捕获Mach异常、或Unix信号两种方式来抓取crash事件,于是总结起来实现方案就一共有3种。
1)Mach异常方式
2)Unix信号方式
1 | signal (SIGSEGV,signalHandler); |
3)Mach异常+Unix信号方式
Github上多数开源项目都采用的这种方式,即使在优选捕获Mach异常的情况下,也放弃捕获EXC_CRASH异常,而选择捕获与之对应的SIGABRT信号。著名开源项目在代码注释中给出了详细的解释:
We still need to use signal handlers to catch SIGABRT in-process. The kernel sends an EXC_CRASH mach exception to denote SIGABRT termination. In that case, catching the Mach exception in-process leads to process deadlock in an uninterruptable wait. Thus, we fall back on BSD signal handlers for SIGABRT, and do not register for EXC_CRASH.
另外,需要重点说明的是:对于应用级异常NSException,还需要特殊处理。 你是否见过崩溃在main函数的crash日志,但是函数栈里面没有你的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | Thread 0 Crashed: 0 libsystem_kernel.dylib 0x3a61757c __semwait_signal_nocancel + 0x18 1 libsystem_c.dylib 0x3a592a7c nanosleep$NOCANCEL + 0xa0 2 libsystem_c.dylib 0x3a5adede usleep$NOCANCEL + 0x2e 3 libsystem_c.dylib 0x3a5c7fe0 abort + 0x50 4 libc++abi.dylib 0x398f6cd2 abort_message + 0x46 5 libc++abi.dylib 0x3990f6e0 default_terminate_handler() + 0xf8 6 libobjc.A.dylib 0x3a054f62 _objc_terminate() + 0xbe 7 libc++abi.dylib 0x3990d1c4 std::__terminate( void (*)()) + 0x4c 8 libc++abi.dylib 0x3990cd28 __cxa_rethrow + 0x60 9 libobjc.A.dylib 0x3a054e12 objc_exception_rethrow + 0x26 10 CoreFoundation 0x2f7d7f30 CFRunLoopRunSpecific + 0x27c 11 CoreFoundation 0x2f7d7c9e CFRunLoopRunInMode + 0x66 12 GraphicsServices 0x346dd65e GSEventRunModal + 0x86 13 UIKit 0x32124148 UIApplicationMain + 0x46c 14 XXXXXX 0x0003b1f2 main + 0x1f2 15 libdyld.dylib 0x3a561ab4 start + 0x0 可以看出是因为某个NSException导致程序Crash的,只有拿到这个NSException,获取它的reason,name,callStackSymbols信息才能确定出问题的程序位置。 /* NSException Class Reference */ @property(readonly, copy) NSString *name; @property(readonly, copy) NSString *reason; @property(readonly, copy) NSArray *callStackSymbols; @property(readonly, copy) NSArray *callStackReturnAddresses; 方法很简单,可通过注册NSUncaughtExceptionHandler捕获异常信息: static void my_uncaught_exception_handler (NSException *exception) { //这里可以取到 NSException 信息 } NSSetUncaughtExceptionHandler(&my_uncaught_exception_handler); |
将拿到的NSException细节写入Crash日志,精准的定位出错程序位置:
1 2 3 4 5 6 7 8 9 10 | Application Specific Information: *** Terminating app due to uncaught exception 'NSUnknownKeyException' , reason: '[ setValue:forUndefinedKey:]: this class is not key value coding-compliant for the key key.' Last Exception Backtrace: 0 CoreFoundation 0x2f8a3f7e __exceptionPreprocess + 0x7e 1 libobjc.A.dylib 0x3a054cc objc_exception_throw + 0x22 2 CoreFoundation 0x2f8a3c94 -[NSException raise ] + 0x4 3 Foundation 0x301e8f1e -[NSObject(NSKeyValueCoding) setValue:forKey:] + 0xc6 4 DemoCrash 0x00085306 -[ViewController crashMethod] + 0x6e 5 DemoCrash 0x00084ecc main + 0x1cc 6 DemoCrash 0x00084cf8 start + 0x24 |
那么,是不是收到了大量crash在main函数却没有NSException信息的日志,就代表自己集成的Crash日志收集服务没有注册NSUncaughtExceptionHandler呢?不一定,还有另外一种可能,就是被同时存在的其他Crash日志收集服务给坑了。
多个Crash日志收集服务共存的坑
是的,在自己的程序里集成多个Crash日志收集服务实在不是明智之举。通常情况下,第三方功能性SDK都会集成一个Crash收集服务,以及时发现自己SDK的问题。当各家的服务都以保证自己的Crash统计正确完整为目的时,难免出现时序手脚,强行覆盖等等的恶意竞争,总会有人默默被坑。
1)拒绝传递 UncaughtExceptionHandler
如果同时有多方通过NSSetUncaughtExceptionHandler注册异常处理程序,和平的作法是:后注册者通过NSGetUncaughtExceptionHandler将先前别人注册的handler取出并备份,在自己handler处理完后自觉把别人的handler注册回去,规规矩矩的传递。不传递强行覆盖的后果是,在其之前注册过的日志收集服务写出的Crash日志就会因为取不到NSException而丢失Last Exception Backtrace等信息。(P.S. iOS系统自带的Crash Reporter不受影响)
在开发测试阶段,可以利用 框架去hookNSSetUncaughtExceptionHandler方法,这样就可以清晰的看到handler的传递流程断在哪里,快速定位污染环境者。不推荐利用调试器添加符号断点来检查,原因是一些Crash收集框架在调试状态下是不工作的。
检测代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 | static NSUncaughtExceptionHandler *g_vaildUncaughtExceptionHandler; static void (*ori_NSSetUncaughtExceptionHandler)( NSUncaughtExceptionHandler * ); void my_NSSetUncaughtExceptionHandler( NSUncaughtExceptionHandler * handler) { g_vaildUncaughtExceptionHandler = NSGetUncaughtExceptionHandler(); if (g_vaildUncaughtExceptionHandler != NULL) { NSLog(@ "UncaughtExceptionHandler=%p" ,g_vaildUncaughtExceptionHandler); } ori_NSSetUncaughtExceptionHandler(handler); NSLog(@ "%@" ,[NSThread callStackSymbols]); g_vaildUncaughtExceptionHandler = NSGetUncaughtExceptionHandler(); NSLog(@ "UncaughtExceptionHandler=%p" ,g_vaildUncaughtExceptionHandler); } |
对于越狱插件注入应用进程内部,恶意覆盖NSSetUncaughtExceptionHandler的情况,应用程序本身处理起来比较弱势,因为越狱环境下操作时序的玩法比较多权利比较大。
2)Mach异常端口换出+信号处理Handler覆盖
和NSSetUncaughtExceptionHandler的情况类似,设置过的Mach异常端口和信号处理程序也有可能被干掉,导致无法捕获Crash事件。
3)影响系统崩溃日志准确性
应用层参与收集Crash日志的服务方越多,越有可能影响iOS系统自带的Crash Reporter。由于进程内线程数组的变动,可能会导致系统日志中线程的Crashed 标签标记错位,可以搜索abort()等关键字来复查系统日志的准确性。 若程序因NSException而Crash,系统日志中的Last Exception Backtrace信息是完整准确的,不会受应用层的胡来而影响,可作为排查问题的参考线索。
ObjC野指针类的Crash
收集Crash日志这个步骤没有问题的情况下,还是有很多全系统栈的日志的情况,没有自己一行代码,分析起来十分棘手,ObjC野指针类的Crash正是如此,这里推荐几篇好文章:
除此之外,在Crash日志中补充记录一些额外信息可以辅助定位,如切面标记线程出处、队列出处,记录用户操作轨迹等等……
来源:
野指针访问
表现
EXC_BAD_ACCESS
具体场景
- 定义property该用
strong/weak
修饰误用成assign
- 在
objc_setAssociatedObject
方法中该用OBJC_ASSOCIATION_RETAIN_NONATOMIC
修饰的对象误用成OBJC_ASSOCIATION_ASSIGN
- CoreFoundation层对象
Toll-Free Bridging
到Foundation层中,已经用了__bridge_transfer
关键字转移了对象的所有权之后,又对CoreFoundation层对象调用了一次CFRelease
,如:CFUUIDRef uuid = CFUUIDCreate(NULL);CFStringRef cfString = CFUUIDCreateString(NULL, uuid); NSString *string = (__bridge_transfer NSString *)cfString; CFRelease(cfString);
- NSNotification/KVO 只addObserver并没有removeObserver
- block回调之前并没有判空而是直接调用
解决方案
- 深刻了解各种关键字修饰内存语义的区别,正确运用,例如delegate属性一般都用weak修饰(可以引入静态代码检查 和commit检查 对assign关键字做警告⚠️)
Toll-Free Bridging
中三个关于内存语义关键字的含义:(__bridge T) op
:告诉编译器在 bridge 的时候不要做任何事情(__bridge_retained T) op
:( ObjC 转 CF 的时候使用)告诉编译器在 bridge 的时候 retain 对象,开发者需要在CF一端负责释放对象(__bridge_transfer T) op
:( CF 转 ObjC 的时候使用)告诉编译器转移 CF 对象的所有权,开发者不再需要在CF一端负责释放对象 - debug阶段启动僵尸对象模式,enbale Zombie Objects帮助辅助定位问题原理:在对象释放(retainCount为0)时,使用一个内置的Zombie对象,替代原来被释放的对象。无论向该对象发送什么消息(函数调用),都会触发异常,抛出调试信息。
- 对于NSNotificatio/KVO addObserver和removeObserver一定要成对出现,推荐使用FaceBook开源的第三方库FBKVOController
- block回调之前先做判空,如:
if (self.didDelete) { self.didDelete(sender == self.clearAllButton ? YES : NO, self.viewModel); }
查找不到指定的方法
表现
Terminating app due to uncaught exception 'NSInvalidAgumentException', reason: '-[Class methodName]: unrecognized selector sent to instance 0x1dd96160'
场景
- 头文件声明方法但是在.m文件中没有实现/把方法名修改但是没有在头文件中同步
- 调用代理类的方法的时候没有判断代理类是否已经实现对应的方法而直接调用,编译可以通过但是运行时会crash
- 对于id类型的对象没有判断类型直接强转调用方法
@property (nonatomic, copy) NSMutableArray *mutableArray;
用copy修饰的可变属性在赋值之后会变成不可变属性,比如这里调用addObject
方法之后就会crash- 在低版本的系统用了高版本才有的api 如:
if([str containsString:@"a"]){ //do something }
iOS7下会crash,因为该api是从iOS8系统才开始支持
解决方案
- 新建方法时先在.m文件中写方法实现,再把方法名拷贝到头文件中,修改方法名时先改头文件中的声明
- 调用代理类的方法前先用
respondsToSelector
方法先判断一下,然后再进行调用。如:if ([self.delegate respondsToSelector:@selector(methodNotExist)]) { [self.delegate methodNotExist];}
- swizzle掉
NSObject
的- (void)doesNotRecognizeSelector
方法给一个空的实现(风险比较大),因为方法查找不到在正常消息派发和三次消息转发之后crash之前一定会调用到此方法。 - 判断类型之后再强转调用对应类中的方法 或者从设计阶段规避这种问题,如父类定义接口,子类重载实现
- 牢记一些高本版才有的api,如
if([str containsString:@"a"]){ //do something }
可以替换为if ([str rangeOfString:@"a"]].location != NSNotFound) { //do something}
集合类相关
表现
Terminating app due to uncaught exception 'NSRangeException', reason: '*** -[__NSArrayI objectAtIndex:]: index 8 beyond bounds [0 .. 7]
failed: caught "NSInvalidArgumentException", " * -[__NSPlaceholderDictionary initWithObjects:forKeys:count:]: attempt to insert nil object from objects[1]
failed: caught "NSInvalidArgumentException", " * setObjectForKey: object cannot be nil (key: no_nillKey)
failed: caught "NSInvalidArgumentException", " * setObjectForKey: key cannot be nil"
Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '*** -[__NSArrayM insertObject:atIndex:]: object cannot be nil'
场景
- 数组越界
NSArray *array = @[@"a",@"b",@"c"];id letter = [array objectAtIndex:3];
- 向数组中插入空对象
NSMutableArray *mutableArrray = [NSMutableArray array];[mutableArray addObject:nil];
- 调用可变字典
setObject:ForKey
方法,key或者value为空,特别注意字面量写法@{@"itemID":article.itemID}//这里itemID可能为空
4.一边遍历数组,一边修改数组内容for(id item in self.itemArray) { if (item != self.currentItem) { [self.itemArray removeItem:item]; } }
或者多线程环境中,一个线程在读另外一个线程在写
解决方案
- 从数组中的某个下标取对于元素的时候先判断下标与数组长度的关系,如:
if (index < [[self currentUsers] count]) { UserModel * model = [[self currentUsers] objectAtIndex:index]; return model; }
-
对
NSMutableArray
以及NSMutableDictionary
自定义一些安全的扩展方法,如:-(id)objectAtIndexSafely:(NSUInteger)index { if (index >= self.count) { return nil; } return [self objectAtIndex:index]; }
-(void)setObjectSafely:(id)anObject forKey:(id
)aKey { if (!aKey) { return; } if (!anObject){ return; } [self setObject:anObject forKey:aKey]; } - 调用
NSMutableDictionary
的setValue:ForKey:
方法而不是setObject:ForKey:
方法,少用字面量语法NSDictionary
内部对value做了处理,[mutableDictionary setValue:nil ForKey:@"name"]
不会崩溃 - 保证多线程中读写操作的原子性:方法:加锁,信号量,GCD串行队列,GCD
dispatch_barrier_async
方法等,dispatch_barrier_async
用法示例:_cache = [[NSMutableDictionary alloc] init];_queue = dispatch_queue_create("com.mutablearray.safety", DISPATCH_QUEUE_CONCURRENT);-(id)cacheObjectForKey: (id)key { __block obj; dispatch_sync(_queue, ^{ obj = [_cache objectForKey: key]; }); return obj; } -(void)setCacheObject: (id)obj forKey: (id)key { dispatch_barrier_async(_queue, ^{ [_cache setObject: obj forKey: key]; }); }
遍历时需要修改原数组的时候可以遍历原数组的一个拷贝,如:NSMutableArray *copyArray = [NSMutableArray arrayWithArray:self.items]; for(id item in copyArray) { if (item != self.currentItem) { [self.items removeGuideViewItem:item]; } }
KVO 对同一keypath多次removeObserver
表现
*** Terminating app due to uncaught exception 'NSRangeException', reason: 'Cannot remove an observer <UIView 0x7f9a90f0a0d0> for the key path "frame" from <ViewController 0x7f9a90e07010> because it is not registered as an observer.'
场景
当对同一个keypath进行两次removeObserver时会导致程序crash,这种情况常常出现在父类有一个KVO,父类在dealloc中remove了一次,子类又remove了一次
解决方案:- try&catch(容易掩盖问题)
- 确保addObserver和removeObserver一定要成对出现,推荐使用FaceBook开源的第三方库FBKVOController
- 可以分别在父类以及本类中定义各自的context字符串,比如在本类中定义context为@"ThisIsMyKVOContextNotSuper";然后在dealloc中remove observer时指定移除的自身添加的observer。这样iOS就能知道移除的是自己的KVO,而不是父类中的KVO,避免二次remove造成crash。
KVC相关
表现
*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '[ setNilValueForKey]: could not set nil as the value for the key age.' // 调用setNilValueForKey抛出异常
*** Terminating app due to uncaught exception 'NSUnknownKeyException', reason: '[<ViewController 0x7ff968606ee0> setValue:forUndefinedKey:]: this class is not key value coding-compliant for the key undefined.'//在类中找不到对应的key
场景
- value为nil,如:
[people1 setValue:nil forKey:@"age"]
- 在本类中找不到对应的key,如:
[viewController setValue:@"crash" forKey:@"undefined"];
解决方案
- 重写setNilValueForKey方法
-(void)setNilValueForKey:(NSString *)key{ NSLog(@"不能将%@设成nil",key);}
- 重写setValue:forUndefinedKey:方法
-(void)setValue:(id)value forUndefinedKey:(NSString *)key{ NSLog(@"出现异常,该key不存在%@",key); }
UITableView或者UICollectionView的代理方法中返回空的cell
表现
以UITableView为例:
*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'UITableView (<ContainerTableView: 0x7fec623a6400; baseClass = UITableView; frame = (0 0; 375 567); clipsToBounds = YES; autoresize = W+H; gestureRecognizers = <NSArray: 0x608002844bf0>; layer = <CALayer: 0x60800183eec0>; contentOffset: {0, 0}; contentSize: {375, 2946}>) failed to obtain a cell from its dataSource (<FeedBaseDelegate: 0x600003668540>)'
场景
UITableView
的 cellForRowAtIndexPath
方法或者UICollectionView
的cellForItemAtIndexPath
方法因为异常返回了nil
numberOfRowsInSection
返回的数目不正确,导致行数比cellForRoAtIndexPath预期的多,于是cellForRowAtIndexPath
方法就不能正确返回超出预期的cell了。cellForRowAtIndexPath
中逻辑有误,漏了一些情况,导致有些cell不能正确返回。
解决方案
在相应的代理方法返回之前加一层如果cell为空就返回一个默认cell的兜底保护策略,如:
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { ExploreCellBase * cell = nil; if (!cell) { UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"preventCrashCellIdentifier"]; if (!cell) { cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"preventCrashCellIdentifier"]; } cell.textLabel.text = @""; return cell; } return cell; }