iOS中對unrecognized selector的防御

在 iOS 開發中,App的崩潰原因有很多種,這篇文章主要闡述我所使用的防止發送未知消息(unrecognized selector)**導致崩潰的方法及思路,希望能起到拋磚引玉的作用。若有錯誤,歡迎指出!

unrecognized selector sent to instance 0x7faa2a132c0

調試過程中如果看到輸出這句話,我們馬上就能知道某個對象并沒有實現向他發送的消息。如果是在已經上線的版本中發現的……GAME OVER...(你也可以用熱修復)

消息發送的機制我們都明白,通過superclass指針逐級向上查找該消息所對應的方法實現。如果直到根類都沒有找到這個方法的實現,運行時會通過補救機制,繼續嘗試查找方法的實現。那么我們能不能通過重寫其中的某個方法,來達到不崩潰的目的?

我們先了解下這個補救機制:

runtime_sendMsg.png

直到最后一步消息無法處理后,我們的App就崩潰了,隨后我們就看到了熟悉的unrecognized selector...
這些方法究竟能做什么,我們來看看蘋果官方的描述(我對其中比較重要的部分翻譯了一下):

resolveInstanceMethod:

resolveInstanceMethod:resolveClassMethod: 方法允許你為一個給定的 selector 動態的提供方法的實現。
OC 方法在底層的C函數的實現中需要至少兩個參數:self 和 _cmd。使用** class_addMethod **函數,你能夠添加一個函數到一個類來作為方法使用。

** forwardingTargetForSelector:**

如果一個對象實現了這個方法,并且返回了一個非空(以及非 self)的結果,返回的對象會用來作為一個新的接收對象,隨后消息會被重新派發給這個新對象。(很明顯,如果你在這個方法中返回了self,那這段代碼將會墜入無限循環。)
如果你這段方法在一個非 root 的類中實現,并且如果這個類根據給定的selector什么都不作返回,那么你應該返回一個 執行父類的實現后返回的結果。

這個方法為對象在開銷大的多的 forwardInvocation: 方法接管之前提供了一次轉發未知消息的機會。這對你只是想簡單的重新定位消息到另一個對象是非常有用的,并且相對普通轉發更快一個數量級。如果轉發的目的是捕捉到NSInvocation,或者操作參數,亦或者是在轉發過程中返回一個值,那這個方法就沒有用了。

** forwardInvocation: **

當對象接受到一條自己不能響應的消息時,運行時會給接收者一次機會來把消息委托給另一個接收者。他委托的消息是通過NSInvocation對象來表示的,然后將這個對象作為** forwardInvocation: 的參數。接收者收到 forwardInvocation: **這條消息后可以選擇轉發這個NSInvacation對象給其他接收對象。(如果這個接收對象也不能響應這條消息,他也會給一次轉發這條消息的機會。)

因此 forwardInvocation: 允許在兩個對象之間通過某個消息來建立關系。轉發給其他對象的這種行為,從某種意義上來說,他“繼承”了他所轉發給的對象的一些特征。

注意
為了響應這個你無法識別的方法,你除了 forwardInvocation: 方法外,還必須重寫 methodSignatureForSelector: ** 方法。在轉發消息的機制中會從 methodSignatureForSelector: **方法來創建NSInvocation對象。所以你必須為給定的 selector 提供一個合適的 method signature ,可以通過預先設置一個或者向另一個對象請求一個。

以上,是蘋果官方文檔對這三個關鍵方法的解釋。

簡而言之:

  • **resolveInstanceMethod: ** 會為對象或類新增一個方法。如果此時這個類是個系統原生的類,比如 NSArray ,你向他發送了一條 setValue: forKey: 的方法,這本身就是一次錯發。此時如果你為他添加這個方法,這個方法一般來說就是冗余的。

  • ** forwardInvocation: ** 必須要經過 methodSignatureForSelector: ** 方法來獲得一個NSInvocation,開銷比較大。蘋果在 forwardingTargetForSelector **的discussion中也說這個方法是一個相對開銷多的多的方法。

  • ** forwardingTargetForSelector: ** 這個方法目的單純,就是轉發給另一個對象,別的他什么都不干,相對以上兩個方法,更適合重寫。

既然** forwardingTargetForSelector: **方法能夠轉發給別其他對象,那我們可以創建一個類,所有的沒查找到的方法全部轉發給這個類,由他來動態的實現。而這個類中應該有一個安全的實現方法來動態的代替原方法的實現。

整理下思路:

  1. 創建一個接收未知消息的類,暫且稱之為 Protector
  2. 創建一個 NSObject 的分類
  3. 在分類中重寫** forwardingTargetForSelector: **,在這個方法中截獲未實現的方法,轉發給 Protector。并為 Protector 動態的添加未實現的方法,最后返回 Protector 的實例對象。
  4. 在分類中新增一個安全的方法實現,來作為 Protector 接收到的未知消息的實現

上代碼:

創建一個Protector類,沒必要new文件出來,動態生成一個就可以了。注意,如果這個方法被執行到兩次,連續兩次創建同一個類一定會崩潰,所以我們要加一層判斷:

- (id)forwardingTargetForSelector:(SEL)aSelector
{
    
    Class protectorCls = NSClassFromString(@"Protector");
    if (!protectorCls)
    {
        protectorCls = objc_allocateClassPair([NSObject class], "Protector", 0);
        objc_registerClassPair(protectorCls);
    }
}

然后我們要為這個類添加方法,在添加方法之前我們也要做一層判斷,是否已經添加過這個方法(此處文末有更新說明)

        NSString *selectorStr = NSStringFromSelector(aSelector);
        // 檢查類中是否存在該方法,不存在則添加
        if (![self isExistSelector:aSelector inClass:protectorCls])
        {
            class_addMethod(protectorCls, aSelector, [self safeImplementation:aSelector],
                            [selectorStr UTF8String]);
        }

這里面有一個** safeImplementation: **方法,其實就是生成一個IMP,然后返回。這里我只是簡單的輸出一句話:

// 一個安全的方法實現
- (IMP)safeImplementation:(SEL)aSelector
{
    IMP imp = imp_implementationWithBlock(^()
    {
        NSLog(@"PROTECTOR: %@ Done", NSStringFromSelector(aSelector));
    });
    return imp;
}

isExistSelector: inClass:的實現代碼如下,主要是根據給定的selector在class中查找,如果找到對應的實現則返回YES:

// 判斷某個class中是否存在某個SEL
- (BOOL)isExistSelector: (SEL)aSelector inClass:(Class)currentClass
{
    BOOL isExist = NO;
    unsigned int methodCount = 0;
    Method *methods = class_copyMethodList(currentClass, &methodCount);
    
    for (int i = 0; i < methodCount; i++)
    {
        Method temp = methods[i];
        SEL sel = method_getName(temp);
        NSString *methodName = NSStringFromSelector(sel);
        if ([methodName isEqualToString: NSStringFromSelector(aSelector)])
        {
            isExist = YES;
            break;
        }
    }
    return isExist;
}

回到我們的** forwardingTargetForSelector: **方法,接下來就該返回Protector的實例了:

        Class Protector = [protectorCls class];
        id instance = [[Protector alloc] init];
        
        return instance;

但是經過測試,目前的代碼還有個問題:App啟動時有些系統方法也會經由這個方法轉發對象,啟動完成就不存在這種問題。所以我們在** forwardingTargetForSelector: **方法中要再加一次判斷,如果 self 是我們所關心的類,我們才轉發對象,否則返回nil。
以下是 **forwardTargetForSelector: **完整的代碼,這里我關心的是UIResponder 和 NSNull這兩個類(你也可以添加諸如NSArray\NSDictionary等類):

// 重寫消息轉發方法
- (id)forwardingTargetForSelector:(SEL)aSelector
{
    NSString *selectorStr = NSStringFromSelector(aSelector);
    // 做一次類的判斷,只對 UIResponder 和 NSNull 有效
    if ([[self class] isSubclassOfClass: NSClassFromString(@"UIResponder")] ||
        [self isKindOfClass: [NSNull class]])
    {
        NSLog(@"PROTECTOR: -[%@ %@]", [self class], selectorStr);
        NSLog(@"PROTECTOR: unrecognized selector \"%@\" sent to instance: %p", selectorStr, self);
        // 查看調用棧
        NSLog(@"PROTECTOR: call stack: %@", [NSThread callStackSymbols]);

        // 對保護器插入該方法的實現
        Class protectorCls = NSClassFromString(@"Protector");
        if (!protectorCls)
        {
            protectorCls = objc_allocateClassPair([NSObject class], "Protector", 0);
            objc_registerClassPair(protectorCls);
        }
        
        // 檢查類中是否存在該方法,不存在則添加
        if (![self isExistSelector:aSelector inClass:protectorCls])
        {
            class_addMethod(protectorCls, aSelector, [self safeImplementation:aSelector],
                            [selectorStr UTF8String]);
        }
        
        Class Protector = [protectorCls class];
        id instance = [[Protector alloc] init];
        
        return instance;
    }
    else
    {
        return nil;
    }
}

以上就是所有代碼(所以我就不上傳DEMO了)。

實驗結果:

試驗中,我對一個label perform了一個未知的方法:callMeTryTry,由于他是一個UIRespnder的子類,所以會進入調用我們的 Protector。控制臺輸出如下,并且沒有崩潰。(所有日志不是真的崩潰時候的日志,前面都帶有 PROTECTOR 字樣,全都是我代碼里的輸出),你也可以不進行類的判斷試一下,你會看到很多這樣的輸出。

console_log.png

以上就是本文全部,希望對各位有幫助,有問題也可以互相交流。

20170214 更新:
class_addMethod 方法之前,其實不需要判斷是否已添加過這個方法。因為蘋果官方文檔說 class_addMethod 方法只會覆蓋父類的方法,或者不存在的方法。如果是已經存在的方法,他不會重復添加或替代。
所以** - (BOOL)isExistSelector: (SEL)aSelector inClass:(Class)currentClass **可以不要了。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容