iOS 錯誤記錄中最常見的訊息之一,就是 unrecognized selector sent to instance。
不過,這個錯誤其實不是「立即死亡」。Objective-C 執行階段在發生錯誤前,會給物件三次機會。這套補救流程就是訊息轉送(Message Forwarding)。
上一篇 objc_msgSend 文章整理到「方法搜尋失敗後會執行訊息轉送的 3 個階段」。這次會用程式碼逐一拆解這三個階段。重點是為什麼第 2 階段被稱為Fast Forwarding,以及第 3 階段為什麼需要方法簽章。
全貌:三次機會與成本
如果方法搜尋一路到達最上層類別,仍找不到 IMP(指向方法實際實作的函式指標),執行階段就會依序提出以下問題。
| 階段 | 方法 | 問題 | 成本 |
|---|---|---|---|
| 1. 動態方法解析 | resolveInstanceMethod: |
「現在要不要新增方法?」 | 低 |
| 2. 替代接收者 | forwardingTargetForSelector: |
「有沒有其他物件可以代為接收?」 | 低(Fast) |
| 3. 完整轉送 | methodSignatureForSelector: + forwardInvocation: |
「我把整個訊息交給你,你要自行處理嗎?」 | 高 |
順序也就是成本順序。執行階段會先嘗試最便宜的方法;全部遭到拒絕後,呼叫doesNotRecognizeSelector:,接著就會發生那個著名的錯誤。
第 1 階段:resolveInstanceMethod: — 現在新增方法
第一個問題會以類別方法的形式送出。在這裡透過class_addMethod加入實作並回傳 YES,訊息傳送就會從頭重新開始,這次便能成功。
void dynamicIMP(id self, SEL _cmd) {
NSLog(@"動態新增的實作");
}
+ (BOOL)resolveInstanceMethod:(SEL)sel {
if (sel == @selector(dynamicMethod)) {
class_addMethod(self, sel, (IMP)dynamicIMP, "v@:");
return YES;
}
return [super resolveInstanceMethod:sel];
}
第三個引數的"v@:"是型別編碼。回傳值是 void(v)、接收者是 id(@)、選擇子是(:)——所有 Objective-C 方法都會接收兩個隱藏引數 self 與_cmd,這件事在此便清楚呈現。
Core Data 就是這個階段的代表性使用者。以@dynamic宣告的屬性在編譯時沒有存取子,第一次呼叫時才會在這個階段即時建立。
有一點要注意:resolveInstanceMethod:即使不是轉送情境,也可能被呼叫。respondsToSelector:或 KVC(Key-Value Coding)在內部查詢方法時也會呼叫它,因此在這裡記錄日誌,頻率會遠高於預期。
第 2 階段:forwardingTargetForSelector: — Fast Forwarding 的真相
第二個問題是:「如果你無法處理,能不能告訴我哪個物件可以代為處理?」
- (id)forwardingTargetForSelector:(SEL)aSelector {
if ([self.helper respondsToSelector:aSelector]) {
return self.helper;
}
return [super forwardingTargetForSelector:aSelector];
}
回傳非 nil 物件後,整個訊息會重新傳送給該物件。與第 3 階段相比,就能清楚看出這個階段為何稱為Fast Forwarding:它不建立 NSInvocation 物件,只替換接收者,因此成本幾乎和一般訊息傳送相同。
這也是實務用途很明確的階段。
- 模擬多重繼承:依選擇子將訊息委派給多個 helper 物件,就能不靠繼承整合多個類別的能力。
- weak proxy:用來打破 NSTimer retain cycle 的中介代理物件,便利用了這個位置(精確地說,是 NSProxy 的轉送)。
- API 安全網:在舊版 OS 上,將僅存在於新版 OS 的方法導向替代物件的防禦性程式碼。
第 3 階段:完整轉送 — 為什麼必須先取得簽章
第 2 階段也遭拒絕後,執行階段會使用最後手段。但這個階段不是覆寫一個方法,而是必須成對覆寫兩個方法。
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
NSMethodSignature *sig = [super methodSignatureForSelector:aSelector];
if (!sig) {
sig = [self.target methodSignatureForSelector:aSelector];
}
return sig;
}
- (void)forwardInvocation:(NSInvocation *)invocation {
for (id target in self.targets) {
if ([target respondsToSelector:invocation.selector]) {
[invocation invokeWithTarget:target];
}
}
}
為什麼簽章要先取得?因為執行階段若要把訊息包裝成 NSInvocation 物件,就必須知道有幾個引數,以及每個引數佔多少位元組。如果methodSignatureForSelector:無法回傳有效簽章,forwardInvocation: 根本不會被呼叫,而是直接發生錯誤。
取得 NSInvocation 後,能做的事就多了。你可以修改引數,像上面的範例一樣把同一則訊息廣播給多個物件(多播代理),也可以記錄回應,稍後再重播。NSUndoManager 的prepareWithInvocationTarget:正是採用這種方式:先將可復原的方法呼叫擷取成 NSInvocation,再於 undo 時重播。
它最有彈性,但成本也最高。由於建立 NSInvocation 和封裝引數都需要成本,Apple 文件也明確指出它「比一般訊息傳送慢得多」。若是重視效能的路徑,在第 2 階段結束才是正確答案。
陷阱:respondsToSelector: 不知道訊息轉送
即使物件能透過轉送正常處理訊息,詢問respondsToSelector:時仍會回答 NO。選擇子查詢只查看方法清單,而轉送路徑必須實際傳送訊息才會運作。
因此,要正確建立以轉送為基礎的 proxy,也必須覆寫respondsToSelector:,配合說出「那則訊息我可以接收」的謊言。在常見委派檢查(if ([delegate respondsToSelector:...]))的 Objective-C 世界裡,漏掉這一步,就會遇到轉送已準備妥當卻完全沒有被呼叫的謎題。
NSProxy 不繼承 NSObject,而是獨立根類別的原因也與此有關。繼承的方法越多,就越多訊息會由自己處理而不會進入轉送,因此才另外建立只保留骨架的類別。這部分會在 NSProxy 文章中另行說明。
總結
- unrecognized selector 錯誤不是立即死亡,而是3 階段補救流程全部失敗的結果。
- 第 1 階段
resolveInstanceMethod:會即時新增方法——@dynamic 與 Core Data 都在這裡運作。 - 第 2 階段
forwardingTargetForSelector:只替換接收者,也就是Fast Forwarding——不需要 NSInvocation 就能低成本完成。 - 第 3 階段是
methodSignatureForSelector:與forwardInvocation:的組合——必須有簽章才能建立 NSInvocation。 - 多播代理、NSUndoManager 和 weak proxy,都是建立在這個結構上的應用。
- 建立轉送 proxy 時,別忘了覆寫
respondsToSelector:。
接著了解 Swift 為何預設捨棄這種彈性、選擇靜態分派,以及即使如此仍能透過@objc dynamic重新進入這個世界的原因,就能更立體地理解兩種語言的設計哲學。

