iOS调试篇(二)——崩溃捕获篇
1 崩溃信息分类
崩溃信息有的能通过信号捕获到,有的不可以。下图列出了常见的部分崩溃情况:
常见部分崩溃情况分类.png
通过图片可以看出:KVO问题,NSNotification线程问题、数组越界、野指针等崩溃信息是可以通过信号捕获的,但后台任务超时、内存打爆、主线程卡顿超阈值等信息是无法通过信号捕捉的。
2 信号可捕获崩溃日志收集
Signal信号类型:
- SIGABRT--程序中止命令中止信号
- SIGALRM--程序超时信号
- SIGFPE--程序浮点异常信号
- SIGILL--程序非法指令信号
- SIGHUP--程序终端中止信号
- SIGINT--程序键盘中断信号
- SIGKILL--程序结束接收中止信号
- SIGTERM--程序kill中止信号
- SIGSTOP--程序键盘中止信号
- SIGSEGV--程序无效内存中止信号
- SIGBUS--程序内存字节未对齐中止信号
- SIGPIPE--程序Socket发送失败中止信号
通过信号注册来捕获崩溃信息的代码可参看如下。下面的代码对各种信号都进行了注册,捕获到异常信号后,在处理方法handleSignalException里通过backtrace_symbols方法获取当前堆栈的信息。堆栈信息可暂时保留在本地(App崩溃后内存数据就丢失了),下次启动时上传到崩溃监控服务器。
void registerSignalHandler(void) {
signal(SIGSEGV, handleSignalException);
signal(SIGFPE, handleSignalException);
signal(SIGBUS, handleSignalException);
signal(SIGPIPE, handleSignalException);
signal(SIGHUP, handleSignalException);
signal(SIGINT, handleSignalException);
signal(SIGQUIT, handleSignalException);
signal(SIGABRT, handleSignalException);
signal(SIGILL, handleSignalException);
}
void handleSignalException(int signal) {
NSMutableString *crashString = [[NSMutableString alloc]init];
void* callstack[128];
int i, frames = backtrace(callstack, 128);
char** traceChar = backtrace_symbols(callstack, frames);
for (i = 0; i <frames; ++i) {
[crashString appendFormat:@"%s\n", traceChar[i]];
}
NSLog(crashString);
}
3 信号捕获不到的崩溃信息收集
3.1 后台崩溃
退后台是会把关键业务数据保存在内存中,但保存过程中就出现了崩溃就会丢失数据或损坏关键数据,进而数据损坏又会导致应用不可用。可App退到后台后容易被系统强杀,而且系统强杀抛出的信号由于系统限制还不可捕获。首先看看iOS后台保活的5种方式:
- Background Mode:只有地图、音乐播放、VoIP(网络电话)等App才可以开启此权限
- Background Fetch:唤醒时间不稳定,用户可在系统设置关闭这种方式,因此它的使用场景很少
- Silent Push:会在后台唤起App30秒,优先级很低,会调用application:didReceiveRemoteNotifiacation:fetchCompletionHandler:这个delegate,和普通的remote push notification推送调用的delegate是一样的。
- PushKit:后天唤醒App后能够保活30秒,主要用于提示VoIP应用体验。
- Background Task:使用最多的,App退后台后,默认都会使用这种方式,向系统多争取一段时间来完成退后台后还需要一些时间去处理一些任务。当App退到后台后,只有几秒时间可以执行代码,接下来就会被系统挂起。进程挂起后所有线程都会暂停,无论是文件读写还是内存读写都会被暂停。但是数据读写过程无法暂停只能被中断,中断时易出现读写异常且损坏文件,所以系统会主动杀掉App进程。
Background Task的使用方法如下:
- (void)applicationDidEnterBackground:(UIApplication *)application {
self.backgroundTaskIdentifier = [application beginBackgroundTaskWithExpirationHandler:^( void) {
[self yourTask];
}];
}
这段代码中,yourTask最多执行三分钟,三分钟内运行完成就可以挂起,如果没有执行完成就会被强杀从而造成崩溃,这就是为什么App退后台易出现崩溃的原因。
如何避免后台崩溃呢?我们知道App退后台后执行时间过长就会被强杀,若要避免首先要严格控制后台读写操作。
- 比如可判断需要处理的数据的大小,如果数据过大在系统限制时间甚至是延长后台执行时间也处理不完则可以考虑程序下次启动或后台唤醒时再处理。
- 采用Background Task方式,设计一个定时器在接近3分钟阈值时判断后台程序是否还在执行,如果还在执行可判断程序即将后台崩溃,进行上报、记录已达到监控的效果。
3.2 主线程卡顿超过阈值
我们所说的卡顿问题,就是在主线程上无法响应用户交互。导致卡顿的几个主要原因:
- 复杂UI、图文混排绘制量过大
- 在主线程上做网络同步请求
- 在主线程做大量的IO操作
- 运算量过大,CPU持续高占用
- 死锁和主、子线程抢锁
一般不推荐通过监视FPS来确定是否出现卡顿,一般推荐通过监控主线程RunLoop的状态来判断是否出现卡顿。回忆一下RunLoop可以参看iOS多线程--RunLoop。
再次贴出RunLoop运行过程图:
RunLoop在以下几个状态中切换,当进入睡眠前方法(kCFRunLoopAfterWaiting)和线程唤醒后接收消息时间过长(kCFRunLoopBeforeSources)却不能进入下一步,就可以认为是线程受阻。如果这个线程是主线程,则表现出来就是卡顿。
typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
kCFRunLoopEntry , // 进入 loop
kCFRunLoopBeforeTimers , // 触发 Timer 回调
kCFRunLoopBeforeSources , // 触发 Source0 回调
kCFRunLoopBeforeWaiting , // 等待 mach_port 消息
kCFRunLoopAfterWaiting ), // 接收 mach_port 消息
kCFRunLoopExit , // 退出 loop
kCFRunLoopAllActivities // loop 所有状态改变
}
实现关键代码如下:
dispatchSemaphore = dispatch_semaphore_create(0); //Dispatch Semaphore保证同步
//创建一个观察者
CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL};
runLoopObserver = CFRunLoopObserverCreate(kCFAllocatorDefault,
kCFRunLoopAllActivities,
YES,
0,
&runLoopObserverCallBack,
&context);
//将观察者添加到主线程runloop的common模式下的观察中
CFRunLoopAddObserver(CFRunLoopGetMain(), runLoopObserver, kCFRunLoopCommonModes);
//创建子线程监控
dispatch_async(dispatch_get_global_queue(0, 0), ^{
//子线程开启一个持续的loop用来进行监控
while (YES) {
long semaphoreWait = dispatch_semaphore_wait(dispatchSemaphore, dispatch_time(DISPATCH_TIME_NOW, 20*NSEC_PER_MSEC));
if (semaphoreWait != 0) {
if (!runLoopObserver) {
timeoutCount = 0;
dispatchSemaphore = 0;
runLoopActivity = 0;
return;
}
//两个runloop的状态,BeforeSources和AfterWaiting这两个状态区间时间能够检测到是否卡顿
if (runLoopActivity == kCFRunLoopBeforeSources || runLoopActivity == kCFRunLoopAfterWaiting) {
//出现三次出结果
// if (++timeoutCount < 3) {
// continue;
// }
NSLog(@"monitor trigger");
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
// [SMCallStack callStackWithType:SMCallStackTypeAll];
});
} //end activity
}// end semaphore wait
timeoutCount = 0;
}// end while
});
static void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info){
SMLagMonitor *lagMonitor = (__bridge SMLagMonitor*)info;
lagMonitor->runLoopActivity = activity;
dispatch_semaphore_t semaphore = lagMonitor->dispatchSemaphore;
dispatch_semaphore_signal(semaphore);
}
3.3 内存被打爆
iOS系统会开启优先级最高的线程vm_pressure_monitor来监控系统的内存压力情况,并通过一个堆栈来维护所有App的进程。另外,iOS系统还会维护一个内存快照表,用于保存每个进程内存页的消耗情况。
当vm_pressure_monitor发现某App有内存压力了,就发出通知,内存有压力的App就会去执行对应的didReceiveMemoryWarning代理,通过这个代理,你可以获得最后一个编写逻辑代码释放内存的机会。这段代码的执行,就可能会避免你的App被系统强杀。
系统在强杀App前,会做优先级判断。判断依据是:
- 内核用线程的优先级最高,操作系统优先级其次,App的优先级排在最后。
- 前台App程序的优先级高于后台运行的App
- CPU占用多的线程优先级会被降低
一些内存打爆的监控思路:
- 在接收到didReceiveMemoryWarning代理时打印出app内存的占用,以及内存的分配情况,了解到是哪些占用了大量内存
- hook malloc_logger(这个后续还有待补充更加详细的内容)
4 崩溃信息收集
采集崩溃日志主要需要包含以下信息:
- 进程信息:崩溃进程的相关信息,比如崩溃报告的唯一标识符、唯一键值、设备标识
- 基本信息:崩溃发生的日期,iOS版本
- 异常信息:异常类型、异常编码、异常线程
-
线程回溯:崩溃时线程的方法调用栈
方法调用栈展示图.png
一些被系统杀掉的情况,可通过异常编码来分析,最常见的异常编码如下:
- 0x8badf00d:App在一定时间内无响应被watchdog杀掉(也就是我们所说的主线程被卡)
- 0xdeadfa11:用户强制退出(无需关注)
- 0xc00010ff:设备运行温度太高而被杀掉(针对CPU进行针对性检查和优化)