0%

KVC/KVO 笔记

KVC/KVO 并不属于 Objective-C 语言的特性,而是 Cocoa 提供的一种特性。KVC/KVO 是通过采用NSKeyValueCoding / NSKeyValueObserving 非正式协议的方式所实现的一种机制。从协议的角度讲,它是给我们定义了一套去遵循和实现的方法。不过NSObject 提供了相关方法的默认实现,不需要我们显示的实现了。

KVC

除了通过调用访问方法和直接设置实例变量来更改对象状态外,还可以通过键-值编码(key-value coding)的间接方式。这种间接访问能让开发者在运行时而非编译期决定访问哪个属性。即使在编译期还不知道属性的键是什么也无所谓。这种动态访问对于 nib 文件的加载和 iOS 中的 Core Data 尤其重要,在 MacOS 中,KVC 则是 AppleScript 接口的基础部分。

基本使用

键-值编码中的基本调用是-valueForKey:-setValue:forKey:方法,向对象发送消息,并传递想要访问的属性名称字符串(键)作为参数。KVC 的默认实现可以参考苹果的官方文档《Key-Value Coding Programming Guide》中的这一章:Accessor Search Patterns。下面简单介绍一下,当调用-valueForKey:方法时,会按照如下步骤进行:

  1. 首先查找名为get<Key><key>is<Key>或者_<key>的 getter 方法,若找到则跳到第5步,否则进行下一步。

  2. 查找匹配如下模式的实例方法,若匹配则 KVO 系统会创建一个可以响应所有 NSArray 方法的 NSKeyValueArray 数组代理对象并返回,它是NSArray的子类,这个代理数组对象与 KVC 结合起来使用中行为与真正的数组对象一样。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    -countOf<Key>
    //以下二选一
    -objectIn<Key>AtIndex:
    -<key>AtIndexes:
    //<优化可选>
    -get<Key>:range:

    //若是可变容器类的属性还需实现
    -insertObject:in<Key>AtIndex:
    -removeObjectFrom<Key>AtIndex:
    //<优化可选>
    -replaceObjectIn<Key>AtIndex:withObject:
  3. 查找如下的匹配模式,若匹配则创建与上述数组代理对象类似的行为与 NSSet 一致的集合代理对象并返回。

    1
    2
    3
    -countOf<Key>
    -enumeratorOf<Key>
    -memberOf<Key>:
  4. 若接收者的类方法accessInstanceVariablesDirectly返回YES,则查找名为_<key>, _is<Key>, <key>, 或者is<Key>的实例变量,找到则跳到5,否则跳到6

  5. 若获取到的属性是对象指针则直接返回。若获取到的属性是标量类型的值且可以封装为 NSNumber 对象则封装后返回。若获取到的属性是标量类型的值但不能封装为 NSNumber 对象则封装为 NSValue 对象后返回。

  6. 若上面的所有步骤都失效,则调用valueForUndefinedKey:方法,该方法默认会抛出异常,但是 NSObject 子类可以重写并对特殊的 key 提供相应的行为。

-valueForKey:在 Objective-C 运行时中使用元数据打开对象并进入其中查找需要的信息。在 C 或 C++ 中不能执行这种操作。通过使用 KVC,没有相关 getter 方法也能获取对象值。
对于上面步骤5中的自动装箱和开箱标量值。可以看下如下的例子:

1
2
3
4
5
6
@interface Car : NSObject
@property (nonatomic, assign) float mileage;
@end

[car setValue:@(200.0) forKey:@"mileage"];
NSNumber *mileage = [car valueForKey:@"mileage"];

setValue:forKey:会将 @(200.0) 开箱取出值,再调用setMileage:或者更改 mileage 变量。

键路径

可以使用 valueForKeyPath:setValue:forKeyPath:来通过键路径访问属性。键路径可以包含嵌套关系,如下所示:

1
[car setValue:@(200) forKeyPath:@"engine.horsepower"];

KVC 实现高阶消息传递

如果键路径中有 NSArray 或 NSSet 这样的容器类属性,则该键路径的其余部分将被发送给容器类的每个对象。而不是对容器本身进行操作。结果会被添加进返回的容器中。这样可以方便的用一个容器对象创建另一个容器对象。示例如下:

1
2
NSArray *array = @[@"foo",@"bar",@"ted"];
NSArray *capitals = [array valueForKey:@"capitalizedString"];

把消息(capitalizedString)作为参数传递称为高阶消息传递。

容器操作符

键路径不仅能引用对象值,还可以引用一些运算符来进行一些运算。如:

1
2
NSArray *array = @[@"foo",@"bar",@"ted"];
NSInteger totalLength = [[array valueForKeyPath:@"@sum.length"] intValue];

@sum是一个操作符,对指定的属性(length)求和。key path 中的 @ 符号代表了一个特定的集合方法。具体的使用也可以参考 NSHipster 的这篇文章:KVC Collection Operators。 KVC 能非常轻松的处理集合类,但是它需要解析字符串来计算你想要的答案,所以通常速度比较慢。

nil

对于标量值的属性,如果使用 nil 作为参数调用setValue:forKey:,那么键会被传递给setNilValueForKey:方法,如果该对象支持标量值属性这是为 nil,则需要实现这个方法。默认的行为是抛出异常。
对 NSDictionary 对象的setObject:forKey:方法传入 nil 值,会发出警告。但是若使用setValue:forKey:传入 nil 值,则会把对应键的值从字典中删除。

KVO

Cocoa 有若干观察者机制,包括委托和 NSNotification,但是 KVO 的开销更小,被观察的对象允许其他对象去监听它的某个属性的改变,并且不需要有任何额外的代码来通知观察者,而如果没有观察者,KVO 就没有运行时的消耗,只有对象被真正观察时,KVO 系统才添加通知代码。
关于 KVO 的基本使用可以看看以下几篇博客:

注册观察者

要让一个对象监听另一个对象属性的变化,首先被观察对象要添加观察者对象为其相关属性的观察者。调用方法如下(**注意这个方法调用时,两个对象都不会被retain**):

1
2
3
4
- (void)addObserver:(NSObject *)observer 
forKeyPath:(NSString *)keyPath
options:(NSKeyValueObservingOptions)options
context:(nullable void *)context;

注册 KVO 时,要给 context 传递进去一个随机数据作为上下文数据。因为一个类只能有一个 KVO 回调,所以可能收到父类注册的属性变化事件。如果是这样,需要把回调传递给 super,所以要用唯一的 context 来识别观察的事件,这样子类和父类都能安全的观察同样的键值而不会冲突。推荐一个比较好的 context 值的声明方法:

1
static void * xxContext = &xxContext;

将其声明在 .m 文件的顶端,xxContext 静态变量中存储着自己的指针,可以用来确定唯一的 context。
传入的 keyPath 最好也不要用字符串字面值,因为不能被编译器检查拼写错误。推荐使用 NSStringFromSelector(@selector(length))这种写法。

options 为 NSKeyValueObservingOptions 的选项组合。它指定了观察通知中包含了什么数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef NS_OPTIONS(NSUInteger, NSKeyValueObservingOptions) {
//通知的 change 字典中应该包含新值
NSKeyValueObservingOptionNew = 0x01,
//通知的 change 字典中应该包含旧值
NSKeyValueObservingOptionOld = 0x02,
/*立即发送一个通知给观察者,甚至在观察者注册方法返回之前。如果同时组合了 NSKeyValueObservingOptionNew 选项的话,
通知的 change 字典中总会包含新值。但是永远不会包含旧值。(虽然最初的那一个通知中值可能是旧值,但是对于观察者来说也是新值。)
可以将这个选项用来避免初始化时需要显示的调用观察者的 -observeValueForKeyPath:ofObject:change:context: 回调方法中的代码。
当这个选项用于 addObserver:toObjectAtIndexes:forKeyPath:options:context: 方法时。通知将会发送给观察者被添加的每一个索引对象。
*/
NSKeyValueObservingOptionInitial = 0x04,
/*不仅在属性改变后发送通知,还在改变前发送一条通知。改变前发送的通知的 change 字典中会包含NSKeyValueChangeNotificationIsPriorKey 入口,
但是永远不会包含 NSKeyValueChangeNewKey 入口。通常用于当观察者自身的 KVO 需要为自己的某个属性调用 -willChange... 方法,而这个属性的值又依赖于被观察对象的属性时。
改变后发送的通知的 change 字典中与不指定此选项时是相同的。(按顺序排列的 to-many 关系的 NSOrderedSets属性例外)。
*/
NSKeyValueObservingOptionPrior = 0x08
};

观察者回调

观察者必须实现-observerValueForKeyPath:ofObject:change:context:方法,来对被观察对象属性修改的通知做相应的处理。对观察者对象来说,它所有观察的改变都被聚集到这个方法回调中来,所以需要通过 context 上下文信息来准确的判断是哪个被观察对象的改变通知。
change 字典是NSDictionary<NSKeyValueChangeKey,id>类型的, NSKeyValueChangeKey 定义如下:

1
2
3
4
5
6
typedef NSString * NSKeyValueChangeKey;
NSKeyValueChangeKey const NSKeyValueChangeKindKey;
NSKeyValueChangeKey const NSKeyValueChangeNewKey;
NSKeyValueChangeKey const NSKeyValueChangeOldKey;
NSKeyValueChangeKey const NSKeyValueChangeIndexesKey;
NSKeyValueChangeKey const NSKeyValueChangeNotificationIsPriorKey;

举个例子,当注册时的 options 包含NSKeyValueObservingOptionInitial时:

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
@interface Engine: NSObject
@property (nonatomic, assign) NSInteger horsepower;
@end

@implementation Engine
@end

@interface Car : NSObject
@end

static void * carContext = &carContext;
@implementation Car
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
if (context == carContext) {
//仅打印 change 字典
NSLog(@"change = %@",change);
} else {
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}

- (void)setupCar {
Engine *engine = [[Engine alloc] init];
engine.horsepower = 300;

[engine addObserver:self forKeyPath:NSStringFromSelector(@selector(horsepower)) options:NSKeyValueObservingOptionInitial | NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:carContext];
engine.horsepower = 100;
}
@end

打印结果如下如下所示,注册时会回调一次,change 字典中包含了 new 值:300。改变属性为 100 时,又回调一次, change 字典中包含 old 值:300,new 值:100。

1
2
3
4
5
6
7
8
9
change = {
kind = 1;
new = 300;
}
change = {
kind = 1;
new = 100;
old = 300;
}

将上述注册方法中的NSKeyValueObservingOptionInitial选项换成NSKeyValueObservingOptionPrior时,打印结果如下:

1
2
3
4
5
6
7
8
9
10
change = {
kind = 1;
notificationIsPrior = 1;
old = 300;
}
change = {
kind = 1;
new = 100;
old = 300;
}

在属性改变为 100 前回调一次,change 字典中包含了notificationIsPrior键,且它的值总为@(YES)。属性改变后回调中的 change 字典跟其他选项时一样。
上面的例子中,kind 的值总是 1,其实 NSKeyValueChangeKindKey 键的值的定义如下所示:

1
2
3
4
5
6
typedef NS_ENUM(NSUInteger, NSKeyValueChange) {
NSKeyValueChangeSetting = 1,
NSKeyValueChangeInsertion = 2,
NSKeyValueChangeRemoval = 3,
NSKeyValueChangeReplacement = 4,
};

当观察的对象关系是一对多时,出现插入、剔除或替换时就会出现其他值了,change 字典中其他键值对也会相应变化。

移除观察者

当观察者完成了对监听对象的观察之后,被观察对象需要调用-removeObserver:forKeyPath:-removeObserver:forKeyPath:context:方法移除观察者。如果忘记调用该方法移除观察者的话,程序会崩溃,因为在这些对象被释放之后,KVO 依然保留着它们的注册信息,KVO对这种情况的处理就是让程序直接崩溃。
若调用该方法时,传入的观察者对象并没有被注册为该键路径的观察者,程序也会崩溃。因此要追踪自己观察的属性。一个妥协的方法是可以使用 @try/@catch 来使它不崩溃,但这个方法并不推荐。如下所示:

1
2
3
4
@try {
[string removeObserver:self forKeyPath:NSStringFromSelector(@selector(length))];
}
@catch (NSException *exception) {}//@catch中没有做处理

计算属性

计算属性是指该属性依赖于其他一个或多个属性的变化而变化。因此在 KVO 中,所依赖的其他属性变化时,我们也想要监听到计算属性的相应通知。NSKeyValueObserving协议提供了下面的两个方法:

1
2
3
+(NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key
//或针对某一属性的:
+(NSSet<NSString *> *)keyPathsForValuesAffecting<key>

举个例子,一般的 color 属性都是依赖于RGB三个部分的变化:

1
2
3
4
5
6
7
8
9
10
11
12
+(NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key{
//调用父类的此方法,获取父类中可能对 key 指定属性产生影响的属性集合
NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
if([key isEqualToString:@"color"]){
keyPaths = [keyPaths setByAddingObjectsFromArray:@[@"red",@"green",@"blue"]];
}
return keyPaths;
}
//或者如下方法,确定父类没有对此属性的依赖属性时:
+(NSSet<NSString *> *)keyPathsForValuesAffectingColor {
return [NSSet setWithObjects:@"red",@"green",@"blue",nil]
}

重写了这个方法之后,无论是color属性自己发生了变化还是redgreenblue属性发生变化,我们都会收到color属性的通知回调。

容器类

观察容器类和观察其中的对象是不同的。KVO 旨在观察关系而不是集合。对于集合属性,我们将其当成一个整体来监听它的变化,而无法监听集合中的某个元素的变化。举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@interface Car : NSObject
@property (nonatomic, strong) NSMutableArray *wheels;
@end

@interface Garage : NSObject
- (void)repair;
@end

@implementation Car
@end

static void * garContext = &garContext;
@implementation Garage
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
NSLog(@"change = %@",change);
}
- (void)repair {
Car *car = [[Car alloc] init];
[car addObserver:self forKeyPath:NSStringFromSelector(@selector(wheels)) options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:garContext];
car.wheels = [NSMutableArray array];
[car.wheels addObject:@"frontWheel"];
}
@end

上述例子在调用了 garage 对象的 repair方法后的打印结果如下:

1
2
3
4
5
6
change = {
kind = 1;
new = (
);
old = "<null>";
}

可见当我们观察 car 对象的 wheels 数组属性时,获取的是真正的可变数组,只有在 wheels 属性被整体修改的时候,才会触发 KVO,在后面添加元素进去的时候并没有触发 KVO!

代理集合对象

在上面的 KVC 部分我们说过,只要实现了指定的方法,我们就可以使用**代理集合对象(collection proxy object)**来通过 KVC 处理集合相关的操作。此外我们可以通过调用mutableArrayValueForKey:mutableSetValueForKey:来获取可变数组属性或可变集合属性的代理对象。集合代理对象与 KVO 结合起来也十分强大,KVO 机制在这些代理集合对象改变的时候可以把详细变化放进 change 字典中。我们可以将上个例子中的最后两行代码换成下面的试试:

1
2
3
car.wheels = [NSMutableArray array];
NSMutableArray *wheels = [car mutableArrayValueForKey:@"wheels"];
[wheels addObject:@"backWheel"];

打印结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
change = {
kind = 1;
new = (
);
old = "<null>";
}
change = {
indexes = "<_NSCachedIndexSet: 0x100703a70>[number of indexes: 1 (in 1 ranges), indexes: (0)]";
kind = 2;
new = (
backWheel
);
}

可以看到往 wheels 数组中添加对象时,也触发了KVO。并且 change 字典中有详细的变化信息。

KVO的实现

KVO 是通过 Objective-C 的 runtime 来实现的,第一次对一个对象调用addObserver:forKeyPath:options:context:时,框架会创建这个类的新的 KVO 子类,并将被观察对象转换为新子类的对象(将新对象的 isa 指针指向新子类)。在这个特殊的子类中重写所有被观察属性的 setter 方法,并在其中添加通知变化的代码。这种继承和方法注入是在运行时而不是编译时实现的。
在 KVO 的默认实现中,在被观察属性发生改变之前,会自动调用willChangeValueForKey:方法,这就会记录旧的值。而当改变发生后,didChangeValueForKey:会被调用,继而观察者的observeValueForKey:ofObject:change:context:也会被调用。
我们也可以手动来发送这些通知,从而达到更精确的管理。实现方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//被观察类必须重写如下方法:
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key {
if([key isEqualToString:@"horsepower"]) {
return NO;//关闭了 -willChangeValueForKey: 和 -didChangeValueForKey: 的自动调用,
}else {
return [super automaticallyNotifiesObserversForKey:key];
}
}
//手动发送通知:
-(void)setHorsepower:(NSInteger)horsepower {
if(_horsepower != horsepower) {
[self willChangeValueForKey:@"horsepower"];
_horsepower = horsepower;
[self didChangeValueForKey:@"horsepower"];
}
}
//注:对于集合代理对象,发送通知的方法更为详细。

KVO 和线程

KVO 的行为是同步的,并且与所观察值的变化发生在同样的线程上。没有队列或者 run-loop 的处理。因此当我们试图从其他线程改变属性值的时候要十分小心,除非能确定所有的观察者都是用线程安全的方法处理 KVO 通知,我们不应该将 KVO 和多线程混用起来。
KVO 的同步特性十分强大,只要我们在单一线程运行,则对于:

1
self.horsepower = 200;

KVO 能保证所有 horsepower 的观察者在 setter 方法返回前收到通知。

调试

我们可以在 lldb 中查看一个被观察对象的所有观察信息:

1
2
(lldb)po [observedObject observationInfo]

observationInfo 这个方法的默认实现是以对象的指针作为键从一个全局字典中获取信息。得到的是一个 NSKeyValueObservationInfo 对象,它包含了指定对象上的所有的监听信息。
由此可见 KVO 的信息是存储在一个全局字典中,而不是存储在对象本身。

其他

注意:并不是所有的类的所有属性都兼容 KVO。如果类的所有者不保证类的某个属性兼容 KVO,我们就不能保证 KVO 正常工作。详情可以查看官方文档。