五一假期的第一天,闲来无事,今天来聊一聊 iOS 中的 Crash 捕获与防护。
前言
工程师们苦 Crash 久矣,尤其是用户感知最为明显的客户端。那居高不下的崩溃率、数不胜数的用户反馈、迟迟无法完成的 KPI,折磨着每一位客户端的开发同学。
加断点再Debug,堆栈瞬间就爆炸,日志输出如雨下,看到异常就害怕;调试一夜没人陪,心想这锅该归谁?回想当初心后悔,不该重构这地雷;翻日志查半天,博客看了千百遍,低头又点一根烟,闪退还是没复现。
上面这段文字很形象的描述了一位深夜排查闪退问题的工程师。那么 Crash 为何如此难以解决且反复发作,它究竟难在哪里,从客户端工程诞生至今一直困扰着无数的工程师。
主动出击
进攻是最好的防守。
在工作中,你不能总是守株待兔,期待 Crash 自己找上门来,主动躺在你的 Todolist 里。你需要在问题大面积爆发前,提前感知,迅速解决,就像碟中谍里的汤姆斯·布鲁克,在悄然无息间拯救地球,深藏功与名。
我们需要一套 Crash 主动防护机制,来帮助工程师们快速处理这些问题。
防护的核心是定位,定位问题的前提是能够发现问题,首先需要做的是捕获到异常然后将其上报。
现存问题
市面上能够找到的统计崩溃等异常的 SDK 非常多,友盟、Bugly、GrowingIO 等等,他们通常都是将捕获到的信息日志上传到对应的服务器上,以此来做一些后续的统计和分析。在一些规模较小的团队当中,无法面面俱到,直接引用这些 SDK 是可以接收的,但如果你的团队对应用性能有一定要求,那么这些 SDK 就会显得有些 “不够用” 了。
作者对市面上主流的统计 SDK 都有过较长时间的使用经验,发现无论哪一家 SDK 都有或多或少存在着两个通病:上报延迟 和 定位表述模糊。
我们要在用户感知之前提前发现问题,就需要在研发和提测阶段尽可能早的暴露出问题,这样一来可以在崩溃现场或第一时间捕获崩溃信息,然后将崩溃堆栈信息及时反馈给我们的工程师,也会大大提高测试和开发之前的沟通效率。
由于上述 SDK 存在的问题,所以这部分工作就需要我们自己来进行补充。
Crash 捕获
我们需要先了解 iOS 发生崩溃的底层原理,下面是 Crash 捕获处理系统几个核心的关键领域知识。
- UNIX signals 信号
- Mach Exceptions Mach异常
- Basic Thread details 线程现场信息
- Binary image information 动态库信息
- Frame pointer-based stack traces 基于 fp 的 callstack 回溯
- Compact Unwind support 辅助 callstack 回溯的 Compact Unwind 信息
在这里我们主要是针对 Mach异常 和 UNIX信号 的处理。
异常处理流程
首先要明确几个概念及其之间的联系:硬件异常
, 软件异常
,mach异常
, Signal异常
。这四种异常概念,自底向上构建了iOS系统的异常处理模型。
其之间的关系如下:
Mach异常与Signal
iOS 中的 Crash 主要分为Mach Exception
、Singal
、NSException
三种类型,每一种类型的 Crash 都处在不同的系统层级上,也有各自不同的捕获方式。
Mach Exception
Mach是一个XNU的微内核核心,Mach异常是指最底层的内核级异常,被定义在 <mach/exception_types.h>
下 。Mach异常由CPU陷阱引发,在异常发生后会被异常处理程序转换成Mach消息,接着依次投递到thread
、task
和host
端口。
如果没有上述任何一个端口来处理这个异常并返回KERN_SUCCESS
,那么应用将被终止。每个端口都拥有一个异常端口数组,系统暴露了后缀为_set_exception_ports
的多个 API 让我们注册对应的异常处理到端口中,用以来捕获Mach异常,抓取Crash事件。
在Mach中,异常是通过内核中的主要设施消息传递机制进行处理的。一个异常与一条消息并无差别,由出错的线程或任务(通过 msg_send()
)发送,并通过一个处理程(通过 msg_recv()
)接收。
由于Mach的异常以消息机制处理而不是通过函数调用,exception messages可以被转发到先前注册的Mach exception处理程序。这意味着你可以插入一个exception处理程序,而不干扰现有的无论是调试器或Apple’s crash reporter。可以使用mach_msg() // flag MACH_SEND_MSG
发送原始消息到以前注册的处理程序的Mach端口,将消息转发到一个现有的处理程序。
综上所述,基于 mach message 机制, 可以
- 通过注册端口监听异常消息(可以为 host, task, thread 注册异常处理端口)
- 发送异常消息
Mach异常与Signal的转换
Mach异常如果不处理,默认会转化为Signal异常(所有Mach异常都默认在host
层被ux_exception
转换为相应的Signal,并通过threadsignal
将信号投递到出错的线程)。如:EXC_BAD_ACCESS(SIGSEGV)
表示的意思就是:Mach层的EXC_BAD_ACCESS异常,在host层被转换成SIGSEGV信号投递到出错的线程。
其中内核注册了host-level的exception handler,负责将mach异常转换为对应的Signal信号。
1 | /* Called with kernel funnel held */ |
Mach异常信号的来源,主要通过两种途径:
- 硬件级别的触发异常
- proc退出时会触发异常(EXC_CRASH)。
Mach异常与软硬件异常
硬件异常触发流程
硬件异常会转化为Mach异常
1 | void |
1 | void |
软件异常
处理流程如下图所示:
通过kill() 直接抛出signal异常,这里需要注意,网上有些观点认为
因为硬件产生的信号(通过CPU陷阱)被Mach层捕获,然后才转换为对应的Unix信号;苹果为了统一机制,于是操作系统和用户产生的信号(通过调用kill和pthread_kill)也首先沉下来被转换为Mach异常,再转换为Unix信号。
软件异常的处理流程:abort()-> kill()/pthread_kill信号-> Mach异常-> Unix信号(SIGABRT)
也就是说,软件异常与硬件异常相同,都是先转为Mach异常,再转换成的Signal。
但这个观点经过验证后发现是站不住脚的,如果按照此观点所说,那么我们在软件异常发生时,只实现mach exceptiona捕获,是可以抓到abort的;但如果你去试验一下会发现是抓不到abort的,这也就意味着在软件异常发生后根本就没有走mach exceptiona的流程。
软件产生的信号来自kill()
、pthread_kill()
两个函数的调用,大概过程是这样的:kill()
/pthread_kill()
–> ...
–> psignal_internal()
–> act_set_astbsd()
。最终也会调用act_set_astbsd()
发送信号到目标线程。
abort()的源码也印证了这一点:
1 | __private_extern__ void |
可以看到,在abort()源码注释着:<rdar://problem/7397932> abort() should call pthread_kill to deliver a signal to the aborting thread
, 它是这样调用的
1 | (void)pthread_kill(pthread_self(), SIGABRT); |
这里也可以看出,软件异常并不是转换为Mach再转换到Signal,而是直接转换为Signal的。
EXC_CRASH
为何以像《漫谈 iOS Crash 收集框架》这些文章会得出“首先沉下来被转换为Mach异常,再转换为Unix信号”这样的结论呢?我想大概是被 EXC_CRASH
所误导了。
系统通过launchd监听了EXC_CRASH。而 EXC_CRASH
是一种特殊类型,囊括硬件和软件异常,它什么都能抓。但是因为信号发出的时候,进程已经跪了,所以需要 out-of-process 处理。
看进程退出逻辑,EXC_CRASH 基本上会囊括所有的崩溃类型。
1 | void |
可以看到,在通知 Apple’s Crash Reporter 的时候,会把所有类型的的mach exception type都记为EXC_CRASH,自然也包括了SIGABRT
1 | Exception Type: EXC_CRASH (SIGABRT) |
所以,软/硬件异常最终都会转化为Signal信号,但他们的处理流程是不同的。
Jetsam
MacOS/iOS是一个从BSD衍生而来的系统。其内核是Mach,但是对于上层暴露的接口一般都是基于BSD层对于Mach包装后的。虽然说Mach是个微内核的架构,真正的虚拟内存管理是在其中进行,但是BSD对于内存管理提供了相对较为上层的接口,同时,各种常见的JetSam事件也是由BSD产生。
关于Jetsam,可能有些人还不是很理解。我们可以从手机设置->隐私->分析这条路径看看系统的日志,会发现手机上有许多JetsamEvent
开头的日志。打开这些日志,一般会显示一些内存大小,CPU时间什么的数据。
之所以会发生这么JetsamEvent,主要还是由于iOS设备不存在交换区导致的内存受限,所以iOS内核不得不把一些优先级不高或者占用内存过大的杀掉。这些JetsamEvent
就是系统在杀掉App后记录的一些数据信息。
从某种程度来说,JetsamEvent是一种另类的Crash事件,但是在常规的Crash捕获工具中,由于iOS上能捕获的信号量的限制,所以因为内存导致App被杀掉是无法被捕获的。
篇幅有限,这里不做过多介绍,感兴趣可以自行了解。
Mach异常与Signal的选择
该捕获谁?
捕获Mach异常或者Signal都可以抓到Crash事件,这两种方式哪个更好呢?
理论上优选Mach异常,因为Mach异常处理会先于Signal处理发生,如果Mach异常的handler让程序exit了,那么Signal就永远不会到达这个进程了。
为何微软的著名框架PLCrashReporter会放弃捕捉Mach异常,而选择与之对应的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.
Signal的捕获
从上面的结论可以看出,尽管Mach exception handle 比 UNIX signal handle 更有优势,但我们还是须要注册signal handle用于处理EXC_SOFTWARE/EXC_CRASH。
既然异常最终都会转换为Signal信号,那么我们可以将如何捕获Mach Exception
的注意力转移到如何捕获Signal
上了。
UNIX signals是一套基于POSIX标准
开发的通信机制,POSIX API 就是通过 Mach 之上的 BSD 层实现的。
在signal.h
中声明了32种异常信号,以下六种为iOS常见的信号,它们均会导致程序崩溃。
信号 | 说明 |
---|---|
SIGILL | 执行了非法指令,一般是可执行文件出现了错误 |
SIGTRAP | 断点指令或者其他trap指令产生 |
SIGABRT | 调用abort产生 |
SIGBUS | 非法地址。比如错误的内存类型访问、内存地址对齐等 |
SIGSEGV | 非法地址。访问未分配内存、写入没有写权限的内存等 |
SIGFPE | 致命的算术运算。比如数值溢出、NaN数值等 |
Signal可以通过注册信号处理函数来捕获:
1 | // 装有6种常见信号的信号数组 |
Signal的调试
NSException异常的捕获
NSException
就属于我们前面所说的软件异常。它是应用级异常,发生在CoreFoundation
以及更高抽象层级,会通过__cxa_throw
函数抛出异常。如果没有人为进行捕获或者在捕获回调函数中没有进行操作终止应用,那么最终会通过abort()
函数来向进程抛出一个SIGABRT
的信号。
NSException
可以通过@try-@catch
机制捕获,以此来避免应用Crash。同样地,如果没有catch处理,那么会被系统自带的错误处理所捕获,这个时候可以通过注册NSUncaughtExceptionHandler
来捕获NSException异常。
1 | // 注册NSUncaughtExceptionHandler |
避免覆盖
如文章开头所提,我们的工程里可能已经包含一个或多个第三方的统计SDK,他们大多数都是基于NSUncaughtExceptionHandler
进行崩溃收集。那么这个时候,一个不可避免的问题就产生了:由于NSSetUncaughtExceptionHandler
函数存在覆盖现象,后注册的总会顶替掉前面注册的,当Crash发生时,永远只会触发最后注册传入的捕获回调函数。
而各家的SDK都会以保证自己的Crash统计正确完整为目的,难免出现强行覆盖等等的恶意竞争,就可能导致在其之前注册过的日志收集服务写出的Crash日志因为取不到NSException而丢失Last Exception Backtrace等重要信息。
所以正确的作法是:总是通过NSGetUncaughtExceptionHandler将之前别人注册的handler取出并备份(因为你可能并不清楚自己真正的注册顺序,所以最好每次都这么做),在自己handler处理完后记得把别人的handler注册回去,形成规范的SOP。
1 | // 记录之前的Crash回调函数(如果有的话) |
关于断点调试
Signal
因为Xcode屏蔽了Signal的回调,我们需要在lldb
中输入以下命令,Signal的回调才可以进来
1 | pro hand -p true -s false SIGABRT |
NSSetUncaughtExceptionHandler
在开发测试阶段,可以利用 fishhook 框架去hook NSSetUncaughtExceptionHandler
方法,这样就可以清晰的看到handler的传递流程断在哪里,快速定位污染环境者。不推荐利用调试器添加符号断点来检查,原因是一些Crash收集框架或统计SDK在调试状态下是不工作的。
题外话
在调试阶段我们可能会看到许多crash callstack信息,下面列了几个常见的对应关系:
地址 | 说明 |
---|---|
0x8badf00d | 在启动、终止应用或响应系统事件花费过长时间,意思是”ate bad food”。 |
0xdeadfa11 | 用户强制退出,意为”dead fall”。(系统无响应时,用户按电源开关和HOME) |
0xbaaaaaad | 用户按住Home键和音量键,获取当前内存状态,不代表崩溃 |
0xbad22222 | VoIP应用因为恢复得太频繁导致Crash |
0xc00010ff | 因为太烫了被干掉,意为”cool off” |
0xdead10cc | 因为在后台时仍然占据系统资源(比如通讯录)被干掉,意为”dead lock” |
线程保活提醒
按照我们之前所说,需要在崩溃现场或第一时间捕获崩溃信息,然后将崩溃堆栈信息及时反馈给我们的工程师。但是崩溃发生后,程序在完成回调后会立刻被杀死,在被杀死后无法进行任何后续操作,那么该怎么做呢?
利用Runloop
我们可以像这样创建一个Runloop
,将主线程的所有Runmode
都拿过来跑,作为应用程序主Runloop的替代。
1 | CFRunLoopRef runLoop = CFRunLoopGetCurrent(); |
这样固然可以实现我们想要做的事情,但是会带来一个问题:因为我们为了继续执行程序而没有将控制权返回给导致崩溃的调用函数,并且我们启动了自己的Runloop
,所以永远不会返回到原始的Runloop
中去了,这将意味着导致异常的线程使用的堆栈内存
将永久泄漏。因此这种类型的方法应被视为调试工具或最后手段,所以,不要在Debug以外的环境使用它。
1 | void InstallUncaughtExceptionHandler(void) { |
1 | void HandleException(NSException *exception) { |
1 | void SignalExceptionHandler(int signal) { |
1 | + (NSArray *)backtrace { |
1 | - (void)handleException:(NSException *)exception { |
Crash 定位
前面我们已经将Crash的上报工作做完了,也就是解决了第一个问题: 发现问题。问题既然被发现,那么就必须要被解决,一个Crash如何保证得到处理,需要准确地对Crash行为进行复现,也就是回到案发现场,场景再现。
有些Crash比较容易复现,比如UI操作类、数组越界等,只要定位到具体的类,很快就能复现出崩溃,这种问题就比较好解决,我们称之为可稳定复现。既然有可以稳定复现的Crash,相对的也存在不那么稳定复现的Crash,它需要特定的条件才得以触发。比如同样的数据,在32位机器下的某个操作场景下就会Crash,而在64位机器下就无事发生,像这种问题我们就称为不稳定复现。
像上述提到的两种问题,还都属于可复现的范畴内,通过排查各种客观因素,还是有很大概率能够复现出来的,这些都算不上定位上的难点。而真正难以复现的问题,往往是使用者无法直接感知到原因的,它可能在A页面发生闪退,重启后A没事了缺又在B页面闪退,令人头疼不已。这类问题往往都发生在内存上,最臭名昭著的就是野指针,像这种极难复现的问题,它需要工程师花费大量的时间和精力去排查,着手分析,不仅解决效率极低,且难以稳定暴露,最痛苦的是它往往就是你CrashList里出现频率最高的问题,那么有没有办法可以治它呢,答案是:有。
野指针
当所指向的对象被释放或者收回,但是对该指针没有作任何的修改,以至于该指针仍旧指向已经回收的内存地址,此情况下该指针便称野指针。
为什么线上的野指针问题层出不穷,我们在测试阶段都干嘛了?按理说测试阶段该跑的case都跑了,如果有问题早就有了,为什么就没有被发现呢?这个问题还真问题对了,它往往就是没有在测试阶段被发现,它的出现有太多的不确定性,不是依靠简单的提高测试覆盖率就能解决的。即使你跑进了有问题的逻辑,但是野指针指向的地址并不一定会导致Crash
为什么不必现
野指针是指,指向一个已删除
的对象或未申请访问受限
的内存的指针。我们这里主要说的是objc对象释放后指针未置空所导致的野指针。
既然是访问已经释放的对象为什么不是必现呢?
这是 iOS 内存管理方式所造成的,当析构执行(dealloc)后,只是告诉系统,这片内存我不用了,而系统并没有就让这片内存真的不能访问。
比如说下面这几种场景
对象释放后内存没被改动过,原来的内存保存完好,可能不Crash或者出现逻辑错误(随机Crash)。
对象释放后内存没被改动过,但是它自己析构的时候已经删掉某些必要的东西,可能不Crash、Crash在访问依赖的对象比如类成员上、出现逻辑错误(随机Crash)。
对象释放后内存被改动过,写上了不可访问的数据,直接就出错了很可能Crash在objc_msgSend上面(必现Crash,常见)。
对象释放后内存被改动过,写上了可以访问的数据,可能不Crash、出现逻辑错误、间接访问到不可访问的数据(随机Crash)。
对象释放后内存被改动过,写上了可以访问的数据,但是再次访问的时候执行的代码把别的数据写坏了,遇到这种Crash只能哭了(随机Crash,难度大,概率低)。
对象释放后再次release(几乎是必现Crash,但也有例外,很常见)。
野指针定位
目前有两种主要的方法来进行野指针定位
- 通过
free函数
来进行野指针定位 - 通过
dealloc
函数来进行野指针定位
通过free函数
通过fishhook
替换C函数的free方法为自身方法safe_free,就类似runtime方法交换。
1 | bool init_safe_free() { |
然后在safe_free
方法中对已经释放变量
的内存,填充0x55
,使已经释放变量不能访问,从而使某些野指针从不必现Crash变成了必现。
1 | void safe_free(void *p) { |
这里之所以填充为0x55
是因为Xcode的僵尸对象( Zombie Object)填充的就是0x55
。
如果填充为像0x22
这样的数据也是可以,因为之前这里是存储的是一个对象,这个对象被数据覆盖了,当你调用方法的时候,数据无法响应对应的方法
,因此也会导致崩溃。
但是由于填充了0x55的内存地址很可能被新的数据内容填充,使得野指针的crash又变得不必现。
例如下面这种情况:
1 | UIView *testObj = [[UIView alloc] init]; |
没有发生Crash可不是好事,因为这种情况如果后续再Crash,问题就非常难查,因为你看到的Crash栈很可能和出错的代码完全没有关联。既然这个问题这么棘手,最好还是和之前一样,让这个Crash提前暴露。
为了防止上面这种情况,我们干脆就不释放这片内存了。也就是当free被调用的时候我们不真的调用free,而是自己保留着内存,这样系统不知道这片内存已经不需要用了,自然就不会被再次写上别的数据。
1 | struct DSQueue* _unfreeQueue = NULL;//用来保存自己偷偷保留的内存:1这个队列要线程安全或者自己加锁;2这个队列内部应该尽量少申请和释放堆内存。 |
为了防止系统内存过快耗尽,我们需要在自己保留的内存大于一定值的时候就释放一部分,防止被系统杀死。同时在系统内存警告的时候,也要释放一部分内存。
1 | //系统内存警告的时候调用这个函数释放一些内存 |
但是如果只是对已经释放的对象内存空间填充为0x55,这样发生Crash的时候,我们得到的崩溃信息非常有限,但对于崩溃信息,我们肯定希望知道更具体一点:比如是哪个类,调了什么方法,对象的地址之类。
为了解决上述的问题,我们需要引入一个继承自NSProxy的代理类,同时它持有一个originClass,重写消息转发的三个方法以及NSObject的实例方法,来进行异常信息的打印。
1 | - (BOOL)respondsToSelector: (SEL)aSelector |
因为NSProxy只能作为Objc对象的代理,所以safe_free函数需要添加判断。
1 | void safe_free(void* p){ |
通过dealloc函数
通过objc的runtime方法进行方法交换,交换了根类的NSObject和NSProxy的dealloc方法为originalDeallocImp。
1 | NSMutableDictionary *deallocImps = [NSMutableDictionary dictionary]; |
为了避免 内存空间释放之后被复写造成野指针问题,通过字典_rootClassDeallocImps存储被释放的对象,同时设置在30秒之后调用dealloc方法将存储的对象释放,避免内存空间的增大。
1 | static dispatch_once_t onceToken; |
也同样为了获取更多的崩溃信息采用了继承自NSProxy类的代理类的来进行消息转发,重写消息转发方法以及内存管理相关的方法。
因为objc内部还有一些底层的类,这些类我们项目中一般不涉及,因此不会是这些类造成野指针,就可以通过白名单的机制,放弃对这些类的dealloc方法的捕获。
1 | static inline NSMutableSet *__lxd_sniff_white_list() { |
通过free函数
来进行野指针定位
- 优点: 覆盖范围广,覆盖了objc、C++、C函数,对于iOS项目适用于混编的工程。
- 缺点: 想要获得具体的
崩溃信息
,还是需要进行objc对象的判断,同时free函数
的覆盖范围广,也会造成一定性能的损耗,毕竟我们在safe_free
中添加了一些判断。
通过dealloc
函数来进行野指针
定位
- 优点: 针对objc语言,利用objc的
方法交换
、消息转发
等特性,对于iOS 项目来说更具有针对性
和可扩展性
。 - 缺点: 相对作用范围较小
Crash 防护
在这个阶段需要做的事情主要就是问题修复和防止再次发生了,我们会列举几种 iOS 中常见的崩溃场景,并给出解决方案和相应的防护措施。
找不到方法的实现unrecognized selector sent to instance
常见场景:
- 没有实现代理
- 可变属性使用copy修饰
- 低版本系统使用高版本API
原因:由于找不到方法 iOS 系统抛出异常导致崩溃。
解决方案:
尽量避免使用
performSelector
一系列方法delegate
方法调用前进行respondsToSelector
判断,或者Release模式下使用ProtocolKit给协议添加默认实现防止崩溃,Debug模式下关闭默认实现使用
高版本的系统方法
的时候做判断可变属性(如
NSMutableArray
),不要使用copy
修饰,或者重写set
方法,我们也可以通过 LLVM & Clang 编写 Xcode 插件来及时提醒工程师对修饰符的使用没有实现代理,可以给 NSObject 添加一个分类,实现
消息转发
的几个方法,以此来规避 Crash 行为。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
if ([self respondsToSelector:aSelector]) {
// 已实现不做处理
return [self methodSignatureForSelector:aSelector];
}
return [NSMethodSignature signatureWithObjCTypes:"v@:"];
}
- (void)forwardInvocation:(NSInvocation *)anInvocation {
NSLog(@"在 %@ 类中, 调用了没有实现的实例方法: %@ ", NSStringFromClass([self class]), NSStringFromSelector(anInvocation.selector));
}
+ (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
if ([self respondsToSelector:aSelector]) {
// 已实现不做处理
return [self methodSignatureForSelector:aSelector];
}
return [NSMethodSignature signatureWithObjCTypes:"v@:"];
}
+ (void)forwardInvocation:(NSInvocation *)anInvocation {
NSLog(@"在 %@ 类中, 调用了没有实现的类方法: %@ ", NSStringFromClass([self class]), NSStringFromSelector(anInvocation.selector));
}
KVC造成的crash
常见场景:
- 对象不支持KVC
- key为nil
- key不是object的属性产生的crash
原因:给不存在的key(包括key为nil)设置value
解决方案:
如果属性存在,利用iOS的反射机制来规避,
NSStringFromSelector(@selector())
将SEL
反射为字符串作为key。这样在@selector()
中传入方法名的过程中,编译器会有合法性检查,如果方法不存在或未实现会报黄色警告重写类的
setValue:forUndefinedKey:
和valueForUndefinedKey:
1
2
3
4
5
6
7-(void)setValue:(id)value forUndefinedKey:(NSString *)key{
}
-(id)valueForUndefinedKey:(NSString *)key{
return nil;
}
EXC_BAD_ACCESS
常见场景:
基本都是悬垂指针/野指针问题
- 访问没有实现的blcok
- 对象没有被初始化
- 访问的对象已经被释放掉
- 循环引用引起的内存泄露
- unsafe_unretained修饰的对象释放后,不会自动置nil,变成野指针
- 应该使用strong/weak修饰的对象,却错误的使用assign修饰,释放后不会自动置nil,导致崩溃
- 给类添加添加关联变量的时候,类似上面的场景,应该使用OBJC_ASSOCIATION_RETAIN_NONATOMIC修饰,却错误使用OBJC_ASSOCIATION_ASSIGN
原因:出现悬垂指针,对象没有被初始化,或者访问的对象被释放
解决方案:
Debug
阶段开启僵尸模式,Release
时关闭僵尸模式使用Xcode的
Address Sanitizer
检查地址访问越界创建对象的时候记得初始化
对象的属性使用正确的修饰方式
调用
block
的时候,须做判断造成内存泄露常见的原因是在闭包中造成了循环引用,在objc中我们需要注意闭包内外的weak-strong关系,而在Swift中,我们可以设计一个安全闭包来避免循环引用问题
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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82public struct Delegated<Input, Output> {
private(set) var callback: ((Input) -> Output?)?
public init() { }
public mutating func delegate<Target : AnyObject>(to target: Target,
with callback: @escaping (Target, Input) -> Output) {
self.callback = { [weak target] input in
guard let target = target else {
return nil
}
return callback(target, input)
}
}
public func call(_ input: Input) -> Output? {
return self.callback?(input)
}
public var isDelegateSet: Bool {
return callback != nil
}
}
extension Delegated {
public mutating func stronglyDelegate<Target : AnyObject>(to target: Target,
with callback: @escaping (Target, Input) -> Output) {
self.callback = { input in
return callback(target, input)
}
}
public mutating func manuallyDelegate(with callback: @escaping (Input) -> Output) {
self.callback = callback
}
public mutating func removeDelegate() {
self.callback = nil
}
}
extension Delegated where Input == Void {
public mutating func delegate<Target : AnyObject>(to target: Target,
with callback: @escaping (Target) -> Output) {
self.delegate(to: target, with: { target, voidInput in callback(target) })
}
public mutating func stronglyDelegate<Target : AnyObject>(to target: Target,
with callback: @escaping (Target) -> Output) {
self.stronglyDelegate(to: target, with: { target, voidInput in callback(target) })
}
}
extension Delegated where Input == Void {
public func call() -> Output? {
return self.call(())
}
}
extension Delegated where Output == Void {
public func call(_ input: Input) {
self.callback?(input)
}
}
extension Delegated where Input == Void, Output == Void {
public func call() {
self.call(())
}
}1
2
3
4
5
6
7
8
9
10
11
12
13// Before
self.downloader = ImageDownloader()
downloader.didDownload = { [weak self] image in
guard let strongSelf = self else {
return
}
strongSelf.currentImage = image
}
// After
self.downloader = ImageDownloader()
downloader.didDownload.delegate(to: self) { (self, image) in
self.currentImage = image
}
KVO引起的崩溃
常见场景:
- 观察者/被观察者是局部变量,会崩溃
- 没有实现observeValueForKeyPath:ofObject:changecontext:方法:,会崩溃
- 重复移除观察者,会崩溃
原因:添加了观察者,没有在正确的时机移除;以及没有实现相应的监听方法
解决方案:
- addObserver和removeObserver一定要成对出现
- 保证observeValueForKeyPath:ofObject:changecontext:的实现
集合类相关崩溃
常见场景:
- 数组越界
- 向数组中添加nil元素
- 数组遍历的时候使用错误的方式移除元素
- 使用setObject:forKey:向字典中添加value为nil的键值对
原因:越界、添加nil、多线程非原子性操作、遍历的同时移除元素
解决方案:
- 给集合类添加
category
重写原来的方法,在内部做判断 - 使用
Runtime
把原来的方法替换成自定义的安全方法 - 给
NSMutableDictionary
添加元素的时候,使用setObject:forKey:
向字典中添加value为nil的键值对,推荐使用KVC的setValue:nil forKey:
。[mutableDictionary setValue:nil ForKey:@"name"]
不会崩溃,只是从字典中移除name键值对 - 因为
NSMutableArray、NSMutableDictionary
不是线程安全的,所以在多线程环境下要保证读写操作的原子性,使用 加锁 、信号量 、GCD串行队列 、GCD栅栏dispatch_barrier_async
、CGD组的dispatch_group_enter
和dispatch_group_leave
多线程中的崩溃
常见场景:
- dispatch_group_leave比dispatch_group_enter执行的次数多
- 在子线程更新UI
- 多个线程同时释放一个对象(多线程下非线程安全类的使用)
- 多线程中的数组扩容、浅复制
- 扩容:数组的地址已经改变,报错was mutated while being enumerated
- 浅复制:访问僵尸对象,报错EXC_BAD_ACCESS
原因:死锁、子线程中更新UI、多个线程同时释放一个对象
解决方案:多线程遇到需要同步的时候,加锁,添加信号量等进行同步操作。一般多线程发生的Crash,会收到SIGSEGV信号,表明试图访问未分配给自己的内存,或试图往没有写权限的内存地址写数据。
Socket长连接导致的崩溃
常见场景:长连接socket或重定向管道进入后台,没有关闭导致崩溃
原因:当服务器close一个连接时,若client端接着发数据。根据TCP协议的规定,会收到一个RST响应,client再往这个服务器发送数据时,系统会发出一个SIGPIPE信号给进程,告诉进程这个连接已经断开了,不要再写了。而根据信号的默认处理规则,SIGPIPE信号的默认执行动作是terminate(终止、退出),所以client会退出。
解决方案:
- 切换到后台时,关闭长连接和管道,回到前台重新创建
- 使用signal(SIGPIPE, SIG_IGN),将SIGPIP交给系统处理,这么做将SIGPIPE设为SIG_IGN,使客户端不执行默认操作,即不退出
Watch Dog超时造成的崩溃
常见场景:应用启动阶段用时过长造成崩溃
原因:这是由于触发了看门狗(Watch Dog)机制造成的,通常是应用花费太多的时间无法启动、终止或者响应系统事件,一般异常编码是0x8badf00d,表示应用发生watch dog超时而被iOS终止。在不同的生命周期,触发看门狗机制的超时时间有所不同:
解决方案:主线程只负责更新UI和事件响应,将耗时操作(网络请求、数据库读写等)异步放到后台线程执行。
服务器返回NSNull导致的崩溃
常见场景:多见于Java做后台服务器开发语言
原因:NSNull用于objc对象的占位,一般会作为集合中的占位元素,给NSNull对象发送消息会crash的
解决方案:利用消息转发。参考:NullSafe。当我们给一个NSNull对象发送消息的话,可能会崩溃(null是有内存的),而发送给nil的话,是不会崩溃的。
某些32位机型才会出现的崩溃
常见场景:在32位机型上使用NSNumber时,由于修饰符使用不当可能会造成Crash
原因:ARC模式下系统在32位设备上对NSNumber
类型的对象做的优化不够彻底,只对-1~12这少数的几个数做了优化,在该范围内创建的实例对象存储在内存共享区
,永远不会被销毁。而只要大于12或小于-1就是正常的创建在堆上的对象,系统根据引用计数管理对象是否回收。如果此时恰好使用了错误的修饰符(如assgin),导致对象可能被提前释放,就会引发Crash
解决方案:NSNumber 一律采用 strong 修饰
最后
最后感谢头条的谢俊逸同学对软件异常转换信号方面疑惑的解答!
参考
Handling unhandled exceptions and signals
Addressing Watchdog Terminations
Apple Kernel Programming Guide
iOS Mach 异常、Unix 信号 和NSException 异常
《Mac OS X and iOS Internals:To the Apple’s Core》