1. 程式人生 > >iOS-APP-執行時防Crash工具XXShield練就

iOS-APP-執行時防Crash工具XXShield練就

原文地址

前言

正在執行的 APP 突然 Crash,是一件令人不爽的事,會流失使用者,影響公司發展,所以 APP 執行時擁有防 Crash 功能能有效降低 Crash 率,提升 APP 穩定性。但是有時候 APP Crash 是應有的表現,我們不讓 APPCrash 可能會導致別的邏輯錯誤,不過我們可以抓取到應用當前的堆疊資訊並上傳至相關的伺服器,分析並修復這些 BUG。

所以本文介紹的 XXShield 庫有兩個重要的功能:

  1. 防止Crash
  2. 捕獲異常狀態下的崩潰資訊

類似的相關技術分析也有 網易iOS App執行時Crash自動防護實踐

目前已經實現的功能

  1. Unrecoginzed Selector Crash
  2. KVO Crash
  3. Container Crash
  4. NSNotification Crash
  5. NSNull Crash
  6. NSTimer Crash
  7. 野指標 Crash

1 Unrecoginzed Selector Crash

出現原因

由於 Objective-C 是動態語言,所有的訊息傳送都會放在執行時去解析,有時候我們把一個資訊傳遞給了錯誤的型別,就會導致這個錯誤。

解決辦法

Objective-C 在出現無法解析的方法時有三部曲來進行訊息轉發。
詳見Objective-C Runtime 執行時之三:方法與訊息

  1. 動態方法解析
  2. 備用接收者
  3. 完整轉發

1 一般適用與 Dynamic 修飾的 Property
2 一般適用與將方法轉發至其他物件
3 一般適用與訊息可以轉發多個物件,可以實現類似多繼承或者轉發中心的概念。

這裡選擇的是方案二,因為三裡面用到了 NSInvocation 物件,此物件效能開銷較大,而且這種異常如果出現必然頻次較高。最適合將訊息轉發到一個備用者物件上。

這裡新建一個智慧轉發類。此物件將在其他物件無法解析資料時,返回一個 0 來防止 Crash。返回 0 是因為這個通用的智慧轉發類做的操作接近向 nil 傳送一個訊息。

程式碼如下


#import <objc/runtime.h>

/**
 default Implement
 @param target trarget
 @param cmd cmd
 @param ... other param
 @return default Implement is zero
 */
int smartFunction(id target, SEL cmd, ...) {
    return 0;
}

static BOOL __addMethod(Class clazz, SEL sel) {
    NSString *selName = NSStringFromSelector(sel);
    
    NSMutableString *tmpString = [[NSMutableString alloc] initWithFormat:@"%@", selName];
    
    int count = (int)[tmpString replaceOccurrencesOfString:@":"
                                                withString:@"_"
                                                   options:NSCaseInsensitiveSearch
                                                     range:NSMakeRange(0, selName.length)];
    
    NSMutableString *val = [[NSMutableString alloc] initWithString:@"
[email protected]
:"]; for (int i = 0; i < count; i++) { [val appendString:@"@"]; } const char *funcTypeEncoding = [val UTF8String]; return class_addMethod(clazz, sel, (IMP)smartFunction, funcTypeEncoding); } @implementation XXShieldStubObject + (XXShieldStubObject *)shareInstance { static XXShieldStubObject *singleton; if (!singleton) { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ singleton = [XXShieldStubObject new]; }); } return singleton; } - (BOOL)addFunc:(SEL)sel { return __addMethod([XXShieldStubObject class], sel); } + (BOOL)addClassFunc:(SEL)sel { Class metaClass = objc_getMetaClass(class_getName([XXShieldStubObject class])); return __addMethod(metaClass, sel); } @end

我們這裡需要 Hook NSObject的 - (id)forwardingTargetForSelector:(SEL)aSelector 方法啟動訊息轉發。
很多人不知道的是如果想要轉發類方法,只需要實現一個同名的類方法即可,雖然在標頭檔案中此方法並未宣告。


XXStaticHookClass(NSObject, ProtectFW, id, @selector(forwardingTargetForSelector:), (SEL)aSelector) {
    // 1 如果是NSSNumber 和NSString沒找到就是型別不對  切換下型別就好了
    if ([self isKindOfClass:[NSNumber class]] && [NSString instancesRespondToSelector:aSelector]) {
        NSNumber *number = (NSNumber *)self;
        NSString *str = [number stringValue];
        return str;
    } else if ([self isKindOfClass:[NSString class]] && [NSNumber instancesRespondToSelector:aSelector]) {
        NSString *str = (NSString *)self;
        NSNumberFormatter *formatter = [[NSNumberFormatter alloc] init];
        NSNumber *number = [formatter numberFromString:str];
        return number;
    }
    
    BOOL aBool = [self respondsToSelector:aSelector];
    NSMethodSignature *signatrue = [self methodSignatureForSelector:aSelector];
    
    if (aBool || signatrue) {
        return XXHookOrgin(aSelector);
    } else {
        XXShieldStubObject *stub = [XXShieldStubObject shareInstance];
        [stub addFunc:aSelector];
        
        NSString *reason = [NSString stringWithFormat:@"*****Warning***** logic error.target is %@ method is %@, reason : method forword to SmartFunction Object default implement like send message to nil.",
                            [self class], NSStringFromSelector(aSelector)];
        [XXRecord recordFatalWithReason:reason userinfo:nil errorType:EXXShieldTypeUnrecognizedSelector];
        
        return stub;
    }
}
XXStaticHookEnd

這裡彙報了 Crash 資訊,出現訊息轉發一般是一個 logic 錯誤,為必須修復的Bug,上報尤為重要。


2 KVO Crash

出現原因

KVOCrash總結下來有以下2大類。

  1. 不匹配的移除和新增關係。
  2. 觀察者和被觀察者釋放的時候沒有及時斷開觀察者關係。

解決辦法

 

 

尼古拉斯趙四說過 :

趙四

 

對比到程式世界就是,程式世界沒有什麼難以解決的問題都是不可以通過抽象層次來解決的,如果有,那就兩層。
縱觀程式的架構設計,計算機網路協議分層設計,作業系統核心設計等等都是如此。

問題1 : 不成對的新增觀察者和移除觀察者會導致 Crash,以往我們使用 KVO,觀察者和被觀察者都是直接互動的。這裡的設計方案是我們找一個 Proxy 用來做轉發, 真正的觀察者是 Proxy,被觀察者出現了通知資訊,由 Proxy 做分發。所以 Proxy 裡面要儲存一個數據結構 {keypath : [observer1, observer2,...]} 。


@interface XXKVOProxy : NSObject {
    __unsafe_unretained NSObject *_observed;
}

/**
 {keypath : [ob1,ob2](NSHashTable)}
 */
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSHashTable<NSObject *> *> *kvoInfoMap;

@end

我們需要 Hook NSObject的 �KVO 相關方法。


- (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(void *)context;

- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;

  1. 在新增觀察者時


    addObserver

    addObserver

  1. 在移除觀察者時

removeObserver

問題2: 觀察者和被觀察者釋放的時候沒有斷開觀察者關係。
對於觀察者, 既然我們是自己用 Proxy 做的分發,我們自己就需要儲存觀察者,這裡我們簡單的使用 NSHashTable 指定指標持有策略為 weak 即可。

對於被觀察者,我們使用 iOS 界的毒瘤-MethodSwizzling
一文中到的方法。我們在被觀察者上繫結一個關聯物件,在關聯物件的 dealloc 方法中做相關操作即可。


- (void)dealloc {
    @autoreleasepool {
        NSDictionary<NSString *, NSHashTable<NSObject *> *> *kvoinfos =  self.kvoInfoMap.copy;
        for (NSString *keyPath in kvoinfos) {
            // call original  IMP
            __xx_hook_orgin_function_removeObserver(_observed,@selector(removeObserver:forKeyPath:),self, keyPath);
        }
    }
}


3 Container Crash

出現原因

容器在任何程式語言中都尤為重要,容器是資料的載體,很多容器對容器放空值都做了容錯處理。不幸的是 Objective-C 並沒有,容器插入了 nil 就會導致 Crash,容器還有另外一個最容易 Crash 的原因就是下標越界。

解決辦法

常見的容器有 NS(Mutable)Array , NS(Mutable)Dictionary, NSCache 等。我們需要 hook 常見的方法加入檢測功能並且捕獲堆疊資訊上報。

例如


XXStaticHookClass(NSArray, ProtectCont, id, @selector(objectAtIndex:),(NSUInteger)index) {
if (self.count == 0) {
    
    NSString *reason = [NSString stringWithFormat:@"target is %@ method is %@,reason : index %@ out of count %@ of array ",
                        [self class], XXSEL2Str(@selector(objectAtIndex:)), @(index), @(self.count)];
    [XXRecord recordFatalWithReason:reason userinfo:nil errorType:EXXShieldTypeContainer];
    return nil;
}

if (index >= self.count) {
    NSString *reason = [NSString stringWithFormat:@"target is %@ method is %@,reason : index %@ out of count %@ of array ",
                        [self class], XXSEL2Str(@selector(objectAtIndex:)), @(index), @(self.count)];
    [XXRecord recordFatalWithReason:reason userinfo:nil errorType:EXXShieldTypeContainer];
    return nil;
}

return XXHookOrgin(index);
}
XXStaticHookEnd

但是需要注意的是 NSArray 是一個 Class Cluster 的抽象父類,所以我們需要 Hook 到我們真正的子類。

這裡給出一個輔助方法,獲取一個類的所有直接子類:

+ (NSArray *)findAllOf:(Class)defaultClass {
    
    int count = objc_getClassList(NULL, 0);
    
    if (count <= 0) {
        
        @[email protected]"Couldn't retrieve Obj-C class-list";
        
        return @[defaultClass];
    }
    
    NSMutableArray *output = @[].mutableCopy;
    
    Class *classes = (Class *) malloc(sizeof(Class) * count);
    
    objc_getClassList(classes, count);
    
    for (int i = 0; i < count; ++i) {
        
        if (defaultClass == class_getSuperclass(classes[i]))//子類
        {
            [output addObject:classes[i]];
        }
        
    }
    
    free(classes);
    
    return output.copy;
    
}

// 對於NSarray :

//[NSarray array] 和 @[] 的型別是__NSArray0
//只有一個元素的陣列型別 __NSSingleObjectArrayI,
// 其他的大部分是//__NSArrayI,



// 對於NSMutableArray :
//[NSMutableDictionary dictionary] 和 @[].mutableCopy__NSArrayM



// 對於NSDictionary: :

//[NSDictionary dictionary];。 @{}; __NSDictionary0
// 其他一般是  __NSDictionaryI

// 對於NSMutableDictionary: :
// 一般用到的是 __NSDictionaryM

4 NSNotification Crash

出現原因

在 iOS8 及以下的作業系統中新增的觀察者一般需要在 dealloc 的時候做移除,如果開發者忘記移除,則在傳送通知的時候會導致 Crash,而在 iOS9 上即使移忘記除也無所謂,猜想可能是 iOS9 之後系統將通知中心持有物件由 assign 變為了weak

解決辦法

所以這裡兩種解決辦法

  1. 類似 KVO 中間加上 Proxy 層,使用 weak 指標來持有物件
  2. 在 dealloc 的時候將未被移除的觀察者移除

這裡我們使用 iOS 界的毒瘤-MethodSwizzling
一文中到的方法。


5 NSNull Crash

出現原因

雖然 Objecttive-C 不允許開發者將 nil 放進容器內,但是另外一個代表使用者態 的類 NSNull 卻可以放進容器,但令人不爽的是這個類的例項,並不能響應任何方法。

容器中出現 NSNull 一般是 API 介面返回了含有 null 的 JSON �資料,
呼叫方通常將其理解為 NSNumber,NSString,NSDictionary 和 NSArray。 這時開發者如果沒有做好防禦 一旦對 NSNull 這個型別呼叫任何方法都會出現 unrecongized selector 錯誤。

解決辦法

我們在 NSNull 的轉發方法中可以判斷�上面的四種類型是否可以解析。如果可以解析直接將其轉發給�這幾種物件,如果不能則呼叫父類的預設實現。


XXStaticHookClass(NSNull, ProtectNull, id, @selector(forwardingTargetForSelector:), (SEL) aSelector) {
    static NSArray *sTmpOutput = nil;
    if (sTmpOutput == nil) {
        sTmpOutput = @[@"", @0, @[], @{}];
    }
    
    for (id tmpObj in sTmpOutput) {
        if ([tmpObj respondsToSelector:aSelector]) {
            return tmpObj;
        }
    }
    return XXHookOrgin(aSelector);
}
XXStaticHookEnd

6. NSTimer Crash

出現原因

在使用 + (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo 建立定時任務的時候,target� 一般都會持有 timer,timer又會持有 target 物件,在我們沒有正確關閉定時器的時候,timer 會一直持有target 導致記憶體洩漏。

解決辦法

同 KVO 一樣,既然 timer 和 target 直接互動容易出現問題,我們就再找個代理將 target 和 selctor 等資訊儲存到 Proxy 裡,並且是弱引用 target。
這樣避免因為迴圈引用造成的記憶體洩漏。然後在觸發真正 target 事件的時候如果 target 置為 nil 了這時候手動去關閉定時器。


XXStaticHookMetaClass(NSTimer, ProtectTimer,  NSTimer * ,@selector(scheduledTimerWithTimeInterval:target:selector:userInfo:repeats:),
                      (NSTimeInterval)ti , (id)aTarget, (SEL)aSelector, (id)userInfo, (BOOL)yesOrNo ) {
    if (yesOrNo) {
        NSTimer *timer =  nil ;
        @autoreleasepool {
            XXTimerProxy *proxy = [XXTimerProxy new];
            proxy.target = aTarget;
            proxy.aSelector = aSelector;
            timer.timerProxy = proxy;
            timer = XXHookOrgin(ti, proxy, @selector(trigger:), userInfo, yesOrNo);
            proxy.sourceTimer = timer;
        }
        return  timer;
    }
    return XXHookOrgin(ti, aTarget, aSelector, userInfo, yesOrNo);
}
XXStaticHookEnd
@implementation XXTimerProxy

- (void)trigger:(id)userinfo  {
    id strongTarget = self.target;
    if (strongTarget && ([strongTarget respondsToSelector:self.aSelector])) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
        [strongTarget performSelector:self.aSelector withObject:userinfo];
#pragma clang diagnostic pop
    } else {
        NSTimer *sourceTimer = self.sourceTimer;
        if (sourceTimer) {
            [sourceTimer invalidate];
        }
        NSString *reason = [NSString stringWithFormat:@"*****Warning***** logic error target is %@ method is %@, reason : an object dealloc not invalidate Timer.",
                            [self class], NSStringFromSelector(self.aSelector)];
        
        [XXRecord recordFatalWithReason:reason userinfo:nil errorType:(EXXShieldTypeTimer)];
    }
}

@end

7. 野指標 Crash

出現原因

一般在單執行緒條件下使用 ARC 正確的處理引用關係野指標出現的並不頻繁, 但是多執行緒下則不盡然,通常在一個執行緒中釋放了物件,�另外一個執行緒還沒有更新指標狀態 後續訪問就可能會造成隨機性 bug。

之所以是隨機 bug 是因為被回收的記憶體不一定立馬被使用。而且崩潰的位置可能也與原來的邏輯相聚很遠,因此收集的堆疊資訊也可能是雜亂無章沒有什麼價值。
具體的分類請看Bugly整理的腦圖。

 

x

更多關於野指標的文章請參考:

  1. 如何定位Obj-C野指標隨機Crash(一)
  2. 如何定位Obj-C野指標隨機Crash(二)
  3. 如何定位Obj-C野指標隨機Crash(三)

解決辦法

這裡我們可以借用系統的NSZombies物件的設計。
參考buildNSZombie

解決過程

  1. 建立白名單機制,由於系統的類基本不會出現野指標,而且 hook 所有的類開銷較大。所以我們只過濾開發者自定義的類。

  2. hook dealloc 方法 這些需要保護的類我們並不讓其釋放,而是呼叫objc_desctructInstance 方法釋放例項內部所持有屬性的引用和關聯物件。

  3. 利用 object_setClass(id,Class) 修改 isa 指標將其指向一個Proxy 物件(類比�系統的 KVO 實現),此 Proxy 實現了一個和前面所說的智慧轉發類一樣的 return 0的函式。

  4. 在 Proxy 物件內的 - (void)forwardInvocation:(NSInvocation *)anInvocation 中收集 Crash 資訊。

  5. 快取的物件是有成本的,我們在快取物件到達一定數量時候將其釋放(object_dispose)。

存在問題

  1. 延遲釋放記憶體會造成效能浪費,所以預設快取會造成野指標的Class例項的物件限制是50,超出之後會釋放,如果這時候再此觸發了剛好釋放掉的野指標,還是會造成Crash的,

  2. 建議使用的時候如果近期沒有野指標的Crash可以不必開啟,如果野指標型別的Crash突然增多,可以考慮在 hot Patch 中開啟野指標防護,待收取異常資訊之後,再關閉此開關。


收集資訊

由於希望此庫沒有任何外部依賴,所以並未實現響應的上報邏輯。使用者如果需要上報資訊 只需要自行實現 XXRecordProtocol 即可,然後在開啟 SDK 之前將其註冊進入 SDK。
在實現方法裡面會接收到 XXShield 內部定義的錯誤資訊。
開發者無論可以使用諸如 CrashLytics,友盟, bugly等第三庫,或者自行 dump堆疊資訊都可。

@protocol XXRecordProtocol <NSObject>

- (void)recordWithReason:(NSError * )reason userInfo:(NSDictionary *)userInfo;

@end

使用方法

示例工程


git clone [email protected]:ValiantCat/XXShield.git
cd Example
pod install 
open XXShield.xcworkspace

Install

    
  pod "XXShield"
    

Usage


/**
 註冊彙報中心
 
 @param record 彙報中心
 */
+ (void)registerRecordHandler:(id<XXRecordProtocol>)record;

/**
 註冊SDK,預設只要開啟就開啟防Crash,如果需要DEBUG關閉,請在呼叫處使用條件編譯
 本註冊方式不包含EXXShieldTypeDangLingPointer型別
 */
+ (void)registerStabilitySDK;

/**
 本註冊方式不包含EXXShieldTypeDangLingPointer型別
 
 @param ability ability
 */
+ (void)registerStabilityWithAbility:(EXXShieldType)ability;

/**
 ///註冊EXXShieldTypeDangLingPointer需要傳入儲存類名的array,暫時請不要傳入系統框架類
 
 @param ability ability description
 @param classNames 野指標類列表
 */
+ (void)registerStabilityWithAbility:(EXXShieldType)ability withClassNames:(nonnull NSArray<NSString *> *)classNames;


ChangeLog

ChangeLog

單元測試

相關的單元測試在示例工程的Test Target下,有興趣的開發者可以自行檢視。並且已經接入 TrivisCI保證了�程式碼質量。

�Bug&Feature

如果有相關的 Bug 請提 Issue

如果覺得可以擴充新的防護型別,請提 PR 給我。

作者

�ValiantCat, [email protected]
個人部落格
南梔傾寒的簡書

License

XXShield 使用 Apache-2.0 開源協議.



作者:南梔傾寒
連結:https://www.jianshu.com/p/f18876bbe2c4
來源:簡書
簡書著作權歸作者所有,任何形式的轉載都請聯絡作者獲得授權並註明出處。