KVO
KVO 的官方文档相较于 KVC 的官方文档要好上很多,不会像 KVC 那样出现大量的莫名奇妙的内容,但我个人感觉说实话作为 Apple 出的官方文档,这个水平写的也是真的不能说好,只是相对而言比 KVC 写的要更加像是给人看的。同样作者也对该文档做了全篇翻译,有需要的读者可以自行 下载。
是什么?
KVO 是英文 Key-Value Observing 的首字母拼接来的,翻译成中文就是键值观察。它是 Apple 公司使用 Objective-C 语言开发的一种技术,当被观察者的被观察属性发生变化的时候会主动通知观察者来及时的响应这个变化。这样就不需要定时查询被观察者的被观察属性是否发生了变化。KVO 是 Apple 对观察者设计模式的一种具体且强大的实现。
KVO 的实现方式和 KVC 一样,都是在 Foundation 框架下的一对 Objective-C 文件。NSKeyValueObserving.h 和 NSKeyValueObserving.m。查阅头文件可以知道 KVO 跟 KVC 一样都是通过 Objective-C 语言的分类功能给 NSObject 添加了一系列相关的方法声明供开发者使用。
首次面世的时间?
根据 Apple 的 KVO 官方文档 版本历史记录可以得知,KVO 作为公开的 API 出现在 macOS 系统的 Cocoa 框架中的时间是 2003 年 10 月 15 日。在后续 Apple 发布(2007 年)的 iOS 系统中的 Cocoa Touch 框架中同样存在。所以在 iOS 系统发布之前就已经存在 KVO 技术了。
设计它的初衷?
KVO(Key-Value Observing)的设计初衷源于苹果在构建 Cocoa 框架(特别是其 Model-View-Controller 架构) 时面临的核心挑战和追求的目标。它的出现并非偶然,而是为了解决当时开发模式中的痛点并提升框架的优雅性和效率。它的引入是为了更好地支持 MVC 架构,实现模型和视图之间的解耦与通信。
实现 Model 和 View 的自动化同步、解耦:
- 核心问题:在图形化用户界面应用程序中,数据模型(Model)的属性发生变化时,依赖这些数据的视图(View)应该及时更新以反映数据的变化。传统做法(在 KVO 之前)通常需要:
- Model 对象持有对 View 对象的引用,并在数据变化时显式调用 View 的更新方法。
- 或在 Controller 中注册 Model 发生变化了的回调,然后在回调中显式调用 View 的更新方法。
- 或者,使用 Notification(通知)广播模型变化,View 注册监听通知并响应更新。
- 痛点:
- 强耦合:Model 需要知晓 View 的存在或依赖 Controller 作为中介,违反了 MVC 的松耦合原则。
- 繁琐易错:开发者需要手动管理更新逻辑,容易遗漏更新点,尤其是在属性间存在依赖关系或嵌套对象变化时。
- 性能:通知广播可能给不相关的观察者带来开销。
- KVO 解决方案:KVO 允许 View(或任何对象)直接观察 Model 对象的符合条件的属性。当属性值通过 KVC 兼容的方式发生变化时,KVO 机制自动通知所有注册的观察者。这实现了:
- 自动化:Model 属性的变化自动触发观察者的更新回调,无需 Model 主动通知。
- 解耦:Model 完全不知道谁在观察它,只需专注于管理自身的状态。View 只需注册自己关心的属性。
- 精准:观察者只接收其注册的特定属性变化的通知。
当初最想解决的问题一句话概括就是:减少样板、解耦组件、方便把模型状态与视图/控制器保持同步,即实现更方便的MVC/Bindings
使用场景?
依赖 KVO 的 Cocoa 技术
Cocoa Bindings: KVO 是 Cocoa Bindings 技术的基石。Bindings 是一种只存在于 macOS 上的技术并没有推广到 iOS 上,它允许在 Interface Builder 或代码中直接将 View 的属性(如 NSTextField 的 value)绑定到 Model 的属性(如 Person 对象的 name)。这种绑定的动态更新能力完全依赖于 KVO 在底层自动监测 Model 属性的变化并通知 View。
Core Data: Core Data 的托管对象(NSManagedObject)严重依赖 KVO。它使用 KVO 来:
- 跟踪对象属性(包括关系)的变化,以管理脏状态(isChanged)。
- 实现延迟加载(Faulting)和关系维护。
- 通知 NSFetchedResultsController(在 iOS/macOS 中)关于数据变化,以驱动表格视图更新。
对于 iOS 开发者来说,Cocoa Bindings 是一个挺陌生的东西。而 CoreData 相对来说并没有那么陌生,相信大部分 iOS 开发者应该都听闻过或者使用过。以下说明为什么 Cocoa Bindings 没有在 iOS 上推广使用,以及 Core Data 在 iOS 的现状。
为什么 Cocoa Bindings 没有在 iOS 上推广使用?
- 技术根源与时代背景:
- Cocoa Bindings 的 macOS 基因: Bindings 是深度集成在 macOS AppKit 框架 和 Interface Builder (IB) 中的技术。它依赖于 AppKit 特有的 NSController 类族(NSObjectController, NSArrayController 等)以及 IB 强大的可视化绑定设置界面。
- iOS 的 UIKit 起源: iOS 诞生时(2007年),其 UIKit 框架是一个全新的、为移动设备优化设计的框架,而不是直接从 macOS AppKit 移植。Apple 在 UIKit 1.0 中做了战略性取舍,优先保证框架的轻量级、高性能和运行效率(特别是在早期 iPhone 硬件性能有限的情况下)。移植 Bindings 这套相对重量级、依赖特定 IDE 的复杂机制并非优先事项,甚至可能被视为包袱。
- 技术路径依赖: UIKit 和 AppKit 虽然共享 Cocoa Touch / Cocoa 基础(Foundation, Core Data),但在 UI 层差异很大。为 UIKit 重新设计和实现一套等效的 Bindings 系统(包括控制器、可视化工具)是一个巨大的工程。Apple 可能认为在移动开发的初期,投入资源到更核心的 UIKit 控件、触摸交互、动画和性能优化上更为重要。
- 设计理念差异:
- macOS 的“声明式”偏好: Cocoa Bindings 代表了 macOS 开发中一种高度声明式的 UI 构建理念。开发者主要在 IB 中通过拖拽和配置建立绑定关系,代码量少。
- iOS 早期的“命令式”主导: 早期 iOS 开发更侧重于命令式编程。UITableViewDataSource 和 UITableViewDelegate 就是典型的命令式模式,开发者需要明确提供数据项数量、配置每个单元格等。虽然代码量可能更多,但它提供了更精细的控制,这对于资源受限、需要高度优化 UI 性能和内存占用的移动设备来说,在当时被认为更合适、更透明。Apple 为 iOS 提供了 Key-Value Observing (KVO),开发者可以手动利用 KVO 在 MVC 中实现类似 Bindings 的自动同步效果,但这需要自己写代码(注册观察者、实现回调、处理解绑),不如 Bindings 那么“开箱即用”和可视化。
- 移动开发的挑战:
- 视图控制器的复杂性: iOS 应用通常涉及更复杂的视图控制器层级和导航(UINavigationController, UITabBarController),以及更频繁的视图创建和销毁(内存管理压力)。Bindings 建立的绑定关系在这种动态环境下管理起来可能更复杂(需要处理观察者的生命周期,避免野指针),不如命令式代码清晰可控。
- 性能考量(特别是集合视图): NSArrayController 的自动管理在复杂数据集和频繁更新下可能引入性能开销(虽然做了优化)。对于需要极致流畅滚动的 iOS UITableView 或 UICollectionView,直接控制数据源 (UITableViewDataSource) 和更新过程 (beginUpdates/endUpdates) 能提供最精准的性能调优手段。Apple 后来推出的 NSFetchedResultsController 就是为 Core Data + UITableView 量身定做的优化方案,它内部使用 KVO 但提供了更结构化的更新信息。
- 替代方案的演进:
- 手动 KVO: 开发者可以利用 KVO 自行实现 Model 到 View/Controller 的同步。虽然不如 Bindings 方便,但提供了灵活性。
- 更好的设计模式: Apple 推动的 Model-View-ViewModel (MVVM) 模式在 iOS 社区流行。在 MVVM 中,ViewModel 暴露可观察的属性(常借助 KVO 的现代替代品,如 Combine 的 @Published 或 RxSwift/ReactiveSwift 的 Observables),View (Controller) 观察这些属性并更新 UI。这比原始的 MVC + 手动 KVO 更结构化,同时也避免了 Bindings 的复杂性和平台绑定。
- 现代声明式 UI 框架: SwiftUI (2019) 的出现从根本上改变了局面。SwiftUI 的 @State, @ObservedObject, @EnvironmentObject 等属性包装器,以及 Bindable 协议,提供了一套现代化、跨平台(Apple 生态内)、类型安全、编译期支持的声明式数据绑定机制。它解决了 Cocoa Bindings 的许多缺点(如字符串 KeyPath、运行时错误、平台限制、IDE 依赖),代表了 Apple 在数据绑定技术上的未来方向。SwiftUI 可以被视为 Cocoa Bindings 理念在新时代的精神继承者和全面升级版。因此,Apple 更没有动力将老式的 Cocoa Bindings 移植到 UIKit 或 iOS 了。
Core Data 在 iOS 的现状与第三方替代品:
- Core Data 依然是 Apple 生态的官方主力:
- 深度集成: Core Data 与 iOS/macOS/iPadOS/watchOS/tvOS 系统深度集成。它与 CloudKit (NSPersistentCloudKitContainer) 的配合提供了开箱可用的云同步方案,这是其他库难以复制的核心优势。
- 强大的工具链: Xcode 提供了强大的 Core Data 模型编辑器、代码生成(NSManagedObject 子类)、调试工具(如 SQL Debug 输出)。
- 成熟稳定: 经过多年发展,Core Data 非常成熟,稳定性高,功能丰富(关系管理、迁移、撤销重做、谓词查询等)。
- Apple 持续投入: Apple 仍在积极维护和更新 Core Data,加入新特性(如 NSPersistentHistoryTracking, 更现代的并发模型支持)。
- 第三方 ORM/数据库的兴起:
- 易用性诉求: Core Data 的学习曲线相对陡峭,其概念(Managed Object Context, Persistent Store Coordinator, Faulting 等)和 API 对新手不友好。开发者社区追求更简单、更符合直觉的 API。
- SQLite 直接封装: 像 FMDB 这样的库提供了对 SQLite 更直接、更轻量级的封装,深受熟悉 SQL 的开发者的喜爱。
- 现代对象关系映射 (ORM): Realm 是 Core Data 最著名的挑战者。它的优势在于:
- 极简 API: 学习曲线平缓,操作对象更直观。
- 卓越性能: 在某些基准测试(尤其是写入和复杂查询)上表现优异。
- 跨平台支持: 原生支持 Android,方便跨平台项目共享数据模型和逻辑。
- 实时协作 (Realm Sync): 提供了强大的实时同步解决方案。
- 轻量级替代: 对于简单需求,UserDefaults, 文件存储 (Codable + FileManager), 甚至简单的内存缓存库就能满足。
- Core Data 的挑战与共存:
- “替代趋势”存在,但非取代: 确实存在开发者(尤其是新项目或特定需求)选择 Realm 或其他方案的趋势,但这更多是多样化选择的表现,而非 Core Data 被淘汰。Realm 等提供了有价值的替代选项,满足了不同开发者的偏好和项目需求。
- 适用场景差异:
- Core Data: 适合中大型复杂应用,需要深度系统集成(如 Spotlight 搜索、CloudKit 同步)、利用现有 Cocoa 模式(如 NSFetchedResultsController)、以及需要其高级功能(复杂迁移、撤销管理)的场景。
- Realm: 适合追求快速开发、极致性能(特定操作)、跨平台(尤其 Android 共存)、或偏好其 API 设计风格的项目。
- SQLite/FMDB: 适合需要直接、精细控制 SQLite 或对文件大小有严格限制的场景。
- 轻量级方案: 适合存储少量简单数据。
- Swift 的影响: Core Data 的 API 设计带有浓重的 Objective-C 历史印记。虽然可以使用 @NSManaged 和代码生成,但它在与 Swift 语言特性(如强类型、可选值安全、并发模型)的融合上曾遇到挑战(Apple 正在持续改进,如 NSPersistentContainer 和新的并发 API)。一些现代的 Swift ORM 库(如 GRDB)试图结合 SQLite 的强大和 Swift 的现代特性。
总结
Cocoa Bindings 未登陆 iOS: 根本原因是技术路线分歧(UIKit 独立发展)、设计理念差异(早期 iOS 侧重命令式控制)、移动环境挑战(性能、生命周期)以及平台特性(缺乏 NSController 和深度 IB 集成)。其核心思想最终被 SwiftUI 的现代化声明式绑定所继承和超越。
Core Data 现状: 它仍是 Apple 官方首选且功能强大的持久化框架,尤其在深度系统集成和 CloudKit 同步方面无可替代。虽然面临 Realm 等优秀第三方库的竞争(主要在易用性、性能、API 设计),但这更多是丰富了开发者的选择。Core Data 在复杂、原生集成要求高的场景中依然稳固,而第三方库则在特定优势领域(易用性、跨平台、特定性能)赢得青睐。两者(甚至更多方案)将在 iOS 生态中长期共存,开发者根据项目需求和偏好进行选择。
在 iOS 中情况
在 SwiftUI 出现之前,纯 Objective-C 或早期 Swift 开发 iOS 应用时,KVO 的应用场景确实远不如在 macOS Cocoa Bindings 中那样普遍和“开箱即用”,尤其是在构建 UITableView / UICollectionView 这类核心 UI 组件时。在 iOS 中自定义 Cell + 模型属性显式赋值 是绝对的主流实践,但 KVO 在 iOS 中也并非毫无用处。原因如下:
📌 为何 KVO 在传统 iOS 开发中应用相对受限?
- UIKit 设计范式:命令式数据源 (Imperative Data Source)
- UITableViewDataSource 协议是核心: 这是 UIKit 为表格视图设计的标准、官方、强制性的接口。开发者必须实现 numberOfRowsInSection: 和 cellForRowAtIndexPath: 等方法。
- cellForRowAtIndexPath: 是赋值点: 在这个方法里,开发者从数据源数组(Model)中取出对应 indexPath 的数据对象,然后调用自定义 Cell 的方法(通常是 configureWithModel: 或直接设置其 model 属性)来显式地、一次性将模型数据“灌”给 Cell 进行显示。这是 UIKit 框架本身引导的模式。
- 清晰的生命周期与性能: 这种模式非常清晰:
- Cell 被创建或复用 -> 系统调用 cellForRowAtIndexPath: -> 开发者从数据源获取最新模型 -> 设置给 Cell -> Cell 根据模型属性更新 UI。
- 它避免了在 Cell 内部或外部对模型进行 KVO 观察带来的潜在复杂性(注册/注销、多观察者、性能开销)。一次赋值,显示即确定。
- Cell 的复用机制与 KVO 的冲突风险
- 复用池 (Reuse Pool): UITableView 的核心优化是复用 Cell。当 Cell 滚出屏幕进入复用池时,它会被稍后其他行复用。
- KVO 观察的生命周期问题: 如果一个 Cell 在其 model 属性的 setter 方法中自动注册 KVO 观察 (观察该模型的属性变化),那么当 Cell 被复用时:
- 需要注销对旧模型(之前设置的那个)的观察。
- 需要重新注册对新模型(新设置的那个)的观察。
- 实现复杂度与易错性: 正确处理这个注册/注销的时机(尤其是在 prepareForReuse 中注销旧模型)对初学者不友好,容易遗漏导致野指针崩溃(观察者被销毁但未注销)或观察了错误的对象。显式赋值模式则完全规避了这个问题。
- 性能考量
- KVO 的开销: KVO 的运行时动态创建子类、消息转发等机制会带来轻微但可测量的性能开销。在需要极致流畅滚动的列表视图(尤其是包含复杂 Cell 或大量数据)中,避免每个 Cell 都建立 KVO 观察链是更安全的选择。
- 批量更新效率: 当需要更新整个列表(如排序、筛选)或大批量数据时,直接操作数据源数组,然后调用 reloadData 或 performBatchUpdates 是最高效的方式。KVO 可能触发大量离散的、逐行甚至逐属性的通知,反而不如批量刷新高效可控。
- 模型变更的集中管理
- 在典型 MVC 中,模型数据的变更通常发生在 Controller 层(网络请求回调、用户操作处理等)。当 Model 变化时,Controller 能直接感知(因为是它持有数据源数组和触发了变更)。
- Controller 可以选择:
- 直接更新数据源数组。
- 判断是否需要局部刷新(计算 indexPaths 变化,调用 reloadRowsAtIndexPaths: / insertRowsAtIndexPaths: / deleteRowsAtIndexPaths:)。
- 或者简单调用 reloadData。
- Controller 是变化的发起者和协调者,它天然知道哪里需要刷新。 让每个 Cell 自己去观察模型变化显得冗余且难以协调(例如,当模型变化源于网络请求时,Cell 可能还未创建)。
- KVO 的 API 体验
- 字符串 KeyPath: Objective-C 时代的 KVO 依赖字符串 KeyPath (@”propertyName”),容易拼写错误,编译器无法检查,运行时才发现失败。Swift 虽然引入了 #keyPath() 语法改善,但仍不如点语法安全直接。
- 繁琐的回调处理: 需要实现冗长的 observeValueForKeyPath:ofObject:change:context: 方法,并在其中进行 KeyPath 判断,代码不够优雅。
- 手动管理注册/注销: 如前所述,需要仔细管理观察者的生命周期(addObserver: / removeObserver:)。尤其在复杂视图层级或异步场景中,容易出错。
✅ 传统 iOS 开发中 KVO 的实际应用场景 (相对常见的)
尽管在 Cell 绑定中不主流,KVO 在以下场景仍有其价值:
- 监听系统组件或框架对象的状态变化:
- AVPlayer / AVPlayerItem: 监听 status, rate, currentItem 等属性变化来更新播放器 UI 或处理错误。
- NSOperation / NSOperationQueue: 监听 isFinished, isCancelled, isExecuting 等状态。
- UIView 动画 (较少用): 理论上可监听 frame, bounds 等,但通常用动画 block 或 delegate 更合适。
- Core Animation (CALayer): 监听 position, bounds 等属性变化。
- NSTimer (非 GCD timer): 监听其有效性 (但通常用其他方式)。
- 自定义 Model 属性变化触发跨组件更新 (非 Cell):
- 一个全局配置对象 (SettingsModel) 的某个属性改变,需要通知多个不同的 ViewController 或 View 更新其 UI (非列表)。Controller 或 View 注册观察该配置属性。
- 一个数据模型 (UserProfile) 的改变,需要更新导航栏标题、底部工具栏等多个分散的 UI 元素。
- 此时 Controller 作为观察者,协调更新多个视图。 这比让每个视图都去直接观察模型更可控。
- 配合 NSFetchedResultsController (Core Data):
- 如前所述,FRC(NSFetchedResultsController) 内部大量使用 KVO 监听 NSManagedObjectContext 的变化。
- 开发者不直接操作 KVO,而是通过 FRC 提供的 delegate 方法来获得结构化的变更信息,从而驱动 UITableView / UICollectionView 的局部刷新。
- 依赖键 (keyPathsForValuesAffectingValueForKey:):
- 当计算属性 (fullName = firstName + lastName) 依赖于其他属性时,使用 KVO 的依赖键机制可以自动在 firstName 或 lastName 改变时触发 fullName 的 KVO 通知。这在 Model 层内部很有用。
🔄 总结:KVO 在传统 iOS 开发中的定位
- 在 UITableView/UICollectionView Cell 绑定上,KVO 是“非主流”: 主流是命令式、一次性的显式数据赋值模式(cellForRowAtIndexPath: + configureWithModel:)。这由 UIKit 设计范式、Cell 复用机制、性能考量、易管理性共同决定。
- KVO 有其特定价值场景: 主要用于监听系统框架对象状态、在 Controller 层协调 Model 变化更新多个非列表视图、Core Data (FRC) 内部驱动以及 Model 内部的依赖键管理。
- 缺点限制了其普遍性: 字符串 KeyPath、繁琐的生命周期管理、潜在性能开销、与 Cell 复用机制的冲突风险,使其在需要精细控制和性能敏感的列表视图中竞争力不足。
SwiftUI 的声明式绑定 (@State, @ObservableObject, @Binding) 才真正为 iOS 带来了类似 Cocoa Bindings 的、对开发者友好的、安全高效的 Model-View 自动同步体验,从根本上改变了数据驱动的 UI 开发方式。在 SwiftUI 之前,iOS 开发者更多地依赖显式模式和框架提供的特定更新机制(如 dataSource 协议、FRC 的 delegate)。
如何使用?
如何使用这里就不多做介绍了,因为官方文档中的 注册键值观察 介绍的很不错了。如果不太看得习惯英文文档,也可以下载作者翻译好的中文版。
底层原理如何?
底层原理是这篇文章的重点,也是难点,也是面试有可能会考察的地方。
怎么探索 KVO 的底层原理?
方案 | 结论 |
---|---|
1. 官方文档 | 只有简单的一句话,没有详细讲解 |
2. Apple 开源工程 | KVO 在 Foundation 框架,Apple 并没有开源 |
3. 市面上的书籍资料? | 基本没有讲解 KVO 底层原理的。都是介绍基本使用的。 |
4. 利用底层知识和 lldb 调试探索了 | 可行,但是对知识储备要求也不低。一般的 iOS 应用开发者根本发现不了。 |
5. 逆向工程? | 可行,但是难度较高。可以作为没有其他办法的情况下最后的手段吧。 |
综合考虑和实践下来,对于 KVO 的底层原理探索来说,方案 4 是最推荐的。
利用底层知识和 lldb 探索 KVO 底层
首先是 KVO 的最基本使用,对比不使用 KVO,观察现象。下面是一段 macOS 的 Command Line Tool 工程代码,选择它的原因是使用 KVO 并不是一定要用到 iOS,对于 KVO 的底层研究使用它足够了。
1 |
|
观察上面代码,person1.name = @"Steve Jobs";
执行的时候触发了观察者的观察方法。但是 person2.name = @"Tim Cook";
执行的时候没有触发。这就非常奇怪了,从表面上来看可以说是同样的代码执行的流程却不一样?
这不科学,不符合逻辑,不讲道理。。。一定是有什么不对劲的地方。从代码层面分析,对象 person1
和 person2
唯一不同之处就是 person1
调用了 KVO 的 addObserver:forKeyPath:options:context:
方法而 person2
没有。
那么推测应该是 person1
调用了 KVO 的注册监听方法 addObserver:forKeyPath:options:context:
之后发生了某些变化。那么使用 lldb 调试工具进行分析一下。对于知识储备不足或者说一般的 iOS 开发者,肯定只会打印 person1
和 person2
或者查看对象的 class
,结果发现两者都是一样的。如下图所示:

其实应该打印这些东西,对象的 isa
属性,或者调用 runtime 的 object_getClassName
或 object_getClass
。这时候有些读者可能会问了,你怎么知道要打印对象的 isa
属性了,或者调用 object_getClassName
object_getClass
等方法了?没办法,只有学习了 iOS 底层,runtime 相关知识自然而然就知道这些了。

从打印结果可以明显的看到,对象 person1
所属的类居然发生了变化,按照代码逻辑预期它应该也是 Person
的实例,实际却是一个不知道哪里来的 NSKVONotifying_Person
类的实例。虽然暂时不知道 NSKVONotifying_Person
类是从何而来(猜测应该是在 addObserver:forKeyPath:options:context:
方法中创建的)但目前可以合理解释为什么同样是调用 setter 方法,代码执行流程不一样的问题了,因为 person1
在调用 addObserver:forKeyPath:options:context:
方法之后它变成了 NSKVONotifying_Person
类的实例,这样在调用 setter 方法时执行的肯定是 NSKVONotifying_Person
类的 setter 方法逻辑,而不是 Person
类的 setter 方法逻辑了。从这里也可以看出 Objective-C 这样的语言居然可以在运行时动态创建一个新的类,并且可以修改对象的 isa
属性改变其原始的类。
此时有几个新问题:
NSKVONotifying_Person
和Person
类是什么关系?按名字来猜测,应该是父子类关系,因为一般在开发中都是用这种习惯命名的,子类命名都是在父类的基础上加前缀。
可以借助 lldb 命令
po class_getSuperclass((Class)object_getClass(person1))
打印person1
和person2
的父类来验证:NSKVONotifying_Person
类里是不是真的有它自己的setName:
方法?还有没有其他方法?如何知道一个类的所有方法?可以通过 runtime API 来实现一个函数或方法打印类的方法列表。这里需要注意对象方法存放在类对象的方法列表中,类方法存放在元类对象的方法列表中,所以如果你传入的参数是类对象那么打印的就是对象方法,如果传入的参数是元类对象那么打印的就是类方法,对象方法和类方法并没有什么本质的不同,在底层它们都是 C 函数只是保存的位置不同。代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13- (void)printMethodNamesOfClass:(Class)cls {
NSMutableString *methodNames = [NSMutableString string];
unsigned int count = 0;
Method *list = class_copyMethodList(cls, &count);
for (int i = 0; i < count; i++) {
Method method = list[i];
NSString *methodName = NSStringFromSelector(method_getName(method));
[methodNames appendString:methodName];
[methodNames appendString:@", "];
}
free(list);
NSLog(@"%@ %@", cls, methodNames);
}然后在合适的地方调用该函数查看打印的结果:
NSKVONotifying_Person
有它自己的setName:
方法没有问题,那它这个setName:
方法是如何实现触发 KVO 的观察者通知的?这个问题算是触及到 KVO 的核心了。其实如果阅读过完整的 KVO 文档,就能知道手动触发 KVO 通知的时候出现了一对方法,
willChangeValueForKey:
和didChangeValueForKey:
。那么就可以猜测到就是这两个方法触发了 KVO 的观察者通知,当然目前这个只是猜测,如果能看到NSKVONotifying_Person
的setName:
方法中调用了上面两个方法就算是验证了这个猜测。那么接下来就利用 lldb + runtime API + 分析汇编来验证一下:在
person1
调用添加观察者方法之后打一个断点,然后获取它的类NSKVONotifying_Person
的setName:
方法的实现,可以通过以下两种方法实现:p (IMP)class_getMethodImplementation((Class)object_getClass(person1), @selector(setName:))
p (IMP)[person1 methodForSelector:@selector(setName:)]
最后通过
dis -a 内存地址
查看汇编代码,如下图所示:由于完整的汇编代码比较长,截图不方便,我就当成代码贴到下面了:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112(lldb) dis -a 0x00007ff81c962051
Foundation`_NSSetObjectValueAndNotify:
0x7ff81c962051 <+0>: pushq %rbp
0x7ff81c962052 <+1>: movq %rsp, %rbp
0x7ff81c962055 <+4>: pushq %r15
0x7ff81c962057 <+6>: pushq %r14
0x7ff81c962059 <+8>: pushq %r13
0x7ff81c96205b <+10>: pushq %r12
0x7ff81c96205d <+12>: pushq %rbx
0x7ff81c96205e <+13>: subq $0x58, %rsp
0x7ff81c962062 <+17>: movq %rdx, %r14
0x7ff81c962065 <+20>: movq %rsi, %r15
0x7ff81c962068 <+23>: movq %rdi, %rbx
0x7ff81c96206b <+26>: movq 0x3eea59c6(%rip), %rax ; (void *)0x00007ff85e593e90: __stack_chk_guard
0x7ff81c962072 <+33>: movq (%rax), %rax
0x7ff81c962075 <+36>: movq %rax, -0x30(%rbp)
0x7ff81c962079 <+40>: callq 0x7ff81d34ae80 ; symbol stub for: object_getClass
0x7ff81c96207e <+45>: movq %rax, %r12
0x7ff81c962081 <+48>: movq 0x3ef5db18(%rip), %rsi ; "_isKVOA"
0x7ff81c962088 <+55>: movq %rax, %rdi
0x7ff81c96208b <+58>: callq 0x7ff81d34a7ae ; symbol stub for: class_getMethodImplementation
0x7ff81c962090 <+63>: leaq 0x88e4ac(%rip), %rcx ; NSKVOIsAutonotifying
0x7ff81c962097 <+70>: cmpq %rcx, %rax
0x7ff81c96209a <+73>: je 0x7ff81c9620cd ; <+124>
0x7ff81c96209c <+75>: movq 0x3eea5995(%rip), %rax ; (void *)0x00007ff85e593e90: __stack_chk_guard
0x7ff81c9620a3 <+82>: movq (%rax), %rax
0x7ff81c9620a6 <+85>: cmpq -0x30(%rbp), %rax
0x7ff81c9620aa <+89>: jne 0x7ff81c9621e5 ; <+404>
0x7ff81c9620b0 <+95>: movq %rbx, %rdi
0x7ff81c9620b3 <+98>: movq %r15, %rsi
0x7ff81c9620b6 <+101>: movq %r14, %rdx
0x7ff81c9620b9 <+104>: addq $0x58, %rsp
0x7ff81c9620bd <+108>: popq %rbx
0x7ff81c9620be <+109>: popq %r12
0x7ff81c9620c0 <+111>: popq %r13
0x7ff81c9620c2 <+113>: popq %r14
0x7ff81c9620c4 <+115>: popq %r15
0x7ff81c9620c6 <+117>: popq %rbp
0x7ff81c9620c7 <+118>: jmpq *0x3eea6b63(%rip) ; (void *)0x00007ff81b464e40: objc_msgSend
0x7ff81c9620cd <+124>: movq %r12, %rdi
0x7ff81c9620d0 <+127>: callq 0x7ff81d34ae8c ; symbol stub for: object_getIndexedIvars
0x7ff81c9620d5 <+132>: testq %rax, %rax
0x7ff81c9620d8 <+135>: je 0x7ff81c96209c ; <+75>
0x7ff81c9620da <+137>: movq %rax, %r12
0x7ff81c9620dd <+140>: movq %rax, %r13
0x7ff81c9620e0 <+143>: addq $0x20, %r13
0x7ff81c9620e4 <+147>: movq %r13, %rdi
0x7ff81c9620e7 <+150>: xorl %esi, %esi
0x7ff81c9620e9 <+152>: callq 0x7ff81d34aefe ; symbol stub for: os_unfair_recursive_lock_lock_with_options
0x7ff81c9620ee <+157>: movq 0x18(%r12), %rdi
0x7ff81c9620f3 <+162>: movq %r15, %rsi
0x7ff81c9620f6 <+165>: callq 0x7ff81d34951e ; symbol stub for: CFDictionaryGetValue
0x7ff81c9620fb <+170>: movq 0x3ef599b6(%rip), %rsi ; "copyWithZone:"
0x7ff81c962102 <+177>: movq %rax, %rdi
0x7ff81c962105 <+180>: xorl %edx, %edx
0x7ff81c962107 <+182>: callq *0x3eea6b23(%rip) ; (void *)0x00007ff81b464e40: objc_msgSend
0x7ff81c96210d <+188>: movq %rax, -0x78(%rbp)
0x7ff81c962111 <+192>: movq %r13, %rdi
0x7ff81c962114 <+195>: callq 0x7ff81d34af04 ; symbol stub for: os_unfair_recursive_lock_unlock
0x7ff81c962119 <+200>: cmpb $0x0, 0x28(%r12)
0x7ff81c96211f <+206>: je 0x7ff81c962164 ; <+275>
0x7ff81c962121 <+208>: movq 0x3ef59408(%rip), %rsi ; "willChangeValueForKey:"
0x7ff81c962128 <+215>: movq %rbx, %rdi
0x7ff81c96212b <+218>: movq -0x78(%rbp), %r13
0x7ff81c96212f <+222>: movq %r13, %rdx
0x7ff81c962132 <+225>: callq *0x3eea6af8(%rip) ; (void *)0x00007ff81b464e40: objc_msgSend
0x7ff81c962138 <+231>: movq (%r12), %rdi
0x7ff81c96213c <+235>: movq %r15, %rsi
0x7ff81c96213f <+238>: callq 0x7ff81d34a7ae ; symbol stub for: class_getMethodImplementation
0x7ff81c962144 <+243>: movq %rbx, %rdi
0x7ff81c962147 <+246>: movq %r15, %rsi
0x7ff81c96214a <+249>: movq %r14, %rdx
0x7ff81c96214d <+252>: callq *%rax
0x7ff81c96214f <+254>: movq 0x3ef593f2(%rip), %rsi ; "didChangeValueForKey:"
0x7ff81c962156 <+261>: movq %rbx, %rdi
0x7ff81c962159 <+264>: movq %r13, %rdx
0x7ff81c96215c <+267>: callq *0x3eea6ace(%rip) ; (void *)0x00007ff81b464e40: objc_msgSend
0x7ff81c962162 <+273>: jmp 0x7ff81c9621bd ; <+364>
0x7ff81c962164 <+275>: movq 0x3eea5645(%rip), %rax ; (void *)0x00007ff85e58c448: _NSConcreteStackBlock
0x7ff81c96216b <+282>: leaq -0x70(%rbp), %r9
0x7ff81c96216f <+286>: movq %rax, (%r9)
0x7ff81c962177 <+294>: movq %rax, 0x8(%r9)
0x7ff81c96217b <+298>: leaq 0x7077(%rip), %rax ; ___NSSetObjectValueAndNotify_block_invoke
0x7ff81c962182 <+305>: movq %rax, 0x10(%r9)
0x7ff81c962186 <+309>: leaq 0x3eed7753(%rip), %rax ; __block_descriptor_64_e8_32o40o_e5_v8?0l
0x7ff81c96218d <+316>: movq %rax, 0x18(%r9)
0x7ff81c962191 <+320>: movq %r12, 0x30(%r9)
0x7ff81c962195 <+324>: movq %r15, 0x38(%r9)
0x7ff81c962199 <+328>: movq %rbx, 0x20(%r9)
0x7ff81c96219d <+332>: movq %r14, 0x28(%r9)
0x7ff81c9621a1 <+336>: movq 0x3ef5da18(%rip), %rsi ; "_changeValueForKey:key:key:usingBlock:"
0x7ff81c9621a8 <+343>: movq %rbx, %rdi
0x7ff81c9621ab <+346>: movq -0x78(%rbp), %r13
0x7ff81c9621af <+350>: movq %r13, %rdx
0x7ff81c9621b2 <+353>: xorl %ecx, %ecx
0x7ff81c9621b4 <+355>: xorl %r8d, %r8d
0x7ff81c9621b7 <+358>: callq *0x3eea6a73(%rip) ; (void *)0x00007ff81b464e40: objc_msgSend
0x7ff81c9621bd <+364>: movq %r13, %rdi
0x7ff81c9621c0 <+367>: callq *0x3eea6aba(%rip) ; (void *)0x00007ff81b467350: objc_release
0x7ff81c9621c6 <+373>: movq 0x3eea586b(%rip), %rax ; (void *)0x00007ff85e593e90: __stack_chk_guard
0x7ff81c9621cd <+380>: movq (%rax), %rax
0x7ff81c9621d0 <+383>: cmpq -0x30(%rbp), %rax
0x7ff81c9621d4 <+387>: jne 0x7ff81c9621e5 ; <+404>
0x7ff81c9621d6 <+389>: addq $0x58, %rsp
0x7ff81c9621da <+393>: popq %rbx
0x7ff81c9621db <+394>: popq %r12
0x7ff81c9621dd <+396>: popq %r13
0x7ff81c9621df <+398>: popq %r14
0x7ff81c9621e1 <+400>: popq %r15
0x7ff81c9621e3 <+402>: popq %rbp
0x7ff81c9621e4 <+403>: retq
0x7ff81c9621e5 <+404>: callq 0x7ff81d34a556 ; symbol stub for: __stack_chk_fail如果看不懂汇编也没有关系,只要在 62 和 74 行的看到了熟悉的
willChangeValueForKey:
和didChangeValueForKey:
也能猜到是调用了这两个方法,因此刚刚的猜测得到了验证。其实刚刚的说法还有一点小小的问题,会让人非常的困惑?到底是
willChangeValueForKey:
触发了通知,还是didChangeValueForKey:
触发了通知,还是说一定要两者搭配才能触发通知?实际的情况是willChangeValueForKey:
会触发观察者通知,didChangeValueForKey:
也会触发观察者通知,不是一定要两者配对出现才能触发 KVO 通知,不然 KVO 底层还得增加这个保证配对出现才会触发 KVO 的逻辑,这个怎么实现?想想也挺头疼。但是实际使用手动 KVO 通知的时候为什么经常会强调要成对调用了?那是为了保证 KVO 功能的完整性。在指定特定的 Options 值
NSKeyValueObservingOptionPrior
的时候willChangeValueForKey:
会在调用 setter 方法修改属性值之前就触发一次观察者通知,在调用完 setter 方法修改完属性值之后didChangeValueForKey:
再去触发一次观察者通知方法。如果某些需求的确需要在调用 setter 方法之前触发观察者通知,而你手动实现的 KVO 通知时没有调用willChangeValueForKey:
方法的话自然就不会触发观察者通知导致出现 bug。可能说的比较绕,总结下来就是willChangeValueForKey:
方法内触发观察者通知是有条件判断的,而didChangeValueForKey:
方法内触发观察者通知是无条件判断的。可以在Person
中重写willChangeValueForKey:
和didChangeValueForKey:
和指定NSKeyValueObservingOptionPrior
然后查看打印信息验证一下。
一句话概括
KVO 的“底层原理”是 在运行时为被观察对象动态创建一个私有子类(通常名为 NSKVONotifying_原类名),把对象的 isa 指向这个子类,并在子类中重写相关的 setter(或触发点)以插入 willChangeValueForKey: / didChangeValueForKey:
的通知逻辑;观察者信息则以运行时的对象关联或侧表(SideTable)形式存储。这整个过程就是常说的 isa-swizzling / 动态子类化。
实现一个简单的 KVO
在知道了 KVO 的底层原理之后,我们可以尝试着自己实现一个极简的 KVO。只需要实现最基本的功能就足够了,如:
- 可以添加观察者,被观察的属性。
- 属性修改之后能通知观察者。
- 可以移除观察者。
自定义 KVO 的 API 设计
这需要对 iOS 的 runtime 有足够的知识储备,否则是不建议自己动手尝试的。首先是 API 的设计,这个不需要做过多的思考,直接参考 Apple 的 KVO 接口设计就好了。自己新建一个 NSObject 的分类,提供三个方法,分别是添加观察者,移除观察者,观察者通知方法。如下:
1 | @interface NSObject (CustomKeyValueObserver) |
自定义 KVO 的实现代码
接下来就是如何实现这几个方法了。其中添加和移除观察者是最关键的方法,而 mk_observeValueForKey:ofObject:change:
方法实现或者不实现都不重要,因为本来就是提供给观察者类自己去实现的。
初步实现添加观察者方法
首先我们来实现 - (void)mk_addObserver:(NSObject *)observer forKey:(NSString *)key;
方法。通过前文对 KVO 底层的讲解,我们大致知道了应该做什么。
- 获取动态子类,如果已经存在则直接获取,如果不存在则使用运行时创建
- 给动态子类添加必要的方法
- 修改 self 的 isa 属性为动态子类
1 |
|
保存观察者对象
在注释这里动态子类的 class
方法我们已经知道如何实现了,返回它的父类。然后是动态子类的 setter 方法,这个方法是实现 KVO 通知的关键。要做的事情是给属性真正的赋值,然后通知观察者属性发生了变化。在子类中我们一般无法获取到父类的属性对应的成员变量,无法直接赋值(这是面向对象编程的封装特性要求的),只能通过调用父类的 setter 方法实现。然后是通知观察者属性发生了变化,那么在动态子类的 setter 方法中我们需要拿到观察者,而观察者又是在 mk_addObserver:forKey:
方法中传入的,于是我们需要在这个方法中对观察者进行保存。从极简思维出发,暂时先不考虑太多复杂的问题,我们可以使用关联对象对观察者进行简单的保存。那么我们的代码就应该变成了下面这样:
1 |
|
初步实现移除观察者方法
添加观察者的方法到这里暂时就算实现了最基本的功能,接下是移除观察者方法的实现。移除观察者的实现相对来说比较简单,就是通过关联对象移除观察者,当没有观察者了的时候恢复 isa 的指向为原始的类。代码如下:
1 | - (void)mk_removeObserver:(NSObject *)observer forKey:(NSString *)key { |
测试基本功能是否正常
到这里算是实现最基本的 KVO 功能,可以测试一下能否实现最基本的键值观察。不出意外是能正常实现监听功能的。但此时代码中还是存在很多明显问题的。一是无法添加多个观察者,二是在控制器中使用时,会发现控制器无法释放了(这个问题其实是循环引用了)。代码如下:
1 |
|
解决循环引用导致观察者(ViewController)无法释放的问题
通过导航控制器 push 进入当前 ViewController 视图控制器测试一下能正常监听了,但是当前视图控制器退出时无法释放了,即 dealloc
方法不执行了。原因是当前视图控制器对 Person 是强引用的,然后 person 对象在添加观察者时,使用关联对象对观察者也就是当前视图控制器也进行了强引用。解决办法有多个
- 设置关联对象时,不要 retain 传入的对象。即使用 OBJC_ASSOCIATION_ASSIGN。
- 使用一个中间对象封装观察者,并使用 weak 修饰观察者属性。
虽然在我们这个简单的 KVO 实现中,目前的情况使用方法 1 能解决控制器无法释放的问题,但是这并不是一个较好的解决方法。因为这样的方式,被观察者(person 实例对象)无法保证观察者(控制器对象)的生命,当观察者被释放的时候,被观察者如果继续使用、访问这个观察者的时候就会导致糟糕的内存访问崩溃。
所以这里还是采用第 2 种方式来解决循环引用问题。代码如下:
1 | @interface MKKVOInfo : NSObject |
然后更新动态子类的 setter
方法和 mk_addObserver:forKey:
方法。代码如下:
1 | void setter(id self, SEL _cmd, id newValue) { |
解决多个观察者和观察多个属性的问题
此时,控制器无法释放的问题得到了解决。接下来的问题是,目前的添加观察者方法无法添加多个观察者,如果多次调用添加观察者方法只有最后一次方法调用的观察者能正常收到通知。很明显是因为我们的 mk_addObserver:forKey:
方法中只保存了最后一次调用的时传入的观察者。这里我们可以使用数组保存每次调用 mk_addObserver:forKey:
方法时的观察者及观察的属性 key
。为什么使用数组?因为 Apple 自己的 KVO 实现中也是使用的数组且没有做去重的处理,读者可以试试重复添加观察者观察同一个属性和相同的 options 和 context。结果就是重复添加了几次,修改属性时观察者通知就触发几次。所以我们也同样采用数组来实现添加多个观察者,代码如下:
1 |
|
以上,就完成了一个实现基本功能的自定义 KVO。虽然基本功能是实现了,但是代码明显不够健壮,很多边界情况条件都没有仔细考虑,多线程环境没有考虑,功能也不够完善,没有 options,context 参数等很多小问题。实际上也并不建议去实现一个 KVO 去取代 Apple 的 KVO,更现实的做法是给 Apple 的 KVO 做一层封装,来解决 Apple 的 KVO 使用过程中的一些缺陷或者痛点,如 FBKVOController。
使用 KVO 需要注意什么?
- 直接修改 ivar 会绕过 KVO
KVO 依赖 setter,如果你直接写_ivar = x
,那么动态子类中重写的 setter 不会被调用自然无法触发 KVO 的观察者通知,除非你手动调用willChangeValueForKey:
didChangeValueForKey:
。 - 与其他“isa swizzle/修改”冲突
如果某些第三方库也在修改isa
或全局 swizzle,会和 KVO 产生冲突,导致奇怪崩溃或者通知缺失。所以在使用第三方库时需要留意它们的实现原理。 - 添加和移除观察者要保证匹配。否则可能会导致崩溃,或者触发多余的通知
- 如果多次添加相同的观察者,keyPath,options,context,那么在修改被观察对象的 keyPath 的时候就会触发多少次通知
- 如果没有移除观察者可能会导致糟糕的访问崩溃,即访问了已经释放的对象。
- 如果重复移除同一个观察者会触发未捕获的异常
FBKVOController 介绍
FBKVOController 是 Facebook(Meta)开源的一个 Objective-C 库,用于简化和增强 Key-Value Observing (KVO) 的使用。它的 GitHub 项目地址是:
👉 facebook/KVOController
背景
在使用 Objective-C 的 iOS / macOS 开发中,KVO 是观察者模式的基础机制,但原生 API 存在几个常见问题:
- 代码分散,添加,移除,响应通知回调分散在三个不同的地方。使用起来不是很方便。
- 容易出错,重复添加一模一样的观察者属性会触发多次通知。移除不匹配添加会导致未捕获的异常。不移除观察者会有潜在的崩溃可能(访问了已经释放的对象导致糟糕的访问崩溃)
- 维护困难,在回调方法里需要自己添加很多的判断,如 keyPath、context。
FBKVOController 的特点
- 安全:内部会在观察者释放的时候,自动将观察者从 KVO 中移除,从而不需要开发者显式的移除观察者,也避免了忘记移除时可能的崩溃。
- 简洁:提供了 Block、SEL 的 API,注册观察者,响应回调都在一起,编码时非常方便。
- 支持多个 keyPath:一次调用可以同时监听多个属性的变化
- 解除耦合:每个对象都自带 KVOController 属性(通过分类),不需要额外管理复杂的 observer 生命周期。
- 性能优化:相对于手写的 KVO,它内部有一些高效的实现方式。
适用场景
- 需要大量使用 KVO 的项目。
- 想避免 KVO crash 的情况。
- 对于 MVC / MVVM 模式,监听模型层属性变化时非常实用。
局限性
- 仅适用于 Objective-C,不适合纯 Swift 项目(Swift 社区更推荐 Combine 或 RxSwift)。
- Facebook 几年前已经停止积极维护(但代码稳定,仍然可用)。
实现原理分析
FBKVOController 的源码非常简洁但是一点儿也不简单,虽然也许很多人都能看懂源码在干什么,但真让他们从头到尾写一个 KVOController 的时候真的没几个人能写出来的。它使用到的类有三个,分别是
FBKVOController
weak id observer
NSMapTable<id, NSMutableSet<_FBKVOInfo *> *> *_objectInfosMap
pthread_mutex_t _lock
_FBKVOSharedController
NSHashTable<_FBKVOInfo *> *_infos
pthread_mutex_t _mutex
_FBKVOInfo
__weak FBKVOController *_controller
NSString *_keyPath
NSKeyValueObservingOptions _options
SEL _action
void *_context
FBKVONotificationBlock _block
_FBKVOInfoState _state
FBKVOController
用来记录外部有真实需求得观察者,被观察对象以及被观察属性和相关的观察信息。然后让 _FBKVOSharedController
单例为观察者观察所有被观察对象,当被观察者的属性通过 setter 方法修改时 _FBKVOSharedController
单例会收到通知,然后由它转发给外部有真实需求的观察者响应这次通知事件。
总结
FBKVOController 是一个 更安全、更好用的 KVO 封装库,如果你在 Objective-C 项目里大量使用 KVO,非常推荐用它来减少 crash 风险和简化代码。
相关面试题
iOS 用什么方式实现对一个对象的 KVO?(KVO的本质是什么?)
上文中的一句话概括的很好。
如何手动触发 KVO?
willChangeValueForKey:
和didChangeValueForKey:
直接修改成员变量会触发 KVO 通知吗?
不会。但可以在修改前后手动触发 KVO 通知。