Swift 与 Objective-C

KVO 内部机制详解:isa-swizzling 的真相

添加观察者的瞬间,运行时会将对象的类动态替换为子类。本文梳理 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。


实际开发中的注意事项

观察生命周期的管理仍然很重要。 在需要观察期间,必须保留基于 block 的 API 返回的 NSKeyValueObservation token(Apple KVO 文档)。

结束观察时,使 token 失效或释放它,注册也会随之清理。如果继续使用基于字符串的 API,就必须自行协调 addObserverremoveObserver 的生命周期(Apple KVO 文档)。

在同一个类中混用 KVO 和 method 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 开始使用基于 block 的 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 加上基于 block 的 observe API 是标准做法,释放 token 就等同于取消注册

把 method swizzling 和 isa-swizzling 放在一起看,就能理解 Objective-C 运行时的整体图景。方法分发表和对象所属的类,都是可以在运行时替换的数据;这种灵活性正是 Cocoa 框架大多数魔法的来源。

延伸阅读

来源与验证