6-9.【OC】【KVC/KVO】KVO 中的 observer 生命周期问题有哪些?请列举常见 crash 模式,并说明触发条件。

1 阅读4分钟

KVO 的生命周期管理是 Objective-C 开发中最容易出错的环节之一。在 ARC 环境下,虽然内存管理变得简单了,但由于 KVO 内部持有的是观察者的原始指针(unsafe_unretained)而非强引用或弱引用,这导致了大量的生命周期陷阱。

以下是 KVO 中最常见的 Crash 模式及其触发条件:


1. 观察者销毁引起的野指针崩溃 (The Dangling Observer)

这是 KVO 最经典的 Crash。

  • 现象: Thread 1: EXC_BAD_ACCESS (code=1, address=0x...)
  • 触发条件: 被观察对象(Subject)还活着,但观察者(Observer)已经被释放,且在销毁前没有调用 removeObserver:
  • 底层根源: KVO 内部维护的是观察者的原始内存地址。当被观察属性发生变化时,KVO 尝试向该地址发送消息。由于该地址的对象已销毁,程序尝试访问已回收的内存,直接导致 Crash。
  • 典型场景:ViewController 监听一个全局单例(如 UserCenter.shared.name),但在 dealloc 中忘记移除监听。

2. 重复移除观察者 (Double Remove)

KVO 要求移除动作必须与添加动作精确匹配

  • 现象: Cannot remove an observer <...> for the key path "name" from <...> because it is not registered as an observer.
  • 触发条件: 对同一个 keyPath 调用了多次 removeObserver:
  • 底层根源: KVO 的注册表是一个严格的线性表或集合。如果你尝试移除一个并不存在的监听关系,系统会认为这是一种逻辑错误并主动抛出异常导致 Crash。
  • 典型场景:viewWillDisappear:dealloc 中都写了移除逻辑,或者在子类和父类中重复移除同一个监听。

3. 被观察者销毁时的残留监听 (The Dead Subject)

在老版本 iOS 或特定复杂场景下,如果被观察者销毁了,而观察者还试图保持监听。

  • 现象: 虽然被观察者销毁通常不直接导致即时 Crash,但会导致内存管理紊乱或后续逻辑异常。
  • 触发条件: 被观察对象进入 dealloc,但 KVO 的内部子类(NSKVONotifying_X)尚未清理干净。
  • 注意点: 现在的系统已经做了很多优化,但如果观察者是一个持久化对象(如单例),而它持有的被观察者频繁创建和销毁,会导致观察者的 change 回调里出现 nil 对象或野指针,间接引发崩溃。

4. 参数不匹配导致的移除失败

KVO 的移除不仅检查 ObserverKeyPath,还会检查 context(如果注册时提供了)。

  • 现象: Crash 提示与“重复移除”类似,提示找不到观察者。
  • 触发条件: 注册时使用了特定的 context 指针,但移除时传入了 nil 或者不同的指针。
  • 底层根源: KVO 内部使用 (observer, keyPath, context) 这个三元组来唯一标识一个监听关系。任何一个参数不匹配,移除操作都会失败。

5. 跨线程并发修改与移除

  • 现象: 随机、难以复现的 EXC_BAD_ACCESS
  • 触发条件: 在线程 A 触发属性修改(导致回调),同时在线程 B 执行 removeObserver:
  • 底层根源: KVO 的通知分发过程并不是完全线程安全的。如果在通知正在分发时强行拆除观察者,可能会导致分发链路访问到正在销毁的上下文数据。

KVO Crash 模式汇总表

Crash 模式错误类型触发核心
野指针回调EXC_BAD_ACCESSObserver 销毁了,但没解绑。
多余移除NSRangeException移除了一个不存在的 Observer。
未处理通知业务逻辑错乱观察者已部分销毁,但仍收到 change 回调。
路径错误NSUndefinedKeyException监听了一个不存在的 KeyPath。

💡 最佳实践方案

为了彻底避免上述生命周期问题,现代 iOS 开发建议:

  1. 使用 Apple 的新版 Block API:

    observe(_:options:changeHandler:) (iOS 11+ / Swift)。它返回一个 NSKeyValueObservation 对象,该对象在销毁时会自动取消订阅,完全避免了手动解绑的风险。

  2. 防御性解绑:

    如果不确定是否已绑定,可以将 removeObserver: 放入 @try-@catch 块中(虽然不推荐,但在老旧代码中有效)。

  3. 使用 context 参数:

    始终为不同的监听提供唯一的 context(如静态变量的地址),防止子类/父类监听冲突。