目录
关于集中常见的崩溃场景
发散思维
UI更新一定要在UI线程里实现的原因
崩溃问题分类
信号可捕获崩溃
信号不可捕获崩溃
崩溃日志捕获
信号可捕获崩溃
利用Xcode
第三方工具
捕获原理
信号不可捕获崩溃
后台任务超时崩溃
后台任务超时崩溃原因
如何捕获后台崩溃呢?
其他崩溃
采集到崩溃信息后如何分析并解决崩溃问题
总结
关于集中常见的崩溃场景
- 数组越界:在取数据索引时越界,App 会发生崩溃。还有一种情况,就是给数组添加了 nil 会崩溃。
- 多线程问题:在子线程中进行 UI 更新可能会发生崩溃(不一定都会崩溃,但不建议在其他线程更新UI,这是一个约定俗成的规则)。多个线程进行数据的读取操作,因为处理时机不一致,比如有一个线程在置空数据的同时另一个线程在读取这个数据,可能会出现崩溃情况。
- 主线程无响应:如果主线程超过系统规定的时间无响应,就会被 Watchdog 杀掉。这时,崩溃问题对应的异常编码是 0x8badf00d。关于这个异常编码,我还会在后文和你说明。
- 野指针:指针指向一个已删除的对象访问内存区域时,会出现野指针崩溃。野指针问题是需要我们重点关注的,因为它是导致 App 崩溃的最常见,也是最难定位的一种情况。
发散思维
UI更新一定要在UI线程里实现的原因
1.UI控件操作不加锁:为其加锁则会耗费大量资源并拖慢运行速度
UI的效率(对于UI控件的操作不加锁)和和安全性(都在UI线程操作即加了所谓“伪锁”),以此带来流畅的体验。原因是:
伪锁。
多线程处理UI并没有给我们开发带来更多的便利,假如你代入了这些情景进行思考,你很容易得出一个结论: “我在一个串行队列对这些事件进行处理就可以了。” 苹果也是这样想的,所以UIKit的所有操作都要放到主线程串行执行。
2.整个程序的起点UIApplication是在主线程进行初始化,所有的用户事件都是在主线程上进行传递(如点击、拖动),所以view只能在主线程上才能对事件进行响应
3.在渲染方面由于图像的渲染需要以60帧的刷新率在屏幕上 同时 更新,在非主线程异步化的情况下无法确定这个处理过程能够实现同步更新
崩溃问题分类
信号可捕获崩溃
- KVO问题
- 添加了观察者,但未实现 observeValueForKeyPath:ofObject:change:context: 方法,导致崩溃。(观察者必须遵循的协议,不符合规矩的观察则)
- 观察者对象dealloc了,但是没有removeKVO
- 多次removeKVO
- 添加和remove的次数不一致
- 添加或者移除时 keypath == nil,导致崩溃。
防护方法:
通过runtime特性对addObserver:forKeyPath:options:context:、removeObserver:forKeyPath:方法做替换,避免下面几种情况:
添加观察者时:通过关系哈希表判断是否重复添加,只添加一次。
移除观察者时:通过关系哈希表是否已经进行过移除操作,避免多次移除。
观察键值改变时:同样通过关系哈希表判断,将改变操作分发到原有的观察者上。
详细介绍参考:
- NSNotification线程问题
- 在主线程发送通知
- 注意不要多次添加
- 数组越界
- 野指针
信号不可捕获崩溃
- 后台任务超时
- 内存打爆
- 主线程卡顿超阈值
崩溃日志捕获
信号可捕获崩溃
利用Xcode
就是打开 Xcode 的菜单选择 Product -> Archive。然后,在提交时选上“Upload your app’s symbols to receive symbolicated reports from Apple”,以后你就可以直接在 Xcode 的 Archive 里看到符号化后的崩溃日志了。
第三方工具
但是这种查看日志的方式,每次都是纯手工的操作,而且时效性较差。所以,目前很多公司的崩溃日志监控系统,都是通过PLCrashReporter 这样的第三方开源库捕获崩溃日志,然后上传到自己服务器上进行整体监控的。而没有服务端开发能力,或者对数据不敏感的公司,则会直接使用 Fabric或者Bugly来监控崩溃。
捕获原理
// 表示的是,EXC_BAD_ACCESS 这个异常会通过 SIGSEGV 信号发现有问题的线程。
Exception Type: EXC_BAD_ACCESS (SIGSEGV)
// 虽然信号的种类有很多,但是都可以通过注册 signalHandler 来捕获到。其实现代码,如下所示:
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);
}
上面这段代码对各种信号都进行了注册,捕获到异常信号后,在处理方法 handleSignalException 里通过 backtrace_symbols 方法就能获取到当前的堆栈信息。
堆栈信息可以先保存在本地,下次启动时再上传到崩溃监控服务器就可以了。先将捕获到的堆栈信息保存在本地,是为了实现堆栈信息数据的持久化存储。
那么,为什么要实现持久化存储呢?这是因为,在保存完这些堆栈信息以后,App 就崩溃了,崩溃后内存里的数据也就都没有了。而将数据保存在本地磁盘中,就可以在 App 下次启动时能够很方便地读取到这些信息。
问题:使用 registerSignalHandler 和handleSignalHandler 是能够捕获到异常。 但是,这个时候 app 已经立即要 crash 了。捕获的异常怎么才能确保能够持久化到沙盒呢?写入文件的代码是没法保证在 crash 前正常执行完的吧!
答案:写入数据的时候 - (void)performSelectorOnMainThread:(SEL)aSelector withObject:(nullable id)arg waitUntilDone:(BOOL)wait; waitUntilDone 设置为YES 就是主线程同步执行 执行完才会崩溃
思考:是否还有其他方式?
信号不可捕获崩溃
后台任务超时崩溃
后台任务超时崩溃原因
后台崩溃原因?先来看下后台保活5种方式
- Background Task 方式,是使用最多的。App 退后台后,默认都会使用这种方式。
- 使用 Background Mode 方式的话,App Store 在审核时会提高对 App 的要求。通常情况下,只有那些地图、音乐播放、VoIP 类的 App 才能通过审核。
- Background Fetch 方式的唤醒时间不稳定,而且用户可以在系统里设置关闭这种方式,导致它的使用场景很少。
- Silent Push 是推送的一种,会在后台唤起 App 30 秒。它的优先级很低,会调用 application:didReceiveRemoteNotifiacation:fetchCompletionHandler: 这个 delegate,和普通的 remote push notification 推送调用的 delegate 是一样的。
- PushKit 后台唤醒 App 后能够保活 30 秒。它主要用于提升 VoIP 应用的体验。
大概10s)的时间可以执行代码,接下来就会被系统挂起。进程挂起后所有线程都会暂停,不管这个线程是文件读写还是内存读写都会被暂停。但是,数据读写过程无法暂停只能被中断,中断时数据读写异常而且容易损坏文件,所以系统会选择主动杀掉 App 进程。
而 Background Task 这种方式,就是系统提供了 beginBackgroundTaskWithExpirationHandler 方法来延长后台执行时间,可以解决你退后台后还需要一些时间去处理一些任务的诉求。
- (void)applicationDidEnterBackground:(UIApplication *)application {
self.backgroundTaskIdentifier = [application beginBackgroundTaskWithExpirationHandler:^( void) {
[self yourTask];
}];
}
如果 yourTask 在 3 分钟之内没有执行完的话,系统会强制杀掉进程,从而造成崩溃,这就是为什么 App 退后台容易出现崩溃的原因。
如何捕获后台崩溃呢?
怎么去收集退后台后超过保活阈值而导致信号捕获不到的那些崩溃信息呢?
采用 Background Task 方式时,我们可以根据 beginBackgroundTaskWithExpirationHandler 会让后台保活 3 分钟这个阈值,先设置一个计时器,在接近 3 分钟时判断后台程序是否还在执行。如果还在执行的话,我们就可以判断该程序即将后台崩溃,进行上报、记录,以达到监控的效果。(也就是判断后台任务执行是否超过阈值)
其他崩溃
其他捕获不到的崩溃情况还有很多,主要就是内存打爆和主线程卡顿时间超过阈值被 watchdog 杀掉这两种情况。
先要找到它们的阈值,然后在临近阈值时还在执行的后台程序,判断为将要崩溃,收集信息并上报。
采集到崩溃信息后如何分析并解决崩溃问题
我们采集到的崩溃日志,主要包含的信息为:进程信息、基本信息、异常信息、线程回溯。
- 进程信息:崩溃进程的相关信息,比如崩溃报告唯一标识符、唯一键值、设备标识;
- 基本信息:崩溃发生的日期、iOS 版本;
- 异常信息:异常类型、异常编码、异常的线程;
- 线程回溯:崩溃时的方法调用栈
通常情况下,我们分析崩溃日志时最先看的是异常信息,分析出问题的是哪个线程,在线程回溯里找到那个线程;然后,分析方法调用栈,符号化后的方法调用栈可以完整地看到方法调用的过程,从而知道问题发生在哪个方法的调用上。
一些被系统杀掉的情况,我们可以通过异常编码来分析。你可以在维基百科上,查看完整的异常编码。这里列出了 44 种异常编码,但常见的就是如下三种:
- 0x8badf00d,表示 App 在一定时间内无响应而被 watchdog 杀掉的情况。
- 0xdeadfa11,表示 App 被用户强制退出。
- 0xc00010ff,表示 App 因为运行造成设备温度太高而被杀掉。
除了崩溃日志外,崩溃监控平台还需要对所有采集上来的日志进行统计。我以腾讯的 Bugly 平台为例,和你一起看一下崩溃监控平台一般都会记录哪些信息,来辅助开发者追溯崩溃问题。
总结
现有的崩溃监控系统,不管是开源的崩溃日志收集库还是类似 Bugly 的崩溃监控系统,离最优解都还有一定的距离。
这个“非最优”,我们需要分两个维度来看:一个维度是,怎样才能够让崩溃信息的收集效率更高,丢失率更低;另一个维度是,如何能够收集到更多的崩溃信息,特别是系统强杀带来的崩溃。
随着 iOS 系统的迭代更新,强杀阈值和强杀种类都在不断变化,因此崩溃监控系统也需要跟上系统迭代更新的节奏,同时还要做好向下兼容。