加入 Firebase Analytics 後,畫面進入事件會自動記錄。明明我們在 viewDidAppear 裡連一行程式碼都沒加。
之所以能做到這點,是因為方法置換(Method Swizzling)。這是一種在執行階段整個替換方法實作的技術,既是展現 Objective-C 執行階段彈性的代表案例,也是誤用時會開啟除錯地獄的雙面刃。
如同 objc_msgSend 文章所述,Objective-C 方法呼叫是「透過選擇器找到 IMP(函式指標)後跳轉」的結構。方法置換就是在執行階段修改這個選擇器→IMP 對照表。
原理:交換對照表中的兩列
類別的方法清單就是「選擇器 → IMP」對照表。method_exchangeImplementations會交換兩個項目的 IMP。
| 選擇器 | 置換前 IMP | 置換後 IMP |
|---|---|---|
viewDidAppear: |
原始實作 | 我的實作 |
swz_viewDidAppear: |
我的實作 | 原始實作 |
置換後,系統呼叫viewDidAppear:的瞬間就會執行我的實作。原始實作並沒有消失,只是搬到swz_viewDidAppear:這個名稱後面。
慣用完整實作
實務上通用的安全防護都包含在內時,完整形式如下。
@implementation UIViewController (Tracking)
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
Class cls = [self class];
SEL originalSEL = @selector(viewDidAppear:);
SEL swizzledSEL = @selector(swz_viewDidAppear:);
Method original = class_getInstanceMethod(cls, originalSEL);
Method swizzled = class_getInstanceMethod(cls, swizzledSEL);
BOOL added = class_addMethod(cls, originalSEL,
method_getImplementation(swizzled),
method_getTypeEncoding(swizzled));
if (added) {
class_replaceMethod(cls, swizzledSEL,
method_getImplementation(original),
method_getTypeEncoding(original));
} else {
method_exchangeImplementations(original, swizzled);
}
});
}
- (void)swz_viewDidAppear:(BOOL)animated {
[self swz_viewDidAppear:animated]; // 不是遞迴!以下說明
NSLog(@"畫面進入: %@", NSStringFromClass([self class]));
}
@end
這段程式碼有兩個容易混淆的重點。
**第一,[self swz_viewDidAppear:animated]不是遞迴。**這段程式碼執行時,IMP 已經完成交換,因此swz_選擇器連接的是原始實作。也就是說,這一行就是「呼叫原始實作」。如果漏掉原始呼叫,整個畫面轉場邏輯都會消失,因此實際上是必要的。
第二,為什麼不直接呼叫method_exchangeImplementations,而要先嘗試class_addMethod?因為目標方法可能不是實作在該類別,而是只有父類別有實作。此時直接 exchange 會改到父類別的方法,導致所有繼承該父類別的其他子類別都受到影響。若class_addMethod成功,就等於「在此類別新增原始實作」,因此只會在自己的類別範圍內安全置換。
使用+load與dispatch_once的理由很簡單。方法置換會改變全域狀態,因此必須在 App 生命週期中最早的時機只執行一次。+load的確切呼叫時機會在下一篇文章(+load vs +initialize)另外說明。
安靜的副作用:_cmd 會說謊
在完成方法置換的方法中輸出_cmd(目前的選擇器)時,結果會是swz_viewDidAppear:而不是viewDidAppear:。因為實作已經改變,但呼叫路徑中的選擇器仍然不變。
平常不會有問題,但若和使用_cmd作為鍵值的程式碼混用(例如用_cmd作為 Associated Objects 的鍵值),就會形成細微的錯誤。如果原始實作在內部依賴_cmd,行為可能會改變。
可以使用到什麼程度
方法置換能合理使用的範圍相當狹窄。
- 全畫面共用的埋點:Analytics、記錄 SDK 自動收集畫面進入與按鈕點擊時
- 繞過第三方與系統錯誤:暫時修正沒有原始碼之框架的行為時
- 開發用除錯工具:想追蹤特定方法的所有呼叫時
反過來說,遇到以下情況就該提高警覺。
- 如果多個程式庫對相同方法進行方法置換,執行順序就會取決於載入順序;只要其中一個漏掉原始呼叫,其餘全部都會崩潰
swz_方法混入堆疊追蹤後,會讓崩潰報告難以解讀- 依賴系統內部實作的方法置換,可能因一次 OS 更新就失效
因此,如果能用子類別化、委派 Proxy 或組合來解決,就應該永遠優先採用那些方式。方法置換應保留為「在結構上不可能使用其他方法時」的最後手段。
Swift 中呢?
純 Swift 方法採用靜態派送(或 vtable),因此這項技術無法套用。若要進行方法置換,目標方法必須公開給 Objective-C 執行階段。
class Tracker: NSObject {
@objc dynamic func fire() { }
}
必須加上@objc dynamic,呼叫才會經由 objc_msgSend 路徑流動,這樣才能替換對照表。UIKit 類別以 Objective-C 為基礎,因此仍可進行方法置換,但越接近 SwiftUI 世界,這項技術的用武之地就越小。
總結
- 方法置換是在執行階段交換選擇器→IMP 對照表的技術——原始實作沒有消失,而是搬到另一個選擇器後面
- swizzled 實作中的
[self swz_...]呼叫不是遞迴,而是原始呼叫 - 先嘗試
class_addMethod是為了避免碰觸父類別方法 - 使用
+load+dispatch_once,讓它在 App 生命週期中只執行一次 _cmd不一致、程式庫衝突與 OS 更新風險都是真實存在的,因此只把它當作最後手段- 在 Swift 中,只有加上
@objc dynamic的方法才會被置換
下一篇文章將深入拆解另一種容易和方法置換混淆的執行階段魔法:KVO(Key-Value Observing)的 isa-swizzling。這是在替換類別本身,而不是方法。

