Swift 與 Objective-C

KVO 內部運作完整整理:isa-swizzling 的真相

KVO 在加入觀察者的瞬間,執行階段會將物件的類別動態替換成子類別。本文整理 isa-swizzling 的運作方式、class 為何會說謊,以及不經過 setter 就不會收到通知的條件。

閱讀 5 分鐘
KVO 內部運作完整整理:isa-swizzling 的真相 封面圖

KVO(Key-Value Observing)光看用法很簡單。透過 addObserver 註冊後,每次屬性變更都會收到通知。

相關文章 Objective-C 方法 Swizzling 完整整理 會一併介紹背景概念與延伸的實際應用案例。

但這不是很奇怪嗎?明明沒有在屬性的 setter 中加入通知程式碼,執行階段究竟怎麼知道值變了?

答案很直接。加入觀察者的瞬間,執行階段會悄悄替換該物件的類別。

這項技術稱為 isa-swizzling。如果上一篇文章的 method swizzling 是修改分派表中的「列」,這次談的則是替換物件所屬的「類別」本身。


呼叫 addObserver 的瞬間發生什麼事

Person 將觀察者加入物件後,執行階段會執行以下操作。

  1. 動態建立名為 NSKVONotifying_Person子類別
  2. 覆寫被觀察屬性的 setter,換成在變更前後插入通知程式碼的版本
  3. 將物件的 isa 指標替換成新的子類別

之後執行 person.name = @"Kim" 時,呼叫流程會如下所示。

// NSKVONotifying_Person 覆寫 setter的虛擬碼
- (void)setName:(NSString *)name {
    [self willChangeValueForKey:@"name"];
    [super setName:name];             // 執行原本的 setter 
    [self didChangeValueForKey:@"name"];  // 在此通知觀察者
}

物件的記憶體內容不變,只有所屬類別改變,因此既有程式碼完全不會察覺。移除最後一個觀察者後,isa 會回到原本的類別。

KVO 建立 NSKVONotifying_Person 動態子類別並替換 Person 物件 isa 的流程圖
KVO 建立動態子類別並替換 isa 的順序

類別在說謊:class vs object_getClass

這裡會出現一個有趣的細節。詢問已加入觀察者的物件其類別時,答案會分成兩種。

[person class];              // Person — 謊言
object_getClass(person);     // NSKVONotifying_Person — 真相

執行階段甚至會在動態子類別中覆寫 -class 方法,讓它回覆 原本的類別。這是為了避免開發者困惑「我的物件類別為什麼是奇怪的名稱?」,也是隱藏實作細節的設計。

object_getClass()會直接讀取 isa 指標,因此能看到真正的類別。若除錯時兩個值不同,就表示該物件目前正被某個觀察者觀察。


不經過 setter 就不會有通知

了解 isa-swizzling 的原理後,KVO 廣為人知的限制就很合理了。

person.name = @"Kim";       // 產生通知 — 經由覆寫的 setter 
person->_name = @"Kim";     // 沒有通知 — ivar 直接修改, setter未經過

KVO 通知的來源是覆寫的 setter。直接修改 ivar 會繞過這條路徑,因此什麼也不會發生。這也是 Objective-C 建議使用點語法存取屬性(內部會呼叫 setter)的實際原因之一。

如果情況要求不透過 setter 變更值,可以用手動通知包住這段操作。

[self willChangeValueForKey:@"name"];
_name = @"Kim";
[self didChangeValueForKey:@"name"];

反過來,如果想關閉特定屬性的自動通知,只要在 automaticallyNotifiesObserversForKey: 中回傳 NO 即可。


實務上需要注意的事項

觀察生命週期的管理仍然很重要。 使用區塊式 API 時,必須在需要觀察期間保留它回傳的 NSKeyValueObservation token(Apple KVO 文件)。

結束觀察時,使 token 失效或釋放它,註冊也會一併清理。如果繼續使用字串式 API,就必須自行對齊 addObserverremoveObserver 的生命週期(Apple KVO 文件)。

在同一個類別混用 KVO 與方法 swizzling,會產生順序問題。 若先 swizzling 再加入 KVO,動態子類別會包住已 swizzling 的 setter;反過來則可能讓彼此的假設互相衝突。

兩種操作執行階段的技術在同一個物件上相遇時,除錯難度會倍增。

在 Swift 中,@objc dynamic是必要的。

class Person: NSObject {
    @objc dynamic var name: String = ""
}

let observation = person.observe(\.name, options: [.new]) { _, change in
    print(change.newValue ?? "")
}

因為 isa-swizzling 要能運作,setter 呼叫必須經過 Objective-C 訊息路徑。

Swift 4 起使用區塊式 observe(_:options:changeHandler:) API。回傳的 NSKeyValueObservation token 釋放時,也會自動解除觀察註冊。Using Key-Value Observing in Swift

因此,字串 keyPath 時代的大多數問題,都能由這個結構加以避免。

KVO isa-swizzling 前的 Person 物件與 NSKVONotifying_Person 動態子類別比較圖
表面上是 Person,但執行階段類別是 NSKVONotifying_Person

總結

  • 註冊觀察者時,KVO 會動態建立 NSKVONotifying_ 子類別,並替換 isa
  • 通知的來源是 覆寫的 setter—也就是插入 willChange/didChange 的版本
  • -class被設計成回覆原本的類別,而真正的類別會顯示為 object_getClass()
  • 直接修改 ivar 會繞過 setter,因此不會送出通知——需要時以 willChange/didChange 手動通知
  • Swift 中,@objc dynamic 加上區塊式 observe API 是標準做法,而釋放 token 就等同於解除註冊

把方法 swizzling 與 isa-swizzling 一起納入,就能掌握 Objective-C 執行階段的全貌。方法連結表與物件所屬類別,都是可以在執行階段替換的資料;這份彈性正是 Cocoa 框架大多數魔法的來源。

延伸閱讀

來源與驗證