既然我们熟悉崩溃的原因及类型、崩溃产生的底层机制,那我们今天就给大家讲解下如何来防护我们的应用,避免一些低级的bug。
异常防护
信号捕获
信号是什么?
“信号”是操作系统模拟软硬中断的工作过程的一种处理异步事件的机制,例如:终端用户键入ctrl+c
会产生SIGINT
信号默认会终止终端进程。
是操作系统内核纯粹软件实现的,跟硬件关系不大,不过不排除一些场景,强行将中断(包括硬中断和软中断)联系起来,比如
SIGINT
信号就是由键盘中断触发。
信号类型
那存在哪些信号呢,通过man signal
就可以查看具体的信号类型,如下:
其中,
Default Action
默认动作包括terminate process
终止应用、create core image
产生内核镜像、stop process
停止应用、discard signal
忽略信号;
比如开发过程中经常使用到或者崩溃看到的的信号:SIGINT/SIGPIPE/SIGBUS/SIGSEGV/SIGKILL/SIGABRT
;
重点介绍以上信号
-
SIGHUP
终端连接断开,则将此信号送给与该终端相关的控制进程(会话首进程)。
SIGHUP
会在以下3种情况下被发送给相应的进程:-
终端关闭时,该信号被发送到
session
首进程以及作为job
提交的进程(即用 & 符号提交的进程) -
session
首进程退出时,该信号被发送到该session中的前台进程组中的每一个进程 -
若父进程退出导致进程组成为孤儿进程组,且该进程组中有进程处于停止状态(收到SIGSTOP或SIGTSTP信号),该信号会被发送到该进程组中的每一个进程。
网络编程中需要处理的常用信号总结附有举例说明
-
通常用词信号通知守护进程再次读取他们的配置文件(选用
SIGHUP
理由是:守护进程不会有控制终端,通常绝不会接收到这种信号);
nohup 命令用于无视该信号,例如,
nohup ./test &
-
-
SIGQUIT
当用户在终端上按退出键(一般为
ctrl+\
)时,中断驱动程序就会产生此信号,并发送给前台进程组中的所有进程,此信号不仅终止前台进程组,同时产生一个core
文件; -
SIGSEGV
:试图访问未分配给自己的内存, 或试图往没有写权限的内存地址写数据。比如:给已经release
的对象发送消息; -
SIGBUS
:非法地址, 包括内存地址对齐(alignment)出错或者不存在对应的物理地址。比如访问一个四个字长的整数, 但其地址不是4的倍数。注意:
x86
架构支持内存非对齐访问,而arm
架构采用RISC
架构,为了提高访问效率,不支持非对齐访问; -
SIGABRT
,比如断言或者开发者调用abort()
生成的信号,有可能是OC
异常也有可能是C++
未捕获异常; -
SIGSYS
,无效的系统调用。例如,用户使用新系统调用,而操作系统已不支持该系统调用的较早版本;或者系统调用类型的参数无效; -
SIGTRAP
,断点或陷阱指令(trap
指令)产生,由debugger
使用; -
SIGPIPE
往没有打开读的管道写数据就会产生
SIGPIPE
信号;或者tcp
连接两端其中一方关闭的话,第一次write
会让发送对端发送RST
报文,再继续写数据就会产生SIGPIPE
信号;或者发送到一个断开连接的socket
上就会抛出SIGPIPE
信号。 -
SIGCHLD
子进程结束时, 父进程会收到这个信号。
如果父进程没有处理这个信号,也没有等待(wait)子进程,子进程虽然终止,但是还会在内核进程表中占有表项,这时的子进程称为僵尸进程。这种情 况我们应该避免(父进程或者忽略SIGCHILD信号,或者捕捉它,或者wait它派生的子进程,或者父进程先终止,这时子进程的终止自动由init进程 来接管)。
-
SIGURG
有”紧急”数据或out-of-band数据到达socket时产生。 -
SIGSTOP
停止(stopped)进程的执行. 注意它和terminate
以及interrupt
的区别:该进程还未结束, 只是暂停执行. 本信号不能被阻塞, 处理或忽略。 -
SIGCONT
让一个停止(stopped)的进程继续执行. 本信号不能被阻塞. 可以用一个handler来让程序在由stopped状态变为继续执行时完成特定的工作。 -
SIGTTIN
当后台作业要从用户终端读数据时, 该作业中的所有进程会收到SIGTTIN
信号. 缺省时这些进程会停止执行。 -
SIGTTOU
类似于SIGTTIN
, 但在写终端(或修改终端模式)时收到。 -
SIGXCPU
超过CPU时间资源限制. 这个限制可以由getrlimit/setrlimit
来读取/改变。 -
SIGXFSZ
当进程企图扩大文件以至于超过文件大小资源限制。 -
SIGVTALRM
虚拟时钟信号. 类似于SIGALRM
, 但是计算的是该进程占用的CPU时间。 -
SIGPROF
类似于SIGALRM/SIGVTALRM
, 但包括该进程用的CPU时间以及系统调用的时间。 -
SIGWINCH
窗口大小改变时发出。 -
SIGIO
文件描述符准备就绪, 可以开始进行输入/输出操作。 -
SIGUSR1/SIGUSR2
用户保留信号。
信号处理方式
-
忽略信号
比如
SIGURG/SIGCONT/SIGCHLD/SIGIO/SIGWINCH/SIGINFO
默认就是忽略信号,大部分信号都是可以忽略的,但是SIGKILL
、SIGSTOP
信号是不能忽略的,这两种信号向内核和超级用户提供了使进程终止或者停止的可靠方法。 -
捕捉信号
通过
signal
或者sigaction
来捕捉信号指向自己的实现; -
执行系统默认动作
有两种信号是不能恢复至默认动作的:
SIGILL
、SIGTRAP
;
OC异常防护
对于OC
语言抛出的异常如何防护呢?众所周知所有OC
方法都会调用到objc_msgSend
,其中有两个参数分别是id
对象及SEL
方法选择器,如果知道了对象和方法选择器的对应关系,那我们就可以动态修改两种对应关系,从而实现方法交换,就是我们熟知的Method Swizzle
方法调配的原理,因此OC
语言是动态语言。
OC
语言的防护就是利用OC
的runtime
特性,采用Method Swizzle
方法调配技术,实现防护方法的无痕植入。但是防护总归不能完全避免,并且需要权衡利弊,比如性能问题(毕竟使用了Hook
特性),以及各种Hook
带来的未知性,还有一味地规避异常,可能会产生更多的异常情况,特别是业务逻辑上。
其实OC
语言的防护主要就是预防一些低级错误,这种可以通过code review
代码审查及测试已经降低了很大部分风险,并且对于防护技术不可能完全避免,也存在一些不确定性,因此,对于一些团队整体实力较强的其实没必要进行防护,主要是对于小团队且新人较多的,可以使用该技术来降低低级错误的风险。因防护的类型较多,且已经存在的框架已经成熟,我们主要需要熟悉其思想即可,不会做过多的代码层面的讲解。
unrecognized selector
这种异常通常是调用了一个不属于该对象拥有的方法导致的,比如:
[self performSelector:@selector(xxx)];
复制代码
对于有经验的开发者一般不会写出这种低级的错误,但是项目合并或者重构等大的改动时难免会不小心把方法的实现删除掉。那如何防护呢?就是大家熟知的OC
的消息转发流程,如下图:
runtime
中具体的方法调用流程大致如下:
- 首先,在相应操作的对象中的缓存方法列表中找调用的方法,如果找到,转向相应实现并执行;
- 如果没找到,在相应操作的对象中的方法列表中找调用的方法,如果找到,转向相应实现执行;
- 如果没找到,去父类指针所指向的对象中执行1,2;
- 以此类推,如果一直到根类还没找到,转向拦截调用,走消息转发机制;
- 如果没有重写拦截调用的方法,程序报错。
整个消息转发机制流程分为动态方法解析->备用接收者->完整的消息转发三个步骤,如果消息转发都未实现,就会调用doesNotRecongnizeSelector
抛出异常。
那既然方法会走消息转发流程,那我们是不是就可以通过截获方法来避免最终的异常发生,那具体在哪个阶段来截获“方法找不到”的错误呢?大家可以想一想!
- 如果在动态方法解析阶段,那就需要在类本身上动态添加到它本身不存在的方法,这些方法对于该类本身来说是冗余的;
forwardInvocation
可以通过NSInvocation
的形式将消息转发给多个对象,但是其开销较大,需要创建新的NSInvocation
对象,并且forwardInvocation
的函数经常被使用者调用,来做多层消息转发选择机制,不适合多次重写forwardingTargetForSelector
可以将消息转发给一个对象,开销较小,并且被重写的概率较低,适合重写。
那如何实现呢?很简单!就是利用Method Swizzle
方法调配来替换掉基类NSObject
对象的forwardingTargetForSelector
,来指向自定义实现的,来看下具体的代码,就是“大白系统”,关键代码如下:
- 方法调配
NSObject
的forwardingTargetForSelector
方法;
- 自定义实现调配的
forwardingTargetForSelector
,首先要判断是否forwardInvocation
被重写,避免重写forwardingTargetForSelector
影响后续的原有的消息转发流程;
- 在桩类
BayMaxCrashHandler
上添加未实现的方法,将消息直接转发至这个桩类BayMaxCrashHandler
,统一指向DynamicAddMethodIMP
方法实现。
KVO
KVO(Key-Value-Observing)
键值观察,其技术原理就是通过isa swizzle
技术添加被观察对象中间类,并重写相应的方法来监听键值变化。当被观察对象属性被修改后,则对象就会接收到通知,即每次指定的被观察对象的属性被修改后,KVO
就会自动通知相应的观察者。如下图:
isa swizzle
不同于method swizzle
,其交换的是isa
,对象的isa指针定义了它的类,所以ISA swizzling指修改对象所指向的类,KVO则是使用该技术实现的,还有Zombie objects检测也用到了该技术,而method swizzle
交换的是method
;
KVO
引起的crash
情景如下:
observer
已销毁,但是未及时移除监听;addObserver
与removeObserver
不匹配- 移除了未注册的观察者,导致崩溃。
- 重复移除多次,移除次数多于添加次数,导致崩溃。
- 重复添加多次,虽然不会崩溃,但是发生改变时,也同时会被观察多次。
- 添加了观察者,但未实现
observeValueForKeyPath:ofObject:change:context:
方法,导致崩溃。 - 添加或者移除时
keypath == nil
,导致崩溃。
通过如上场景就可以发现其实KVO
崩溃最主要的原因就是观察者管理混乱,特别是观察者关系复杂时,开发者容易导致混乱,如下图所示:
那如何管理呢?既然观察者都是开发者来管理,由人来管理必然会出现失误的时候,那我们是不是可以通过一个代理对象来管理呢?答案是:肯定的!
具体实现如下:
-
通过
Method Swizzle
方法调配交换KVO
相应的方法到NSObject
基类,如下: -
然后在观察者和被观察者之间建立一个
KVODelegate 对象
,两者之间通过KVODelegate 对象
建立联系。然后在添加和移除操作时,将 KVO 的相关信息例如observer
、keyPath
、options
、context
保存为KVOInfo 对象
,并添加到KVODelegate 对象
中对应 的关系哈希表
中,对应原有的添加观察者。 -
在添加和移除操作的时候,利用
KVODelegate 对象
做转发,把真正的观察者变为KVODelegate 对象
,而当被观察者的特定属性发生了改变(会被调用到observeValueForKeyPath:ofObject
),再由KVODelegate
对象分发到原有的观察者上。 -
为了避免被观察者提前被释放,被观察者在
dealloc
时仍然注册着KVO
导致崩溃。BayMax
系统还利用Method Swizzling
实现了自定义的dealloc
,在系统dealloc
调用之前,将多余的观察者移除掉。
KVC
KVC(Key Value Coding)
键值编码,提供一种机制来间接访问对象的属性,而不是通过Setter/Getter
方法进行访问。
通常导致崩溃的原因不外乎键值设置不正确,如下:
key
不是对象的属性keyPath
不正确value
为nil
,为非对象设值key
为nil
那如何防护呢,熟悉KVC
机制的同学肯定清楚:runtime
提供了相应的补救措施来避免应用崩溃,包括如下:
setValue:forKey:
找不到相应的key
会调用setValue: forUndefinedKey:
方法;valueForKey:
找不到相应的keyPath
会调用valueForUndefinedKey:
方法;setValue:forKey:
添加value
为nil
方法,会调用setNilValueForKey
方法来避免;
因此,针对上面崩溃的前3中场景,就可以通过分辨实现上述三种方法来避免,但对于key
为nil
的情况该如何防护呢?这里直接告诉答案:毅然是通过熟悉的Method Swizzle
来替换原有的setValue:forKey:
方法,并判断传入的key
是否为nil
。具体代码如下:
Container
对于容器类崩溃常见的就是越界访问或者置为nil
,错误使用的API
如objectAtIndex/addObject
,因此防护很简单,毅然是通过Method Swizzle
来替换容易出错的API
,来判断是否越界以及添加的是否为nil
,比如:NSMutableArray
中的objectAtIndex:
防止越界访问,代码如下:
不过要注意容器类是类簇,直接hook
容器类是无法成功的,需要hook
对外隐藏的实际起作用的类,比如NSMutableArray
的实际类名为__NSArrayM
。
Bad Access
Bad Access
最头痛的就是野指针,对象已释放但仍然访问就会造成崩溃,其实苹果已经提供了一套线下调试的手段,就是大家熟知的Zombie
僵尸对象机制。其原理很简单:通过Method Swizzle
技术替换dealloc
的实现,当对象引用计数为0的时候,将需要dealloc
的对象转化为僵尸对象。如果之后再给这个僵尸对象发消息,因僵尸对象未添加相应的方法,就会走消息转发流程,就会调用到__forwarding__
方法,该方法若判断类名前缀为__NSZombie_
则判定为僵尸对象就会抛出异常。如下图所示
但对于线上的问题就无法通过此方式来有效避免野指针引发的崩溃,那如何防护呢?既然Zombie
僵尸对象机制是一种有效的发现僵尸对象的手段,那我们是不是可以借鉴这种机制来防护呢?答案毅然是可行的!
其原理就是Zombie
对象机制,但其存在占用内存问题,并且应用运行会存在大量的对象创建释放,容易导致OOM
。因此,需要针对此问题进行优化,对一些基础类比如NSString/UIView
等仍然走正常流程,采用“黑名单”机制,针对特定类进行防护。并且内存占用设定阈值,一旦达到阈值就释放僵尸对象所占用的内存,来避免应用被杀。
这里大家了解其原理即可,但此方案存在如下风险:
- 野指针防护有效避免了崩溃,但业务层面难以确定,可能会进入业务异常的状态;
- 会占用内存,且延时释放对象,但超过缓存区上限仍然会被释放,再次调用野指针仍然会崩溃;
- 有一定性能损失;
@try@catch
OC
语言存在异常机制,可以通过@try@catch
来捕获异常,但这种方式不推荐大家去使用,并且实际开发过程中也很少存在使用此方式的情况。究其原因是:OC
的异常捕获机制效率不高,且存在内存泄露的情况。
@try@catch
基于block
处理会存在额外的开销,效率不高;Xcode
默认不会对@try@catch
中的代码进行ARC
管理,如果在抛出异常代码后存在内存释放的话,就需要异常捕获后手动释放,否则就会导致内存泄露;
但这个不能完全否定异常捕获的作用,在一些容易出现异常的操作上,比如文件读写或者需要配合使用@throw
的情况等,这里指的不适合是针对大范围使用@try@catch
捕获并不适合。
C++异常捕获
iOS开发中对于SDK及跨平台或者性能有要求的基本都需要C++
语言来支撑,所以这里重点来讲解下如何防护C++
异常。
C++
同OC
语言本身一样提供了异常机制(具体的异常机制就在此不表),如何使用大家已经烂熟于心了,就是使用try catch finally
,对一些存在异常的场景或者需要自定义异常场景,需要捕获异常处理,否则会导致未捕获异常引发应用被系统kill
掉。
那除了使用try catch
外,对于意外未捕获的异常是否存在有效的手段来捕获所有未捕获的异常呢?答案是:肯定的!
当我们抛出一个异常的时候,异常会随着函数调用关系,一级一级向上抛出,直到被捕获才会停止,如果最终没有被捕获将会导致调用terminate
函数,为了保证更大的灵活性,C++提供了set_terminate
函数可以用来设置自己的terminate
函数.设置完成后,抛出的异常如果没有被捕获就会被自定义的terminate
函数进行处理.下面是一个使用的例子:
#include <exception>
#include <iostream>
#include <cstdlib>
#include <unistd.h>
using namespace std;
static terminate_handler _handler;
//自定义错误类
class MyError {
const char* data;
public:
MyError(const char* const msg = 0):data(msg) {}
const char* what() {
return data;
}
};
//自定义的terminate函数,函数原型需要一致
void terminator() {
cout << "I'll be back" << endl;
exit(0);
}
int main(int argc, const char * argv[]) {
//设置自定义的terminate,返回的是原有的terminate函数指针
_handler = set_terminate(terminator);
//try {
throw MyError("something bad happend");
//}
//catch(MyError &error) {
//cout << "MyError:" << error.what() << endl;
//}
while(1) {
sleep(1);
cout << "sleep" << endl;
}
}
复制代码
静态预防
随着项目的扩大,依靠人工codereview
来保证项目的质量,越来越不现实,这时就有必要借助于一种(自动化)的代码审查工具:程序静态分析。
程序静态分析(Program Static Analysis)是指在不运行代码的方式下,通过词法分析、语法分析、控制流、数据流分析等技术对程序代码进行扫描,验证代码是否满足规范性、安全性、可靠性、可维护性等指标的一种代码分析技术。
稍微大点的团队都会在自动化集成中集成了代码分析工具,比如有些团队可能使用到的OCLint
分析工具,这里就给大家介绍下大家忽略的静态分析工具,方便自己来提前预防潜在的代码错误。
xcode静态分析
那如何静态分析程序呢?本身xcode
就提供了静态分析工具,具体入口Product->Analyze(Command+Shift+B)
,可以检测出代码潜在的文本本地化问题、逻辑问题、内存问题、数据问题和语法问题等,也可以在Build Settings
中启用Analyze During 'build'
来启动编译时自动静态分析。
文本本地化分析
具体开启入口如下图:
设置Missing Localizability
选项修改为Yes
,使用xcode product->analyze
静态分析工具来分析本地化问题,具体的NSLocalizedString
定义如下图:
其中,
key
需要传递的管检测,comment
可以是nil,可以是一段为空的字符串,也可以是对key的注释;
面向用户的文本应该使用本地化的字符串宏。此为代码中配置了本地化,面向用户的应该用字符串宏,而我们直接赋值为汉字,会出现编译警告信息,如下图:
会出现User-facing text should use localized string macro
警告提示。
若取消此警告信息,可在
build setting->Static Analyzer -> Missing Localizability
设置为No
,关闭此静态检查;
逻辑分析
逻辑分析静态分析代码中潜在的逻辑问题,比如访问空指针、未初始化的变量或者初始化0长度的数组等等,如下图所示:
解引用空指针;
NSMutableArray
使用copy
属性会变为不可变对象,若调用可变对象方法会造成崩溃,代码如下:@interface ViewController () @property (nonatomic, copy) NSMutableArray *arr1; @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; NSMutableArray *array = [NSMutableArray arrayWithObjects:@1,@2,@3, nil]; self.arr1 = array; [self.arr1 removeObject:@1]; //会crash 报错reason: '-[__NSArrayI removeObject:]: unrecognized selector sent to instance 0x6000000487c0' } 复制代码
声明变量但未初始化,这种逻辑问题
code review
也较难发现,静态分析给出了错误提示“xxx is a garbage value”;
潜在的声明的数组长度为0;
内存分析
内存管理错误,比如内存泄露,如ARC
下内存管理不包括core foundation
;
循环引用是不能检测出来的;
数据分析
如从未使用过的变量,如下图所示:
创建对象申请内存但未使用导致内存泄露,如下图:
语法分析
错误的使用==
等语法问题,如下图:
静态分析工具
OC
语言对应的静态分析工具有Infer
和OCLint
,swift
语言可以使用SwiftLint,通过静态分析工具可以继承到自动化构建中。
Infer是Facebook开发的针对C、OC、Java语言的静态分析工具,它同时支持对iOS和Android应用的分析。对于Facebook内部的应用像是 Messenger、Instagram 和其他一些应用均是有它进行静态分析的。它主要检测隐含的问题,主要包括以下几条:
- 资源泄露,内存泄露
- 变量和参数的非空检测
- 循环引用
- 过早的nil操作
OCLint是基于Clange Tooling编写的库,它支持扩展,检测的范围比Infer要大。不光是隐藏bug,一些代码规范性的问题,例如命名和函数复杂度也均在检测范围之内。
动态预防
除了静态分析外,xcode
提供了运行时检查工具,比如Scheme
中的Diagnostics
诊断分析工具,以及Instument
中的动态分析工具,如下图所示:
我们可以借用这些工具在调试阶段来有效的进行bug
分析预防,避免线上出现低级且难以排查的bug
,下面就来一一介绍这些与崩溃相关的工具。
动态诊断工具
动态诊断工具包括主线程UI检查(Main Thread Checker
)、地址消毒剂(Address Sanitizer
)、线程消毒剂(Thread Sanitizer
)、未定义行为诊断(Undefined Behavior Sanitizer
)以及内存管理诊断(Memory Management
),比如Zombie Objects
僵尸对象、Leaks
内存泄露检测。
Main Thread Checker
涉及UI操作的API必须要在主线程执行,比如AppKit/UIKit
这两个框架操作,若出现在其他线程更新UI的操作,可以通过该分析工具动态定位,具体是在Scheme->Diagnostics->Main Thread Checker
默认开启的。
举例说明如下:
易容易出错的地方:
-
网络回调
-
创建与销毁
NSView
、UIView
对象 -
设计异步接口
设计一个API用于执行长时间和CPU密集型计算,此时需要异步执行,执行完后通过回调形式来完成指定的用户动作。但此时若未明确指定需要哪个队列执行时,有可能调用者会执行UI更新操作,导致异常。
主线程诊断工具能够检测违反API线程规则的行为,支持AppKit/UiKit/WebKit
常用的三个框架,适合Swift/C
语言,该工具不需要重新编译且默认开启的。
Address Sanitizer -- 检测内存问题
概述
ASan(Address Sanitizer)
是一个快速内存错误检测工具,它包含一个编译器的instrumentation
模块和一个替换malloc/free
的运行库,可以检测如下内存问题:
- 内存释放后又被使用;
- 内存重复释放;
- 释放未申请的内存;
- 使用栈内存作为函数返回值;
- 使用了超出作用域的栈内存;
- 内存越界访问;
该工具不能检测内存泄露、访问未初始化内存及整数溢出错误,但可以看到具体的Memory
使用情况及对象创建及销毁的调用栈,方便定位分析,如下图所示:
原理
启用Address Sanitizer
后,会在APP中增加libclang_rt.asan_ios_dynamic.dylib
,它将在运行时加载。
Address Sanitizer
替换了malloc
和free
的实现。当调用malloc
函数时,它将分配指定大小的内存A,并将内存A周围的区域标记为”off-limits“,即中毒内存。当free方法被调用时,内存A也被标记为”off-limits“,同时内存A被添加到隔离队列,这个操作将导致内存A无法再被重新malloc
使用。代码中所有的内存访问操作都被编译器转换为如下形式:
// Before
*address = ...; // or: ... = *address;
// After
if (IsMarkedAsOffLimits(address)) {
ReportError(address);
}
*address = ...; // or: ... = *address;
复制代码
当访问到被标记为”off-limits“的内存时,Address Sanitizer
就会报告异常。
Address Sanitizer 与Zombie Objects
僵尸对象原理如下:
开启Zombie Objects后,dealloc将会被hook,被hook后执行dealloc,内存并不会真正释放,系统会修改对象的isa指针,指向_NSZombie_前缀名称的僵尸类,将该对象变为僵尸对象。
僵尸类做的事情比较单一,就是响应所有的方法:抛出异常,打印一条包含消息内容及其接收者的消息,然后终止程序。
因此,僵尸对象是无法检测内存越界的及malloc
对象错误,地址消毒剂比僵尸对象有强大的捕捉能力,但是地址消毒剂会占用较多的内存资源,对性能营销较大。
Thread Sanitizer -- 检测多线程问题
多线程技术在日常开发中是被频繁使用的,但是也伴随着各种问题,比如数据竞争,多个线程同时访问修改共享数据,就会导致未定义的行为,甚至导致内存错误或者程序崩溃,并且难以排查定位。因此,TSan(Thread Sanitizer)
工具就是动态分析多线程问题的,不过只适用于64位macOS及64位模拟器。
比如:全局共享变量Global
多线程去设置值就会提示"Data race
"数据竞争问题,如下图所示:
Undefined Behavior Sanitizer
UBSan(Undefined Behavior Sanitizer)
未定义行为消毒剂工具专注检查基于C体系语言的不安全的结构,可以和其它工具配合使用。检测范围很广,如下图:
涉及导致应用崩溃的问题主要包括:
- 除以0
- 内存对齐
- 解引用NULL指针
- 越界访问
除此之外,还包括整数溢出、非空返回值问题等。
Undefined Behavior Sanitizer 官方文档
总结
The sanitizer tools support all C-based languages. The tools also support the Swift language, with the exception of the Undefined Behavior Sanitizer tool, which supports only C-based languages.
消毒剂工具支持所有基于C的语言,比如c/c++/OC,也支持Swift
语言;
Memory Management
-
Malloc Scribble
申请内存后在申请的内存上填
0xAA
,内存释放后在释放的内存上填0x55
;再就是说如果内存未被初始化就被访问,或者释放后被访问,就会引发异常,这样就可以使问题尽快暴漏出来。Scribble
其实是malloc
库libsystem_malloc.dylib
自身提供的调试方案; -
Malloc Guard Edges
申请大片内存的时候在前后page上加保护;
-
Guard Malloc
使用
libgmalloc
捕获常见的内存问题,比如越界、释放之后继续使用。由于
libgmalloc
在真机上不存在,因此这个功能只能在模拟器上使用; -
Zombie Objects
僵尸对象
-
Malloc Stack
启动
malloc
调用栈记录;
Instruments
Zombies
Instruments
为我们提供了一个检测僵尸对象的工具:Zombies
。使用这个工具时,将会自动开启Enable Zombie Objects
模式,而不需要我们自己手动去设置。
Leaks
检测内存泄露工具,具体使用如下:
总结
Crash防护只能算作一个兜底策略,目前来说,很多问题可以通过热修复解决,更有针对性。当然,最核心最重要的是小伙伴们都有良好的编程习惯,仔细review。
实战:崩溃起死回生
既然这么多原因都会导致应用崩溃,那应用崩溃后能否起死回生?答案是:肯定的!代码见demo
关键代码如下:
CFRunLoopRef runLoop = CFRunLoopGetCurrent();
CFArrayRef allModes = CFRunLoopCopyAllModes(runLoop);
while (1) {
for (NSString *mode in (__bridge NSArray *)allModes) {
if ([mode isEqualToString:(NSString *)kCFRunLoopCommonModes]) {
continue;
}
CFStringRef modeRef = (__bridge CFStringRef)mode;
//重新运行另一个循环
CFRunLoopRunInMode(modeRef, 0.1, false);
}
}
//释放内存
CFRelease(allModes);
复制代码
通过异常捕获调用到上述代码,获取当前runloop
并重新运行另一个循环,保证线程继续执行不退出,应用就不会退出。
那Runloop
是什么呢?从名称看就是一个“循环”,大家熟知的原理图如下:
其实质就是一个循环,不断接收事件来处理。当前主线程Runloop
运行阻塞后,我们可以新创建另一个循环来让主线程循环接收各种事件,比如定时器、UI刷新等。
但此方案存在不足:
-
因为我们为了继续执行程序而没有将控制权返回给导致崩溃的调用函数,并且我们启动了自己的
Runloop
,所以永远不会返回到原始的Runloop
中去了,这将意味着导致异常的线程使用的堆栈内存
将永久泄漏。这是这种解决方案的代价。 -
且需要执行
Runloop
的方法,比如performSelector:withObject:afterDelay:
,若单独在主线程/其他线程直接调用会阻塞当前Runloop
无法继续执行,比如Button
点击执行方法。 -
若崩溃发生导致当前堆栈损坏时,这就无法继续执行。
-
若应用程序未配置主运行循环,则此种方案无效;
UIApplication
构造窗口和主运行循环的确切方法是私有的。这意味着,如果尚未构建主运行循环和初始窗口,则我给出的异常代码将不起作用-该代码将运行,但UIAlert
对话框将永远不会出现。因此,我在App Delegate上使用performSelector:withObject:afterDelay:0
从applicationDidFinishLaunching:
方法安装异常处理程序,以确保仅在完全配置主运行循环之后才安装此异常处理程序。在启动之前发生的任何异常都会使应用程序正常崩溃。 -
非主线程异常可能导致无法继续执行,导致业务异常,因此,并非所有崩溃都需要恢复。
-
整个 App 的不确定性将会更大,crash 的部分可能会再次发生;
友情感谢
RunLoop总结:RunLoop的应用场景(五)阻止App崩溃一次
iOS性能优化 - 工具Instruments之Leaks内存泄漏
Enabling the Malloc Debugging Features