KVO, 即键值观察,提供了一种让一个对象监听另一个对象的特定属性变化的机制。这在 MVC 的 Model 层 和 Controller 层间通信十分有用。通常情况下,Controller 会监听 Model 对象的属性变化,或者 View 对象会通过 Controller 来监听 Model 对象的属性变化。除此之外,在 Model 对象需要感知其依赖值的改变的时候,该 Model 对象也可以监听其他 Model 对象或者其自身的属性变化。

监听属性变化需要以下几步:

  1. 使用函数 addObserver:forKeyPath:options:context: 建立观察者和被观察者对象之间的连接,这种连接不是建立在这两个类之间,而是两个对象实例之间。
  2. 为了响应被观察者对象的变化通知,观察者必须实现 observeValueForKeyPath:ofObject:change:context: 方法,该方法定义了观察者是如何对被观察者的变化做出响应的。
  3. 当被观察的属性发生变化时,observeValueForKeyPath:ofObject:change:context: 方法会自动调用。
  4. 调用 – removeObserver:forKeyPath:context: 取消注册。

本文内容如下:

  • 注册KVO
  • 实现KVO反馈
  • 移除KVO观察者
  • 自动化及手动的属性通知
  • 注册对依赖键路径的KVO

注册KVO

任意一个对象都可以订阅以便被通知到其他对象状态的改变。这个过程大部分是内建的,自动的,透明的。KVO 的机制可以很方便的使用多个监听者监听同一属性的变化。

注册通知使用 addObserver:forKeyPath:options:context: 方法实现,接下来看看各个参数的含义。参考文档 NSKeyValueObserving Protocol Reference , 注册 KVO 的函数定义如下:

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

其中

  • anObserver 指注册 KVO 通知的对象。观察者必须实现 observeValueForKeyPath:ofObject:change:context: 以对被观察对象的改变做出响应。
  • keyPath 指相对于被观察者的属性,此值必须不能为 nil
  • optionsNSKeyValueObservingOptions 定义的常量值的组合,这些值指定了在发出的观察通知中会包含哪些东西。不同的指定值会导致观察通知中包含的值不同。
  • context 该值可以是任一数据值,会在 observeValueForKeyPath:ofObject:change:context: 中传递给 anObserver,也就是这个参数值与 observeValueForKeyPath:ofObject:change:context:context 参数的值相等。

关于keyPath

关于 keyPath, 如果直接传入字符串值,对于拼写错误这种不能由编译器检查到的错误,会导致 KVO 不会执行。可使用 NSStringFromSelector 和 一个 @selector 字面值的组合来避免,例如:

NSStringFromSelector(@selector(isFinished))

由于 @selector 检查目标中的所有可用 selector,这并不能阻止所有的错误,但它可用捕获大部分-包括捕获 Xcode 自动重构带来的改变。

关于options

上面的参数 options 的值决定了传向 observeValueForKeyPath:ofObject:change:context:change 字典包含的值。如果传值 0 表示没有 change 字典值。 NSKeyValueObservingOptions 的定义如下:

1
2
3
4
5
6
typedef NS_OPTIONS(NSUInteger, NSKeyValueObservingOptions) {
NSKeyValueObservingOptionNew = 0x01,
NSKeyValueObservingOptionOld = 0x02,
NSKeyValueObservingOptionInitial NS_ENUM_AVAILABLE(10_5, 2_0) = 0x04,
NSKeyValueObservingOptionPrior NS_ENUM_AVAILABLE(10_5, 2_0) = 0x08
};
  • NSKeyValueObservingOptionNew 表示 change 字典中应该包含监听对象的新属性值。
  • NSKeyValueObservingOptionOld 表示 change 字典中应该包含监听对象的旧属性值,即改变前的值。
  • NSKeyValueObservingOptionInitial 如果设定了该值,在注册观察者的方法返回之前就会发送通知给观察者。在注册观察者时,如果同时指定了 NSKeyValueObservingOptionNew,那么在发出的通知中, change 字典中会包含 NSKeyValueChangeNewKey 及被观察对象的当前值,但是却不会包含 NSKeyValueChangeOldKey,且 NSKeyValueChangeKindKey 对应的值是 NSKeyValueChangeSetting
  • NSKeyValueObservingOptionPrior 使得被观察对象的值在改变之前和改变之后都会发送通知,而不仅仅是在改变之后发送一个通知。在被观察对象的值发生改变之前(和- willChange...:被触发的时间相对应)发送的通知中,change 字典中包含 NSKeyValueChangeNotificationIsPriorKey,其值是 [NSNumber numberWithBool:YES]。指定该值后,想要知道被观察对象改变前后的值,还是需要指定 NSKeyValueObservingOptionNewNSKeyValueObservingOptionOld。我们可以像以下这样区分通知是在改变之前还是之后被触发的:
1
2
3
4
5
6
7
if ([change[NSKeyValueChangeNotificationIsPriorKey] boolValue]) {

// 改变之前
} else {

// 改变之后
}

关于context

关于 context 参数,其作用可用来标识观察者的身份,在多个观察者观察同一键值时,尤其在处理父类和子类都观察同一键值时非常有用。

那么如何正确声明一个 context 呢? 建议如下:

static void * XXContext = &XXContext;

其值就是一个存储自身指针的静态变量值,使用示例如下:

1
2
3
4
5
6
7
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
if (context == XXContext) {

} else {
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}

实现KVO反馈

注册 KVO 之后,观察者需要实现 - observeValueForKeyPath:ofObject:change:context:,其实现类似于:

1
2
3
4
5
6
7
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
if (context == XXContext) {

} else {
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}

其中:

  • keyPath 指相对于被监听对象 object 的键路径
  • object 指键路径 keyPath 所属对象,即被监听对象
  • change 描述被监听属性的变化信息
  • context 注册 KVO 时由监听者提供,见上节关于 context 的描述

change 字典中包含的 key 值包括:

  • NSKeyValueChangeKindKey

其值是 NSNumber 对象,与 NSKeyValueChange 定义的枚举值之一对应,指示发生了哪种类型的变化。

NSKeyValueChangeSetting 指示被观察者接收到了 setValue:forKey: 消息,或者其 set 方法被调用,或者调用了 willChangeValueForKey: or didChangeValueForKey: 系列方法。

NSKeyValueChangeInsertion, NSKeyValueChangeRemoval, 或者 NSKeyValueChangeReplacement 表示向集合代理发送了改变集合值的消息,或者发送了其他符合 key-value-coding-compliant 规则的集合操作消息。

可以使用 NSNumber 的 intValue 方法获取监控对象发生的改变类型。

  • NSKeyValueChangeNewKey

如果 NSKeyValueChangeKindKey 的值是 NSKeyValueChangeSetting,且在注册观察者时指定了 NSKeyValueObservingOptionNew 值,那么该键所对应的值就是监测对象的新值。

对于 NSKeyValueChangeInsertion 或者 NSKeyValueChangeReplacement,如果注册观察者时指定了 NSKeyValueObservingOptionNew, 那么该键所对应的值是一个 NSArray 实例,里面分别对应被插入和替换后的值。

  • NSKeyValueChangeOldKey

如果 NSKeyValueChangeKindKey 的值是 NSKeyValueChangeSetting,且在注册时指定了 NSKeyValueObservingOptionOld,那么该键所对应的值就是被监测对象改变前的值。

对于 NSKeyValueChangeRemoval 或者 NSKeyValueChangeReplacement,如果注册观察者时指定了 NSKeyValueObservingOptionOld,该键所对应的值是 NSArray 实例,里面分别对应的是检测对象中已被移除和被替换前的值。

  • NSKeyValueChangeIndexesKey

如果 NSKeyValueChangeKindKey 的值是 NSKeyValueChangeInsertionNSKeyValueChangeRemoval 或者 NSKeyValueChangeReplacement,那么该键所对应的值是 NSIndexSet 实例,里面包含了被插入、被移除或者被替换的值。

  • NSKeyValueChangeNotificationIsPriorKey

当注册观察者时指定了 NSKeyValueObservingOptionPrior,那么该消息会在检测对象发生改变前发送通知,该键对应的值是 [NSNumber numberWithBool:YES] 对象。

移除KVO观察者

当一个观察者完成了监听一个对象的改变,需要调用 –removeObserver:forKeyPath:context:。它经常在 -observeValueForKeyPath:ofObject:change:context:,或者 -dealloc 中被调用。

如果你调用 –removeObserver:forKeyPath:context: 移除一个观察者对象,但这个对象没有被注册为观察者(因为它已经解注册了或者开始没有注册),则会抛出一个异常。Objective-C 中,没有一个内建的方式来检查对象是否注册,这会导致我们需要用一种相当不好的方式 @try 和一个没有处理的 @catch,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
- (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 的自动通知,对于非集合对象,直接使用属性的 set 方法即可自动触发 KVO;对于集合对象,可使用如下代理方法:

1
2
3
- mutableArrayValueForKey:
- mutableSetValueForKey:
- mutableOrderedSetValueForKey:

关于如何为 NSMutableArray 添加 KVO,可参考如何为NSMutableArray添加KVO.

KVO 很有用并且被广泛采用,正是因为这样,大部分需要得到正确绑定的工作自动被编译和进行时接管。Class 可以通过重写 +automaticallyNotifiesObserversForKey: ,使需要关闭自动 KVO 的键路径返回 NO,如下所示,关闭 openingBalance 路径的自动 KVO 通知:

1
2
3
4
5
6
7
8
9
10
11
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)theKey {

BOOL automatic = NO;
if ([theKey isEqualToString:@"openingBalance"]) {
automatic = NO;
}
else {
automatic = [super automaticallyNotifiesObserversForKey:theKey];
}
return automatic;
}

那么,关闭键路径的自动 KVO 通知后,如何手动触发一个观察对象的 KVO 呢?你需要在改变值之前调用 willChangeValueForKey:, 在改变值之后调用 didChangeValueForKey:, 以下示例实现了 openingBalance 属性的手动触发:

1
2
3
4
5
6
7
- (void)setOpeningBalance:(double)theBalance {
if (theBalance != _openingBalance) {
[self willChangeValueForKey:@"openingBalance"];
_openingBalance = theBalance;
[self didChangeValueForKey:@"openingBalance"];
}
}

如果一个单一操作引发多个键路径值的改变,那么你可以使用嵌套的 KVO 来通知观察者,如下所示:

1
2
3
4
5
6
7
8
- (void)setOpeningBalance:(double)theBalance {
[self willChangeValueForKey:@"openingBalance"];
[self willChangeValueForKey:@"itemChanged"];
_openingBalance = theBalance;
_itemChanged = _itemChanged+1;
[self didChangeValueForKey:@"itemChanged"];
[self didChangeValueForKey:@"openingBalance"];
}

对于有序多元素的集合,手动触发 KVO 时,不仅需要指定哪个键值改变了,还需要指出发生改变的类型以及这种改变影响到的对象在集合中的索引,改变的类型由 NSKeyValueChange 定义,包括 NSKeyValueChangeInsertion, NSKeyValueChangeRemoval, NSKeyValueChangeReplacement。发生改变的元素在集合中的索引以 NSIndexSet 对象的形式传递给观察者。以下代码展示了移除 transactions 中元素时手动触发 KVO 通知:

1
2
3
4
5
6
7
8
9
- (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"];
}

注册对依赖键路径的KVO

考虑如下情形:一个属性A值依赖于另外一个或者多个其他的属性,如果依赖的属性值发生了改变,那么属性A也应该相应的发生改变,并且向观察者发出 KVO 通知,这种情况如何处理呢?

有两种方法:

  • 重写 keyPathsForValuesAffectingValueForKey:
  • 为注册依赖键实现符合一定规则的方法 keyPathsForValuesAffecting<Key>
  1. 重写 keyPathsForValuesAffectingValueForKey:

举个例子,一个人的全名有其姓和名组成,那么返回全名的方法会如下所示:

1
2
3
- (NSString *)fullName {
return [NSString stringWithFormat:@"%@ %@", _firstName, _lastName];
}

观察者观察 fullName 属性的变化时,应该在改变 firstNamelastName 时都得到通知以更新 fullName 的值,下面重写 keyPathsForValuesAffectingValueForKey: 来实现此目的:

1
2
3
4
5
6
7
8
9
10
+ (NSSet *)keyPathsForValuesAffectingValueForKey:(NSString *)key {

NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];

if ([key isEqualToString:NSStringFromSelector(@selector(firstName))]) {
NSArray *affectingKeys = @[NSStringFromSelector(@selector(firstName)), NSStringFromSelector(@selector(lastName))];
keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];
}
return keyPaths;
}

需要注意需调用父类的方法以处理其余的 key 值。

  1. 实现服从 +keyPathsForValuesAffecting<Key> 规则的类方法

另外,你也可以通过实现服从 +keyPathsForValuesAffecting<Key> 规则的类方法来达到监测复合属性的变化,其中 key 指的是需要监测的属性名(其首字母需大写)。对于上述示例,需要监测 fullName 的变化,是实现 + (NSSet *)keyPathsForValuesAffectingFullName 类方法:

1
2
3
+ (NSSet *)keyPathsForValuesAffectingFullName {
return [NSSet setWithObjects:NSStringFromSelector(@selector(firstName)), NSStringFromSelector(@selector(lastName)), nil];
}

参考:


目前已转行教育行业,欢迎加微信交流:CaryaLiu