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 会将以下信息存入一个双向链表或哈希表中:
- Observer 指针: 这是一个
void *指针,不增加引用计数。 - KeyPath: 字符串标识。
- Context: 原始指针,用于区分不同的监听。
C. 缺失的“自动置空”
由于存储的是 unsafe_unretained 指针,当观察者对象执行 dealloc 时,Runtime 并不会主动去修改 KVO 的注册表。
- 这就导致了: 观察者的内存被回收了,但被观察对象依然记录着那个旧地址。
4. 崩溃的物理过程
- 观察者释放:
Observer销毁,内存归还给堆。 - 属性变更: 被观察对象的属性发生变化,触发
didChangeValueForKey:。 - 分发通知: KVC 逻辑遍历注册表,找到之前存的
void *观察者指针。 - 消息发送: 系统尝试执行
[void* observeValue...]。 - 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 对象 |