iOS線上防Crash處理並上傳未發生的崩潰日誌,降低線上APP崩潰率

     線上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

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章