iOS 開發:『Crash 防護系統』(一)Unrecognized Selector

行走少年郎發表於2019-08-23
  • 本文首發於我的個人部落格:『不羈閣』
  • 文章連結:傳送門
  • 本文更新時間:2019年08月23日12:15:21

本文是 『Crash 防護系統』系列 第一篇。 這個系列將會介紹如何設計一套 APP Crash 防護系統。這套系統採用 AOP(面向切面程式設計)的設計思想,利用 Objective-C語言的執行時機制,在不侵入原有專案程式碼的基礎之上,通過在 APP 執行時階段對崩潰因素的的攔截和處理,使得 APP 能夠持續穩定正常的執行。

通過本文,您將瞭解到:

  1. Crash 防護系統開篇
  2. 防護原理簡介和常見 Crash
  3. Method Swizzling 方法的封裝
  4. Unrecognized Selector 防護 4.1 unrecognized selector sent to instance(找不到物件方法的實現) 4.2 unrecognized selector sent to class(找不到類方法實現)

文中示例程式碼在: bujige / YSC-Avoid-Crash


1. Crash 防護系統開篇

APP 的崩潰問題,一直以來都是開發過程中重中之重的問題。日常開發階段的崩潰,發現後還能夠立即處理。但是一旦釋出上架的版本出現問題,就需要緊急加班修復 BUG,再更新上架新版本了。在這個過程中, 說不定會因為崩潰而導致關鍵業務中斷、使用者存留率下降、品牌口碑變差、生命週期價值下降等,最終導致流失使用者,影響到公司的發展。

當然,避免崩潰問題的最好辦法就是不產生崩潰。在開發的過程中就要儘可能地保證程式的健壯性。但是,人又不是機器,不可能不犯錯。不可能存在沒有 BUG 的程式。但是如果能夠利用一些語言機制和系統方法,設計一套防護系統,使之能夠有效的降低 APP 的崩潰率,那麼不僅 APP 的穩定性得到了保障,而且最重要的是可以減少不必要的加班。

這套 Crash 防護系統被命名為:『YSCDefender(防衛者)』。Defender 也是路虎旗下最硬派的越野車系。在電影《Tomb Raider》裡面,由 Angelina Jolie 飾演的英國女探險家 Lara Croft,所駕駛的就是一臺 Defender。Defender 也是我比較喜歡的車之一。

不過呢,這不重要。。。我就是為這個專案起了個花裡胡哨的名字,並給這個名字賦予了一些無聊的意義。。。


2. 防護原理簡介和常見 Crash

Objective-C 語言是一門動態語言,我們可以利用 Objective-C 語言的 Runtime 執行時機制,對需要 Hook 的類新增 Category(分類),在各個分類的 +(void)load; 中通過 Method Swizzling 攔截容易造成崩潰的系統方法,將系統原有方法與新增的防護方法的 selector(方法選擇器) 與 IMP(函式實現指標)進行對調。然後在替換方法中新增防護操作,從而達到避免以及修復崩潰的目的。

通過 Runtime 機制可以避免的常見 Crash :

  1. unrecognized selector sent to instance(找不到物件方法的實現)
  2. unrecognized selector sent to class(找不到類方法實現)
  3. KVO Crash
  4. KVC Crash
  5. NSNotification Crash
  6. NSTimer Crash
  7. Container Crash(集合類操作造成的崩潰,例如陣列越界,插入 nil 等)
  8. NSString Crash (字串類操作造成的崩潰)
  9. Bad Access Crash (野指標)
  10. Threading Crash (非主執行緒刷 UI)
  11. NSNull Crash

這一篇我們先來講解下 unrecognized selector sent to instance(找不到物件方法的實現)unrecognized selector sent to class(找不到類方法實現) 造成的崩潰問題。


3. Method Swizzling 方法的封裝

由於這幾種常見 Crash 的防護都需要用到 Method Swizzling 技術。所以我們可以為 NSObject 新建一個分類,將 Method Swizzling 相關的方法封裝起來。

/********************* NSObject+MethodSwizzling.h 檔案 *********************/

#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface NSObject (MethodSwizzling)

/** 交換兩個類方法的實現
 * @param originalSelector  原始方法的 SEL
 * @param swizzledSelector  交換方法的 SEL
 * @param targetClass  類
 */
+ (void)yscDefenderSwizzlingClassMethod:(SEL)originalSelector withMethod:(SEL)swizzledSelector withClass:(Class)targetClass;

/** 交換兩個物件方法的實現
 * @param originalSelector  原始方法的 SEL
 * @param swizzledSelector 交換方法的 SEL
 * @param targetClass  類
 */
+ (void)yscDefenderSwizzlingInstanceMethod:(SEL)originalSelector withMethod:(SEL)swizzledSelector withClass:(Class)targetClass;

@end

/********************* NSObject+MethodSwizzling.m 檔案 *********************/

#import "NSObject+MethodSwizzling.h"
#import <objc/runtime.h>

@implementation NSObject (MethodSwizzling)

// 交換兩個類方法的實現
+ (void)yscDefenderSwizzlingClassMethod:(SEL)originalSelector withMethod:(SEL)swizzledSelector withClass:(Class)targetClass {
    swizzlingClassMethod(targetClass, originalSelector, swizzledSelector);
}

// 交換兩個物件方法的實現
+ (void)yscDefenderSwizzlingInstanceMethod:(SEL)originalSelector withMethod:(SEL)swizzledSelector withClass:(Class)targetClass {
    swizzlingInstanceMethod(targetClass, originalSelector, swizzledSelector);
}

// 交換兩個類方法的實現 C 函式
void swizzlingClassMethod(Class class, SEL originalSelector, SEL swizzledSelector) {

    Method originalMethod = class_getClassMethod(class, originalSelector);
    Method swizzledMethod = class_getClassMethod(class, swizzledSelector);

    BOOL didAddMethod = class_addMethod(class,
                                        originalSelector,
                                        method_getImplementation(swizzledMethod),
                                        method_getTypeEncoding(swizzledMethod));

    if (didAddMethod) {
        class_replaceMethod(class,
                            swizzledSelector,
                            method_getImplementation(originalMethod),
                            method_getTypeEncoding(originalMethod));
    } else {
        method_exchangeImplementations(originalMethod, swizzledMethod);
    }
}

// 交換兩個物件方法的實現 C 函式
void swizzlingInstanceMethod(Class class, SEL originalSelector, SEL swizzledSelector) {
    Method originalMethod = class_getInstanceMethod(class, originalSelector);
    Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);

    BOOL didAddMethod = class_addMethod(class,
                                        originalSelector,
                                        method_getImplementation(swizzledMethod),
                                        method_getTypeEncoding(swizzledMethod));

    if (didAddMethod) {
        class_replaceMethod(class,
                            swizzledSelector,
                            method_getImplementation(originalMethod),
                            method_getTypeEncoding(originalMethod));
    } else {
        method_exchangeImplementations(originalMethod, swizzledMethod);
    }
}

@end
複製程式碼

4. Unrecognized Selector 防護

4.1 unrecognized selector sent to instance(找不到物件方法的實現)

如果被呼叫的物件方法沒有實現,那麼程式在執行中呼叫該方法時,就會因為找不到對應的方法實現,從而導致 APP 崩潰。比如下面這樣的程式碼:

UIButton *testButton = [[UIButton alloc] init];
[testButton performSelector:@selector(someMethod:)];
複製程式碼

testButton 是一個 UIButton 物件,而 UIButton 類中並沒有實現 someMethod: 方法。所以向 testButoon 物件傳送 someMethod: 方法,就會導致 testButoon 物件無法找到對應的方法實現,最終導致 APP 的崩潰。

那麼有辦法解決這類因為找不到方法的實現而導致程式崩潰的方法嗎?

我們從『 iOS 開發:『Runtime』詳解(一)基礎知識』知道了訊息轉發機制中三大步驟:訊息動態解析訊息接受者重定向訊息重定向。通過這三大步驟,可以讓我們在程式找不到呼叫方法崩潰之前,攔截方法呼叫。

大致流程如下:

  1. 訊息動態解析:Objective-C 執行時會呼叫 +resolveInstanceMethod: 或者 +resolveClassMethod:,讓你有機會提供一個函式實現。我們可以通過重寫這兩個方法,新增其他函式實現,並返回 YES, 那執行時系統就會重新啟動一次訊息傳送的過程。若返回 NO 或者沒有新增其他函式實現,則進入下一步。
  2. 訊息接受者重定向:如果當前物件實現了 forwardingTargetForSelector:,Runtime 就會呼叫這個方法,允許我們將訊息的接受者轉發給其他物件。如果這一步方法返回 nil,則進入下一步。
  3. 訊息重定向:Runtime 系統利用 methodSignatureForSelector: 方法獲取函式的引數和返回值型別。
    • 如果 methodSignatureForSelector: 返回了一個 NSMethodSignature 物件(函式簽名),Runtime 系統就會建立一個 NSInvocation 物件,並通過 forwardInvocation: 訊息通知當前物件,給予此次訊息傳送最後一次尋找 IMP 的機會。
    • 如果 methodSignatureForSelector: 返回 nil。則 Runtime 系統會發出 doesNotRecognizeSelector: 訊息,程式也就崩潰了。

Runtime 訊息轉發步驟圖.png

這裡我們選擇第二步(訊息接受者重定向)來進行攔截。因為 -forwardingTargetForSelector 方法可以將訊息轉發給一個物件,開銷較小,並且被重寫的概率較低,適合重寫。

具體步驟如下:

  1. 給 NSObject 新增一個分類,在分類中實現一個自定義的 -ysc_forwardingTargetForSelector: 方法;
  2. 利用 Method Swizzling 將 -forwardingTargetForSelector:-ysc_forwardingTargetForSelector: 進行方法交換。
  3. 在自定義的方法中,先判斷當前物件是否已經實現了訊息接受者重定向和訊息重定向。如果都沒有實現,就動態建立一個目標類,給目標類動態新增一個方法。
  4. 把訊息轉發給動態生成類的例項物件,由目標類動態建立的方法實現,這樣 APP 就不會崩潰了。

實現程式碼如下:

#import "NSObject+SelectorDefender.h"
#import "NSObject+MethodSwizzling.h"
#import <objc/runtime.h>

@implementation NSObject (SelectorDefender)

+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
 
        // 攔截 `-forwardingTargetForSelector:` 方法,替換自定義實現
        [NSObject yscDefenderSwizzlingInstanceMethod:@selector(forwardingTargetForSelector:)
                                          withMethod:@selector(ysc_forwardingTargetForSelector:)
                                           withClass:[NSObject class]];
        
    });
}

// 自定義實現 `-ysc_forwardingTargetForSelector:` 方法
- (id)ysc_forwardingTargetForSelector:(SEL)aSelector {
    
    SEL forwarding_sel = @selector(forwardingTargetForSelector:);
    
    // 獲取 NSObject 的訊息轉發方法
    Method root_forwarding_method = class_getInstanceMethod([NSObject class], forwarding_sel);
    // 獲取 當前類 的訊息轉發方法
    Method current_forwarding_method = class_getInstanceMethod([self class], forwarding_sel);
    
    // 判斷當前類本身是否實現第二步:訊息接受者重定向
    BOOL realize = method_getImplementation(current_forwarding_method) != method_getImplementation(root_forwarding_method);
    
    // 如果沒有實現第二步:訊息接受者重定向
    if (!realize) {
        // 判斷有沒有實現第三步:訊息重定向
        SEL methodSignature_sel = @selector(methodSignatureForSelector:);
        Method root_methodSignature_method = class_getInstanceMethod([NSObject class], methodSignature_sel);
        
        Method current_methodSignature_method = class_getInstanceMethod([self class], methodSignature_sel);
        realize = method_getImplementation(current_methodSignature_method) != method_getImplementation(root_methodSignature_method);
        
        // 如果沒有實現第三步:訊息重定向
        if (!realize) {
            // 建立一個新類
            NSString *errClassName = NSStringFromClass([self class]);
            NSString *errSel = NSStringFromSelector(aSelector);
            NSLog(@"出問題的類,出問題的物件方法 == %@ %@", errClassName, errSel);
            
            NSString *className = @"CrachClass";
            Class cls = NSClassFromString(className);
            
            // 如果類不存在 動態建立一個類
            if (!cls) {
                Class superClsss = [NSObject class];
                cls = objc_allocateClassPair(superClsss, className.UTF8String, 0);
                // 註冊類
                objc_registerClassPair(cls);
            }
            // 如果類沒有對應的方法,則動態新增一個
            if (!class_getInstanceMethod(NSClassFromString(className), aSelector)) {
                class_addMethod(cls, aSelector, (IMP)Crash, "@@:@");
            }
            // 把訊息轉發到當前動態生成類的例項物件上
            return [[cls alloc] init];
        }
    }
    return [self ysc_forwardingTargetForSelector:aSelector];
}

// 動態新增的方法實現
static int Crash(id slf, SEL selector) {
    return 0;
}

@end
複製程式碼

4.2 unrecognized selector sent to class(找不到類方法實現)

同物件方法一樣,如果被呼叫的類方法沒有實現,那麼同樣也會導致 APP 崩潰。

例如,有這樣一個類,宣告瞭一個 + (id)aClassFunc; 的類方法, 但是並沒有實現,就像下邊的 YSCObject 這樣。

/********************* YSCObject.h 檔案 *********************/
#import <Foundation/Foundation.h>

@interface YSCObject : NSObject

+ (id)aClassFunc;

@end

/********************* YSCObject.m 檔案 *********************/
#import "YSCObject.h"

@implementation YSCObject

@end
複製程式碼

如果我們直接呼叫 [YSCObject aClassFunc]; 就會導致崩潰。

找不到類方法實現的解決方法和之前類似,我們可以利用 Method Swizzling 將 +forwardingTargetForSelector:+ysc_forwardingTargetForSelector: 進行方法交換。

#import "NSObject+SelectorDefender.h"
#import "NSObject+MethodSwizzling.h"
#import <objc/runtime.h>

@implementation NSObject (SelectorDefender)

+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        
        // 攔截 `+forwardingTargetForSelector:` 方法,替換自定義實現
        [NSObject yscDefenderSwizzlingClassMethod:@selector(forwardingTargetForSelector:)
                                       withMethod:@selector(ysc_forwardingTargetForSelector:)
                                        withClass:[NSObject class]];
    });
}

// 自定義實現 `+ysc_forwardingTargetForSelector:` 方法
+ (id)ysc_forwardingTargetForSelector:(SEL)aSelector {
    SEL forwarding_sel = @selector(forwardingTargetForSelector:);
    
    // 獲取 NSObject 的訊息轉發方法
    Method root_forwarding_method = class_getClassMethod([NSObject class], forwarding_sel);
    // 獲取 當前類 的訊息轉發方法
    Method current_forwarding_method = class_getClassMethod([self class], forwarding_sel);
    
    // 判斷當前類本身是否實現第二步:訊息接受者重定向
    BOOL realize = method_getImplementation(current_forwarding_method) != method_getImplementation(root_forwarding_method);
    
    // 如果沒有實現第二步:訊息接受者重定向
    if (!realize) {
        // 判斷有沒有實現第三步:訊息重定向
        SEL methodSignature_sel = @selector(methodSignatureForSelector:);
        Method root_methodSignature_method = class_getClassMethod([NSObject class], methodSignature_sel);
        
        Method current_methodSignature_method = class_getClassMethod([self class], methodSignature_sel);
        realize = method_getImplementation(current_methodSignature_method) != method_getImplementation(root_methodSignature_method);
        
        // 如果沒有實現第三步:訊息重定向
        if (!realize) {
            // 建立一個新類
            NSString *errClassName = NSStringFromClass([self class]);
            NSString *errSel = NSStringFromSelector(aSelector);
            NSLog(@"出問題的類,出問題的類方法 == %@ %@", errClassName, errSel);
            
            NSString *className = @"CrachClass";
            Class cls = NSClassFromString(className);
            
            // 如果類不存在 動態建立一個類
            if (!cls) {
                Class superClsss = [NSObject class];
                cls = objc_allocateClassPair(superClsss, className.UTF8String, 0);
                // 註冊類
                objc_registerClassPair(cls);
            }
            // 如果類沒有對應的方法,則動態新增一個
            if (!class_getInstanceMethod(NSClassFromString(className), aSelector)) {
                class_addMethod(cls, aSelector, (IMP)Crash, "@@:@");
            }
            // 把訊息轉發到當前動態生成類的例項物件上
            return [[cls alloc] init];
        }
    }
    return [self ysc_forwardingTargetForSelector:aSelector];
}

// 動態新增的方法實現
static int Crash(id slf, SEL selector) {
    return 0;
}

@end
複製程式碼

將 4.1 和 4.2 結合起來就可以攔截所有未實現的類方法和物件方法了。具體實現可參考程式碼: bujige / YSC-Avoid-Crash


參考資料


相關文章