本文主要内容来自于对官方文档 Key-Value Observing Programming Guide 的翻译,以及一部分我自己的理解和解释,如果有说错的地方请及时联系我。
At a Glance
KVO 也就是 键值观察 ,它提供了一种机制,使得当某个对象特定的属性发生改变时能够通知到别的对象。这经常用于 model 和 controller 之间的通信。KVO主要的优点是你不需要在每次属性改变时手动去发送通知。并且它支持为一个属性注册多个观察者。
注册 KVO
- 被观察对象 的属性必须是 [KVO Compliant](file:///Users/hcy/Library/Developer/Shared/Documentation/DocSets/com.apple.adc.documentation.iOS.docset/Contents/Resources/Documents/documentation/Cocoa/Conceptual/KeyValueObserving/Articles/KVOCompliance.html#//apple_ref/doc/uid/20002178-BAJEAIEE)
- 必须用 被观察对象 的
addObserver:forKeyPath:options:context:
方法注册观察者 -
观察者 必须实现
observeValueForKeyPath:ofObject:change:context:
方法
注册成为观察者
为了能够在属性改变时被通知到,一个 观察者对象 必须通过 被观察对象 的addObserver:forKeyPath:options:context:
方法注册成为观察者。
observer
参数也就是一个观察者对象keyPath
表示要观察的属性-
options
决定了提供给观察者change字典中的具体信息有哪些。(change字典是一个提供给观察者的参数,后面会提到)-
NSKeyValueObservingOptionOld
表示在change字典中包含了改变前的值。 -
NSKeyValueObservingOptionNew
表示在change字典中包含新的值。 -
NSKeyValueObservingOptionInitial
在注册观察者的方法return的时候就会发出一次通知。 -
NSKeyValueObservingOptionPrior
会在值发生改变前发出一次通知,当然改变后的通知依旧还会发出,也就是每次change都会有两个通知。
-
context
这个参数可以是一个 C指针,也可以是一个 对象引用,它可以作为这个context的唯一标识,也可以提供一些数据给观察者。
注意:
addObserver:forKeyPath:options:context:
方法不会持有观察者对象,被观察对象,以及context的强引用。你要确保自己持有了他们的强引用。
属性变化时接收通知
当一个被观察属性的值发生改变时,观察者会收到 observeValueForKeyPath:ofObject:change:context:
的消息。所有的观察者必须实现这个方法。这个方法中的参数和注册观察者方法的参数基本相同,只有一个 change
不同。 change
是一个字典,它里面包含了的信息由注册时的 options
决定。
官方提供了这些key给我们来取到 change
中的value:
NSString *const NSKeyValueChangeKindKey;
NSString *const NSKeyValueChangeNewKey;
NSString *const NSKeyValueChangeOldKey;
NSString *const NSKeyValueChangeIndexesKey;
NSString *const NSKeyValueChangeNotificationIsPriorKey;
-
NSKeyValueChangeKindKey
这个key包含的value是一个 NSNumber 里面是一个 int,与之对应的是NSKeyValueChange
的枚举
enum {
NSKeyValueChangeSetting = 1,
NSKeyValueChangeInsertion = 2,
NSKeyValueChangeRemoval = 3,
NSKeyValueChangeReplacement = 4
};
typedef NSUInteger NSKeyValueChange;
当 change[NSKeyValueChangeKindKey]
是 NSKeyValueChangeSetting
的时候,说明被观察属性的setter方法被调用了。
而下面三种,根据官方文档的意思是,当被观察属性是集合类型,且对它进行了 insert,remove,replace 操作的时候会返回这三种Key,但是我自己测试的时候没有测试出来😓不知道是不是我理解错了。
NSKeyValueChangeNewKey
,NSKeyValueChangeOldKey
顾名思义,当你在注册的时候options
参数中填了对应的NSKeyValueObservingOptionNew
和NSKeyValueObservingOptionOld
,并且NSKeyValueChangeKindKey
的值是NSKeyValueChangeSetting
,你就可以通过这两个key取到 旧值和新值。NSKeyValueChangeIndexesKey
, 当NSKeyValueChangeKindKey
的结果是NSKeyValueChangeInsertion
,NSKeyValueChangeRemoval
或NSKeyValueChangeReplacement
的时候,这个key的value是一个NSIndexSet,包含了发生insert,remove,replace的对象的索引集合NSKeyValueChangeNotificationIsPriorKey
,这个key包含了一个 NSNumber,里面是一个布尔值,如果在注册时options
中有NSKeyValueObservingOptionPrior
,那么在前一个通知中的change
中就会有这个key的value, 我们可以这样来判断是不是在改变前的通知[change[NSKeyValueChangeNotificationIsPriorKey] boolValue] == YES;
移除一个观察者
你可以通过 removeObserver:forKeyPath:
方法来移除一个观察。如果你的 context
是一个 对象,你必须在移除观察之前持有它的强引用。当移除了观察后,观察者对象再也不会受到这个 keyPath 的通知。
KVO Compliance
有两种方式能够保证 change notification 能够被发出。
- 自动通知,继承自NSObject,并且所有的属性符合[KVC规范](file:///Users/hcy/Library/Developer/Shared/Documentation/DocSets/com.apple.adc.documentation.iOS.docset/Contents/Resources/Documents/documentation/Cocoa/Conceptual/KeyValueCoding/Articles/Compliant.html#//apple_ref/doc/uid/20002172)这样就不用写额外的代码去实现自动通知。
- 手动通知,让你的子类实现
automaticallyNotifiesObserversForKey:
方法,来决定是否需要自动通知,如果是手动通知需要额外的代码。
自动通知
NSObject 已经实现了自动通知,只要通过 setter 方法去赋值,或者通过 KVC 就可以通知到观察者。自动通知也支持集合代理对象,比如 mutableArrayValueForKey: 方法。
// Call the accessor method.
[account setName:@"Savings"];
// Use setValue:forKey:.
[account setValue:@"Savings" forKey:@"name"];
// Use a key path, where 'account' is a kvc-compliant property of 'document'.
[document setValue:@"Savings" forKeyPath:@"account.name"];
// Use mutableArrayValueForKey: to retrieve a relationship proxy object.
Transaction *newTransaction = <#Create a new transaction for the account#>;
NSMutableArray *transactions = [account mutableArrayValueForKey:@"transactions"];
[transactions addObject:newTransaction];
手动通知
手动通知提供了更自由的方式去决定什么时间,什么方式去通知观察者。这可以帮助你最少限度触发不必要的通知,或者一组改变值发出一个通知。想要使用手动通知必须实现automaticallyNotifiesObserversForKey:
方法。(或者automaticallyNotifiesObserversOfS<Key>
)在一个类中同时使用自动和手动通知是可行的。对于想要手动通知的属性,可以根据它的keyPath返回NO,而其对于其他位置的keyPath,要返回父类的这个方法。
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)theKey {
BOOL automatic = NO;
if ([theKey isEqualToString:@"openingBalance"]) {
automatic = NO;
} else {
automatic = [super automaticallyNotifiesObserversForKey:theKey];
}
return automatic;
}
要实现手动通知,你需要在值改变前调用 willChangeValueForKey:
方法,在值改变后调用 didChangeValueForKey:
方法。你可以在发送通知前检查值是否改变,如果没有改变就不发送通知
- (void)setOpeningBalance:(double)theBalance {
if (theBalance != _openingBalance) {
[self willChangeValueForKey:@"openingBalance"];
_openingBalance = theBalance;
[self didChangeValueForKey:@"openingBalance"];
}
}
如果一个操作会导致多个属性改变,你需要嵌套通知,像下面这样:
- (void)setOpeningBalance:(double)theBalance {
[self willChangeValueForKey:@"openingBalance"];
[self willChangeValueForKey:@"itemChanged"];
_openingBalance = theBalance;
_itemChanged = _itemChanged+1;
[self didChangeValueForKey:@"itemChanged"];
[self didChangeValueForKey:@"openingBalance"];
}
在一个一对多的关系中,你必须注意不仅仅是这个key改变了,还有它改变的类型以及索引。
- (void)removeTransactionsAtIndexes:(NSIndexSet *)indexes {
[self willChange:NSKeyValueChangeRemoval valuesAtIndexes:indexes forKey:@"transactions"];
// Remove the transaction objects at the specified indexes.
[self didChange:NSKeyValueChangeRemoval valuesAtIndexes:indexes forKey:@"transactions"];
}
键之间的依赖
在很多种情况下一个属性的值依赖于在其他对象中的属性。如果一个依赖属性的值改变了,这个属性也需要被通知到。
To-one Relationships
比如有一个教 fullName
的属性,依赖于 firstName
和 lastName
,当 firstName
或者 lastName
改变时,这个 fullName
属性需要被通知到。
- (NSString *)fullName {
return [NSString stringWithFormat:@"%@ %@",firstName, lastName];
}
你可以重写 keyPathsForValuesAffectingValueForKey:
方法。其中要先调父类的这个方法拿到一个set,再做接下来的操作。
+ (NSSet *)keyPathsForValuesAffectingValueForKey:(NSString *)key {
NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
if ([key isEqualToString:@"fullName"]) {
NSArray *affectingKeys = @[@"lastName", @"firstName"];
keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];
}
return keyPaths;
}
你也可以通过实现 keyPathsForValuesAffecting<Key>
方法来达到前面同样的效果,这里的<Key>就是属性名,不过第一个字母要大写,用前面的例子来说就是这样:
+ (NSSet *)keyPathsForValuesAffectingFullName {
return [NSSet setWithObjects:@"lastName", @"firstName", nil];
}
To-many Relationships
keyPathsForValuesAffectingValueForKey:
方法不能支持 to-many 的关系。举个例子,比如你有一个 Department 对象,和很多个 Employee 对象。而 Employee 有一个 salary 属性。你可能希望 Department 对象有一个 totalSalary 的属性,依赖于所有的 Employee 的 salary 。
你可以注册 Department 成为所有 Employee 的观察者。当 Employee 被添加或者被移除时,你必须要添加和移除观察者。然后在 observeValueForKeyPath:ofObject:change:context:
方法中,根据改变做出反馈。
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
if (context == totalSalaryContext) {
[self updateTotalSalary];
}
else
// deal with other observations and/or invoke super...
}
- (void)updateTotalSalary {
[self setTotalSalary:[self valueForKeyPath:@"employees.@sum.salary"]];
}
- (void)setTotalSalary:(NSNumber *)newTotalSalary {
if (totalSalary != newTotalSalary) {
[self willChangeValueForKey:@"totalSalary"];
_totalSalary = newTotalSalary;
[self didChangeValueForKey:@"totalSalary"];
}
}
- (NSNumber *)totalSalary {
return _totalSalary;
}
KVO的实现细节
KVO 的实现用了一种叫 isa-swizzling
的技术。isa 指针就是指向类的指针,当一个对象的一个属性注册了观察者后,被观察对象的isa指针的就指向了一个系统为我们生成的中间类,而不是我们自己创建的类。在这个类中,系统为我们重写了被观察属性的setter方法。你可以通过 object_getClass(id obj)
方法获得对象真实的类,在 addObserver 前后分别打印,就可以看到isa指针被指向了一个中间类。似乎都是在原来的类名前面加上 NSKVONotifying_
isa指针不总是指向真实的类,所以你不应该依赖于 isa 指针来判断这个对象的类型,而应该通过 class
方法来判断对象的类型。如果你还不知道什么是isa指针,可以看我之前写的博客 Objective-C runtime 的简单理解与使用