2020.1.11更新

避免App crash。

App的质量评测主要有两个关注点,一个是Crash,另一个是卡顿。手百已经建立了卡顿监测平台,主要是通过监测主线程的Runloop的状态切换耗时实现。对于Crash只做了收集,及时报告给个业务方,但并没有“防护”平台(貌似由于种种原因目前还没有完成)。假如需要做一个简单的crash防护措施,该怎么着手呢?

《大白健康系统–iOS APP运行时Crash自动修复系统》这篇文章总结地很不错了,网上很多组件都是根据这篇文章开发的(手百内部也有参考)。本文在前辈们的基础上,简单叙述下自己的想法。

一、常见Crash类型

1. unrecognized selector crash

unrecognized selector sent to instance类型的crash是比较常见的,原因是没有找到方法的实现。H5与NA交互时,别人难免会调错;项目在合并分支、重构等大改动的时候也难免不小心把方法的实现删掉了。这类crash的防护是最有意义的,一来解决了体验问题,二来只是添加一个单纯的实现,一般不会影响其他功能和数据。

根据消息机制,resolveInstanceMethod需要在类中提前加上,略显冗余;forwardInvocation需要创建NSInvocation,开销较大且常被外界重写以转发消息。最适合的还是forwardingTargetForSelector

需要考虑的有:

  1. 防护实例方法和类方法。
  2. 防护的方法要可供选择,如白名单,或者前缀等(避免影响其他业务方)。
  3. 获取crash的函数调用栈以供追踪。
  4. 不能影响已重写了forwardInvocation:方法(含实例方法和类方法)的类。

第一条,方法交换时传入类对象或者元类对象即可;
第二条,可以获取当前的classString进行定制化判断;
第三条,[NSThread callStackSymbols]可以获取调用栈;
第四条,可以参考KVO中判断willChangeValueForKey:是否已被重写的逻辑—比较IMP是否一致。也即比较NSObject和当前类中的forwardInvocation:方法的IMP是否一致;当然,需要对-forwardInvocation:+forwardInvocation:区分;

这样,首先添加一个继承自NSObject的target,重写resolveInstanceMethod和resolveClassMethod以添加空白的方法实现:forwardingTargetDynamicMethod。这里也是获取调用栈的好时机:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@interface YAForwardingTarget : NSObject
@end
@implementation YAForwardingTarget
static inline void forwardingTargetDynamicMethod(id self, SEL _cmd) {}

+ (BOOL)resolveInstanceMethod:(SEL)sel {
class_addMethod(self.class, sel, (IMP)forwardingTargetDynamicMethod, "v@:");
[super resolveInstanceMethod:sel];
return YES;
}

+ (BOOL)resolveClassMethod:(SEL)sel {
class_addMethod(object_getClass(self), sel, (IMP)forwardingTargetDynamicMethod, "v@:");
[class_getSuperclass(self) resolveClassMethod:sel];
return YES;
}
@end

利用方法交换,在NSObject的分类中hook forwardingTargetForSelector:方法处理转发逻辑:

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
@interface NSObject (YAResolveUnrecognizedSelector)
@end
@implementation NSObject (YAResolveUnrecognizedSelector)
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
ya_methodSwizzle(self.class, @selector(forwardingTargetForSelector:), @selector(swizzleForwardingTargetForSelector:));
ya_methodSwizzle(object_getClass(self), @selector(forwardingTargetForSelector:), @selector(swizzleForwardingTargetForSelector:));
});
}

#define swizzleForwardingTargetForSelector(arg) \
arg (id)swizzleForwardingTargetForSelector:(SEL)aSelector { \
id result = [self swizzleForwardingTargetForSelector:aSelector]; \
if (result) return result; \
NSString *classString = NSStringFromClass(object_getClass(self)); \
BOOL isClsMethod = [@#arg isEqualToString:@"+"]; /* 区分类方法和实例方法 */\
Class currCls = isClsMethod ? object_getClass(self) : self.class;\
Class oriCls = isClsMethod ? object_getClass(NSObject.class) : NSObject.class;\
IMP currentImp = class_getMethodImplementation(currCls, @selector(forwardInvocation:));\
IMP originImp = class_getMethodImplementation(oriCls, @selector(forwardInvocation:));\
/* 也可以添加白名单. */ \
if ([classString hasPrefix:@"YA"] && currentImp == originImp) { \
/* 避免crash. */\
ya_recordCrashInfo(YACrashTypeUnrecognizedSelector, object_getClass(self), aSelector, nil);\
return isClsMethod ? YAForwardingTarget.class : [YAForwardingTarget new]; \
} else { \
return nil; /* 抛出异常. */ \
} \
} \

// Class method and instance method.
swizzleForwardingTargetForSelector(+)
swizzleForwardingTargetForSelector(-)
#undef swizzleForwardingTargetForSelector
@end

在获取到classString时,可以根据下发的白名单判断是否需要防护,当然也可以直接判断前缀,只防护自己业务的方法。

2. 容器 crash

再一个比较常见的就是容器crash,比如向NSMutableArray中添加空元素抛出异常。这种crash看似很低级,但却防不胜防。当向一个数组中添加元素时,需要由开发者考虑这个元素有没有可能为空,如果有,做空值判断;如果没有,直接添加即可。但是这种“考虑”往往不是百分百可靠的。最保险的办法是对所有被添加的元素都做空值判断。

假定需要对NSMutableArray的addObject做防护,最简单的是添加一个safe方法:

1
2
3
4
5
6
7
@implementation NSMutableArray (YAResolveNilObject)
- (void)ya_addObject:(id)anObject {
if (anObject) {
[self addObject:anObject];
}
}
@end

之后使用时只需要使用ya_addObject:就行了。当然最优雅的还是进行hook,让外界继续使用原生方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@implementation NSMutableArray (YAResolveNilObject)
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
ya_methodSwizzle(NSClassFromString(@"__NSArrayM"), @selector(addObject:), @selector(ya_addObject:));
});
}

- (void)ya_addObject:(id)anObject {
if (anObject) {
[self ya_addObject:anObject];
} else {
NSLog(@"防护了");
}
}
@end

需要注意的是Array系列是类簇,直接hook NSMutableArray是无法成功的,需要hook对外隐藏的实际起作用的类:__NSArrayM。还有就是如果要考虑线程安全的话,自己再通过加锁来保证。不然 if (anObject)判断时发现为YES,而到addObject:时,anObject又为空了,十分尴尬。

除了addObject时的判空,还有objectAtIndex时的range判断,也需要处理。

NSMutableArray是这样,那NSMutableSet、NSMutableDictionary、NSCache等类也好说了。

3. KVO crash

KVO引起crash有两种场景:
1,observer已经销毁,但是未及时移除监听;
2,addObserver与removeObserver不匹配(重复添加或移除、没有添加却移除等);

预防

从“预防”层面讲,可以使用KVO的第三方框架:KVOController
1,使用单例接管了系统的observeValueForKeyPath方法,通过它再来分发调用。如果observer已经销毁,则不再回调(block),解决了第一个问题;
2,只有addObserver接口,其内部依赖容器避免外界重复addObserver,没有暴露removeObserver接口,解决了第二个问题;

而且KVOController极其优雅,只调用一个方法就可以完成一个对象的键值观测。更多介绍可以查看draveness大神的文章:《如何优雅地使用 KVO》,当然也可以简单参考下《「KVOController」的封装》

防护

好了,预防层面有办法解决了,那防护层面呢?“observer已经销毁,但是未及时移除监听”,针对这个问题很自然地想到在observer的dealloc方法中做移除监听操作。这种做法的思路是:
1, hook addObserver方法,object为key,keyPath数组作为value,与observer建立关系,大致是这种结构:

1
2
3
4
5
6
7
8
9
10
observer1: {
object1: ["keyPath1", "keyPath2"],
object2: ["keyPath3", "keyPath4"],
///....
},
observer2: {
object3: ["keyPath5", "keyPath6"],
object4: ["keyPath7", "keyPath8"],
///....
}

2, hook observer的dealloc方法,在这里面逐个移除object的keyPath监听:

1
2
3
4
5
6
7
8
// observer1
[object1 removeObserver:self forKeyPath:keyPath1];
[object1 removeObserver:self forKeyPath:keyPath2];
[object2 removeObserver:self forKeyPath:keyPath3];
[object2 removeObserver:self forKeyPath:keyPath4];

// observer2
/// ...

具体代码实现层面,NSMapTable可以支持直接使用对象(object)作为key;keyPath的重复过滤可以使用NSMutableSet;可以使用一个标志位判断是否重复添加或者移除;

hook后的addObserver方法:

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
- (void)ya_addObserver:(NSObject *)observer
forKeyPath:(NSString *)keyPath
options:(NSKeyValueObservingOptions)options
context:(void *)context {
BOOL shouldAddObserver = YES;
NSString *observerKey = DISGUISE(observer);
if (!gMainMap) gMainMap = [NSMutableDictionary dictionary];

NSMapTable *subMap = [gMainMap objectForKey:observerKey];
if (!subMap) {
subMap = [NSMapTable weakToWeakObjectsMapTable];
NSMutableSet *set = [NSMutableSet setWithObject:keyPath];
[subMap setObject:set forKey:DISGUISE(self)];
} else {
NSMutableSet *set = [subMap objectForKey:DISGUISE(self)];
if ([set containsObject:keyPath]) { // O(1)
// Repeat addObserver.
shouldAddObserver = NO;
}
}
[gMainMap setObject:subMap forKey:observerKey];
if (shouldAddObserver) {
[self ya_addObserver:observer forKeyPath:keyPath options:options context:context];
}
}

同理,removeObserver原理大致相同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- (void)ya_removeObserver:(NSObject *)observer
forKeyPath:(NSString *)keyPath {
NSString *observerKey = DISGUISE(observer);
if (!gMainMap) gMainMap = [NSMutableDictionary dictionary];
NSMapTable *subMap = [gMainMap objectForKey:observerKey];
if (subMap) {
NSMutableSet *set = [subMap objectForKey:DISGUISE(self)];
if ([set containsObject:keyPath]) { // O(1)
[set removeObject:keyPath];
[self ya_removeObserver:observer forKeyPath:keyPath];
if (set.count == 0) {
[gMainMap removeObjectForKey:observerKey];
}
return;
}
}
}

hook后的dealloc方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- (void)ya_dealloc {
NSString *observerKey = DISGUISE(observer);
NSMapTable *subMap = [gMainMap objectForKey:observerKey];
if (subMap) {
[[[subMap keyEnumerator] allObjects] enumerateObjectsUsingBlock:^(id object, NSUInteger idx, BOOL *stop) {
NSSet *set = [subMap objectForKey:object];
[set enumerateObjectsUsingBlock:^(NSString *key, BOOL *stop) {
[object removeObserver:observer forKeyPath:key];
}];
// Remove keyPath.
[subMap removeObjectForKey:object];
}];
// Remove subMap.
[gMainMap removeObjectForKey:observerKey];
}
}

但是这种方式有个问题:hook dealloc方法风险极大,因为这个方法关乎App中所有对象的释放,代码一旦有瑕疵,后果不堪设想。子线程调用、hook方式正确与否都会产生潜在的风险,应该尽量避免这种风险极大的操作。那还有其他方式能在对象销毁之前做点事吗?有的。关联对象。
一个对象在dealloc之前会移除自己的所有关联对象,我们可以自定义一个对象,将其作为NSObject的关联对象,同时在这个自定义对象的dealloc方法中做上面的操作。为了避免循环引用,自定义的对象不能强引用NSObject,但是使用weak的话,在实践中会发现被关联的NSObject已经销毁了,根本获取不到,所以这里使用unsafe_unretained

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
@interface NSObject (YADeallocOperation)
// A block can be executed before the object released.
@property (nonatomic, copy) void (^deallocOperation)(id object);
@end



@interface YADeallocOperationObject : NSObject
@property (nonatomic, copy) void (^deallocOperation)(id object);
@property (nonatomic, unsafe_unretained) id mainObject;
@end
@implementation YADeallocOperationObject
- (void)dealloc {
@autoreleasepool {
if (self.deallocOperation) {
self.deallocOperation(self.mainObject);
}
}
}
@end


@implementation NSObject (YADeallocOperation)
- (void)setDeallocOperation:(void (^)(id))deallocOperation {
YADeallocOperationObject *obj = [YADeallocOperationObject new];
obj.deallocOperation = [deallocOperation copy];
obj.mainObject = self;
objc_setAssociatedObject(self, @selector(deallocOperation), obj, OBJC_ASSOCIATION_COPY);
}

- (void (^)(id))deallocOperation {
YADeallocOperationObject *obj = objc_getAssociatedObject(self, _cmd);
if (obj.deallocOperation) {
return obj.deallocOperation;
}
return nil;
}
@end

通过这种方式,可以在一个object释放之前做一些清理工作。

需要注意的是,由于需要严格依赖observer的dealloc方法,如果系统的自动释放池出现了延时释放,会导致observer被销毁之后过一段时间关联对象才会释放,这时候使用unsafe_unretained访问的就是非法地址。所以需要在关联对象的dealloc方法中添加上自己的自动释放池。这一点我确实没有想到,请参考这篇文章

到这里,removeObserver的时机已经搞定了,至于对这些keyPath和object的保存,可以参考上面的代码使用全局的容器,当然也可以把它们放在关联对象YAKVOInfoObject中存储。还有一种方式,是使用代理,不过我想了一下,要是使用代理,首先要设置observer的delegate为object,然后还要在observer销毁的时候移除delegate(也就是object)的keyPath监听,但是keyPath需要保存,observer销毁的时机同样需要hook,似乎observer与object只是多了一层直接联系,并且把结构搞得更加复杂了。。。

最后设置block,当然每个Observer只需要设置一次即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if (!observer.deallocOperation) return;
observer.deallocOperation = ^(id observer) {
NSString *observerKey = DISGUISE(observer);
NSMapTable *subMap = [gMainMap objectForKey:observerKey];
if (subMap) {
[[[subMap keyEnumerator] allObjects] enumerateObjectsUsingBlock:^(id object, NSUInteger idx, BOOL *stop) {
NSSet *set = [subMap objectForKey:object];
[set enumerateObjectsUsingBlock:^(NSString *key, BOOL *stop) {
[object removeObserver:observer forKeyPath:key];
}];
// Remove keyPath.
[subMap removeObjectForKey:object];
}];
// Remove subMap.
[gMainMap removeObjectForKey:observerKey];
}
};

至于未及时移除NSNotificationCenter的监听产生的crash在iOS9之后已经不存在了,而且我在《NSNotificationCenter探索》这篇文章中还利用弱引用容器简单实现了个demo,即便不移除也不会产生异常,作为练习之用。目前App的最低版本已经是iOS9了,不过还是建议addObserver后能有对应的removeObserver,有头有尾,严谨一些。当然,要是铁了心非要对它加上防护,原理与KVO这个类似,或者说更简单了,毕竟不需要保存keyPath、object之类的。

4. KVC crash

根据KVC原理,取值出现异常最后调用setValue:forUndefinedKey:方法,设值出现异常最后调用valueForUndefinedKey:方法,重写即可。

1
2
3
4
5
6
7
8
9
10
@implementation NSObject (YAKVC)
- (void)setValue:(id)value forUndefinedKey:(NSString *)key {
ya_recordCrashInfo(YACrashTypeKVC, self.class, _cmd, nil);
}

- (id)valueForUndefinedKey:(NSString *)key {
ya_recordCrashInfo(YACrashTypeKVC, self.class, _cmd, nil);
return nil;
}
@end

5. NSTimer crash

NSTimer会强引用着target,NSTimer也会被Runloop强引用。如果target强持有着NSTimer,NSTimer和target会循环引用,产生内存泄漏。即便target不强持有着(使用weak)NSTimer,如果NSTimer的销毁逻辑写在了target的dealloc方法里面,也会造成target的dealloc的方法不会得到执行,还是有内存泄漏。
“crash的展现形式和具体的target执行的selector有关”,我们需要做的,就是避免内存泄漏。

可以举个例子:控制器强引用着YASinger对象,YASinger对象强引用着NSTimer。当控制器调用self.singer = nil;的时候,会发现sing方法依然在不停调用。可见,NSTimer内部又强引用着YASinger对象,控制器把YASinger对象的引用计数减1并不能使得它及时销毁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@interface YASinger : NSObject
@property (nonatomic, strong) NSTimer *timer;
@end
@implementation YASinger
- (void)sing {
NSLog(@"YASinger show");
}

- (void)dealloc {
[self.timer invalidate];
}
@end


- (void)startTask {
YASinger *singer = [YASinger new];
singer.timer = [NSTimer scheduledTimerWithTimeInterval:1 target:singer selector:@selector(sing) userInfo:nil repeats:YES];
self.singer = singer;
}

一般解决NSTimer的内存泄漏问题有四种方法。

方法一

修改逻辑
不把timer的销毁逻辑写在target的dealloc方法里面。但是这种方式比较繁琐,需要给外界增加接口,告诉外界target销毁之前必须调用一下它的某个方法(一旦外界忘记调用,那问题还是没有解决)。

1
2
3
4
5
6
7
8
9
10
11
12
13
@interface YASinger : NSObject
@property (nonatomic, strong) NSTimer *timer;
@end
@implementation YASinger
- (void)sing {
NSLog(@"YASinger show");
}

- (void)invalidateSinger {
[self.timer invalidate];
self.timer = nil;
}
@end

外界在废弃YASinger之前需要调用invalidateSinger方法:

1
2
[self.singer invalidateSinger];
self.singer = nil;

这样就解决问题了。

注意,这里YASinger对NSTimer的强弱引用都是没有关系的,主要矛盾不在这里!当NSTimer通过scheduledTimerWithTimeInterval方式创建的时候,它会被直接添加到Runloop中(被Runloop强引用),当NSTimer通过timerWithTimeInterval方式创建的时候,需要手动把它添加到Runloop中。

方法二

使用block作为中间件,可以参考BlocksKit2.2.5的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@implementation NSTimer (BlocksKit)
+ (id)bk_scheduledTimerWithTimeInterval:(NSTimeInterval)inTimeInterval block:(void (^)(NSTimer *timer))block repeats:(BOOL)inRepeats
{
NSParameterAssert(block != nil);
return [self scheduledTimerWithTimeInterval:inTimeInterval target:self selector:@selector(bk_executeBlockFromTimer:) userInfo:[block copy] repeats:inRepeats];
}

+ (id)bk_timerWithTimeInterval:(NSTimeInterval)inTimeInterval block:(void (^)(NSTimer *timer))block repeats:(BOOL)inRepeats
{
NSParameterAssert(block != nil);
return [self timerWithTimeInterval:inTimeInterval target:self selector:@selector(bk_executeBlockFromTimer:) userInfo:[block copy] repeats:inRepeats];
}

+ (void)bk_executeBlockFromTimer:(NSTimer *)aTimer {
void (^block)(NSTimer *) = [aTimer userInfo];
if (block) block(aTimer);
}
@end

NSTimer实例对象直接强引用着NSTimer的类对象(单例),通过持有block再间接持有外界的target。这样NSTimer与target的引用问题就转化为NSTimer的block与target的引用问题。而这类问题是我们非常熟悉的,可以通过weakSelf-strongSelf解决:

1
2
3
4
5
6
7
8
9
- (void)startTask {
YASinger *singer = [YASinger new];
self.singer = singer;
__weak YASinger *weakSinger = singer;
singer.timer = [NSTimer bk_scheduledTimerWithTimeInterval:1 block:^(NSTimer *timer) {
__strong YASinger *singer = weakSinger;
[singer sing];
} repeats:YES];
}

方法三

使用继承自NSObject的对象作为中间件,弱引用着target:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@interface YAWeakTarget : NSObject
@property (nonatomic, weak) id target;
@property (nonatomic, assign) SEL selector;
@property (nonatomic, strong) NSTimer *timer;;
@end
@implementation YAWeakTarget
- (void)timerTask:(NSTimer *)timer {
if (self.target) {
[self.target performSelector:self.selector withObject:timer.userInfo];
} else {
[self.timer invalidate];
}
}
@end

由YAWeakTarget保存着target、selector和timer,并负责间接调用target的selector方法。使用的时候,统一调用YAWeakTarget的timerTask:方法:

1
2
3
4
5
6
7
8
- (void)startTask {
YASinger *singer = [YASinger new];
self.singer = singer;
YAWeakTarget *weakTarget = [YAWeakTarget new];
weakTarget.target = singer;
weakTarget.selector = @selector(sing);
weakTarget.timer = [NSTimer scheduledTimerWithTimeInterval:1 target:weakTarget selector:@selector(timerTask:) userInfo:@{} repeats:YES];
}

一图胜千言:

解决问题的根本原因是NSTimer的invalidate方法得到执行。NSTimer在其invalidate方法调用后,Runloop会自动移除对它的引用,它也会移除对target和userInfo的强引用。

weakTarget一旦发现YASinger为nil,就会调用定时器的invalidate方法。因此,weakTarget强引用着timer也是没关系的(也就是图中的蓝线)。

方法四

使用继承自NSProxy的对象作消息转发:
forwardInvocation:methodSignatureForSelector:两个基本方法必须实现。在这基础上,加上自己需要的。

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
@interface YAProxy : NSProxy
@property (nonatomic, weak) id target;
@end
@implementation YAProxy
- (instancetype)initWithTarget:(id)target {
_target = target;
return self;
}

- (BOOL)respondsToSelector:(SEL)aSelector{
return [_target respondsToSelector:aSelector];
}

- (id)forwardingTargetForSelector:(SEL)selector {
return _target;
}

- (void)forwardInvocation:(NSInvocation *)invocation {
void *null = NULL;
[invocation setReturnValue:&null];
}

- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector{
return [NSObject instanceMethodSignatureForSelector:@selector(init)];
}
@end

使用的时候,这么做就行了:

1
2
3
4
5
- (void)startTask {
YASinger *singer = [YASinger new];
self.singer = singer;
singer.timer = [NSTimer scheduledTimerWithTimeInterval:1 target:[[YAProxy alloc] initWithTarget:singer] selector:@selector(sing) userInfo:@{} repeats:YES];
}

这四种方法就个人而言,我更喜欢方法四。方法一的“废弃定时器”逻辑需要外界调用,容易忘记;方法二需要时刻牢记weak-strong,影响代码美观;方法三不优雅,还要引入中间件。方法四中的Proxy专门做消息转发,而且复用性强,并不单单针对NSTimer的内存泄漏问题。

如此,根本方法有了,剩下的就是做scheduledTimerWithTimeInterval等方法的hook工作了,修改target,不再赘述。

6. Bad Access crash

这种crash产生的原因是向已经销毁的对象发送消息。类型很常见,但是却又比较难排查。防护层面也是有办法的,原理是hook 需要监测的对象的dealloc方法,不让它释放内存(避免调用dealloc方法),并修改它的isa指针从而把之后的消息转发给固定的对象。

假定需要监测“YA”打头的所有对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@implementation NSObject (YAResolveZombie)
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
ya_methodSwizzle(NSObject.class, NSSelectorFromString(@"dealloc"), @selector(ya_dealloc));
});
}

- (void)ya_dealloc {
NSString *clsName = NSStringFromClass(self.class);
if ([clsName hasPrefix:@"YA"]) {
// 修改isa指针
object_setClass(self, YAZombieInfoObject.class);
// 记录类信息
objc_setAssociatedObject(self, "ya_className", clsName, OBJC_ASSOCIATION_COPY_NONATOMIC);
} else {
// 其他对象正常调用dealloc
[self ya_dealloc];
}
}
@end

而这个YAZombieInfoObject对象是现成的(我看有文章使用动态生成class,确实没有必要),类似unrecognized selector crash的防护,记录类名、方法名、调用栈等信息即可。由于类对象是单例,所以只需要防护实例对象(实例方法)就行了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@interface YAZombieInfoObject : NSObject
@end
@implementation YAZombieInfoObject
static inline void forwardingTargetDynamicMethod(id self, SEL _cmd) {
NSString *clsName = objc_getAssociatedObject(self, "ya_className");
NSLog(@"Class name is %@, zombie instance Method is %@, \n调用栈是:%@",clsName, NSStringFromSelector(_cmd), kYACurrentCallStackSymbols);
}

+ (BOOL)resolveInstanceMethod:(SEL)sel {
class_addMethod(self.class, sel, (IMP)forwardingTargetDynamicMethod, "v@:");
[super resolveInstanceMethod:sel];
return YES;
}
@end

这种方法有几个点需要考虑:
1,被监测对象的范围怎么衡量,是所有自定义的类吗?
2,被监测对象延迟释放会占用内存,内存限制多少合适?
3,被监测对象最终还是调要dealloc的,什么时机比较合适?
4,这种方法hook了dealloc方法,太危险,只能用作小流量排查

预防为主,下面简单列举几个这种类型的例子吧。

block为nil

1
2
void (^block)() = nil;
block();

一般是新人才犯这种错误。block在执行之前一定要判空,不然会造成程序读取内存地址时出错。

使用条件运算符可以使风格更加优雅

1
!block ?: block();

指针传递未判空

1
2
3
4
5
6
7
8
9
10
BOOL saveText(NSString *text, NSError **error) {
// 保存文本却失败了
// ...

// 传递error
*error = [NSError errorWithDomain:@"error" code:-1 userInfo:nil];
return NO;
}

saveText(@"test", nil);

同样地,指针不做判空也是会出问题。

assgin修饰对象

1
@property (nonatomic, assign) NSString *cuid;

这种错误一般是在复制、粘贴的时候没仔细review造成的,一旦出问题也很严重,而且排查起来极其蛋疼。。。

self提前释放

为了保证性能,self的修饰是__unsafe_unretained而不是strong一般情况下调用方都不会在方法执行时把这个对象释放所以不增加引用计数)。这就有可能造成方法还没有执行完毕,而自己(self)被释放掉,从而产生坏内存访问。

比如孙源大神文章中的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@class YARequest;
@protocol YARequestDelegate <NSObject>
- (void)requestFinished;
@end

@interface YARequest : NSObject
@property (nonatomic, weak) id <YARequestDelegate> delegate;
@end
@implementation YARequest
- (void)start {
[self.delegate requestFinished];
NSLog(@"%@", self); // EXC_BAD_ACCESS
}
@end

控制器中的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
static YARequest *gRequest;
// 开始执行
- (void)start {
gRequest = [YARequest new];
gRequest.delegate = self;
[gRequest start];
}

// 代理
- (void)requestFinished {
gRequest = nil;
NSLog(@"请求执行完毕");
}

很容易就发现EXC_BAD_ACCESS了。原因是在YARequest的代理方法中,控制器把YARequest置为nil了。这就造成继续在start方法中访问self产生了问题。

注意:这里的gRequest对象,直接使用了全局变量而不是属性。如果使用属性的getter取值,对象作为返回值会自动被auto release,引用计数会被干扰,就没办法复现了。

这个case出现的可能性比较低,但是也值得注意的。

快速遍历下的__autoreleasing

指向引用类型指针的指针作为参数传递时,是使用__autoreleasing修饰的。比如NSFileManager中的删除文件方法:removeItemAtURL:(NSURL *)url error:(NSError *__autoreleasing *)error;
当这种指针遇见enumerateObjectsUsingBlock时,金风玉露一相逢,就该出问题了。比如这个例子,看着没毛病,其实是有问题的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 解决方法: void enumerateArray(__strong NSError **error) {
void enumerateArray(NSError **error) {
NSArray *array = @[@1, @2, @3, @4, @5];
[array enumerateObjectsUsingBlock:^(NSNumber *obj, NSUInteger idx, BOOL *stop) {
NSLog(@"%@", obj);
if (idx == 0 && error) {
*error = [NSError errorWithDomain:@"error" code:-1 userInfo:nil];
}
}];
}

int main(int argc, const char * argv[]) {
NSError *error = nil;
enumerateArray(&error);
if (error) {
NSLog(@"%@", error);
}
return 0;
}

当然,编译器也会警告的:Block captures an autoreleasing out-parameter, which may result in use-after-free bugs。

__autoreleasing修饰时,会把外界的这个对象放到自动释放池中,可是enumerateObjectsUsingBlock恰恰是会自己创建自动释放池的,这样一来,*error被创建之后在下次迭代之前就已经销毁了。于是外界访问已经释放的*error自然会出错了。解决这个问题也很简单,改为__strong修饰即可。

两个小点:
1、enumerateObjectsUsingBlock内部会自己创建自动释放池,for循环for in循环是没有这个能力的。因为这是属于C语言的语法,自然不存在自动释放池的概念。不过要是自己手动添加@autoreleasepool{},那就又另说了。
2、关于为啥指向引用类型的指针的指针默认是使用__autoreleasing修饰的,这个我觉得和引用类型的返回值需要被添加到自动释放池中是同样的道理:本来自己作用域内retain的对象就应该自己把它release,但是偏偏这个对象要延长它的生命周期以供外界使用,所以就需要被添加到autoreleasepool中延迟release了。

IMP调用

坏内存:

1
2
3
4
SEL sel = @selector(showText:);
IMP imp = [self.target methodForSelector:sel]; // self.target为nil
id (*func)(id, SEL, id) = (void *)imp;
func(self, sel, @"text");

正常:

1
2
3
4
SEL sel = @selector(showText:);
IMP imp = [self.target methodForSelector:sel];
id (*func)(id, SEL, id) = (void *)imp;
if (func) func(self, sel, @"text");

当直接使用函数指针调用方法时,倘若获取的imp为nil而直接调用也会产生异常。比如当self.target返回nil时,就会造成imp也为nil。这时候做一个判空再好不过了。

7. 其他类型的crash

还有几个我见过的crash,一并分享一下,以后慢慢补充:

遍历数组的同时移除数组元素

1
2
3
4
5
6
NSMutableArray *array = [NSMutableArray arrayWithArray:@[@1, @2, @3, @4, @5]];
for (NSNumber *num in array) {
if ([num isEqualToNumber:@1]) {
[array removeObject:num];
}
}

使用逆序遍历可以解决:

1
2
3
4
5
for (NSNumber *num in array.reverseObjectEnumerator) {
if ([num isEqualToNumber:@1]) {
[array removeObject:num];
}
}

为啥使用逆序遍历就能解决问题呢?因为正序遍历时,移除元素会造成没有遍历的元素的索引发生异常,而逆序遍历时,索引改变的是遍历过的元素,而没有遍历到的元素索引却没有改变,自然索引也不会越界了。

还有一点,这个reverseObjectEnumerator目前还是好使的,往后好不好使不好说的:

In Objective-C, it is not safe to modify a mutable collection while enumerating through it. Some enumerators may currently allow enumeration of a collection that is modified, but this behavior is not guaranteed to be supported in the future.

非法数值

1
2
3
4
CGFloat width = 100.0;
CGFloat height = 0.0;
CGFloat scale = width / height;
UIView *view = [[UIView alloc] initWithFrame:CGRectMake(0, 0, width, height * scale)];

一个非法的数值在传来传去传到UIView的frame上的时候,就该出问题了:'CALayer bounds contains NaN: [0 0; 100 nan].。判断数值的合法性可以使用isnan()函数和isinf()函数。遇到除法的时候,一定要做个判断。

二、防护组件

写了一个组件:YAEasyProtector
功能有:

  • 防护实例方法和类方法的Unrecognized Selector
  • 防护KVC取值或者设置异常
  • 防护KVO重复添加或者重复移除异常
  • 防护NSArray、NSMutableArray空值、越界
  • 防护NSDictionary、NSMutableDictionary空值
  • 防护NSTimer循环引用

EXC_BAD_ACCESS防护这块风险太大,暂时没有实现。

Crash防护在一定程度上是有意义的,能增强用户体验,但是还有几个问题需要考虑的:

  1. Crash防护原理大多是hook系统方法,那是否会对App产生性能或者速度等方面的影响?甚至带来异常问题?
  2. 发生Crash一定是App出现异常情况了,系统把它杀死也是一种保护机制。倘若执意让App继续运行,会不会造成数据异常、界面异常?

Crash防护只能算作一个兜底策略,目前来说,很多问题可以通过热修复解决,更有针对性。当然,最核心最重要的是小伙伴们都有良好的编程习惯,仔细review。