天天看點

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)檢測工具來檢測,設定如下:

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

11、非主線程重新整理UI 導緻的Crash:

由于UIKit是非線程安全的,在子線程重新整理UI的相關操作可能會引起Crash,這個我們也可以通過配置Xcode環境變量來在開發的過程中發現這類問題,配置如下,在Edit Scheme中勾選Main Thread Checker 即可:

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

至此,上邊列舉的常見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
           

繼續閱讀