KVO 详解 —— iOS/ObjC 完整学习指南

1. KVO 是什么

1.1 概念

KVO(Key-Value Observing)是 Cocoa 提供的一种观察者模式 实现机制,允许对象监听另一个对象特定属性的变化,当该属性值发生变化时,系统会自动通知所有注册的观察者。

KVO 基于 KVC(Key-Value Coding) 构建,属性必须支持 KVC 才能被 KVO 监听。

1.2 观察者模式回顾

erlang 复制代码
被观察对象 (Subject)
    │
    │ 属性发生变化
    ▼
KVO Runtime 机制
    │
    ├──▶ 观察者 A:observeValueForKeyPath:...
    ├──▶ 观察者 B:observeValueForKeyPath:...
    └──▶ 观察者 C:observeValueForKeyPath:...

1.3 KVO vs NSNotification vs Delegate 对比

特性 KVO NSNotification Delegate
监听目标 特定对象的特定属性 全局通知中心发出的事件 特定协议方法
耦合度 低(无需修改被观察类) 低(需要知道通知名) 高(需实现协议)
观察者数量 多对一 多对多 一对一
线程安全 ⚠️ 通知在属性变更的线程触发 ⚠️ 通知在 post 的线程触发 取决于调用方
类型安全 ❌ 运行时字符串 keyPath ❌ 运行时字符串通知名 ✅ 编译时检查
适用场景 属性值变化监听 系统/全局事件 回调单一代理
崩溃风险 ⚠️ 忘记移除会崩溃 ✅ 相对安全 ✅ 相对安全
Swift 现代写法 ✅ 有 observe API ✅ NotificationCenter ✅ 协议

1.4 KVO 的优势场景

  • 不想(或不能)修改被观察对象的源码
  • 需要同时观察多个属性
  • 需要观察第三方框架的属性(如 AVPlayer.statusUIScrollView.contentOffset

2. 基础用法

2.1 注册观察者

objectivec 复制代码
// ObjC
[self.person addObserver:self
             forKeyPath:@"name"
                options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld
                context:nil];
php 复制代码
// Swift(传统方式,兼容 ObjC)
person.addObserver(self,
                   forKeyPath: "name",
                   options: [.new, .old],
                   context: nil)

2.2 参数详解

参数 类型 说明
observer id 观察者对象,必须实现 observeValueForKeyPath:ofObject:change:context:
forKeyPath NSString * 要观察的属性路径,支持点语法(如 "address.city"
options NSKeyValueObservingOptions 控制通知内容(见第 3 节)
context void * 任意指针,用于标识和区分观察(见第 6 节)

2.3 实现回调

objectivec 复制代码
// ObjC
- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary<NSKeyValueChangeKey, id> *)change
                       context:(void *)context {
    if ([keyPath isEqualToString:@"name"]) {
        NSString *newName = change[NSKeyValueChangeNewKey];
        NSString *oldName = change[NSKeyValueChangeOldKey];
        NSLog(@"name changed: %@ -> %@", oldName, newName);
    }
}
swift 复制代码
// Swift(传统方式)
override func observeValue(forKeyPath keyPath: String?,
                           of object: Any?,
                           change: [NSKeyValueChangeKey: Any]?,
                           context: UnsafeMutableRawPointer?) {
    if keyPath == "name" {
        let newName = change?[.newKey] as? String
        let oldName = change?[.oldKey] as? String
        print("name changed: (oldName ?? "") -> (newName ?? "")")
    }
}

2.4 完整示例(ObjC)

less 复制代码
// Person.h
@interface Person : NSObject
@property (nonatomic, copy) NSString *name;
@property (nonatomic, assign) NSInteger age;
@end

// ViewController.m
@interface ViewController ()
@property (nonatomic, strong) Person *person;
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.person = [[Person alloc] init];
    self.person.name = @"张三";
    
    // 注册观察
    [self.person addObserver:self                 forKeyPath:@"name"                    options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld                    context:nil];
}

- (void)viewDidAppear:(BOOL)animated {
    [super viewDidAppear:animated];
    // 触发通知
    self.person.name = @"李四"; // 会触发 KVO
}

- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context {
    if (object == self.person && [keyPath isEqualToString:@"name"]) {
        NSLog(@"name: %@ -> %@", change[NSKeyValueChangeOldKey], change[NSKeyValueChangeNewKey]);
    }
}

- (void)dealloc {
    // ✅ 必须在 dealloc 中移除
    [self.person removeObserver:self forKeyPath:@"name"];
}

@end

3. options 详解

NSKeyValueObservingOptions 是一个位掩码枚举,可以用 | 组合使用。

3.1 枚举值说明

选项 ObjC 常量 Swift 枚举 说明
New NSKeyValueObservingOptionNew .new change 字典包含变更后的新值(NSKeyValueChangeNewKey
Old NSKeyValueObservingOptionOld .old change 字典包含变更前的旧值(NSKeyValueChangeOldKey
Initial NSKeyValueObservingOptionInitial .initial 注册时立即触发一次通知(旧值不可用)
Prior NSKeyValueObservingOptionPrior .prior 在值变更之前额外触发一次通知

3.2 各选项效果演示

NSKeyValueObservingOptionNew / Old

objectivec 复制代码
// ObjC
[obj addObserver:self
     forKeyPath:@"value"
        options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld
        context:nil];

// change 字典示例:
// {
//   NSKeyValueChangeKindKey: 1 (NSKeyValueChangeSetting),
//   NSKeyValueChangeNewKey: @"新值",
//   NSKeyValueChangeOldKey: @"旧值"
// }

NSKeyValueObservingOptionInitial

objectivec 复制代码
// ObjC ------ 注册时会立即触发一次,可用于初始化 UI
[obj addObserver:self
     forKeyPath:@"value"
        options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionInitial
        context:nil];
// ✅ 适合用来初始化 UI 状态,不需要手动调一次 update 方法
php 复制代码
// Swift
obj.addObserver(self, forKeyPath: "value", options: [.new, .initial], context: nil)

NSKeyValueObservingOptionPrior

css 复制代码
// ObjC ------ 值变更前后各触发一次
// 变更前的通知:change[NSKeyValueChangeNotificationIsPriorKey] == @YES
[obj addObserver:self
     forKeyPath:@"value"
        options:NSKeyValueObservingOptionPrior | NSKeyValueObservingOptionNew
        context:nil];
objectivec 复制代码
- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context {
    BOOL isPrior = [change[NSKeyValueChangeNotificationIsPriorKey] boolValue];
    if (isPrior) {
        NSLog(@"即将变更(Prior 通知)");
        // 此时值还没变,可以做一些"变更前"的操作
    } else {
        NSLog(@"已变更,新值: %@", change[NSKeyValueChangeNewKey]);
    }
}

3.3 options 为 0 时

objectivec 复制代码
// ⚠️ options 为 0 时,change 字典里既没有 new 也没有 old,只有 kind
[obj addObserver:self forKeyPath:@"value" options:0 context:nil];
// 此时 change 只包含 NSKeyValueChangeKindKey

4. observeValueForKeyPath 回调解析

4.1 方法签名

objectivec 复制代码
- (void)observeValueForKeyPath:(NSString *)keyPath    // 被观察的属性路径
                      ofObject:(id)object              // 被观察的对象
                        change:(NSDictionary<NSKeyValueChangeKey, id> *)change  // 变更信息字典
                       context:(void *)context;        // 注册时传入的 context

4.2 change 字典的所有 Key

Key 常量 Swift 枚举 类型 说明
NSKeyValueChangeKindKey .kindKey NSNumber 变更类型(见下方枚举)
NSKeyValueChangeNewKey .newKey id 新值(需设置 .new option)
NSKeyValueChangeOldKey .oldKey id 旧值(需设置 .old option)
NSKeyValueChangeIndexesKey .indexesKey NSIndexSet 集合变更时的索引(插入/删除/替换)
NSKeyValueChangeNotificationIsPriorKey .notificationIsPriorKey NSNumber(BOOL) 是否为 Prior 通知

4.3 NSKeyValueChangeKindKey 枚举

objectivec 复制代码
typedef NS_ENUM(NSUInteger, NSKeyValueChange) {
    NSKeyValueChangeSetting     = 1,  // 普通属性赋值
    NSKeyValueChangeInsertion   = 2,  // 集合元素插入
    NSKeyValueChangeRemoval     = 3,  // 集合元素删除
    NSKeyValueChangeReplacement = 4,  // 集合元素替换
};
objectivec 复制代码
// ObjC 示例
- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context {
    
    NSKeyValueChange kind = [change[NSKeyValueChangeKindKey] unsignedIntegerValue];
    
    switch (kind) {
        case NSKeyValueChangeSetting:
            NSLog(@"属性值被设置: %@", change[NSKeyValueChangeNewKey]);
            break;
        case NSKeyValueChangeInsertion: {
            NSIndexSet *indexes = change[NSKeyValueChangeIndexesKey];
            NSArray *newItems = change[NSKeyValueChangeNewKey];
            NSLog(@"集合插入,索引: %@,新元素: %@", indexes, newItems);
            break;
        }
        case NSKeyValueChangeRemoval: {
            NSIndexSet *indexes = change[NSKeyValueChangeIndexesKey];
            NSArray *oldItems = change[NSKeyValueChangeOldKey];
            NSLog(@"集合删除,索引: %@,删除元素: %@", indexes, oldItems);
            break;
        }
        case NSKeyValueChangeReplacement: {
            NSIndexSet *indexes = change[NSKeyValueChangeIndexesKey];
            NSLog(@"集合替换,索引: %@", indexes);
            break;
        }
    }
}
swift 复制代码
// Swift
override func observeValue(forKeyPath keyPath: String?,
                           of object: Any?,
                           change: [NSKeyValueChangeKey: Any]?,
                           context: UnsafeMutableRawPointer?) {
    guard let change = change else { return }
    
    let kindRaw = change[.kindKey] as? UInt ?? 0
    let kind = NSKeyValueChange(rawValue: kindRaw)
    
    switch kind {
    case .setting:
        print("设置新值: (change[.newKey] ?? "nil")")
    case .insertion:
        let indexes = change[.indexesKey] as? IndexSet
        print("插入索引: (String(describing: indexes))")
    case .removal:
        print("删除元素")
    case .replacement:
        print("替换元素")
    default:
        break
    }
}

4.4 ⚠️ 必须调用 super

objectivec 复制代码
// ⚠️ 如果不是自己注册的 KVO,必须调 super,否则可能崩溃
- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context {
    if (context == &MyObservingContext) {
        // 处理自己的 KVO
    } else {
        // ✅ 未知的 KVO,交给父类处理
        [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
    }
}

5. 移除观察者

5.1 移除方法

objectivec 复制代码
// ObjC ------ 指定 keyPath(推荐)
[self.person removeObserver:self forKeyPath:@"name"];

// ObjC ------ 指定 keyPath + context(最精确,推荐用于有继承关系的场景)
[self.person removeObserver:self forKeyPath:@"name" context:&MyObservingContext];
php 复制代码
// Swift
person.removeObserver(self, forKeyPath: "name")
person.removeObserver(self, forKeyPath: "name", context: &myContext)

5.2 正确的移除时机

objectivec 复制代码
// ObjC ------ 在 dealloc 中移除
- (void)dealloc {
    [self.person removeObserver:self forKeyPath:@"name"];
    [self.person removeObserver:self forKeyPath:@"age"];
}
php 复制代码
// Swift ------ 在 deinit 中移除(传统方式)
deinit {
    person.removeObserver(self, forKeyPath: "name")
}

5.3 ⚠️ 崩溃场景一览

objectivec 复制代码
// ⚠️ 场景 1:忘记移除 ------ observer 被释放后 person 变更属性 → EXC_BAD_ACCESS
// 被观察对象持有 observer 的弱引用,observer 释放后成野指针

// ⚠️ 场景 2:重复移除 ------ 会抛出 NSInternalInconsistencyException
[self.person removeObserver:self forKeyPath:@"name"]; // 第一次,正常
[self.person removeObserver:self forKeyPath:@"name"]; // 第二次,崩溃!

// ⚠️ 场景 3:移除从未注册过的 observer
[someObj removeObserver:self forKeyPath:@"xxx"]; // 从未注册过 → 崩溃

5.4 ✅ 安全移除模式

objectivec 复制代码
// ✅ 方案一:用 @try/@catch 防护(不推荐,治标不治本)
@try {
    [self.person removeObserver:self forKeyPath:@"name"];
} @catch (NSException *exception) {
    NSLog(@"移除观察者失败: %@", exception);
}

// ✅ 方案二:用 context + 标志位
static void * const kNameContext = &kNameContext;

- (void)startObserving {
    if (!_isObserving) {
        [self.person addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:kNameContext];
        _isObserving = YES;
    }
}

- (void)stopObserving {
    if (_isObserving) {
        [self.person removeObserver:self forKeyPath:@"name" context:kNameContext];
        _isObserving = NO;
    }
}

- (void)dealloc {
    [self stopObserving];
}
javascript 复制代码
// ✅ 方案三(Swift 推荐):使用 observe API,token 自动管理生命周期
var observation: NSKeyValueObservation?

func startObserving() {
    observation = person.observe(.name, options: [.new, .old]) { person, change in
        print("name changed: (change.oldValue ?? "") -> (change.newValue ?? "")")
    }
}

// observation 置 nil 或 deinit 时自动移除,无需手动 removeObserver

6. context 的妙用

6.1 为什么需要 context

当子类和父类都注册了相同 keyPath 的 KVO 时,observeValueForKeyPath: 无法区分通知来自哪一层。context 就是用来解决这个问题的。

6.2 使用 static void * 作为唯一标识

objectivec 复制代码
// ✅ ObjC ------ 用静态指针作为 context,每个类唯一
// ParentViewController.m
static void * const kParentObservingContext = (void *)&kParentObservingContext;

@implementation ParentViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    [self.model addObserver:self
                forKeyPath:@"value"
                   options:NSKeyValueObservingOptionNew
                   context:kParentObservingContext];
}

- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context {
    if (context == kParentObservingContext) {
        // ✅ 只处理父类注册的 KVO
        NSLog(@"ParentVC 收到: %@", change[NSKeyValueChangeNewKey]);
    } else {
        // ✅ 其他的交给父类
        [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
    }
}

- (void)dealloc {
    [self.model removeObserver:self forKeyPath:@"value" context:kParentObservingContext];
}

@end
objectivec 复制代码
// ChildViewController.m
static void * const kChildObservingContext = (void *)&kChildObservingContext;

@implementation ChildViewController

- (void)viewDidLoad {
    [super viewDidLoad]; // 父类也注册了相同 keyPath
    [self.model addObserver:self
                forKeyPath:@"value"
                   options:NSKeyValueObservingOptionNew
                   context:kChildObservingContext];
}

- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context {
    if (context == kChildObservingContext) {
        // ✅ 只处理子类自己注册的 KVO
        NSLog(@"ChildVC 收到: %@", change[NSKeyValueChangeNewKey]);
    } else {
        // ✅ 其他交给父类处理(父类会处理它自己的 context)
        [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
    }
}

- (void)dealloc {
    [self.model removeObserver:self forKeyPath:@"value" context:kChildObservingContext];
}

@end

6.3 为什么用 static void * const 而不是字符串

csharp 复制代码
// ❌ 不推荐:用字符串作 context,可能与父类字符串内容相同
[obj addObserver:self forKeyPath:@"value" options:0 context:@"myContext"];

// ✅ 推荐:静态指针,地址唯一,永不冲突
static void * const kMyContext = (void *)&kMyContext;
// &kMyContext 是这个静态变量的内存地址,全局唯一

7. KVO 底层原理

7.1 isa-swizzling

KVO 的核心魔法是运行时动态创建子类 并替换 isa 指针,称为 isa-swizzling

objectivec 复制代码
注册 KVO 前:
    person 对象
    ┌──────────────┐
    │ isa → Person │ ──→ Person 类(正常的 setter)
    └──────────────┘

注册 KVO 后:
    person 对象
    ┌────────────────────────────────┐
    │ isa → NSKVONotifying_Person    │ ──→ 动态子类(重写了 setter)
    └────────────────────────────────┘

7.2 动态子类 NSKVONotifying_XXX

当你对 Person 类的对象注册第一个 KVO 时,Runtime 会:

  1. 动态创建 NSKVONotifying_Person 类(Person 的子类)
  2. 重写被观察属性的 setter 方法
  3. 重写 class 方法(返回原始的 Person,隐藏实现细节)
  4. 重写 dealloc 方法(清理 KVO 状态)
  5. 重写 _isKVOA 方法(标记为 KVO 子类)
  6. 将对象的 isa 指针指向新子类
swift 复制代码
// 验证代码:注册 KVO 前后查看 isa
Person *p = [[Person alloc] init];
NSLog(@"注册前 class: %@", object_getClass(p)); // Person
NSLog(@"注册前 class方法: %@", [p class]);       // Person

[p addObserver:self forKeyPath:@"name" options:0 context:nil];

NSLog(@"注册后 class: %@", object_getClass(p)); // NSKVONotifying_Person
NSLog(@"注册后 class方法: %@", [p class]);       // Person(被 KVO 隐藏了)

7.3 重写后的 setter 实现

动态子类重写的 setter 大致等价于:

objectivec 复制代码
// NSKVONotifying_Person 中 setName: 的伪代码
- (void)setName:(NSString *)name {
    // 1. 通知即将变更(Prior 通知)
    [self willChangeValueForKey:@"name"];
    
    // 2. 调用原始 setter(通过 super 或直接操作 ivar)
    [super setName:name];
    
    // 3. 通知已经变更
    [self didChangeValueForKey:@"name"];
}

7.4 完整流程图

css 复制代码
p.name = @"李四"
    │
    ▼
NSKVONotifying_Person.setName:
    │
    ├─ willChangeValueForKey:@"name"
    │       │
    │       └─ 遍历观察者列表,发送 Prior 通知(如果设置了 .prior option)
    │
    ├─ [super setName:] ──→ Person.setName: ──→ _name = @"李四"
    │
    └─ didChangeValueForKey:@"name"
            │
            └─ 遍历观察者列表,调用每个观察者的 observeValueForKeyPath:...

7.5 ⚠️ 注意事项

ini 复制代码
// ⚠️ 直接修改 ivar 不会触发 KVO(绕过了 setter)
_name = @"李四"; // ❌ 不触发 KVO

// ✅ 通过 setter 才会触发 KVO
self.name = @"李四"; // ✅ 触发 KVO

// ✅ 或者手动触发(见第 8 节手动 KVO)
[self willChangeValueForKey:@"name"];
_name = @"李四";
[self didChangeValueForKey:@"name"];

8. 手动 KVO

8.1 为什么需要手动 KVO

  • 批量更新时,希望只触发一次通知而不是每次 setter 都触发
  • 需要对通知时机精确控制
  • 直接修改 ivar 但仍需触发通知
  • 某个属性不应该通过 KVO 触发(性能优化)

8.2 关闭自动 KVO

objectivec 复制代码
// ObjC ------ 在被观察类中重写这个方法
@implementation Person

+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key {
    if ([key isEqualToString:@"name"]) {
        return NO; // ✅ 关闭 name 属性的自动 KVO
    }
    return [super automaticallyNotifiesObserversForKey:key];
}

@end
kotlin 复制代码
// Swift
@objc class Person: NSObject {
    @objc dynamic var name: String = ""
    
    // Swift 中关闭自动 KVO
    @objc class func automaticallyNotifiesObservers(forKey key: String) -> Bool {
        if key == "name" {
            return false
        }
        return super.automaticallyNotifiesObservers(forKey: key)
    }
}

8.3 手动触发通知

ini 复制代码
// ObjC
- (void)setName:(NSString *)name {
    [self willChangeValueForKey:@"name"];
    _name = name;
    [self didChangeValueForKey:@"name"];
}
scss 复制代码
// Swift
@objc var name: String = "" {
    willSet { willChangeValue(forKey: "name") }
    didSet  { didChangeValue(forKey: "name") }
}

8.4 批量变更(减少通知次数)

ini 复制代码
// ObjC - 用 willChange/didChange 把多次变更合并成一次通知
- (void)updateMultipleProperties {
    [self willChangeValueForKey:@"name"];
    [self willChangeValueForKey:@"age"];
    _name = @"新名字";
    _age = 30;
    [self didChangeValueForKey:@"age"];
    [self didChangeValueForKey:@"name"];
}

9. 集合属性的 KVO

9.1 为什么直接操作数组不会触发 KVO?

less 复制代码
// ObjC - ❌ 不会触发 KVO
[self.items addObject:newItem]; // 数组本身没变,指针没变

// ✅ 触发 KVO 的正确方式
[[self mutableArrayValueForKey:@"items"] addObject:newItem];

mutableArrayValueForKey: 返回一个代理数组,对它的增删改操作会自动触发 KVO 通知。

9.2 集合变更类型(NSKeyValueChangeKindKey)

说明
NSKeyValueChangeSetting 整体替换(普通属性变更)
NSKeyValueChangeInsertion 插入元素
NSKeyValueChangeRemoval 删除元素
NSKeyValueChangeReplacement 替换元素

9.3 监听集合变更

objectivec 复制代码
// ObjC - 被观察者:声明集合属性
@interface DataSource : NSObject
@property (nonatomic, strong) NSMutableArray *items;
@end

// ObjC - 观察者:接收集合变更通知
- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context {
    NSKeyValueChange kind = [change[NSKeyValueChangeKindKey] integerValue];
    NSIndexSet *indexes = change[NSKeyValueChangeIndexesKey];
    
    switch (kind) {
        case NSKeyValueChangeInsertion:
            NSLog(@"插入了元素,位置: %@", indexes);
            break;
        case NSKeyValueChangeRemoval:
            NSLog(@"删除了元素,位置: %@", indexes);
            break;
        case NSKeyValueChangeReplacement:
            NSLog(@"替换了元素,位置: %@", indexes);
            break;
        default:
            break;
    }
}
scss 复制代码
// ObjC - 触发集合 KVO
DataSource *ds = [[DataSource alloc] init];
[[ds mutableArrayValueForKey:@"items"] addObject:@"item1"];     // 触发 Insertion
[[ds mutableArrayValueForKey:@"items"] removeObjectAtIndex:0];  // 触发 Removal

9.4 自定义集合访问器(更规范的做法)

less 复制代码
// ObjC - 实现 KVC 集合访问器方法,KVO 自动生效
@implementation DataSource

- (NSUInteger)countOfItems {
    return _items.count;
}

- (id)objectInItemsAtIndex:(NSUInteger)index {
    return _items[index];
}

- (void)insertObject:(id)object inItemsAtIndex:(NSUInteger)index {
    [_items insertObject:object atIndex:index]; // 自动触发 KVO
}

- (void)removeObjectFromItemsAtIndex:(NSUInteger)index {
    [_items removeObjectAtIndex:index]; // 自动触发 KVO
}
@end

10. 依赖 Key

当一个属性的值依赖于其他属性时,可以用 keyPathsForValuesAffectingValueForKey: 声明依赖关系,这样观察者在依赖属性变化时也会收到通知。

10.1 基本用法

less 复制代码
// ObjC - 场景:fullName 依赖 firstName 和 lastName
@interface Person : NSObject
@property (nonatomic, copy) NSString *firstName;
@property (nonatomic, copy) NSString *lastName;
@property (nonatomic, readonly) NSString *fullName; // 计算属性
@end

@implementation Person

- (NSString *)fullName {
    return [NSString stringWithFormat:@"%@ %@", self.firstName, self.lastName];
}

// 声明 fullName 依赖 firstName 和 lastName
+ (NSSet<NSString *> *)keyPathsForValuesAffectingFullName {
    return [NSSet setWithObjects:@"firstName", @"lastName", nil];
}

@end
less 复制代码
// Swift
@objc class Person: NSObject {
    @objc dynamic var firstName: String = ""
    @objc dynamic var lastName: String = ""
    
    @objc var fullName: String { "(firstName) (lastName)" }
    
    override class func keyPathsForValuesAffectingValue(forKey key: String) -> Set<String> {
        if key == "fullName" {
            return ["firstName", "lastName"]
        }
        return super.keyPathsForValuesAffectingValue(forKey: key)
    }
}

10.2 触发效果

objectivec 复制代码
// ObjC - 观察 fullName,但修改 firstName 也能触发
[person addObserver:self forKeyPath:@"fullName" options:NSKeyValueObservingOptionNew context:nil];

person.firstName = @"张"; // ← 触发 fullName 的 KVO 通知

10.3 注意事项

⚠️ 依赖 key 不支持 keyPath(带点号的路径),只支持同一对象的直接属性名。

✅ 推荐用 keyPathsForValuesAffecting<Key> 类方法(苹果推荐写法),避免字符串拼写错误。


11. Swift 中的 KVO

11.1 前置条件

Swift 中使用 KVO 有两个硬性要求:

  1. 类必须继承 NSObject
  2. 被观察属性必须标记为 @objc dynamic
kotlin 复制代码
// ✅ 正确:继承 NSObject + @objc dynamic
class Counter: NSObject {
    @objc dynamic var count: Int = 0
}

// ❌ 错误:struct 不支持 KVO
struct Counter {
    var count: Int = 0
}

// ❌ 错误:缺少 @objc dynamic,KVO 不生效(编译不报错,但运行时不会触发)
class Counter: NSObject {
    var count: Int = 0
}

11.2 Swift 4+ 新 API(推荐)

typescript 复制代码
// Swift - 类型安全的 KVO,使用 KeyPath
class ViewController: UIViewController {
    let counter = Counter()
    var observation: NSKeyValueObservation?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // observe(_:options:changeHandler:) 返回 NSKeyValueObservation token
        observation = counter.observe(.count, options: [.new, .old]) { object, change in
            print("count 变了:(change.oldValue ?? 0) → (change.newValue ?? 0)")
        }
    }
    
    // ✅ observation token 释放时自动移除观察者,无需手动 removeObserver
    // deinit 时 observation 被释放,KVO 自动取消
}

11.3 旧 API(ObjC 风格,不推荐在 Swift 中用)

swift 复制代码
// Swift - 旧式 KVO(不推荐)
class ViewController: UIViewController {
    let counter = Counter()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        counter.addObserver(self, forKeyPath: "count", options: [.new, .old], context: nil)
    }
    
    override func observeValue(forKeyPath keyPath: String?,
                               of object: Any?,
                               change: [NSKeyValueChangeKey: Any]?,
                               context: UnsafeMutableRawPointer?) {
        if keyPath == "count" {
            print("count changed: (change?[.newKey] ?? "nil")")
        }
    }
    
    deinit {
        counter.removeObserver(self, forKeyPath: "count")
    }
}

11.4 监听系统属性示例

swift 复制代码
// Swift - 监听 UIScrollView 的 contentOffset
class MyViewController: UIViewController {
    @IBOutlet weak var scrollView: UIScrollView!
    var scrollObservation: NSKeyValueObservation?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        scrollObservation = scrollView.observe(.contentOffset, options: .new) { sv, change in
            let offset = change.newValue ?? .zero
            print("滚动到: (offset)")
        }
    }
}

// Swift - 监听 AVPlayer status
var playerObservation: NSKeyValueObservation?

playerObservation = player.observe(.status, options: .new) { player, change in
    switch player.status {
    case .readyToPlay:
        print("播放器就绪")
    case .failed:
        print("播放失败: (player.error?.localizedDescription ?? "")")
    default:
        break
    }
}

12. 常见崩溃场景

12.1 ⚠️ 忘记移除观察者

less 复制代码
// ObjC - ❌ 危险:vc 被释放后,person 变更仍会向已释放的 vc 发通知 → BAD ACCESS
@implementation ViewController
- (void)viewDidLoad {
    [self.person addObserver:self forKeyPath:@"name" options:0 context:nil];
    // 忘记在 dealloc 中移除!
}
// 没有 dealloc 里的 removeObserver
@end
objectivec 复制代码
// ✅ 正确
- (void)dealloc {
    [self.person removeObserver:self forKeyPath:@"name"];
}

12.2 ⚠️ 重复添加观察者

objectivec 复制代码
// ObjC - ❌ 添加两次,通知会触发两次,移除一次后仍有一个残留观察者
[person addObserver:self forKeyPath:@"name" options:0 context:nil];
[person addObserver:self forKeyPath:@"name" options:0 context:nil]; // 重复!

⚠️ KVO 不会自动去重,重复添加会导致通知被触发多次。

12.3 ⚠️ 移除不存在的观察者

csharp 复制代码
// ObjC - ❌ 移除一个从未添加过的观察者 → NSRangeException 崩溃
[person removeObserver:self forKeyPath:@"name"]; // person 从来没被观察过

12.4 ⚠️ 子类和父类 context 冲突

kotlin 复制代码
// ❌ 不传 context,父类也观察同 key,子类无法区分通知来源
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
// 如果父类没实现这个方法,会崩溃:NSInternalInconsistencyException
objectivec 复制代码
// ✅ 正确:用 static context 区分,不是自己的交给 super 处理
static void *MyContext = &MyContext;

- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context {
    if (context == MyContext) {
        // 处理自己的
    } else {
        [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
    }
}

12.5 ⚠️ 线程安全问题

scss 复制代码
// ⚠️ KVO 通知在哪个线程修改属性,就在哪个线程触发回调
// 如果在子线程修改属性,回调也在子线程,直接更新 UI 会崩溃!
dispatch_async(dispatch_get_global_queue(0, 0), ^{
    self.model.progress = 0.5; // 触发 KVO,回调在子线程
});

// ✅ 回调中切回主线程
- (void)observeValueForKeyPath:... {
    dispatch_async(dispatch_get_main_queue(), ^{
        // 更新 UI
    });
}

13. KVO 的替代方案

13.1 对比表

方案 优点 缺点 适用场景
KVO 系统内置,无依赖;能监听系统类(AVPlayer/UIScrollView 等) 字符串类型不安全;需手动管理生命周期;调试难 监听系统属性;ObjC 项目
Combine 类型安全;函数式;生命周期自动管理(AnyCancellable) iOS 13+;学习曲线 Swift 新项目;响应式架构
RxSwift 功能强大;iOS 9+ 支持 三方依赖;包大;学习曲线高 重度响应式项目
NSNotification 解耦彻底;一对多 无类型安全;需手动移除;传值靠 userInfo 字典 跨模块广播
Delegate 简单直接;类型安全 一对一;需额外协议 明确的回调关系
闭包/Block回调 灵活;局部化 注意循环引用 简单单次回调

13.2 Combine 替代 KVO

scss 复制代码
// Swift - Combine(iOS 13+)
import Combine

class ViewController: UIViewController {
    let counter = Counter()
    var cancellables = Set<AnyCancellable>()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // 方式1:publisher(for:) 直接订阅 KVO 属性
        counter.publisher(for: .count)
            .sink { newValue in
                print("count: (newValue)")
            }
            .store(in: &cancellables)
        
        // 方式2:@Published 属性(需要 ObservableObject)
    }
    // cancellables 释放时自动取消订阅,无需 removeObserver
}

13.3 何时坚持用 KVO

  • 监听系统框架的属性AVPlayer.statusUIScrollView.contentOffsetNSOperation.isFinished
  • 需要兼容 iOS 12 及以下
  • 在 ObjC 代码库中

14. 实际应用场景

14.1 监听 UIScrollView contentOffset

swift 复制代码
// Swift
var scrollObservation: NSKeyValueObservation?

scrollObservation = scrollView.observe(.contentOffset, options: .new) { [weak self] sv, change in
    guard let self = self, let offset = change.newValue else { return }
    // 根据滚动位置更新导航栏透明度
    let alpha = min(offset.y / 100.0, 1.0)
    self.navigationController?.navigationBar.alpha = alpha
}

14.2 监听 AVPlayer 状态

swift 复制代码
// Swift
var playerStatusObservation: NSKeyValueObservation?
var playerItemObservation: NSKeyValueObservation?

func setupPlayer() {
    let player = AVPlayer(url: videoURL)
    
    playerStatusObservation = player.observe(.status, options: [.new]) { player, _ in
        switch player.status {
        case .readyToPlay: print("就绪,可以播放")
        case .failed:      print("加载失败: (player.error!)")
        case .unknown:     print("未知状态")
        @unknown default:  break
        }
    }
    
    playerItemObservation = player.currentItem?.observe(.isPlaybackLikelyToKeepUp) { item, _ in
        print("缓冲充足: (item.isPlaybackLikelyToKeepUp)")
    }
}

14.3 监听 NSOperation 进度

objectivec 复制代码
// ObjC
[operation addObserver:self
            forKeyPath:@"isFinished"
               options:NSKeyValueObservingOptionNew
               context:MyOperationContext];

- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context {
    if (context == MyOperationContext) {
        if ([change[NSKeyValueChangeNewKey] boolValue]) {
            dispatch_async(dispatch_get_main_queue(), ^{
                [self operationDidFinish:(NSOperation *)object];
            });
        }
    }
}

14.4 实现进度条绑定

swift 复制代码
// Swift - 将 model 的 progress 绑定到 UI
class DownloadViewController: UIViewController {
    var task: DownloadTask!
    var progressObservation: NSKeyValueObservation?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        progressObservation = task.observe(.progress, options: .new) { [weak self] task, change in
            DispatchQueue.main.async {
                self?.progressView.setProgress(Float(task.progress), animated: true)
            }
        }
    }
}
相关推荐
MonkeyKing1 小时前
iOS 循环引用深度解析:delegate/block/NSTimer/嵌套闭包
ios
MonkeyKing1 小时前
iOS AutoreleasePool 深度解析:原理、Page结构与释放时机
ios
报错小能手2 小时前
Swift经典面试题汇总
开发语言·ios·swift
迷途酱2 小时前
Swift 真的被搞得乱七八糟了吗?写了几年之后说点实话
ios·swift
唐诺2 小时前
iOS UI 框架详解
ui·ios
Zender Han3 小时前
Flutter 轻量存储方案介绍、区别、对比和使用场景
android·flutter·ios
2501_916007473 小时前
XCode 15 IDE新特性:苹果集成开发环境全面升级,提升编程效率与体验
ide·vscode·macos·ios·个人开发·xcode·敏捷流程
MonkeyKing71553 小时前
iOS Tagged Pointer 原理、判断方式、适用场景与避坑指南
ios·objective-c
飞Link16 小时前
iOS 27 开启“AI 开放时代”:Siri 驱动可更换背后的技术范式迁移
人工智能·ios