• 首页
  • 关于
    • zerone-z photo

      zerone-z

      记录下自己学习的过程、点滴,也为了分享自己的收获。

    • Learn More
    • Email
    • Github
  • 博客
    • 博客
    • 标签
  • 随笔
  • 项目

KVO(Key-Value Observing)

20 Oct 2016

Reading time ~2 minutes

KVO(Key-Value Observing)

Overview

Key-Value Observing的缩写为KVO,其实是一种观察者模式,当其它对象的指定属性发生改变时将通知观察者对象,并告知改变的内容。

重要:想要理解KVO,必须先要理解KVC。

总览

KVO提供了一种机制——当其它对象的指定属性改变时将通知观察对象。在应用中KVO对于模型层与控制器层的数据交流是尤其有用的。(在OS X系统中,控制器层的绑定技术很大程度上依赖于KVO)控制器对象通常观察模型对象的属性,试图对象则通过控制器观察一个模型对象的属性。另外,不管怎样,一个模型对象也可以观察其它的模型对象(通常是确定一个依赖的值什么时候改变)或者它自己(再次确定一个依赖的值什么时候改变)。 你可以观察属性(properties),包括简单的属性(attributes)、一对一关系、一对多关系。对多的属性观察者会被通知变更类型,以及改变了哪些对象。 设定属性观察者需要三步。理解了这些步骤,可以对KVO是如何工作的有一个清楚的认识。

  1. 首先,看我们当前的场景,KVO是否更好,例如,当一个对象的特定属性有任何改变时,需要通知另一个对象时。 kvo_objects 例如,PersonObject对象想要知道BankObject对象属性accountBalance所做的任何更改。
  2. 通过方法addObserver:forKeyPath:options:context:,PersonObject对象必须被注册成为BankObject属性accountBalance的观察者。 kvo_objects_connection

    提示:addObserver:forKeyPath:options:context:方法在这两个对象之间建立了链接。并不是在这两个类之间建立链接,而是在这两个对象实例之间建立链接。

  3. 为了响应这个变更的通知,观察者必须实现方法-observeValueForKeyPath:ofObject:change:context:。这个方法的实现决定了观察者是如何响应这个变更通知的。在这个方法中,你可以自定义响应被观察属性的变更。 kvo_objects_implementation 下面会详细介绍如何注册以及接收观察的通知。
  4. 当一个观察属性的值以符合KVO方式发生变更,或者依赖的key发生变更,方法-observeValueForKeyPath:ofObject:change:context:会自动调用。 kvo_objects_notification

KVO的主要好处是:一个属性值每次发生改变,你不必使用自己的方案去通知改变。它对框架的支持很好,这是的它很容易使用,而不必在我们的项目中添加额外的代码。此外,这个架构已经是很全面了,对于单一的属性或依赖的值支持多个观察者也很方便。 KVO合规描述了自动和手动KVO的不同,以及如何实现他们。 不像NSNotificationCenter通知,是没有中央对象给所有的观察者发送变更通知的。而是,当变更发生时,直接发送通知到观察者对象。NSObject提供了基本的KVO实现,你可以重写这些方法,但是这种情况很少发生。 KVO实施细则描述了KVO是如何实现的。

注册成为KVO

为了接收到一个属性的KVO通知,必须实现下面的三件事:

  • 观察类必须兼容KVO的观察属性。
  • 必须为被观察对象注册观察对象,使用方法addObserver:forKeyPath:options:context:。
  • 观察类必须实现方法observeValueForKeyPath:ofObject:change:context:。

重要:并不是所有的类多于所有的属性都是兼容KVO的。通过在下面KVO合规中的描述,你只能确定你自己的类是兼容的KVO。通常,Apple提供的框架中,属性都是兼容KVO的。

注册一个观察者

为了使一个属性的改变可以被通知到,首先需要为一个被观察的对象注册观察对象,使用方法addObserver:forKeyPath:options:context:把观察对象和被观察对象属性的键路径传递到被观察对象中。options参数指定了当一个属性值被改变,什么时候应该发送改变通知。NSKeyValueObservingOptionOld参数指定了把原来的对象值传递给观察者,作为change参数字典中的一个键值。NSKeyValueObservingOptionNew参数则指定了传递一个新的值,在change参数字典中。如果想要接收到这两个值,你需要使用按位或去选择这两个常量。

- (void)registerAsObserver {
    /*
     'inspector'是一个观察者,去接收`account`的属性`openingBalance`的改变通知,并且指定了应该提供`openingBalance`的旧值与新值给观察者
     */
    [account addObserver:inspector
             forKeyPath:@"openingBalance"
                 options:(NSKeyValueObservingOptionNew |
                            NSKeyValueObservingOptionOld)
                    context:NULL];
}

当你注册一个对象作为一个观察者的时候,你应该提供一个context指针。当observeValueForKeyPath:ofObject:change:context:被调用的时候,context指针就会被传递到这个观察者中。context指针可以是一个C指针,也可以是一个Objective-C对象的引用。context指针可以作为一个唯一的标识符,以确定被观察到的改变值是属于谁的,或者也可以传递一些其它的数据到观察者对象中。

提示:KVO方法addObserver:forKeyPath:options:context:不持有观察对象,被观察对象以及context的强引用。所以我们应该确保我们持有观察者对象、被观察者、以及context的强引用。

接收KVO通知

当一个对象的被观察属性值发生改变时,观察者会收到一个消息observeValueForKeyPath:ofObject:change:context:。所有的观察者都必须实现这个方法。 观察者会被传递一个对象和触发观察者通知的键路径,一个包含改变值详情的字典,以及注册观察者时候提供的context指针。 change字典的条目NSKeyValueChangeKindKey提供了发生改变的类型的信息。如果被观察对象的值已经改变了,NSKeyValueChangeKindKey条目返回NSKeyValueChangeSetting。依靠注册观察者对象时指定的options参数,change字典中NSKeyValueChangeOldKey和NSKeyValueChangeNewKey条目包含了这个属性改变前和改变后的值。如果这个属性是一个对象,则这个值直接传递。如果这个属性是一个标量或一个C 的结构体,这个值则会封装成NSValue对象。 如果这个被观察对象的属性是一个一对多的关系,NSKeyValueChangeKindKey条目也会通过返回值NSKeyValueChangeInsertion,NSKeyValueChangeRemoval,或NSKeyValueChangeReplacement分别指出关系对象是否是被插入,移除,或者替换。 change字典的条目NSKeyValueChangeIndexesKey是一个NSIndexSet对象,它列举了改变关系的索引集。当注册观察者的时候,如果指定了NSKeyValueObservingOptionNew或NSKeyValueObservingOptionOld作为options的参数,那么在change字典中NSKeyValueChangeOldKey和NSKeyValueChangeNewKey条目则是两个集合,这两个个集合分别包含了关联对象改变前以及改变后的值。

- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context {

    if ([keyPath isEqual:@"openingBalance"]) {
        [openingBalanceInspectorField setObjectValue:
            [change objectForKey:NSKeyValueChangeNewKey]];
    }
    /*
     如果父类实现了这个方法,这样可以确保父类也会调用该方法。
     NSObject对象不回默认调用。
     */
    [super observeValueForKeyPath:keyPath
                         ofObject:object
                           change:change
                           context:context];
}

移除KVO

被观察对象通过调用方法removeObserver:forKeyPath:,去移除一个KVO观察者。removeObserver:forKeyPath:方法需要指定移除的观察者对象和键路径。

- (void)unregisterForChangeNotification {
    [observedObject removeObserver:inspector forKeyPath:@"openingBalance"];
}

如果context是一个对象,你必须持有一个强引用,直到移除这个观察者。在调用removeObserver:forKeyPath:方法以后,你在这个方法中所指定的观察对象及键路径,将再也不会收到任何observeValueForKeyPath:ofObject:change:context:的消息。

KVO兼容

为了使一个指定属性兼容KVO,一个类必须确保以下几点:

  • 属性在这个类中必须是兼容KVC,与KVC的兼容一致。KVO也像KVC那样支持同样的数据类型。
  • 类可以为这个属性发送KVO改变通知。
  • 在合适的时候注册依赖key。

有两个技术点可以确保发送变更通知。NSObject支持自动发送通知,并作为类中所有属性的默认实现方式。通常,如果我们遵循标准的Cocoa和命名规范,我们就可以使用自动发送通知——我们不必写一些额外的代码。 手动通知提供了额外的控制发送通知的时间,也需要额外的代码。通过实现类方法automaticallyNotifiesObserversForKey:,我们可以控制子类的属性是否自动发送通知。

自动通知

NSObject提供了自动发送键值变更通知的实现方案。使用键值兼容的存取器方法以及KVC方法,键值改变将自动通知变更的观察者。自动通知也支持返回的集合代理对象,例如,方法mutableArrayValueForKey:。 下面的示例显示了属性name被改变后,都会通知观察者。

// 存取器方法改变值
[account setName:@"Savings"];

//使用KVC的方式改变值
[account setValue:@"Savings" forKey:@"name"];

// 使用KVC的键值路径改变值
[document setValue:@"Savings" forKeyPath:@"account.name"];

// 获取集合代理对象后改变值
NSMutableArray *transactions = [account mutableArrayValueForKey:@"transactions"];
[transactions addObject:newTransaction];

手动通知

手动通知提供了更精细的控制方式,应该如何和什么时候发送通知给观察者。这对于帮助减少触发不必要的通知,或者把若干个改变组合成一个单一的通知,都是很有用的。 实现手动通知的类必须重写NSObject的方法automaticallyNotifiesObserversForKey:。在同一个类中,也是可以同时使用手动和自观察者通知的。对于使用手动通知的属性,子类方法automaticallyNotifiesObserversForKey:应该返回NO。对于未确认的keys,子类的实现应该调用super。下面的示例展示了属性openingBalance使用了手动通知,对于其它的属性则由父类决定。

+ (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 {
    [self willChangeValueForKey:@"openingBalance"];
    _openingBalance = theBalance;
    [self didChangeValueForKey:@"openingBalance"];
}

我们可以首先检查这个值是否改变,减少发送不必要的通知。如下:

- (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、改变的类型以及被调用对象所在索引集合。改变的类型是一个NSKeyValueChange值,有NSKeyValueChangeInsertion、NSKeyValueChangeRemoval、NSKeyValueChangeReplacement。影响的对象的索引集合是一个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"];
}

依赖属性

在许多情况下一个属性的值取决于另一个对象的一个或多个属性。如果一个属性的值改变了,那么这些关联属性的值也应该被标记为改变。你如何确保KVO通知告知了那些依赖于关系基数的属性。

一对一关系

要自动触发一对多关系通知,我们应该重写方法keyPathsForValuesAffectingValueForKey:,或者实现一个定义了注册依赖key样式的方法。

例如,person类中fullName是一个依赖于firstName和lastName的属性。返回fullName值的方法如下:

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

当属性firstName或lastName改变时,观察fullName属性值的应用必须被通知,因为fullName的值被影响了。 一个解决方案是重写方法keyPathsForValuesAffectingValueForKey:,指定fullName的属性值是依赖于属性firstName和lastName的。

+ (NSSet *)keyPathsForValuesAffectingValueForKey:(NSString *)key {

    NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];

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

我们的重写通常应该调用super并且返回一个包含集合中的任意成员的的NSSet对象,这样做可以不妨碍父类中方法的重写。 通过实现一个遵循keyPathsForValuesAffecting<Key>命名规范的类方法,你也可以取得同样的效果,其中<Key>是依赖值的属性的名称(首字母大写)。

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

使用category为一个已存在的类添加一个属性时,我们不能重写方法keyPathsForValuesAffectingValueForKey:,因为在category中是不支持重写方法的。在这种情况下,可是使用匹配keyPathsForValuesAffecting<Key>:的类方法。

提示:通过实现keyPathsForValuesAffectingValueForKey:方法,我们不能设置依赖于一对多的关系。相反,我们必须观察一对多集合中的每一个对象的合适属性,并通过更新依赖的key值响应他们的改变值。

一对多关系

keyPathsForValuesAffectingValueForKey:方法不支持键路径,包括一对多的关系。例如,假定我们有一个带有一对多关系employees属性的Department对象,employees是Employee对象集合,并且Employee有一个salary属性。我们希望Department对象有一个totalSalary属性,totalSalary属性依赖于集合employees中的所有employee属性salary。你不能使用方法keyPathsForValuesAffectingTotalsalary并且返回employees.salary作为key。 在下面的两种情况下,有以下两种解决方案:

  1. 我们可以使用KVO去注册这个parent(Department)作为所有children(Employees)相关属性的观察者。当child对象从关系中被添加或移除时,parent必须作为child观察者添加或移除。在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:@"[email protected]"]];
}

- (void)setTotalSalary:(NSNumber *)newTotalSalary {

    if (totalSalary != newTotalSalary) {
        [self willChangeValueForKey:@"totalSalary"];
        _totalSalary = newTotalSalary;
        [self didChangeValueForKey:@"totalSalary"];
    }
}

- (NSNumber *)totalSalary {
    return _totalSalary;
}
  1. 如果使用了Core Data,你可以在应用程序的notification center中将parent注册为其managed object context的观察者。parent应该响应相关的变更通知,这些通知是children以类似于KVO的形式发出去的。


iOSKVO Like Tweet +1