问了几次研究 NSBlock 的人员:Key-Value Observing 在 Cocoa 框架里有着最不好用的 API 。它很难对付,啰嗦,令人迷惑。最糟糕的是,它的 API 掩盖了 framework 中很引人注目的特性。
当处理复杂的,有状态的系统,book-keeping 对于保持清晰是很必要的。免的左手不知道右手做的事。随着时间的推移,对象需要一些方法来发布和订阅状态的改变。
在 Objective-C 和 Cocoa 中,有许多事件之间进行通信的方式,并且每个都有不同程度的形式和耦合
NSNotification
& NSNotificationCenter
提供了一个中央枢纽,一个应用的任何部分都可能通知或者被通知应用的其他部分的变化。唯一需要做的是要知道在寻找什么,主要是通知的名字。例如,UIApplicationDidReceiveMemoryWarningNotification
是给应用发了一个内存不足的信号。SCNetworkReachabilitySetCallback(3)
。在所有这些方法里,Key-Value Observing 是最不好理解的,所以这周 NSHipster 将致力于提供一些最佳实践来解决这一局面。对于一个普通用户,这个练习可能没什么意义,但是对订阅了本博客的人来说确很有用。
<NSKeyValueObserving>
或者 KVO,是一个非正式协议,它定义了对象之间观察和通知状态改变的通用机制的。作为一个非正式协议,你不会看到类的这种引以为豪的一致性(只是隐式的假定了 NSObject 的所有子类)。
KVO 的中心思想其实是相当引人注意的。任意一个对象都可以订阅以便被通知到其他对象状态的改变。这个过程大部分是内建的,自动的,透明的。
题外话,这种观察者模式类似的表现是最现代框架的秘诀,比如 Backbone.js 和 Ember.js。
##注册
对象可以让观察者添加一个特定的 keypath,这个在这篇文章中描述过,其实就是用点符号分隔的 key 指定了一系列的属性。在大多数情况下,这些都是对象的顶级属性。
添加一个观察者的方法是 –addObserver:forKeyPath:options:context:
:
- (void)addObserver:(NSObject *)observer
forKeyPath:(NSString *)keyPath
options:(NSKeyValueObservingOptions)options
context:(void *)context
- observer:注册 KVO 通知的对象。观察者必须实现 key-value observing 方法 observeValueForKeyPath:ofObject:change:context:。
- keyPath:观察者的属性的 keypath,相对于接受者,值不能是 nil。
- options:
NSKeyValueObservingOptions
的组合,它指定了观察通知中包含了什么,可以查看 “NSKeyValueObservingOptions”。- context:在
observeValueForKeyPath:ofObject:change:context:
传给 observer 参数的随机数据
让这个API不堪入目的事实就是最后两个参数经常是 0
和 NULL
。
options
代表 NSKeyValueObservingOptions
的位掩码,需要注意 NSKeyValueObservingOptionNew
& NSKeyValueObservingOptionOld
,因为这些是你经常要用到的,可以跳过 NSKeyValueObservingOptionInitial
& NSKeyValueObservingOptionPrior
:
###NSKeyValueObservingOptions
NSKeyValueObservingOptionNew
: 表明变化的字典应该提供新的属性值,如何可以的话。NSKeyValueObservingOptionOld
: 表明变化的字典应该包含旧的属性值,如何可以的话。NSKeyValueObservingOptionInitial
: 如果被指定,一个通知会立刻发送到观察者,甚至在观察者注册方法之前就返回,改变的字典需要包含一个NSKeyValueChangeNewKey
入口,如果NSKeyValueObservingOptionNew
也被指定的话,但从来不会包含一个NSKeyValueChangeOldKey
入口。(在一个 initial notification 里,观察者的当前属性可能是旧的,但对观察者来说是新的),你可以使用这个选项代替显式的调用,同时,代码也会被观察者的observeValueForKeyPath:ofObject:change:context:
方法调用,当这个选项被用于addObserver:forKeyPath:options:context:
,一个通知将会发送到每个被观察者添加进去的索引对象中。NSKeyValueObservingOptionPrior
:是否各自的通知应该在每个改变前后发送到观察者,而不是在改变之后发送一个单独的通知。一个通知中的可变数组在改变发生之前发送经常包含一个NSKeyValueChangeNotificationIsPriorKey
入口且它的值是@YES
,但从来不会包含一个NSKeyValueChangeNewKey
入口。当这个选项被指定,在改变之后发送的通知中的变化的字典包含了一个与在选项没有被指定的情况下应该包含的同一个入口,当观察者自己的键值观察需要它的时候,你可以使用这个选项来调用-willChange...
方法中的一个来观察它自己的某个属性,那个属性的值依赖于被观察的对象的属性。(在那种情况,调用-willChange...
来对收到的一个observeValueForKeyPath:ofObject:change:context:
消息做出反应可能就太晚了)
这些选项允许一个对象在发生变化的前后获取值。在实践中,这不是必须的,因为从当前属性值获取的新值一般是可用的
也就是说 NSKeyValueObservingOptionInitial
对于在反馈 KVO事件 的时候减少代码路径是很有好处的。比如,如果你有一个方法,它能够动态的使一个基于 text
值的按钮有效,传 NSKeyValueObservingOptionInitial
可以使事件随着它的初始化状态触发一旦观察者被添加进去的话。
至于 context
,它可以被用作区分那些绑定同一个 keypath 的不同对象的观察者。有点复杂,稍后会讨论。
##反馈
导致KVO丑陋的另外一方面是没有方法指定自定义的selectors来处理观察者,就像控件里使用的 Target-Action 模式那样,相反地,对于观察者,所有的改变都被聚集到一个单独的方法 -observeValueForKeyPath:ofObject:change:context:
:
- (void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object
change:(NSDictionary *)change
context:(void *)context
这些参数跟我们指定的 –addObserver:forKeyPath:options:context:
是一样的,change 是个例外,它取决于哪个 NSKeyValueObservingOptions
选项被使用。
一个典型的方法实现看起来像这样
- (void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object
change:(NSDictionary *)change
context:(void *)context
{
if ([keyPath isEqualToString:@"state"]) {
// ...
}
}
这取决于多少种类的对象在一个单独的类里被观察,这个方法也可以用来引出 -isKindOfObject:
或 -respondsToSelector:
为了明确区分传过来的事件种类。但是最安全的方法是做一个 context
等式检查。尤其是处理那些继承自同一个父类的子类,并且这些子类有相同的 keypath。
###正确的上下文声明
如何设置一个好的 context
值呢?这里有个建议:
static void * XXContext = &XXContext;
就是这么简单:一个静态变量存着它自己的指针。这意味着它自己什么也没有,使 <NSKeyValueObserving>
更完美:
- (void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object
change:(NSDictionary *)change
context:(void *)context
{
if (context == XXContext) {
if ([keyPath isEqualToString:NSStringFromSelector(@selector(isFinished))]) {
}
}
}
###更好的 Key Paths
传字符串做为 keypath 比直接使用属性更糟糕,因为任何错字或者拼写错误都不会被编译器察觉,最终导致不能正常工作。
一个聪明的解决方案是使用 NSStringFromSelector
和一个 @selector
字面值:
NSStringFromSelector(@selector(isFinished))
因为 @selector
检查目标中的所有可用 selector,这并不能阻止所有的错误,但它可用捕获大部分-包括捕获 Xcode 自动重构带来的改变
- (void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object
change:(NSDictionary *)change
context:(void *)context
{
if ([object isKindOfClass:[NSOperation class]]) {
if ([keyPath isEqualToString:NSStringFromSelector(@selector(isFinished))]) {
}
} else if (...) {
// ...
}
}
##取消注册
当一个观察者完成了监听一个对象的改变,需要调用 –removeObserver:forKeyPath:context:
。它经常在 -observeValueForKeyPath:ofObject:change:context:
,或者 -dealloc
中被调用。
###利用 @try
/ @catch
安全的取消注册
也许 KVO 最明显的烦恼是它如何在最后获取你,如果你调用 –removeObserver:forKeyPath:context:
当这个对象没有被注册为观察者(因为它已经解注册了或者开始没有注册),抛出一个异常。有意思的是,没有一个内建的方式来检查对象是否注册。
这就会导致我们需要用一种相当不好的方式 @try
和一个没有处理的 @catch
:
- (void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object
change:(NSDictionary *)change
context:(void *)context
{
if ([keyPath isEqualToString:NSStringFromSelector(@selector(isFinished))]) {
if ([object isFinished]) {
@try {
[object removeObserver:self forKeyPath:NSStringFromSelector(@selector(isFinished))];
}
@catch (NSException * __unused exception) {}
}
}
}
当然,这个例子中没有处理一个捕获的异常,这其实是一种妥协的方式。因此,只有当面对连续不断的崩溃并且不能通过一般的措施(竞争条件或者来自父类的非法行为)补救才会用这种方式。
##自动化的属性通知
KVO 很有用并且被广泛采用。正是因为这样,大部分需要得到正确绑定的工作自动被编译和进行时接管。
Classes 可以选择自动退出 KVO 通过复写:
+automaticallyNotifiesObserversForKey:
并且返回No
。
但是如果想复合或者派生 values 又该怎么办呢?让我告诉你有一个带有 @dynamic
, readonly
address
属性,它读取并且格式化它的streetAddress
, locality
, region
和 postalCode
?
好吧,你可以实现 keyPathsForValuesAffectingAddress
方法(或者 +keyPathsForValuesAffectingValueForKey:
):
+ (NSSet *)keyPathsForValuesAffectingAddress {
return [NSSet setWithObjects:NSStringFromSelector(@selector(streetAddress)), NSStringFromSelector(@selector(locality)), NSStringFromSelector(@selector(region)), NSStringFromSelector(@selector(postalCode)), nil];
}
除非另有声明,本文采用知识共享「署名-非商业性使用 3.0 中国大陆」许可协议授权。