iOS 升级打怪 - KVO & KVC

1,223 阅读5分钟

KVO

KVO:Key-Value Observing,一种通过给目标对象添加观察者来监听字段值改变的机制。

原理

它的底层原理是通过 Runtime 的 isa 混写来实现的。

假设我们现在需要使用一个名为 KVOObserve 的类,来监听 Goods 类 price 属性值的改变,下面是实现代码:

// Goods.h
@interface Goods : NSObject
@property (nonatomic, assign) int price;
@end

// KVOObserve.m
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    NSLog(@"object: %@, keyPath:%@, change: %@", object, keyPath, change);
}

// main
KVOObserve *observer = [KVOObserve new];
Goods *goods = [Goods new];
[goods addObserver:observer forKeyPath:@"price" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:NULL];
goods.price = 10;

当执行完 goods.price = 10; 之后,KVOObserve 的回调方法就会打印相关信息:

object: <Goods: 0x100530310>, keyPath:price, change: {
    kind = 1;
    new = 10;
    old = 0;
}

在给 goods 添加观察者之后,Runtime 会动态生成一个名为 NSKVONotifying_Goods 的类,goods 的 isa 会指向该类。NSKVONotifying_Goods 的 superclass 指向 Goods 类。

通过以下代码可以验证上述结论:

NSLog(@"使用 KVO 之前:%@", object_getClass(goods));
[goods addObserver:observer forKeyPath:@"price" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:NULL];
NSLog(@"使用 KVO 之后:%@", object_getClass(goods));

// 打印
使用 KVO 之前:Goods
使用 KVO 之后:NSKVONotifying_Goods

通过打印可以看到,添加观察者之后,goods 的 isa 指向了 NSKVONotifying_Goods。

截屏2021-10-19 下午5.39.49.png

NSKVONotifying_Goods

goods 的 isa 指向了 NSKVONotifying_Goods,那 NSKVONotifying_Goods 都包含那些方法呢?这个可以通过下面的代码获取:

Goods *goods = [Goods new];
// 获取 goods 添加观察者之前的方法列表,也就是 Goods 的方法列表
unsigned int count;
Method *methodList = class_copyMethodList(object_getClass(goods), &count);
for (int i = 0; i < count; i++) {
    Method method = methodList[i];
    NSString *methodName = NSStringFromSelector(method_getName(method));
    NSLog(@"method: %@", methodName);
}
free(methodList);

[goods addObserver:observer forKeyPath:@"price" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:NULL];

// 获取 goods 添加观察者之后的方法列表,也就是 NSKVONotifying_Goods 的方法列表
Method *methodList2 = class_copyMethodList(object_getClass(goods), &count);
for (int i = 0; i < count; i++) {
    Method method = methodList2[i];
    NSString *methodName = NSStringFromSelector(method_getName(method));
    NSLog(@"methodName: %@", methodName);
}

free(methodList2);

下面是打印结果:

使用 KVO 之前
 method: price
 method: setPrice:
使用 KVO 之后
 methodName: setPrice:
 methodName: class
 methodName: dealloc
 methodName: _isKVOA

Goods 与 NSKVONotifying_Goods 类对象内存结构图:

截屏2021-10-20 上午10.38.06.png

通过打印可以得知:Goods 的方法列表中有 get、set 方法;NSKVONotifying_Goods 的方法列表中有 setPrice:、class、dealloc、_isKVOA 四个方法。

得到 NSKVONotifying_Goods 的方法列表后,再来看一下它里面的 setPrice: 的具体实现。

_NSSetXXXValueAndNotify

获取 setPrice: 的方法实现:

[goods addObserver:observer     forKeyPath:@"price"     options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld     context:NULL];
NSLog(@"%p", [goods methodForSelector:@selector(setPrice:)]); 

得到 setPrice: 方法的内存地址后,通过 LLDB 调试得出方法实现:

截屏2021-10-20 上午10.26.34.png

看上图得知 setPrice: 方法内部会调用一个名为 _NSSetIntValueAndNotify 的方法。

而 _NSSetIntValueAndNotify 内部则会调用下面的三个方法:

  • (void)willChangeValueForKey:(NSString *)key
  • [super setPrice: xx]
  • (void)didChangeValueForKey:(NSString *)key
    • 内部调用观察者的回调方法 通过上述方法的调用,完成 KVO 值监听的机制。

KVO 监听流程:

截屏2021-10-20 上午11.24.24.png

手动触发 KVO

调用 willChangeValueForKey 和 didChangeValueForKey 就能手动触发 KVO:

[goods willChangeValueForKey:@"price"];
[goods didChangeValueForKey:@"price"];

关闭自动触发

当我们想自己控制某些条件下才去触发 KVO 时,可以通过重写 + (BOOL)automaticallyNotifiesObserversOfxxx 方法来实现。

比如,在某些业务场景下,仅当 price 的价格与上次的值不同我们才去触发 KVO:

// KVO 不自动触发
+ (BOOL)automaticallyNotifiesObserversOfPrice {
    return NO;
}

- (void)setPrice:(int)price {
    if (_price == price) {
        return;
    }
    [self willChangeValueForKey:@"price"];
    _price = price;
    [self didChangeValueForKey:@"price"];
}

// 只会触发一次
goods.price = 10;
goods.price = 10;

使用场景

  • 监听 model 属性值的改变,来更新 UI。

使用时的注意事项

  • 回调函数的 keyPath 不能为空,为空会崩溃。
  • 添加观察者和移除观察者要一一对应,多次移除会造成崩溃。
  • 因为 KVO 是同步行为,如果在子线程修改了属性值,那回调函数也是在相应的子线程执行的。因为它是同步的,所以不推荐在多线程情况下使用

KVO、代理与通知

这三种机制都是用来处理对象之间的交互的。代理对象之间的关系是一对一的,而 KVO 和通知的关系是多对多的。

代理的优势是逻辑清晰好调试,如果遵守了协议而没有实现代理方法,会有编译⚠️。缺点则是代码量多。一般的使用场景是控件之间的交互。

KVO 的优势就是代码简洁明了,只需添加观察者,在合适的时候移除观察者,即可实现监听。缺点是使用较复杂,代码需注意规范。

通知 的优势也是代码简洁明。缺点则是调试不太好调试🤣。

KVC

Key-value coding (KVC),键值编码。通过官方给出的一套 API 来存值和取值。

取值

// 根据 key 取值
- valueForKey:
// 根据 keyPath 取值
- valueForKeyPath:
// 当 valueForKey 传入的 key 找不到的时候调用
- valueForUndefinedKey:

API 使用实例:

// Car.h
@interface Car : NSObject
@property (nonatomic, copy) NSString *name;
@end

// Goods.h
@interface Goods : NSObject
@property (nonatomic, assign) int price;
@property (nonatomic, strong) Car *car;
@end

[goods valueForKey:@"price"];
[goods valueForKeyPath:@"car.name"];
[goods valueForKey:@"abc"]; //因为 Goods 没有 abc 属性,所以这一句会崩溃,并调用 valueForUndefinedKey 方法

valueForKey: 的底层方法调用流程图:

截屏2021-10-21 上午10.16.50.png

赋值

// 根据 key 设值
- setValue:forKey:
// 根据 keyPath 设值
- setValue:forKeyPath:
//  setValue:forKey: 传入的 key 找不到的时候调用
- setValue:forUndefinedKey:
//  setValue:forKey: 传入的 value  nil 时调用
- setNilValueForKey:

API 使用示例:

[goods setValue:@10 forKey:@"price"];
[goods setValue:@"吉利" forKeyPath:@"car.name"];
// value 传入 nil,崩溃,调用 setNilValueForKey:
[goods setValue:nil forKey:@"price"];
// abc 没有该属性,崩溃,调用 setValue:forUndefinedKey:
[goods setValue:@20 forKey:@"abc"];

setValue:forKey: 的底层方法调用流程图:

截屏2021-10-21 上午10.09.13.png

KVC 与 KVO

  • 使用 KVC 赋值是否会触发 KVO?

会触发,因为 KVC 的本质就是调用 set 方法去修改属性值。而 KVO 的本质就是重写了被监听对象的 set 方法。