线上APP的崩溃率一直是衡量APP用户体验的重要条件之一,所以,我们很有必要做一些安全防护,让APP尽可能少的产生Crash,提高用户体验。在以前的项目中零零散散做过一些防护,这次专门为平台封装了一个Pod库,供各个业务线直接引用,降低线上APP崩溃率,并将错误信息上传到服务器进行分析。
其实,在开发过程中我们通过设置Xcode配置项、代码里边做判断、加宏编译等可以发现和避免很多Crash,如代码里边使用respondsToSelector、@available做系统判断等,这个库做的防护主要是代码在运行过程中动态产生的Crash,比如接口返回的数据类型有问题导致产生unrecognized selector、NSString和NSArray越界、KVC和NSDictionary访问key值和设置value值等问题。
GitHub上也有一些封装好的三方库,但是,由于长时间不进行维护了,在新的系统和机型上可能会产生问题,同时,随着iOS开发语言和系统的更新,之前一些容易产生Crash的问题也修复了,所以没必要在进行防护了,比如对象dealloc之后NSNotification还存在会导致崩溃的问题,以及KVO的一些崩溃问题等。
在这里我先列举一些常见的Crash问题,然后分类型进行分析,列举最新的防护手段(因为有的已经不需要进行防护了):
1、低系统使用高系统API产生的Crash。
2、unrecognized selector类型的Crash,尤其接口返回数据类型有匹配的情况下产生的。
3、NSString、NSMutableString及相关类簇产生的Crash。
4、NSArray、NSMutableArray及相关类簇产生的Crash。
5、NSDictionary、NSMutableDictionary及相关类簇产生的Crash。
6、使用KVC产生Crash。
7、KVO相关Crash。
8、NSNotification相关Crash。
9、NSTimer Crash。
10、野指针 Crash。
11、非主线程刷新UI 导致的Crash。
防护手段:
1、低系统使用高系统API产生的Crash:
这个在Xcode中进入工程的Build Settings页面,在“Other C Flags”和“Other C++ Flags”中增加“-Wunguarded-availablility”,设置好之后,如果误调用了高版本API,Clang会检测到并报出警告。
2、unrecognized selector类型的Crash:
方法找不到这种Crash,在崩溃日志中可以说占了很大的比重,尤其是接口返回的数据没有按约定的类型返回或者返回了一个NSNull对象极易产生崩溃。
解决办法:通过runtime来hook NSObject的方法,在自己的方法里边加入try catch去调用原始的方法,这里利用runtime消息转发三部曲机制来处理,这里思考一下,三个方法中我们应该选择哪个方法去做防护呢?第一个方法resolveInstanceMethod中动态增加方法不太合适,第二个方法forwardingTargetForSelector将消息转发给别的对象其实是可以的,但是如果在这个方法中实现,团队中有人想让消息转发走到第三步(forwardInvocation方法)在第三步中做自己的处理,那就会产生问题,因为根本不会走到第三步就被拦截了。所以,为了尽量减少对项目产生侵入性,我选择在第三步对forwardInvocation和methodSignatureForSelector方法做处理来截获将要产生的异常,虽然第三步开销大一些吧。
注意:这里不能对所有的NSObject类都进行这个防护,因为经过测试发现,系统的一些UIView类会在消息转发第三步做自己的处理,比如UIKeyboard相关的。目前,我主要是对@[@"NSNull",@"NSNumber",@"NSString",@"NSDictionary",@"NSArray",@"NSSet"] 这些OC中基础类做了处理。
那对于实现就很简单了,通过hook NSObject的forwardInvocation实例方法和methodSignatureForSelector实例方法,在hook的methodSignatureForSelector方法中构造一个自定义的方法签名,在hook的forwardInvocation方法中加上try catch防护来拦截异常,大概的代码如下:
- (NSMethodSignature *)avoidCrashMethodSignatureForSelector:(SEL)aSelector {
NSMethodSignature *ms = [self avoidCrashMethodSignatureForSelector:aSelector];
if (ms == nil) {
//只对预设置的类构造NSMethodSignature
for (NSString *classStr in classNameArray) {
if ([self isKindOfClass:NSClassFromString(classStr)]) {
//emptyMethod方法是空实现,主要是为了构造一个方法签名
ms = [AvoidCrashTools instanceMethodSignatureForSelector:@selector(emptyMethod)];
break;
}
}
}
return ms;
}
- (void)avoidCrashForwardInvocation:(NSInvocation *)anInvocation {
@try {
[self avoidCrashForwardInvocation:anInvocation];
} @catch (NSException *exception) {
//解析堆栈,上传错误信息
[AvoidCrashTools noteErrorWithException:exception type:TypeUnrecognizedSelector];
} @finally {
}
}
3、NSString、NSMutableString相关方法的Crash:
这里需要注意一下,由于类簇的存在,在hook相关方法的时候,需要考虑类簇,将类簇的相关方法也要hook,比如__NSCFConstantString、 NSTaggedPointerString、 __NSCFString等。我目前防护的相关方法有:
1. - (unichar)characterAtIndex:(NSUInteger)index
2. - (NSString *)substringFromIndex:(NSUInteger)from
3. - (NSString *)substringToIndex:(NSUInteger)to {
4. - (NSString *)substringWithRange:(NSRange)range {
5. - (NSString *)stringByReplacingOccurrencesOfString:(NSString *)target withString:(NSString *)replacement
6. - (NSString *)stringByReplacingOccurrencesOfString:(NSString *)target withString:(NSString *)replacement options:(NSStringCompareOptions)options range:(NSRange)searchRange
7. - (NSString *)stringByReplacingCharactersInRange:(NSRange)range withString:(NSString *)replacement
NSMutableString防护的方法有:
由于NSMutableString是继承于NSString,所以这里和NSString有些同样的方法就不重复写了
1. - (void)replaceCharactersInRange:(NSRange)range withString:(NSString *)aString
2. - (void)insertString:(NSString *)aString atIndex:(NSUInteger)loc
3. - (void)deleteCharactersInRange:(NSRange)range
4、NSArray、NSMutableArray及相关类簇产生的Crash。
5、NSDictionary、NSMutableDictionary及相关类簇产生的Crash。
4和5 容器类的防护,没啥可说的,和3中NSString的防护是类似的,具体要hook的方法自己进行选择就可以了,注意进行自测。
6、使用KVC产生Crash:
使用KVC访问或设置不存在的key或者访问和设置nil等会产生Crash。所以需要进行防护,防护的方法有:
1、setValue:forKey:
2、setValue:forKeyPath:
3、setValue:forUndefinedKey:
4、setValuesForKeysWithDictionary:
5、valueForKey
6、valueForKeyPath
7、valueForUndefinedKey
7、KVO相关Crash:
现在当被观察者dealloc的时候还被监听着,并不会产生Crash。但是,重复移除观察者或者KVO注册观察者与移除观察者不匹配还是会产生Crash的,不过这两种情景在开发过程中很容易就被发现了,所以,没有必要再做防护了。
8、NSNotification相关Crash:
在iOS9之前当一个对象添加了notification之后,如果dealloc的时候,仍然持有notification,就会出现NSNotification类型的crash。苹果在iOS9之后专门针对于这种情况做了处理,所以在iOS9之后,即使开发者没有移除observer,Notification crash也不会再产生了。如果APP目前从iOS9开始适配的话,NSNotification的Crash也是可以忽略了。
9、NSTimer Crash:
在日常开发中大家会经常使用到NSTimer,但使用NSTimer的 scheduledTimerWithTimeInterval:target:selector:userInfo:repeats:接口做重复性的定时任务时存在一个问题:NSTimer会强引用target实例,所以需要在合适的时机invalidate定时器,否则就会由于定时器timer强引用target的关系导致target不能被释放,造成内存泄露,甚至在定时任务触发时导致crash。 crash的展现形式和具体的target执行的selector有关。
关于NSTimer的防Crash和防循环引用,可以用一个NSProxy对象来做桥接,弱引用原来的target,把这个NSProxy对象当做NSTimer的第一个参数传进去,当调用方法的时候还是让原来的target去调用。这样NSTimer强持有NSProxy对象,但是NSProxy对象是弱持有原来的target,所以就解除循环引用了。可以参考YYText中的YYTextWeakProxy类的设计。
10、野指针 Crash:
在目前的ARC时代,发生野指针的情况很少见了,所以没必要专门做野指针相关的防护了。我们可以设置Xcode的配置,在开发过程中自动检测是否有野指针的情况存在,即利用僵尸对象(Zombie Objects)检测工具来检测,设置如下:
11、非主线程刷新UI 导致的Crash:
由于UIKit是非线程安全的,在子线程刷新UI的相关操作可能会引起Crash,这个我们也可以通过配置Xcode环境变量来在开发的过程中发现这类问题,配置如下,在Edit Scheme中勾选Main Thread Checker 即可:
至此,上边列举的常见Crash都有了预防方案,这些方案需要根据自己的项目进行选择。
这里说明一下,当本来会产生的Crash被我们拦截之后,我们需要上传这些信息到我们的服务器,这样才能发现这些问题并及时的修复。所以,当在try catch中产生异常时,需要解析当前堆栈信息,找到崩溃的具体类和具体方法,上传这些异常信息。
解析堆栈信息我参考了别人的代码同时加入了自己的优化代码,大概如下:
+ (void)noteErrorWithException:(NSException *)exception type:(CrashType)type {
//堆栈数据
NSArray *callStackSymbolsArr = [NSThread callStackSymbols];
//获取在哪个类的哪个方法中实例化的数组 字符串格式 -[类名 方法名] 或者 +[类名 方法名]
NSString *mainCallStackSymbolMsg = [AvoidCrashTools getMainCallStackSymbolMessageWithCallStackSymbols:callStackSymbolsArr];
if (mainCallStackSymbolMsg == nil) {
//没有找到具体的崩溃地址,则默认上传前10条堆栈信息
NSInteger max = callStackSymbolsArr.count >= 10 ? 10 : callStackSymbolsArr.count;
NSMutableString *muStr = [NSMutableString string];
for (int i = 0; i < max; i++) {
[muStr appendString:callStackSymbolsArr[i]];
}
mainCallStackSymbolMsg = muStr;
}
NSMutableDictionary *infoDic = [NSMutableDictionary dictionary];
[infoDic setValue:exception.name forKey:@"errorName"];
[infoDic setValue:[AvoidCrashTools typeStrWithCrashType:type] forKey:@"crashType"];
[infoDic setValue:exception.reason forKey:@"errorReason"];
[infoDic setValue:mainCallStackSymbolMsg forKey:@"errorPlace"];
AvoidCrashLog(@"Crash = %@",infoDic);
//将错误信息放在字典里,用通知的形式发送出去
dispatch_async(dispatch_get_main_queue(), ^{
[[NSNotificationCenter defaultCenter] postNotificationName:AvoidCrashNotification object:infoDic userInfo:nil];
});
}
/**
* 获取Crash的具体类和方法<根据正则表达式匹配出来>
*
* @param callStackSymbols 堆栈主要崩溃信息
*
* @return Crash的地方
*/
+ (NSString *)getMainCallStackSymbolMessageWithCallStackSymbols:(NSArray *)callStackSymbols {
//mainCallStackSymbolMsg的格式为 +[类名 方法名] 或者 -[类名 方法名]
__block NSString *mainCallStackSymbolMsg = nil;
//匹配出来的格式为 +[类名 方法名] 或者 -[类名 方法名]
NSString *regularExpStr = @"[-\\+]\\[.+\\]";
NSRegularExpression *regularExp = [[NSRegularExpression alloc] initWithPattern:regularExpStr options:NSRegularExpressionCaseInsensitive error:nil];
for (int index = 0; index < callStackSymbols.count; index++) {
NSString *callStackSymbol = callStackSymbols[index];
[regularExp enumerateMatchesInString:callStackSymbol options:NSMatchingReportProgress range:NSMakeRange(0, callStackSymbol.length) usingBlock:^(NSTextCheckingResult * _Nullable result, NSMatchingFlags flags, BOOL * _Nonnull stop) {
if (result) {
NSString* tempCallStackSymbolMsg = [callStackSymbol substringWithRange:result.range];
//get className
NSString *className = [tempCallStackSymbolMsg componentsSeparatedByString:@" "].firstObject;
className = [className componentsSeparatedByString:@"["].lastObject;
NSBundle *bundle = [NSBundle bundleForClass:NSClassFromString(className)];
//filter category and system class
if (![className hasSuffix:@")"] && bundle == [NSBundle mainBundle]) {
mainCallStackSymbolMsg = tempCallStackSymbolMsg;
}
*stop = YES;
}
}];
//除去AvoidCrash本身的类
if (mainCallStackSymbolMsg.length && ![mainCallStackSymbolMsg containsString:@"AvoidCrash"]) {
break;
}
}
return mainCallStackSymbolMsg;
}
+ (NSString *)typeStrWithCrashType:(CrashType)type {
NSString *typeStr;
switch (type) {
case TypeUnrecognizedSelector:
typeStr = @"Crash_UnrecognizedSelector";
break;
case TypeKVC:
typeStr = @"Crash_KVC";
break;
case TypeNSString:
typeStr = @"Crash_NSString";
break;
case TypeNSMutableString:
typeStr = @"Crash_NSMutableString";
break;
default:
break;
}
return typeStr;
}
解析完堆栈,拼装这些异常信息,最终得到如下的崩溃信息,之后进行上传就可以了:
注意:如果这里errorPlace(崩溃的具体位置)解析不到,则默认会上传前10条堆栈信息,之后从服务器拿到这里的堆栈信息后,配合dsym文件自己进行解析就可以了。
info = {
crashType = "Crash_UnrecognizedSelector";
errorName = NSInvalidArgumentException;
errorPlace = "-[ViewController viewDidLoad]";
errorReason = "-[__NSCFNumber count]: unrecognized selector sent to instance 0xbd806617e7edf3ae";
}
至此,这个库基本就完成了,下面在项目中调用即可:
#ifndef DEBUG
//开启防Crash处理
[[AvoidCrash sharedInstance] openAllAvoidCrash];
//info里边包含发生的崩溃的原因,崩溃的类型和崩溃的具体位置
[AvoidCrash sharedInstance].reportBlock = ^(NSDictionary * _Nullable info) {
dispatch_async(dispatch_get_global_queue(0, 0), ^{
//在这里将拦截的异常上报服务器
...
});
};
#endif