線上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)檢測工具來檢測,設定如下:
![](https://img.laitimes.com/img/__Qf2AjLwojIjJCLyojI0JCLiAzNfRHLGZkRGZkRfJ3bs92YsYTMfVmepNHL9UleNRTSE1kMNRVT3V1MMBjVtJWd0ckW65UbM5WOHJWa5kHT20ESjBjUIF2X0hXZ0xCMx81dvRWYoNHLrdEZwZ1Rh5WNXp1bwNjW1ZUba9VZwlHdssmch1mclRXY39CXldWYtlWPzNXZj9mcw1ycz9WL49zZuBnLwYjN5MDNwkTMwMDNwAjMwIzLc52YucWbp5GZzNmLn9Gbi1yZtl2Lc9CX6MHc0RHaiojIsJye.png)
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