在 iOS 開發中,App的崩潰原因有很多種,這篇文章主要闡述我所使用的防止發送未知消息(unrecognized selector)**導致崩潰的方法及思路,希望能起到拋磚引玉的作用。若有錯誤,歡迎指出!
unrecognized selector sent to instance 0x7faa2a132c0
調試過程中如果看到輸出這句話,我們馬上就能知道某個對象并沒有實現向他發送的消息。如果是在已經上線的版本中發現的……GAME OVER...(你也可以用熱修復)
消息發送的機制我們都明白,通過superclass指針逐級向上查找該消息所對應的方法實現。如果直到根類都沒有找到這個方法的實現,運行時會通過補救機制,繼續嘗試查找方法的實現。那么我們能不能通過重寫其中的某個方法,來達到不崩潰的目的?
我們先了解下這個補救機制:
直到最后一步消息無法處理后,我們的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: **方法能夠轉發給別其他對象,那我們可以創建一個類,所有的沒查找到的方法全部轉發給這個類,由他來動態的實現。而這個類中應該有一個安全的實現方法來動態的代替原方法的實現。
整理下思路:
- 創建一個接收未知消息的類,暫且稱之為 Protector
- 創建一個 NSObject 的分類
- 在分類中重寫** forwardingTargetForSelector: **,在這個方法中截獲未實現的方法,轉發給 Protector。并為 Protector 動態的添加未實現的方法,最后返回 Protector 的實例對象。
- 在分類中新增一個安全的方法實現,來作為 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 字樣,全都是我代碼里的輸出),你也可以不進行類的判斷試一下,你會看到很多這樣的輸出。
以上就是本文全部,希望對各位有幫助,有問題也可以互相交流。
20170214 更新:
class_addMethod 方法之前,其實不需要判斷是否已添加過這個方法。因為蘋果官方文檔說 class_addMethod 方法只會覆蓋父類的方法,或者不存在的方法。如果是已經存在的方法,他不會重復添加或替代。
所以** - (BOOL)isExistSelector: (SEL)aSelector inClass:(Class)currentClass **可以不要了。