線上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<NSString *> *)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