KVO

KVO(键值观察)提供了一种机制,允许将其他对象的特定属性的更改通知给对象。

你可以观察包括简单属性、对一关系和对多关系在内的属性。对多关系的观察者被告知所做更改的类型,以及更改中涉及哪些对象。

KVO的主要好处是,你不必实现自己的方案来在每次属性更改时发送通知。其定义良好的基础设施具有框架级支持,这使其易于采用—通常不需要向项目添加任何代码。此外,该基础设施已经具有完整的功能,这使得对单个属性和依赖值支持多个观察器变得很容易。

注册KVO(Registering for Key-Value Observing)

你必须执行以下步骤使对象能够接收符合kvo的属性的KVO通知:

  1. 使用addObserver:forKeyPath:options:context:方法将观察者注册到被观察对象。
  2. 在观察者内部实现observeValueForKeyPath:ofObject:change:context:以接受更改通知消息。
  3. 当观察器不再应该接收消息时,使用removeObserver:forKeyPath:方法注销它。至少,在观察者从内存中释放之前调用此方法。
注册为观察者

观察对象首先通过由被观察对象发送addObserver:forKeyPath:options:context:消息注册,将自己作为观察者,将要观察的属性的keyPath传递给被观察对象。观察者还指定了一个options参数和一个context指针来管理通知的各个方面。

  • Options参数

options参数指定为选项常量的位或,它会影响通知中提供的更改字典(changes)的内容,以及生成通知的方式。

  1. 通过指定NSKeyValueObservingOptionOld选项,你可以选择从更改之前接收观察到的属性的值。你可以使用选项NSKeyValueObservingOptionNew请求属性的新值。通过这些选项的位或(NSKeyValueObservingOptionOld |NSKeyValueObservingOptionNew ),你将同时收到旧值和新值。

  2. 使用选项NSKeyValueObservingOptionInitial让被观察对象发送一个立即更改通知(在addObserver:forKeyPath:options:context: returns之前),你可以使用这个附加的一次性通知在观察者中获得属性的初始值。

  3. 通过包含选项NSKeyValueObservingOptionPrior,你可以指示被观察对象在属性更改之前发送一个通知(除了在更改之后发送通常的通知)。更改字典(changes)通过包含键NSKeyValueChangeNotificationIsPriorKey和包装YES的NSNumber值来表示一个预更改通知。这个key没有出现在其他地方。当观察器自身的KVO遵从性要求它为依赖于被观察属性的某个属性调用-willChange…方法时,可以使用预更改通知。通常的变更后通知来得太晚,无法及时调用willChange…

  • Context参数

addObserver:forKeyPath:options:context:消息中的context指针包含将在相应的更改通知中传递回观察者的任意数据。你可以指定NULL并完全依赖keyPath字符串来确定更改通知的起源,但是这种方法可能会导致问题——观察者对象的父类(继承链中的任意一个)出于不同的原因监听了相同的keyPath

一种更安全、更可扩展的方法是使用context来确保接收到的通知将发送给你的观察者,而不是发送给父类。

类中唯一命名的静态变量的地址是一个很好的context。在父类或子类中以类似方式选择的上下文不太可能重叠。你可以为整个类选择一个context,并依赖于通知消息中的键路径字符串来确定更改的内容。或者,你可以为每个观察到的键路径创建不同的context,这完全绕过了字符串比较的需要,从而实现更高效的通知解析。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static void *PersonAccountBalanceContext = &PersonAccountBalanceContext;
static void *PersonAccountInterestRateContext = &PersonAccountInterestRateContext;

- (void)registerAsObserverForAccount:(Account*)account {
[account addObserver:self
forKeyPath:@"balance"
options:(NSKeyValueObservingOptionNew |
NSKeyValueObservingOptionOld)
context:PersonAccountBalanceContext];

[account addObserver:self
forKeyPath:@"interestRate"
options:(NSKeyValueObservingOptionNew |
NSKeyValueObservingOptionOld)
context:PersonAccountInterestRateContext];
}

注意:KVO的addObserver:forKeyPath:options:context:方法不维护对观察对象、被观察对象或上下文的强引用。你应该确保在必要时保持对观察对象、被观察对象、对象和上下文的强引用。

接收属性变更通知(Receiving Notification of a Change)

当一个对象的被观察属性的值发生变化时,观察者会收到一个observeValueForKeyPath:ofObject:change:context:消息。所有的观察者都必须实现这个方法。

观察对象提供触发通知的keyPath(本身作为相关对象)、包含关于更改的详细信息的NSDictionary以及在为该keyPath注册观察者时提供的上下文指针。

更改字典条目NSKeyValueChangeKindKey提供了关于发生的更改类型的信息。如果被观察对象的值发生了变化,NSKeyValueChangeKindKey表项将返回NSKeyValueChangeSetting。根据注册观察者时指定的选项,更改字典中的NSKeyValueChangeOldKeyNSKeyValueChangeNewKeykey包含更改之前和更改之后的属性值。如果属性是一个对象,则直接提供值。如果属性是标量C结构体,则值被包装在NSValue对象中(与KVC一样)。

如果观察到的属性是一对多关系,变更字典海通过keyNSKeyValueChangeKindKey分别返回NSKeyValueChangeInsertionNSKeyValueChangeRemovalNSKeyValueChangeReplacement来指示关系中的插入、删除或替换。

同时,变更字典中的keyNSKeyValueChangeIndexesKey对应的是一个NSIndexSet对象,指定关系中发生更改的索引。如果NSKeyValueObservingOptionNewNSKeyValueObservingOptionOld在观察者注册时被指定为选项,则更改字典中的keyNSKeyValueChangeOldKeyNSKeyValueChangeNewKey是包含更改之前和更改之后相关对象的值的数组。

在任何情况下,当观察者不识别上下文(或者在简单的情况下,任何keyPath)时,应该总是调用父类的实现observeValueForKeyPath:ofObject:change:context:,因为这意味着父类类也注册了通知。

注意:如果一个通知传播到类层次结构的顶部,NSObject抛出一个NSInternalInconsistencyException,这是一个编程错误:子类未能使用它注册的通知。

移除观察者对象(Removing an Object as an Observer)

通过向被观察对象发送removeObserver:forKeyPath:context:消息,指定观察对象keyPathcontext,可以删除键值观察器。

在接收到removeObserver:forKeyPath:context:消息后,观察对象将不再接收指定keyPath对象observeValueForKeyPath:ofObject:change:context:通知。

移除观察者时,请记住以下几点:

  • 如果观察者还没有被注册,请求被移除会导致NSRangeException。你要么调用removeObserver:forKeyPath:context:恰好一次对应的addObserver:forKeyPath:options:context:,或者把removeObserver:forKeyPath:context: 调用放在一个try/catch块中来处理潜在的异常。
  • 观察者在被释放时不会自动移除自身。被观察对象继续发送通知,而不关心观察者的状态。然而,与任何其他消息一样,发送到已释放对象的更改通知将触发内存访问异常。因此,你要确保观察者在从内存中消失之前删除自己。
  • KVO没有提供询问对象是观察者还是被观察者的方法。注意构建代码以避免出现相关的错误。一个典型的模式是在观察者的初始化(例如在initviewDidLoad中)期间注册为观察者,在释放(通常在dealloc中)期间取消注册,确保正确配对和有序的添加和删除消息,并在从内存中释放观察者之前取消注册。
注册依赖key(Registering Dependent Keys)

在许多情况下,一个属性的值依赖于另一个对象中的一个或多个其他属性的值。如果一个属性的值发生了更改,那么派生属性的值也应该被标记为更改。如何确保为这些依赖属性发布键值观察通知取决于关系的基数。

一对一关系(To-One Relationships)

要为一对一关系自动触发通知,你应该要么重写keyPathsForValuesAffectingValueForKey:,要么实现一个合适的方法,遵循它定义的注册依赖键的模式。

例如,一个人的全名取决于他的姓和名。返回全名的方法可以写成如下形式:

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

firstNamelastName属性发生变化时,必须通知观察fullName属性的观察者,因为它们会影响该属性的值。

一个解决方案是覆盖keyPathsForValuesAffectingValueForKey:指定一个人的fullName属性依赖于lastName和firstName属性。

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

NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];

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

你的重写通常应该调用父类并返回一个集合,该集合中包含由此操作产生的任何成员(以便不干扰超类中此方法的重写)。

你还可以通过实现一个遵循命名约定keypathsforvaluesinfluence<Key>的类方法来实现相同的结果,其中<Key>是依赖于值的属性名(首字母大写)。

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

当你使用category将计算类属性添加到现有类时,你不能覆盖keyPathsForValuesAffectingValueForKey:方法,因为你不能在类别中重写方法。 在这种情况下,实现一个匹配的keyPathsForValuesAffecting<Key>类方法来利用这个机制。

一对多关系(To-Many Relationships)

keyPathsForValuesAffectingValueForKey:方法不支持包含一多对关系的keyPath。例如,假设你有一个Department对象,它与Employee有一对多的关系(employees),而Employee有一个salary属性。你可能希望Department对象具有一个totalSalary属性,该属性依赖于关系中所有employeesalary。你不能这样做:在keyPathsForValuesAffectingTotalSalary方法中返回employees.salary作为keyPath

在这种情况下,你可以使用KVO来将父节点(在本例中为Department)注册为所有子节点(在本例中为Employees)相关属性的观察者。当向关系中添加和从关系中删除子对象时,必须将父对象作为观察者添加和删除。在observeValueForKeyPath:ofObject:change:context:方法中,更新相关值以响应更改,如下面的代码片段所示:

1
2
3
4
5
6
7
8
9
10
11
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
if (context == totalSalaryContext) {
[self updateTotalSalary];
} else {
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}

- (void)updateTotalSalary {
[self setTotalSalary:[self valueForKeyPath:@"employees.@sum.salary"]];
}