iOS APP 運行時防Crash工具XXShield練就

原文地址php

前言

正在運行的 APP 忽然 Crash,是一件使人不爽的事,會流失用戶,影響公司發展,因此 APP 運行時擁有防 Crash 功能能有效下降 Crash 率,提高 APP 穩定性。可是有時候 APP Crash 是應有的表現,咱們不讓 APPCrash 可能會致使別的邏輯錯誤,不過咱們能夠抓取到應用當前的堆棧信息並上傳至相關的服務器,分析並修復這些 BUG。html

因此本文介紹的 XXShield 庫有兩個重要的功能:git

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

相似的相關技術分析也有 網易iOS App運行時Crash自動防禦實踐github

目前已經實現的功能

  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 在出現沒法解析的方法時有三部曲來進行消息轉發。 詳見Objective-C Runtime 運行時之三:方法與消息編程

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

1 通常適用與 Dynamic 修飾的 Property 2 通常適用與將方法轉發至其餘對象 3 通常適用與消息能夠轉發多個對象,能夠實現相似多繼承或者轉發中心的概念。數組

這裏選擇的是方案二,由於三裏面用到了 NSInvocation 對象,此對象性能開銷較大,並且這種異常若是出現必然頻次較高。最適合將消息轉發到一個備用者對象上。緩存

這裏新建一個智能轉發類。此對象將在其餘對象沒法解析數據時,返回一個 0 來防止 Crash。返回 0 是由於這個通用的智能轉發類作的操做接近向 nil 發送一個消息。ruby

代碼以下服務器

#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:@"i@:"];
    
    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

  2. 在移除觀察者時

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) {
        
        @throw@"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 git@github.com: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, 519224747@qq.com 我的博客 南梔傾寒的簡書

License

XXShield 使用 Apache-2.0 開源協議.

相關文章
相關標籤/搜索