KVO编程指南

《Key-Value Observing Programming Guide》翻译

Posted by Ted on October 22, 2017

苹果官方文档Key-Value Observing Programming Guide

一、简介

KVO键值观察是一种当对象指定属性更改时,可以通知其他对象的机制

这对于应用程序中的model层和controller层之间的通信特别有用。 (在OS X中,controller层绑定技术在很大程度上依赖于键值观察。)controller对象通常会观察model对象的属性,view对象通过controller观察model对象的属性。另外,model对象可能会观察到其他model对象(通常用于确定依赖值何时发生变化),甚至是自身(再次确定依赖值何时发生变化)。

您可以观察属性,包括简单属性,一对一关系和多对多关系。

一个简单的例子说明KVO如何在你的应用程序中有用。假设一个Person对象与一个Account对象进行交互,表示该人在银行的储蓄账户。 Person的一个实例可能需要知道Account实例某些方面的变化,例如余额或利率。

img

如果这些属性是Account的公共属性,Person可以定期轮询Account来发现更改,但这当然是低效的,而且往往是不切实际的。 更好的方法是使用KVO,类似于发生更改时Person接收一个通知。

要使用KVO,首先必须确保所观察的对象(本例中的Account)符合KVO标准。 通常情况下,如果您的对象继承自NSObject,并且以常规方式创建属性,那么您的对象及其属性将自动成为KVO兼容的。 也可以手动实施合规性。 KVO合规性描述了自动和手动键值观察之间的区别,以及如何实现这两者。

接下来,你必须注册你的观察者实例Person;被观察实例也就是Account。 Person向Account发送一个addObserver:forKeyPath:options:context:消息,为每个观察到的关键路径命名自己为观察者。

img

为了接收来自Account的变更通知,Person实现了obserValueForKeyPath:ofObject:change:context:method,这是所有观察者所必需的。 只要有一个注册的key path发生变化,Account就会将此消息发送给Persono。 然后,Person可以根据更改通知采取适当的措施。

img

最后,当它不再需要通知时,至少在它被释放之前,Person实例必须通过发送消息removeObserver:forKeyPath:到Account来注销。

img

二、注册键值观察

您必须执行以下步骤才能使对象接收KVO,兼容属性的键值观察通知:

  • 使用方法addObserver:forKeyPath:options:context:向观察者注册被观察对象。
  • 实现observeValueForKeyPath:ofObject:change:context:在观察者内部接受更改消息。
  • 使用方法removeObserver:forKeyPath注销观察者:不再应该接收消息时。 至少在观察者从内存中释放之前调用这个方法。

重要提示:并非所有的类都符合KVO标准。 您可以按照KVO合规中所述的步骤确保您自己的类符合KVO标准。 通常,苹果提供的框架中的属性只有符合KVO标准才能被KVO。

注册观察者

观察对象首先通过发送一个addObserver:forKeyPath:options:context:message,将自己作为观察者传递自己,并将其自身作为要观察的属性的关键路径。 观察者另外指定一个选项参数和一个上下文指针来管理通知的各个方面。

options参数

options参数(指定为选项常数的按位或)会影响通知中的内容以及生成通知的方式。

您可以通过指定options为NSKeyValueObservingOptionOld选择从更改前接收观察属性的值。您可以通过options为NSKeyValueObservingOptionNew请求属性的新值。您可以通过这些选项的按位OR来获得旧值和新值。

您指示被观察对象使用options参数NSKeyValueObservingOptionInitial发送立即更改通知(在addObserver:forKeyPath:options:context:returns之前)。另外,您可以使用此一次性通知来确定观察者中某个属性的初始值。

您可以通过包含options参数NSKeyValueObservingOptionPrior来指示被观察对象在属性更改之前发送通知(除了通常在更改之后的通知之外)。更改字典通过包含key为NSKeyValueChangeNotificationIsPriorKey和YES为值来表示prechange通知。你可以使用prechange通知当观察者自己的KVO要求它调用其中一个属性的-willChange ...方法之一时,普通的通知会晚点以致于无法及时调用willChange

context

addObserver:forKeyPath:options:context:message中的context指针包含任意数据,将在相应的更改通知中传回给观察者。您可以指定NULL并完全依赖关键字路径字符串来确定更改通知的来源,但是这种方法可能会导致父类也出于不同原因观察到相同关键路径的对象而出现问题。

更安全和更可扩展的方法是使用context来确保您收到的通知的目的地是您的观察者而不是父类。

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

下面显示了以这种方式选择的balance和interestRate属性的示例context。

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];
}

注意: addObserver:forKeyPath:options:context: 没有强引用 observing object, the observed objects, or the context.

接收通知

当对象的观察属性的值发生变化时,观察者会收到一个observeValueForKeyPath:ofObject:change:context:message。所有的观察者都必须实现这个方法。 被观察对象提供触发通知的keypath,本身作为相关对象,包含有关更改详细信息的字典以及观察者注册此关键路径时提供的context指针。 更改通知字典用NSKeyValueChangeKindKey提供有关发生的更改类型的信息。如果被观察对象的值已更改,则NSKeyValueChangeKindKey返回NSKeyValueChangeSetting。根据注册观察者时指定的选项,通知更改的字典中的NSKeyValueChangeOldKey和NSKeyValueChangeNewKey包含更改之前和之后的属性值。如果该属性是一个对象,则直接提供该值。如果该属性是标量或C结构,则该值将包装在一个NSValue对象中(与键值编码一样)。 如果观察到的属性是一对多的关系,则NSKeyValueChangeKindKey还指示关系中的对象是分别通过返回NSKeyValueChangeInsertion,NSKeyValueChangeRemoval还是NSKeyValueChangeReplacement来插入,移除或替换。 NSKeyValueChangeIndexesKey的值是一个NSIndexSet对象,用于指定已更改的关系中的索引。如果在注册观察者时将NSKeyValueObservingOptionNew或NSKeyValueObservingOptionOld指定为选项,则更改字典中的NSKeyValueChangeOldKey和NSKeyValueChangeNewKey值是包含相关对象在更改之前和之后的值的数组。

- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context {
 
    if (context == PersonAccountBalanceContext) {
        // Do something with the balance…
 
    } else if (context == PersonAccountInterestRateContext) {
        // Do something with the interest rate…
 
    } else {
        // Any unrecognized context must belong to super
        [super observeValueForKeyPath:keyPath
                             ofObject:object
                               change:change
                               context:context];
    }
}

如果在注册观察者时指定了NULL context,则可以将通知的键路径与您正在观察的键路径进行比较,以确定发生了什么变化。 如果您对所有观察到的键路径使用单个context,则首先根据通知的context对其进行测试,并找到匹配项,然后使用键路径字符串比较来确定具体发生了什么变化。 如果您为每个关键路径提供了唯一的上下文,如下所示,一系列简单的指针比较会同时告诉您该通知是否适用于此观察者,如果是,那么哪个关键路径发生了变化。

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

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

移除观察

移除时,要注意以下几点:

- (void)unregisterAsObserverForAccount:(Account*)account {
    [account removeObserver:self
                 forKeyPath:@"balance"
                    context:PersonAccountBalanceContext];
 
    [account removeObserver:self
                 forKeyPath:@"interestRate"
                    context:PersonAccountInterestRateContext];
}
  • 删除未注册的观察对象将导致NSRangeException。您可以调用removeObserver:forKeyPath:context:恰好相应调用一次addObserver:forKeyPath:options:context :,或者如果在您的应用中不可行,则将try / catch块中的removeObserver:forKeyPath:context:call以处理潜在的例外。
  • 观察员在释放时不会自动删除自己。被观察的对象继续发送通知,忘记了观察者的状态。但是,像任何其他消息一样,发送到释放对象的更改通知会触发内存访问异常。因此,您应确保观察者在从内存中消失之前自行消除。
  • 该协议没有办法问一个对象,如果它是一个观察者或观察。构建您的代码以避免发布相关的错误。一个典型的模式是在观察者初始化期间注册为观察者(例如在init或viewDidLoad中),并在释放期间取消注册(通常在dealloc中),确保正确配对和有序的添加和移除消息,并且观察者在注册之前是未注册的从记忆中解脱出来。

三、KVO合规性

为了符合KVO标准,一个类必须确保以下内容:

  • 该类必须符合该属性的键值编码,如确保KVC合规性中所述。 KVO支持与KVC相同的数据类型,包括Objective-C对象以及标量和结构支持中列出的标量和结构。

  • 该类发出该属性的KVO变化通知。
  • 从属按键被正确注册(请参阅注册从属按键)

有两种技术可以确保发出更改通知。自动支持由NSObject提供,并且默认情况下可用于符合键值编码的类的所有属性。通常,如果遵循标准的Cocoa编码和命名约定,则可以使用自动更改通知 - 不必编写任何其他代码。

手动更改通知提供了何时发出通知的额外控制,并且需要额外的编码。您可以通过自动实现类方法自动通知您的子类的属性NotNotifiesObserversForKey :.

自动发出通知

NSObject提供了自动键值更改通知的基本实现。 自动键值更改通知通知观察者使用键值兼容访问器所做的更改以及键值编码方法。 自动通知还受由例如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];

手动通知

在某些情况下,您可能需要控制通知过程,例如,最大限度地减少因应用程序特定原因而不必要的触发通知,或将多个更改分组为单个通知。手动更改通知提供了执行此操作的方法。

手动和自动通知不是相互排斥的。除了已有的自动通知外,您还可以自由发布手动通知。更典型的是,你可能想完全控制一个特定属性的通知。在这种情况下,对于要排除其自动通知的属性,您会覆盖自动通知ObserversForKey的NSObject实现,自动通知观察者关键字的子类实现应返回“NO”。子类的实现应该为任何无法识别的键调用super。

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

要实现手动观察者通知,可以在更改值之前调用willChangeValueForKey,并在更改值之后调用didChangeValueForKey

- (void)setBalance:(double)theBalance {
    [self willChangeValueForKey:@"balance"];
    _balance = theBalance;
    [self didChangeValueForKey:@"balance"];
}

你可以减少没必要的通知

- (void)setBalance:(double)theBalance {
    if (theBalance != _balance) {
        [self willChangeValueForKey:@"balance"];
        _balance = theBalance;
        [self didChangeValueForKey:@"balance"];
    }
}

你可以操作多个key

- (void)setBalance:(double)theBalance {
    [self willChangeValueForKey:@"balance"];
    [self willChangeValueForKey:@"itemChanged"];
    _balance = theBalance;
    _itemChanged = _itemChanged+1;
    [self didChangeValueForKey:@"itemChanged"];
    [self didChangeValueForKey:@"balance"];
}

在有序对多关系的情况下,您不仅必须指定已更改的关键字,还必须指定所涉及对象的更改类型和索引。 更改的类型是指定NSKeyValueChangeInsertion,NSKeyValueChangeRemoval或NSKeyValueChangeReplacement的NSKeyValueChange。 受影响对象的索引作为NSIndexSet对象传递。

- (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"];
}

四、注册依赖keys

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

一对一关系

要为一对一关系自动触发通知,您应该覆盖keyPathsForValuesAffectingValueForKey:或者实现一个合适的方法,该方法遵循它为注册相关键而定义的模式。

例如,一个人的fullName取决于firstName和lastName。 返回fullName的方法可以写成如下形式:

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

观察fullName属性的应用程序必须在firstName或lastName属性发生更改时通知,因为它们会影响属性的值。

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

+ (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> 这种方法

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

一个已经存在的类的属性,不能通过category去重写 keyPathsForValuesAffectingValueForKey: 方法,这种情况下,就应该用 keyPathsForValuesAffecting<Key> 这种。

多对多关系

您可以使用键值观察将所有子项(在此示例中为employees)的相关属性的注册母项(在本例中为Department)注册为观察者。 当子对象添加到关系中并从关系中删除时,您必须添加并删除父项作为观察者(请参阅注册键值观察)。 在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;
}

如果您使用的是CoreData,则可以将母项的通知中心注册为其管理对象上下文的观察者。 母项应以类似于键值观察的方式来回应子项发布的相关变化通知。

五、KVO声明原理

自动键值观察是使用称为isa-swizzling的技术实现的。

顾名思义,isa指针指向维护调度表的对象的类。 这个调度表本质上包含指向类实现的方法的指针,以及其他数据。

当一个观察者注册一个对象的属性时,被观察对象的isa指针被修改,指向一个中间类而不是真实类。 因此,isa指针的值不一定反映实例的实际类。

你不应该依靠isa指针来确定类成员关系。 相反,您应该使用类方法来确定对象实例的类。