6-14.【OC】【KVC/KVO】KVO 在 ARC 下是如何管理 observer 的引用的?弱引用 / 强引用,Runtime 是怎么实现的?

5 阅读3分钟

KVO 在 ARC 下依然使用“不安全且不自动置为 nil”的原始指针(unsafe_unretained)来管理观察者。

它既不是强引用,也不是弱引用(Weak)。这种设计是导致 KVO 容易崩溃的根本原因。


1. 为什么不是强引用(Strong)?

如果 KVO 对观察者进行强引用,那么极易造成循环引用

  • 场景: ViewController 监听自己的 model

  • 结果: ViewController 强引用 model,而 model 的 KVO 注册表又强引用了 ViewController。如果没有手动移除,这两者将永远无法从内存中释放。

    因此,为了避免内存泄漏,系统设计之初就排除了强引用。

2. 为什么不是弱引用(Weak)?

这主要是由于性能历史负担

  • 性能开销: Swift/OC 中的 weak 引用需要将对象注册到全局的弱引用表(Side Table)中。当对象销毁时,Runtime 需要遍历表并将指针置为 nil
  • KVO 的逻辑: KVO 诞生于早期的手工内存管理(MRC)时代,那时的 weak 机制并不像现在这样高效。为了追求极高的通知分发性能,系统选择了直接记录内存地址。

3. Runtime 的内部实现

当调用 addObserver: 时,Runtime 在被观察对象的隐藏内部数据结构中做了以下事情:

A. 查找或创建 Observation Info

每个对象都有一个关联的 ObservationInfo 结构。Runtime 使用 objc_getAssociatedObject 或类似的私有机制(通常是一个名为 observationInfo 的成员变量)来获取它。

B. 存储三元组

KVO 会将以下信息存入一个双向链表或哈希表中:

  1. Observer 指针: 这是一个 void * 指针,不增加引用计数。
  2. KeyPath: 字符串标识。
  3. Context: 原始指针,用于区分不同的监听。

C. 缺失的“自动置空”

由于存储的是 unsafe_unretained 指针,当观察者对象执行 dealloc 时,Runtime 并不会主动去修改 KVO 的注册表。

  • 这就导致了: 观察者的内存被回收了,但被观察对象依然记录着那个旧地址。

4. 崩溃的物理过程

  1. 观察者释放: Observer 销毁,内存归还给堆。
  2. 属性变更: 被观察对象的属性发生变化,触发 didChangeValueForKey:
  3. 分发通知: KVC 逻辑遍历注册表,找到之前存的 void * 观察者指针。
  4. 消息发送: 系统尝试执行 [void* observeValue...]
  5. Crash: 此时该地址可能已经被分配给了另一个新对象,或者已经标记为不可读。向此地址发送消息会触发著名的 EXC_BAD_ACCESS

5. 进化:iOS 11+ 的变化

为了解决这个“不安全”的问题,iOS 11 引入的 NSKeyValueObservation (Swift) 和相应的 OC 新 API 改变了逻辑:

  • Token 模式: 它不再让被观察者直接记观察者地址,而是返回一个独立的 Token 对象
  • 自动移除: 这个 Token 对象被观察者强引用。当观察者销毁时,Token 随之销毁,并在其 dealloc 方法中主动调用 removeObserver

总结对照

维度旧版 KVO (addObserver)新版 KVO (observe block)
引用方式unsafe_unretained (原始地址)Strong (对 Token 的持有)
安全性差,忘了移除必崩高,利用 ARC 自动管理
底层存储宿主对象的内部哈希表闭包捕获与 Token 对象