Swift 與 Objective-C

Objective-C 方法置換(Method Swizzling)完整整理

方法置換是在執行階段交換連接選擇器與 IMP 的對照表的技術。本篇整理慣用的完整實作、_cmd 說謊的副作用,以及可使用到什麼程度的界線。

閱讀 5 分鐘
Objective-C 方法置換(Method Swizzling)完整整理 封面圖

加入 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:這個名稱後面。

顯示方法置換前後選擇器與 IMP 連接變化的示意圖
原始實作沒有消失,而是搬到另一個選擇器後面

慣用完整實作

實務上通用的安全防護都包含在內時,完整形式如下。

@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成功,就等於「在此類別新增原始實作」,因此只會在自己的類別範圍內安全置換。

使用+loaddispatch_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。這是在替換類別本身,而不是方法。

延伸閱讀