「iOS」——KVO

发布于:2025-07-24 ⋅ 阅读:(23) ⋅ 点赞:(0)


iOS底层学习:KVO 底层原理

KVO

KVO 的全称是 KeyValueObserving,俗称 “键值监听 ",可以用于监听某个对象属性值的改变;KVO 可以通过监听 key,来获得 value 的变化,用来在对象之间监听状态变化。

基本思想:对目标对象的某属性添加观察,当该属性发生变化时,通过触发观察者对象实现的 KVO 接口方法,来自动的通知观察者。

KVO 是苹果提供的一套事件通知机制。KVO 和 NSNotificationCenter 都是 iOS 中观察者模式的一种实现,区别是:NSNotificationCenter 可以是一对多的关系,而 KVO 是一对一的;

注册 KVO 监听

通过[addObserver:forKeyPath:options:context:]方法注册 KVO,这样可以接收到 keyPath 属性的变化事件:

  • observer:观察者,监听属性变化的对象。该对象必须实现observeValueForKeyPath:ofObject:change:context:方法。

  • keyPath:要观察的属性名称,需与属性声明的名称一致。

  • options:回调方法中收到被观察者的属性的旧值或新值等,对 KVO 机制进行配置,修改 KVO 通知的时机以及通知的内容。

  • context:上下文,会传递到观察者的函数中,用于区分消息,应当为不同值。

options所包括的内容:

  • NSKeyValueObservingOptionNew:change 字典包括改变后的值。

  • NSKeyValueObservingOptionOld:change 字典包括改变前的值。

  • NSKeyValueObservingOptionInitial:注册后立刻触发 KVO 通知。

  • NSKeyValueObservingOptionPrior:值改变前是否也要通知(决定是否在改变前、改变后通知两次)。

实现 KVO 监听

通过方法[observeValueForKeyPath:ofObject:change:context:]实现 KVO 的监听:

- (void)observeValueForKeyPath:(NSString *)keyPath  ofObject:(id)object  change:(NSDictionary *)change  context:(void *)context  
  • keyPath:被观察对象的属性。

  • object:被观察的对象。

  • change:字典,存放相关的值,根据options传入的枚举返回新值、旧值。

  • context:注册观察者时传递的context值。

移除 KVO 监听

通过方法[removeObserver:forKeyPath:]移除监听;

处理变更通知

每当监听的 keyPath 发生变化时,会在observeValueForKeyPath函数中回调

- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context


change字典保存了变更信息,具体内容取决于注册时的NSKeyValueObservingOptions

手动KVO(禁用KVO)

KVO 的实现是在注册的 keyPath 的 setter 方法中,自动插入并调用了两个函数:

- (void)willChangeValueForKey:(NSString *)key
- (void)didChangeValueForKey:(NSString *)key

手动实现 KVO 需先关闭自动生成 KVO 通知,再手动调用通知方法,可灵活添加判断条件。

关闭自动通知
 + (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key {  
    if ([key isEqualToString:@"age"]) {  
        return NO;  
    } else {  
        return [super automaticallyNotifiesObserversForKey:key];  
    }  
}  
手动实现 setter 方法

接着手动实现属性的 setter 方法,在setter方法中先调用willChangeValueForKey:接着进行赋值操作,然后调用didChangeValueForKey:

- (void)setAge:(int)theAge {  
    [self willChangeValueForKey:@"age"];  
    age = theAge;  
    [self didChangeValueForKey:@"age"];  
}  

KVO 和线程

  • KVO 行为是同步的,在所观察的值发生变化的同一线程上触发,无队列或 Runloop 处理。

  • 手动或自动调用didChangeValueForKey:会触发 KVO 通知。

  • 单线程保证(如主队列):

  1. 确保所有监听某一属性的观察者在 setter 方法返回前被通知到。

  2. 若键观察时附上NSKeyValueObservingOptionPrior选项,直到observeValueForKeyPath被调用前,监听的属性返回值不变。

    1. 该键对应的值是一个 NSNumberBOOL 类型),用于判断当前 KVO 通知是在属性值 变更前(前置通知,值为 YES)还是 变更后(后置通知,值为 NO)发送。

上述两个特点可以有效解决复杂场景下的数据一致性时序问题

我们看以下代码:

// User.h
@interface User : NSObject
@property (nonatomic, assign) NSInteger age;
@end

// ViewController.m
- (void)viewDidLoad {
    [super viewDidLoad];
    
    // 注册 KVO 监听
    [self.user addObserver:self 
               forKeyPath:@"age" 
                  options:NSKeyValueObservingOptionNew 
                  context:nil];
    
    // 主线程修改 age
    dispatch_async(dispatch_get_main_queue(), ^{
        self.user.age = 20;
        NSLog(@"主线程修改 age 为 20");
    });
    
    // 子线程同时修改 age
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        self.user.age = 30;
        NSLog(@"子线程修改 age 为 30");
    });
}

// KVO 回调
- (void)observeValueForKeyPath:(NSString *)keyPath 
                      ofObject:(id)object 
                        change:(NSDictionary *)change 
                       context:(void *)context {
    if ([keyPath isEqualToString:@"age"]) {
        NSNumber *newAge = change[NSKeyValueChangeNewKey];
        NSLog(@"KVO 接收到 age 变化: %@", newAge);
    }
}
如果 KVO 是多线程的
  • 可能出现通知丢失:主线程和子线程同时修改 age,观察者可能只收到最后一次通知(如只收到 30,丢失 20)。
  • 可能出现通知顺序错乱:观察者先收到 30 的通知,再收到 20 的通知,导致逻辑混乱。
单线程的保证
  • 原子性:KVO 会在 setter 方法返回前同步且顺序地通知所有观察者,确保:
    1. 所有观察者都能收到每一次变化。
    2. 通知顺序与 setter 调用顺序一致(先收到 20,再收到 30)。

再来学习NSKeyValueObservingOptionPrior。该属性主要应用在复杂数据更新与 UI 动画同步

// 注册 KVO,带上 prior 选项
[self.dataSource addObserver:self 
                 forKeyPath:@"items" 
                    options:(NSKeyValueObservingOptionNew | NSKeyValueObservingOptionPrior) 
                    context:nil];

// KVO 回调
- (void)observeValueForKeyPath:(NSString *)keyPath 
                      ofObject:(id)object 
                        change:(NSDictionary *)change 
                       context:(void *)context {
    if ([keyPath isEqualToString:@"items"]) {
        // 1. 先收到 prior 通知(change[NSKeyValueChangeNotificationIsPriorKey] = @YES)
        if ([change[NSKeyValueChangeNotificationIsPriorKey] boolValue]) {
            // 准备动画(此时 items 还是旧值)
            [self.tableView beginUpdates];
        } 
        // 2. 再收到实际变化通知
        else {
            // 执行动画(此时 items 已是新值)
            [self.tableView endUpdates]; // 自动触发 insert/delete 动画
        }
    }
}
如果没有 prior 选项
  • 直接在 endUpdates 时才知道数据变化,无法提前准备动画。
  • 可能导致 UI 闪烁或动画不连贯。
prior 选项的作用
  • 分阶段通知
    1. 第一次通知:在属性值实际变更前触发(NSKeyValueChangeNotificationIsPriorKey = @YES),此时属性值仍为旧值。
    2. 第二次通知:在属性值变更后触发(默认行为),此时属性值已更新。
  • 实际应用
    • 在第一次通知时,计算新旧数据的差异(如哪些行需要插入 / 删除)。
    • 在第二次通知时,执行 beginUpdates/endUpdates,让表格视图平滑过渡。

KVO 实现原理

KVO 通过isa-swizzling实现,基本流程如下:

isa-swizzling 的本质:

修改对象的类型:通过修改对象的 isa 指针,使其指向另一个类,从而改变对象的行为。

  1. 创建派生类:编译器自动为被观察对象创建派生类(如NSKVONotifying_XXX),将被观察实例的isa指向该派生类,派生类的superclass指向原类。

  2. 重写方法:若注册了某属性的观察,派生类会重写该属性的 setter 方法,并添加通知代码。

  3. 消息传递:Objective-C 通过isa指针找到对象所属类,调用派生类重写后的方法,触发通知。

派生类重写的方法

  • setter 方法:插入willChangeValueForKey:didChangeValueForKey:调用,触发通知。
- (void)willChangeValueForKey:(NSString *)key;
- (void)didChangeValueForKey:(NSString *)key;

然后在 didChangeValueForKey: 中,去调用:

- (void)observeValueForKeyPath:(nullable NSString *)keyPath
                      ofObject:(nullable id)object
                        change:(nullable NSDictionary<NSKeyValueChangeKey, id> *)change
                       context:(nullable void *)context;


  • class 方法:返回原类,隐藏子类存在,避免isKindOfClass判断异常。
- (Class)class {  
    return class_getSuperclass(object_getClass(self));  
}  
  • dealloc 方法:释放 KVO 相关资源。

  • _isKVOA 方法:返回YES,标识该类为 KVO 生成的子类。

请添加图片描述

验证 isa 指向示例

#import <Foundation/Foundation.h>  
#import <objc/runtime.h>  

@interface ObjectA: NSObject  
@property (nonatomic) NSInteger age;  
@end  

@implementation ObjectA  
@end  

@interface ObjectB: NSObject  
@end  

@implementation ObjectB  
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {  
    NSLog(@"%@", change);  
}  
@end  

int main(int argc, const char * argv[]) {  
    @autoreleasepool {  
        ObjectA *objA = [[ObjectA alloc] init];  
        ObjectB *objB = [[ObjectB alloc] init];  
        [objA addObserver:objB forKeyPath:@"age" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:nil];  
        NSLog(@"%@", [objA class]); // 输出:ObjectA(表面类型)  
        NSLog(@"%@", object_getClass(objA)); // 输出:NSKVONotifying_ObjectA(实际类型)  
    }  
    return 0;  
}  
  • class方法返回对象所属的类(原类)。

  • object_getClass返回对象的isa指向的实际类(派生类)。

请添加图片描述

KVO 注意事项

  1. 内存管理
  • addObserverremoveObserver需成对调用,避免观察者释放后仍接收通知导致 Crash。

  • KVO 不对观察者强引用,需注意观察者生命周期。否则会导致观察者被释放带来的Crash。

  1. 方法实现:观察者必须实现observeValueForKeyPath:ofObject:change:context:方法,否则崩溃。

  2. KeyPath 安全:在调用KVO时需要传入一个keyPath,由于keyPath是字符串的形式,所以其对应的属性发生改变后,字符串没有改变容易导致Crash。我们可以利用系统的反射机制将keyPath反射出来,这样编译器可以在@selector()中进行合法性检查。

  3. 数组监听:默认仅监听数组对象本身变化,需通过mutableArrayValueForKey操作数组或手动触发通知来监听元素变化。

问题总结

  1. **直接修改成员变量是否触发 KVO?**不会。KVO 本质是替换 setter 方法,仅通过 setter 或 KVC 修改属性值时触发。

  2. **KVC 修改属性会触发 KVO 吗?**会。setValue:forKey:会调用willChangeValueForKeydidChangeValueForKey,触发监听器回调。

  3. 如何监听数组元素变化?

  • 使用NSMutableArray并通过mutableArrayValueForKey获取数组,其操作会自动触发通知。

  • 手动调用willChangeValueForKeydidChangeValueForKey触发通知。

当数组中的元素发生变化时,手动触发KVO通知即可实现监听。具体实现方式如下:

使用NSKeyValueObservingOptionNewNSKeyValueObservingOptionOld选项来监听可变数组中的元素变化。这两个选项会在KVO通知中包含新旧值的信息,因此可以在观察者中获取到数组中元素的变化。
代码如下:

[observedObject addObserver:self forKeyPath:@"myArray" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:nil];

//观察者中实现.
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
    if ([keyPath isEqualToString:@"myArray"]) {
        NSArray *oldArray = change[NSKeyValueChangeOldKey];
        NSArray *newArray = change[NSKeyValueChangeNewKey];
        // 处理数组元素的变化
    }
}


网站公告

今日签到

点亮在社区的每一天
去签到