性能指标包括CPU、FPS、内存、耗电量、流量、Crash等方面:
- CPU:CPU使用率表示单位内CPU工作时间的的占比。占比越高表示单位时间内处理的事务越多。当CPU高负荷运行时可能导致响应速度变慢、卡顿、发烫、耗电过快等问题,如果长时间未响应就会倍系统杀掉。
- FPS:FPS代表页面每秒的刷新次数,理想情况FPS=60。在滑动页面的情况下,通常需要保持FPS>55帧以上的刷新速度才会比较丝滑。
- 内存: 内存消耗测试节点的设计目标是为了让应用不占用过多的系统资源,且及时释放内存,保障整个系统的稳定性。如果占用内存较大还有被系统杀掉的风险。
- Crash:程序闪退会给用户带来糟糕的体验,实际开发中需要保障访问API的安全性,包括数组不越界访问等,维持应用稳定运行。
本文主要从用户体验的角度探讨卡顿优化、Crash防护。
一、卡顿优化
在渲染流程中CPU、GPU和显示器协同工作。CPU计算好显示的内容(包括视图的创建、布局计算、图片解码、文本绘制等),再提交给GPU进行变换、图层合成、纹理渲染,并将渲染的结果提交到帧缓冲区,等待下一次VSync信号显示到屏幕上。由于垂直同步的机制,如果在一个 VSync 时间内,CPU 或者 GPU 没有完成内容提交,则那一帧就会被丢弃,等待下一次机会再显示,而这时显示屏会保留之前的内容不变。这就是界面卡顿的原因。
针对这个问题,可以分别对CPU、GPU做一些方面的优化:
- 针对CPU的优化:在子线程进行对象的创建、调整、销毁;在子线程中预排版、预渲染;异步绘制等等
- 针对GPU的优化:避免离屏渲染,减少涂层的复杂度等。
1.布局优化之预排版
在
UITableView
和UICollectionView
中单元格的显示需要提供给代理方法对应的高度),以快速决定后续单元格布局的位置,而单元格高度与实际渲染的数据相关。我们可以在heightForRow(...)
和cellForRow(...)
方法中通过临时布局计算单元格的高度和实际数据的渲染,但是这样一来就进行了多次布局计算,如果界面非常复杂,这里势必会出现卡顿。
- 解决方案:网络数据返回后进行布局运算,生成数据模型。比如复杂业务逻辑的判断、图像显示的
frame
、文本显示的frame
、单元格height
、富文本拼接、折叠展开数据的计算。计算完毕之后再切换到主线程刷新用户界面。1.heightForRow(...)
中通过遍历找到模型,返回模型上计算好的高度。2.cellForRow(...)
方法中使用计算好的模型进行用户界面的布局显示。 - 优缺点:一次布局计算完成后后续可以直接取出来进行渲染,避免多次布局计算,体改滑动渲染的效率。如果数据量特别大,可以在预估数据能覆盖整个屏幕的情况下切换到主线程进行界面的渲染,计算完成后再刷新一下用户界面。
2、渲染优化之预解码/预渲染
图像的显示需要网络获取到图片的Data-Buffer,再解码生成Image-Buffer,而整个解码的过程是比较耗费性能的。如果有大量的网络图片需要加载,这里可能就会造成一定程度的卡顿。
- 解决方案:网络图片在子线程中提前解码,然后将解码后的数据绑定在模型上。显示的时候直接设置模型中的数据。
关于这一点在创建的图片加载网络框架中都是有迹可循的,比如在SDWebImage
框架中SDWebImageDownloaderOperation
中:
//@property (strong, nonatomic, nonnull) NSOperationQueue *coderQueue; // the serial operation queue to do image decoding
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error {
[self.coderQueue addOperationWithBlock:^{
// decode the image in coder queue, cancel all previous decoding process
UIImage *image = SDImageLoaderDecodeImageData(imageData, self.request.URL, [[self class] imageOptionsFromDownloaderOptions:self.options], self.context);
}];
}
内部根据图片的格式选择不同的解码方式:
特征 | 图片格式 |
---|---|
0xFF | SDImageFormatJPEG |
0x89 | SDImageFormatPNG |
0x47 | SDImageFormatGIF |
0x49、0x4D | SDImageFormatTIFF |
0x42 | SDImageFormatBMP |
0x52 | data.length >= 12 && 0-12前缀"RIFF"或"WEBP"=>SDImageFormatWebP |
0x00 | data.length >= 12 && 4-12包含"ftypheic"、"ftypheix"、"ftyphevc"、"ftyphevx"=>SDImageFormatHEIC |
0x00 | data.length >= 12 && 4-12包含"ftypmif1"、"ftypmsf1"=>SDImageFormatHEIF |
0x25 | data.length >= 4 && 1-4="PDF" => SDImageFormatPDF |
0x3C | 包含</svg>=>SDImageFormatSVG |
- | SDImageFormatUndefined |
- 解码流程:
- 通过
CGImageSourceCreateWithData
创建CGImageSourceRef
。 - 通过
CGImageSourceCreateImageAtIndex
或者CGImageSourceCreateThumbnailAtIndex
获取CGImageRef
3、渲染优化之异步绘制
根据CPU、GPU渲染原理,由于垂直同步的机制,如果在一个 VSync 时间内,CPU 或者 GPU 没有完成内容提交,则那一帧就会被丢弃,等待下一次机会再显示,而这时显示屏会保留之前的内容不变。这就是界面卡顿的原因。从上图中可以看到,CPU 和 GPU 不论哪个阻碍了显示流程,都会造成掉帧现象。
整个流程可以通过如下代码进行验证:
@interface NXAsyncableLayer : CALayer
@end
@implementation NXAsyncableLayer
- (void)setNeedsDisplay {
NSLog(@"%s", __func__);
[super setNeedsDisplay];
}
- (void)display {
NSLog(@"%s", __func__);
[super display];
}
- (void)drawInContext:(CGContextRef)ctx {
NSLog(@"%s", __func__);
[super drawInContext:ctx];
}
- (void)renderInContext:(CGContextRef)ctx{
NSLog(@"%s", __func__);
[super renderInContext:ctx];
}
@end
@interface NXAsyncableLabel : UILabel
@end
@implementation NXAsyncableLabel
- (void)setNeedsDisplay {
NSLog(@"%s", __func__);
[super setNeedsDisplay];
}
- (void)setNeedsDisplayInRect:(CGRect)rect{
NSLog(@"%s", __func__);
[super setNeedsDisplayInRect:rect];
}
- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx {
NSLog(@"%s", __func__);
[super drawLayer:layer inContext:ctx];
}
- (void)drawRect:(CGRect)rect {
NSLog(@"%s", __func__);
[super drawRect:rect];
}
- (void)displayLayer:(CALayer *)layer {
NSLog(@"%s", __func__);
dispatch_async(dispatch_get_global_queue(0, 0), ^{
//1.异步绘制,切换至子线程
UIGraphicsBeginImageContextWithOptions(size, NO, scale);
//2.获取当前上下文
CGContextRef context = UIGraphicsGetCurrentContext();
//3.进行异步绘制(略)
//4.生成位图
UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
dispatch_async(dispatch_get_main_queue(), ^{
//5.子线程完成工作,切换至主线程
self.layer.contents = (__bridge id)image.CGImage;
});
});
}
+ (Class)layerClass {
return [NXAsyncableLayer class];
}
@end
- 测试代码
NXAsyncableLabel *label = [[NXAsyncableLabel alloc] initWithFrame:CGRectMake(0, 0, 100, 100)];
label.text = @"NXAsyncableLabel";
[self.view addSubview:label];
- 将
[NXAsyncableLabel displayLayer]
打开,打印结果为:
-[NXAsyncableLabel setNeedsDisplay]
-[NXAsyncableLayer setNeedsDisplay]
-[NXAsyncableLayer display]
-[NXAsyncableLabel displayLayer:]
- 将
[NXAsyncableLabel displayLayer]
屏蔽掉,打印结果为:
-[NXAsyncableLabel setNeedsDisplay]
-[NXAsyncableLayer setNeedsDisplay]
-[NXAsyncableLayer display]
-[NXAsyncableLayer drawInContext:]
-[NXAsyncableLabel drawLayer:inContext:]
-[NXAsyncableLabel drawRect:]
- 将
[NXAsyncableLabel displayLayer]
屏蔽掉并且将NXAsyncableLabel.layer.delegate
设置为nil,打印结果为:
-[NXAsyncableLabel setNeedsDisplay]
-[NXAsyncableLayer setNeedsDisplay]
-[NXAsyncableLayer display]
-[NXAsyncableLayer drawInContext:]
如上归纳总结如下:
-
[UIView setNeedsDisplay]
会调用[CALayer setNeedsDisplay]
方法,并并打上标记。在runloop
将要结束的时候调用[CALayer display]
方法。 - 接下来判断是否实现了
[UIView displayLayer:]
- 如果实现了则调用
[UIView displayLayer:]
,最终生成一张位图,赋值给layer.contents
,完成自定义绘制流程。这里的绘制可以在子线程中完成,生成位图后再切换到子线程设置layer.contents
。(CGBitmapContextCreate
创建位图、CoreGraphic
绘制、CGBitmapContextCreatImage
生成CGImage
图片). - 如果没有则调用
[CALayer drawInContext:]
,如果CALayer.delegate
不为空继续调用[UIView drawLayer:inContext:]
和[UIView drawRect:]
完成系统默认的同步绘制流程。
假如视图非常复杂(子视图较多、布局相互依赖、有大量图片需要解码),那么这个CPU+GPU的工作就可能超过1帧的时间,这样在快速滑动的过程中就会造成卡顿,接口给我们提供了优化的空间,也就是在
[UIView displayLayer:]
中,自己进行计算布局和绘制,整个过程中我们可以放在子线程中进行,不影响主线程处理滑动等其他的UI事务,这样就不会卡顿。需要补充一点,如果有大量的计算在整个滑动的过程中有时候会出现局部的空白,这是正常的,毕竟计算布局是在子线程中异步操作的,如果没有计算完毕则渲染出来的就会没有内容。
详细的绘制流程和原理可以参考YYKit开源框架的
YYAsyncLayer
和YYLabel
的实现流程。
4、界面渲染优化之离屏渲染
离屏渲染的检测方式:选中模拟器
->Debug
-> Off Off-screen Rendered
,如果使用离屏渲染会有黄色的背景,比如系统的电池。
GPU的渲染分为当前屏幕渲染(On-Screen Rendering)和离屏渲染(Off-Screen Rendering)。当前屏幕渲染的原理上面已经介绍了,在一次Vsync信号周期内CPU计算好布局等,然后将计好的内容交给GPU渲染。GPU渲染好之后就会放入帧缓冲区。所谓离屏渲染就是指GPU在当前屏幕的帧缓冲区意外开辟一个新的缓冲区进行渲染操作。
那为什么说离屏渲染耗费性能的呢?
- 创建新的缓冲区:要想进行离屏渲染,首先需要创建一个新的缓冲区。
- 上下文切换:离屏渲染的整个过程,需要多次切换上下文环境。先是从当前屏幕切换到离屏缓冲区,渲染结束后将离屏缓冲区的数据渲染到屏幕上有需要从离屏缓冲区切换到当前屏幕。
造成离屏渲染的原因有很多,比如shouldRasterize
(光栅化)、mask
(遮罩层)、shadows
(阴影)、EdgeAnntialiasing
(抗锯齿)、cornerRadius
(圆角)等等
- 为图层设置遮罩
layer.mask
- 将图层的
layer.masksToBounds
/view.clipsToBounds
属性设置为true - 将图层
layer.allowsGroupOpacity
属性设置为YES和layer.opacity
小于1.0 - 为图层设置阴影
layer.shadow *
- 为图层设置
layer.shouldRasterize = true
- 具有
layer.cornerRadius
,layer.edgeAntialiasingMask
,layer.allowsEdgeAntialiasing
的图层 - 文本(任何种类,包括
UILabel
,CATextLayer
,Core Text
等) - 使用
CGContext
在drawRect :
方法中绘制大部分情况下会导致离屏渲染,甚至仅仅是一个空的实现 -
iOS 9.0
之前UIimageView
跟UIButton
设置圆角都会触发离屏渲染。 -
iOS 9.0
之后UIButton
设置圆角会触发离屏渲染,而UIImageView
里png
图片设置圆角不会触发离屏渲染了,如果设置其他阴影效果之类的还是会触发离屏渲染的。
针对以上问题我们针对性的做出优化方案:
- 关于圆角的处理方案,使用CAShapeLayer+UIBezierPath方案来盖住圆角部分。CAShapeLayer使用GPU渲染,专业的人做专业的事情,效率更高。
UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(100, 100, 100, 100)];
imageView.image = [UIImage imageNamed:@"test.png"];
UIBezierPath *maskPath = [UIBezierPath bezierPathWithRoundedRect:imageView.bounds byRoundingCorners:UIRectCornerAllCorners cornerRadii:imageView.bounds.size];
CAShapeLayer *maskLayer = [[CAShapeLayer alloc] init];
maskLayer.frame = imageView.bounds;
maskLayer.path = maskPath.CGPath;
imageView.layer.mask = maskLayer;
[self.view addSubview:imageView];
或者采用YYImage中对图片圆角边框的处理方式,内部处理了圆角和边框(边框宽度、颜色)等多种需求,内部使用CoreGraphics+UIBezierPath的方案,绘制圆角、绘制边框,生成新的图片。
- (UIImage *)imageByRoundCornerRadius:(CGFloat)radius
corners:(UIRectCorner)corners
borderWidth:(CGFloat)borderWidth
borderColor:(UIColor *)borderColor
borderLineJoin:(CGLineJoin)borderLineJoin{}
在实际开发中,需要注意:
- 少量的离屏渲染不会带来性能上的影响,不用为了优化容不下一点离屏渲染。
- 重要需要优化的应当放在
UITableView
、UICollectionView
这种长列表的中。 - 如果有大量的网络图片需要加载,这个时候添加圆角使用第一种方式更为便捷。
二、Crash
防护
App Crash
的常见类型主要包括以下几种:
-
unrecognized selector crash
(没找到方法的实现) KVO crash
NSTimer crash
-
Container crash/NSString
(数组越界,插nil等) -
Bad Access crash
(野指针) NSNotification crash
1.unrecognized selector crash
没有找到方法的实现
在解决这个问题的,我们先了解一下方法调用的流程:
- 1.快速查找流程:
OC
方法的调用底层通过objc_msgSend(obj, sel)
实现的(源码汇编编写),从实例的class
->cache
->_bucketsAndMaybeMask
中查找方法的实现。如果没有找到,则进入2. - 2.慢速查找流程:从当前类到父类的方法列表中查找一次调用的方法。如果没有找到,则进入3.
- 3.消息转发流程:
- 3.1.动态决议:
+ (BOOL)resolveInstanceMethod:(SEL)sel
/+(BOOL)resolveClassMethod:(SEL)sel
,可以动态向类添加他自身不存在的方法实现。 - 3.2.快速转发:
- (id)forwardingTargetForSelector:(SEL)aSelector
, 动态指定一个可以执行该方法的实例。 - 3.3.慢速转发:
-(NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector
+- (void)forwardInvocation:(NSInvocation *)anInvocation
,可以实现一个空函数。也可以重新指定消息的target
和selector
触发消息。 - 4.报错:
doesNotRecognizeSelector
。
从上面的流程中,我们可以看到在消息转发流程中,我们可以有三次机会去补救。但是每个方法各有侧重,第一个方法会向类添加一些冗余的方法;第三个需要创建方法的签名和NSInvocation
有一定的开销,最合适的是第二个方法。
推荐一种较为优雅的做法:我们可以创建一个"傀儡"类,动态为该类添加无法执行的
Selector
方法,然后用一个通用的方法作为该Selector
的实现,将消息转发到该傀儡类的实例上。也可以重写了NSObject
类的-(NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector
+- (void)forwardInvocation:(NSInvocation *)anInvocation
让未找到的方法不调用。
2.KVO
crash
KVO
导致的Crash
主要原因有两方面:
-
KVO
的被观察者dealloc
时仍然注册着KVO
导致Crash
。 - 重复添加
KVO
观察者或者重复移除观察者。
那么解决这个问题,就是保证KVO
的观察者dealloc
的时候,移除观察者,并且保证不重复添加移除观察者。所以内部维护一个观察者的关系映射是十分有必要的。
这里可以参考我的开源框架NXKit中NXKVOObserver
类,它的原理很简单:
open class NXKVOObserver : NSObject {
//弱引用观察者
public fileprivate(set) weak var observer : NSObject? = nil
//内部维护一个被观察信息的列表[NXKVOObserver.Observation]
public fileprivate(set) var observations = [NXKVOObserver.Observation]()
//被观察者的信息实体
open class Observation : NSObject {
//弱引用被观察者
weak open var object : NSObject? = nil
open var key = ""
open var options: NSKeyValueObservingOptions = []
open var context: UnsafeMutableRawPointer? = nil
open var completion : NX.Completion<String, [NSKeyValueChangeKey : Any]?>? = nil
public init(object:NSObject, key:String, options:NSKeyValueObservingOptions, context:UnsafeMutableRawPointer?, completion:NX.Completion<String, [NSKeyValueChangeKey : Any]?>?) {
self.object = object
self.key = key
self.options = options
self.context = context
self.completion = completion
}
}
//初始化
public init(observer:NSObject) {
self.observer = observer
}
//添加观察者和观察者的属性:判断重复?!只有不重复的才会真正添加
open func add(object: NSObject, key: String, options: NSKeyValueObservingOptions = [], context: UnsafeMutableRawPointer? = nil, completion:NX.Completion<String, [NSKeyValueChangeKey : Any]?>? = nil){
if self.observations.contains(where: { kvo in return kvo.object == object && kvo.key == key}) {
}
else {
let observation = NXKVOObserver.Observation(object: object, key: key, options:options, context: context, completion: completion)
self.observations.append(observation)
object.addObserver(self, forKeyPath: key, options: options, context: context)
}
}
//移除观察者
open func remove(object: NSObject, key: String) {
if let index = self.observations.firstIndex(where: { kvo in return kvo.object == object && kvo.key == key}){
self.observations.remove(at: index)
object.removeObserver(self, forKeyPath: key)
}
}
//移除所有观察者
open func removeAll() {
for observation in self.observations {
observation.object?.removeObserver(self, forKeyPath: observation.key)
}
self.observations.removeAll()
}
//拦截回调:可以通过闭包回调或者通过observeValue(...)方法回调
open override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if let __object = object as? NSObject, let observation = self.observations.first(where: { kvo in return kvo.object == __object && kvo.key == keyPath}){
if observation.completion != nil {
observation.completion?(observation.key, change)
}
else if let __observer = self.observer,__observer.responds(to: #selector(NSObject.observeValue(forKeyPath:of:change:context:))) == true {
__observer.observeValue(forKeyPath: observation.key, of: observation.object, change: change, context: context)
}
}
}
deinit {
NX.print(NSStringFromClass(self.classForCoder))
}
}
如何使用?以WebViewController
观察WebView
为例:
open class NXWebViewController: NXViewController {
...
open var webView = NXWebView(frame: CGRect.zero)
//初始化
lazy var observer : NXKVOObserver = {
return NXKVOObserver(observer: self)
}()
override open func viewDidLoad() {
super.viewDidLoad()
....
//添加被观察者和属性
self.observer.add(object:self.webView, key: "title", options: [.new, .old], context: nil, completion: nil)
self.observer.add(object:self.webView, key: "estimatedProgress", options: [.new, .old], context: nil, completion: nil)
}
override open func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
}
deinit {
self.webView.stopLoading()
//移除全部的被观察者
self.observer.removeAll()
}
}
整个结构非常轻巧,特别注意这里边的观察者observer
和被观察者object
都采用了弱引用,不会有循环引用的问题,那么在观察者dealloc
中调用一下removeAll()
即可;并且内部维护了观察者信息的列表,所有的添加、移除、回调都会查找这个列表的数据,所以不存在重复添加移除的问题了。
如果觉得在多线程中操作不安全,可以在
add(...)
、remove(...)
、removeAll(...)
、observeValue(...)
位置添加一把锁。
3.NSTimer crash
我们一般使用[NSTimer scheduledTimerWithTimeInterval:target:selector:userInfo:repeats: ]
做重复性的定时任务,但是这个API会强引用target实例,默认形成循环引用。为此我们需要在合适的时机invalidate
定时器,断开引用环,否则就会因为循环引用双发都无法释放,导致内存泄露,甚至无限重复调用会导致资源的浪费。
解决这个问题的关键就是在合适的时机断开引用环,这里推荐如下方案:
- 推荐方案-YYKit开源框架中的
NSTimer (YYAdd)
类提供的方案:它的本质是将强引用的对应设置为自己,不与NSTimer
的持有者构成循环引用,从而断开循环引用。 - 推荐方案-YYKit开源框架中的
YYTimer
类提供的方案:如果对定时器的精度要求很高,比如不受手指滑动屏幕的影响等建议采用,内部使用dispatch_source_t
实现,并且将target
设置为弱引用。 - 可以根据实际的需要在
viewWillDisappear(:)
/viewDidDisappear(:)
设置定时器失效-打开新的页面或者页面返回的时候都会调用。或在didMoveToParentViewController(:)
设置定时器的失效,页面载入完成(viewDidAppear
)之后会调用一次parent
不为空,页面返回(viewDidDisappear
)之后会调用一次parent
为空,打开新的页面(viewDidDisappear
)之后不会调用。
4.Container/NSString crash
(数组越界,插nil等)
Container Crash是指NSArray
/NSMutableArray
/NSDictionary
/NSMutableDictionary
/NSCache
等类的越界访问或者插入nil等错误操作造成的。
解决方案:
- 可以
swizzle
对应的插值和访问方法,在swizzle
的方法中做好空值和下标越界的判断即可,也可以自己定义一套分类API可以更简洁的插值和读取,同时内部做好空值和越界的判断。两种方式各有利弊,前者有一定的侵入性,后者拦截不是特别彻底。 -
NSString
和NSMutableString
的崩溃闪退问题,通常是越界操作引起的,处理方式同上。
5.Bad Access crash (野指针)
- 野指针:所指向的对象被释放或者收回,但是该指针没有作任何的修改,以至于该指针仍旧指向已经回收的内存地址。这个指针就是野指针。
-
野指针包括两大类:内存没被覆盖、内存被覆盖。
5.1解决方案一:Malloc Scribble
官方解释:申请内存
alloc
时在内存上填0xAA
,释放内存dealloc
在内存上填0x55
。
5.1.1.实现思路:
- 1.通过
fishhook
替换C函数
的free
方法为自定义的safe_free
,类似于Method Swizzling - 2.在
safe_free
方法中对已经释放变量的内存
,填充0x55
,使已经释放变量不能访问
,从而使某些野指针的crash从不必现变成必现。
为了防止填充
0x55
的内存被新的数据内容填充,使野指针crash变成不必现,采用的策略是,safe_free
不释放这片内存,而是自己保留着,即safe_free
方法中不会真的调用free
;同时为了防止系统内存过快消耗(因为要保留内存),需要在保留的内存大于一定值时释放一部分,防止被系统杀死,同时,在收到系统内存警告时,也需要释放一部分内存.
- 3.发生crash时,得到的崩溃信息有限,不利于问题排查,所以这里采用代理类(即继承自
NSProxy
的子类),重写消息转发的三个方法,以及NSObject
的实例方法,来获取异常信息。但是这的话,还有一个问题,就是NSProxy
只能做OC
对象的代理,所以需要在safe_free
中增加对象类型的判断。
5.1.2.具体实现
5.2 解决方案二:Zombie Objects
官方解释:一个对象已经解除了它的引用,已经被释放掉,但是此时仍然是可以接受消息,这个对象就叫做
Zombie Objects
(僵尸对象)。这种方案的重点就是将释放的对象,全都转为僵尸对象.
- 可以用来检测内存错误(EXC_BAD_ACCESS),它可以捕获任何阐释访问坏内存的调用
- 给僵尸对象发送消息的话,它仍然是可以响应的,然后会发生崩溃,并输出错误日志来显示野指针对象调用的类名和方法
5.2.1.底层原理
-
Zombie Objects
生成流程(伪代码):
//1、获取到即将deallocted对象所属类(Class)
Class cls = object_getClass(self);
//2、获取类名
const char *clsName = class_getName(cls)
//3、生成僵尸对象类名
const char *zombieClsName = "_NSZombie_" + clsName;
//4、查看是否存在相同的僵尸对象类名,不存在则创建
Class zombieCls = objc_lookUpClass(zombieClsName);
if (!zombieCls) {
//5、获取僵尸对象类 _NSZombie_
Class baseZombieCls = objc_lookUpClass(“_NSZombie_");
//6、创建 zombieClsName 类
zombieCls = objc_duplicateClass(baseZombieCls, zombieClsName, 0);
}
//7、在对象内存未被释放的情况下销毁对象的成员变量及关联引用。
objc_destructInstance(self);
//8、修改对象的 isa 指针,令其指向特殊的僵尸类
objc_setClass(self, zombieCls);
-
Zombie Objects
对象触发流程(伪代码):
//1、获取对象class
Class cls = object_getClass(self);
//2、获取对象类名
const char *clsName = class_getName(cls);
//3、检测是否带有前缀_NSZombie_
if (string_has_prefix(clsName, "_NSZombie_")) {
//4、获取被野指针对象类名
const char *originalClsName = substring_from(clsName, 10);
//5、获取当前调用方法名
const char *selectorName = sel_getName(_cmd);
//6、输出日志
Log(''*** - [%s %s]: message sent to deallocated instance %p", originalClsName, selectorName, self);
//7、结束进程
abort();
}
- 这种野指针探测方式的思路是:dealloc方法的替换,其关键是调用objc_destructInstance 来解除对象的关联引用。
5.2.2.具体实现
5.3 两种方案对比
- 通过free函数来进行野指针定位:
优点: 覆盖范围广,覆盖了OC、C++、C函数。
缺点: 想要获得具体的崩溃信息,还是需要进行Objc对象的判断,同时free函数的覆盖范围广,也会造成一定性能的损耗,毕竟我们在safe_free中添加了一些判断。 - 通过dealloc函数来进行野指针定位:
优点: 针对OC语言,利用OC的方法交换、消息转发等特性,对于iOS项目来说更具有针对性和可扩展性。
缺点: 相对作用范围较小。
6.NSNotification crash
这个问题主要是由于在NSNotificationCenter添加一个对象为observer之后,如果在observer dealloc的时候,没有调用[[NSNotificationCenter defaultCenter] removeObserver:self]
会导致崩溃。这个问题出现在iOS 9.0之前,高版本苹果对此做了优化,不会再有这个问题了。这个推荐在控制器等基类的dealloc方法中添加[[NSNotificationCenter defaultCenter] removeObserver:self]
的调用即可。网上也有一些说法说是hook add方法,hook dealloc方法,这些都是方法,个人感觉太重了~~。
三、异常的收集
在应用启动之后会对objc运行时异常回调进行初始化,异常回调用到_objc_terminate
函数:
static void _objc_terminate(void){
if (! __cxa_current_exception_type()) {
// No current exception.
(*old_terminate)();
}
else {
// There is a current exception. Check if it's an objc exception.
@try {
__cxa_rethrow();
} @catch (id e) {
// It's an objc object. Call Foundation's handler, if any.
(*uncaught_handler)((id)e);
(*old_terminate)();
} @catch (...) {
// It's not an objc object. Continue to C++ terminate.
(*old_terminate)();
}
}
}
如果捕获到objc异常,回调用uncaught_handler(e)
,并将异常信息传递回去。uncaught_handler
有个默认值是_objc_default_uncaught_exception_handler
,该函数是空实现。在该文件中可以找到另外一个地方给uncaught_handler
赋值:
/***********************************************************************
* objc_setUncaughtExceptionHandler
* Set a handler for uncaught Objective-C exceptions.
* Returns the previous handler.
**********************************************************************/
objc_uncaught_exception_handler objc_setUncaughtExceptionHandler(objc_uncaught_exception_handler fn){
objc_uncaught_exception_handler result = uncaught_handler;
uncaught_handler = fn;
return result;
}
在Foundation层有一个
typedef void NSUncaughtExceptionHandler(NSException *exception);
FOUNDATION_EXPORT void NSSetUncaughtExceptionHandler(NSUncaughtExceptionHandler * _Nullable);
我们可以在应用启动完成后调用该函数,然后捕获异常信息,并将该信息先保存到本地,等下一次应用启动的时候再将该信息通过接口提交给服务器。
@implementation EXExceptionHandler
+ (instancetype)center {
static dispatch_once_t t;
static EXExceptionHandler *center = nil;
dispatch_once(&t, ^{
center = [[self alloc] init];
});
return center;
}
- (void)start{
NSSetUncaughtExceptionHandler(&ExceptionHandler);
}
void ExceptionHandler(NSException *exception) {
NSMutableDictionary *userInfo = [NSMutableDictionary dictionaryWithDictionary:exception.userInfo];
[userInfo setObject:exception.name forKey:EXExceptionHandlerExceptionName];
[userInfo setObject:exception.reason forKey:EXExceptionHandlerExceptionReason];
[userInfo setObject:exception.callStackSymbols forKey:EXExceptionHandlerExceptionCallStackSymbols];
[userInfo setObject:@"EXException" forKey:EXExceptionHandlerExceptionFileKey];
NSException *e = [[NSException alloc] initWithName:exception.name reason:exception.reason userInfo:userInfo];
[EXExceptionHandler.center handleException:e];
}
- (void)handleException:(NSException *)exception{
NSLog(@"将异常信息/设备信息/时间信息保存到本地;合适时提交到服务器:%@", exception.userInfo);
}
@end