KVO

引言

键值观察(KVO)提供了一种机制以允许对象被告知其他对象的特定属性的更改,它对应用程序中的模型和控制器层之间的通信特别有用。在OS X中,控制器层绑定技术严重依赖于键值观察。控制器对象通常观察模型对象的属性,视图对象通过控制器观察模型对象的属性。 此外,模型对象还可以观察其他模型对象(通常用于确定依赖的值何时改变),甚至其自身(再次确定依赖的值何时改变)。

可以观察 simple attributes、to-one relationships 和 to-many relationships 这三种类型的属性(关于属性类型的描述,请参看KVC)。to-many relationships 属性的观察者被告知所做的更改的类型以及更改涉及哪些对象。

一个简单的例子说明了KVO如何在应用程序中发挥作用的。假设一个Person对象和一个Account对象代表某个人在银行的储蓄账户,那么Person实例可能需要知道Account实例的某些详情合适发生变化,例如余额和利率。

图1-1

如果这些属性是Account的公开属性,那么Person可以定期轮询Account以发现变化。但这种做法的效率非常低,并且通常是不切实际的。更好的做法是使用KVO,这类似于在发生更改时,Person接收一个中断。

要使用KVO,首先必须确保被观察的对象兼容KVO。通常情况下,如果对象继承自NSObject并且以常规方式创建属性,对象及其属性将自动兼容KVO。还可以手动实现KVO兼容。KVO兼容描述了自动和手动键值观察之间的区别,以及如何实现它们。

接下来,必须注册观察者Person实例和被观察的Account实例。Person发送一个addObserver:forKeyPath:options:context:消息给Account,对于每个观察到的键路径,将其自身命名为观察者。

图1-2

为了从Account接收更改通知,Person实现了所有观察者都需要的observeValueForKeyPath:ofObject:change:context:方法。只要注册的键路径的其中一个发生变化,Account就会发送observeValueForKeyPath:ofObject:change:context:消息给Person。然后,Person能够基于更改通知采取适当的措施。

图1-3

最后,当Person不再需要通知时,并且在它还没被释放之前,Person实例必须通过向Account实例发送removeObserver:forKeyPath:消息来取消注册。

图1-4

KVO的第一益处是不必在每次属性更改时都实施自己的方案来发送通知。其定义良好的基础架构具有框架级别的支持,使得其易于使用——通常不必向项目添加任何代码。此外,基础架构的功能已经齐全,这使得单个属性以及依赖的值支持多个观察者变得容易。

与使用NSNotificationCenter的通知不同,没有中心对象为所有观察者提供更改通知。 取而代之的是,在进行更改时将通知直接发送到观察对象。NSObject提供了键值观察的基本实现,很少需要覆盖这些方法。

注册KVO

必须执行以下步骤来使对象能够接收键值观察通知:

  • 使用addObserver:forKeyPath:options:context:方法将观察者注册到被观察对象。
  • 在观察者内部实现observeValueForKeyPath:ofObject:change:context:方法来接收更改通知消息。
  • 当观察者不需要在接收更改通知消息时,使用removeObserver:forKeyPath:方法取消注册观察者。

重要:并非所有类的所有属性都是兼容KVO的。可以按照KVO兼容中描述的步骤来确保自己的类是兼容KVO的。

注册为观察者

观察者对象首先通过发送一个addObserver:forKeyPath:options:context:消息并传递其本身以及被观察的属性的键路径,来向被观察对象注册自己。观察者还指定了一个options参数和一个上下文指针来管理通知。

Options

选项参数会影响通知中提供的变更字典的内容以及生成通知的方式。

观察者对象可以使用选项NSKeyValueObservingOptionOld来接收被观察属性被更改之前的值,以及使用选项NSKeyValueObservingOptionNew请求属性的新值。还可以使用按位OR组合这些选项,以便同时接收新值和旧值。

选项NSKeyValueObservingOptionInitial指示被观察的对象在addObserver:forKeyPath:options:context:方法返回之前发送一个立即更改通知,可以使用此额外的一次性通知来在观察者对象中设置属性的初始值。

选项NSKeyValueObservingOptionPrior指示被观察对象在属性更改之前发送一个通知。如果通知提供的变更字典中包含一个键NSKeyValueChangeNotificationIsPriorKey,且键对应的值为一个包装了YESNSNumber对象,则表示这是一个预更改通知。当观察者对象自己也要兼容KVO并且需要调用依赖于其他对象的被观察属性的属性的一个willChange...方法时,可以使用此预更改通知。寻常的已更改通知生成得太晚,会导致无法及时调用willChange...方法。

Context

addObserver:forKeyPath:options:context:消息中的上下文指针包含将在相应的更改通知中回传给观察者对象的任意数据。可以指定该参数为NULL并完全依赖于键路径字符串来确定更改通知的接收者,但是这种方式可能会在观察者对象的父类因为不同的原因也观察相同的键路径时出现问题。

更安全和更可扩展的方法是使用上下文来确保收到的通知是发送给观察者对象的,而不是发送给观察者对象的父类。

在类中唯一命名的静态变量的地址是一个很好的上下文,并且在父类或者子类中以相同方式选择的上下文不会重叠。可以为整个类选择单独一个上下文,并依赖通知消息中的键路径字符串来确定更改的内容。或者,可以为每个被观察的键路径创建不同的上下文来完全绕过字符串比较的需要,从而实现更有效的通知解析。以下代码显示了以这种方式选择的balanceinterestRate属性的示例上下文:

static void *PersonAccountBalanceContext = &PersonAccountBalanceContext;
static void *PersonAccountInterestRateContext = &PersonAccountInterestRateContext

以下代码演示了Person实例如何使用给定的上下文指针将自身注册为Account实例的balanceinterestRate属性的观察者。

- (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:不会保留对观察者对象、被观察对象或者上下文的强引用,我们应该自己在代码中确保在必要时保留对观察者对象、被观察对象或者上下文的强引用。

接收更改通知

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

观察者对象提供了触发通知的键路径、作为相关对象的自身、包含有关更改的详细信息的字典以及观察者为键路径注册时提供的上下文指针。

变更字典中的条目NSKeyValueChangeKindKey提供了与所发生更改的类型有关的信息。如果被观察对象的值已经改变,NSKeyValueChangeKindKey条目会返回NSKeyValueChangeSetting。根据注册观察者时指定的选项,变更字典中的NSKeyValueChangeOldKey条目和NSKeyValueChangeNewKey条目包含更改之前和之后的属性值。如果属性是一个对象,则直接提供该值。如果属性是一个标量或者结构体,该值会被包装在NSValue对象中。

如果被观察的属性是一个to-many relationship,则NSKeyValueChangeKindKey条目分别通过返回NSKeyValueChangeInsertionNSKeyValueChangeRemoval或者NSKeyValueChangeReplacement来表明关系中的对象是否是被插入、移除或者替换的。

变更字典的NSKeyValueChangeIndexesKey条目是一个指定关系中已更改的索引的NSIndexSet对象。如果在注册观察者时,将NSKeyValueObservingOptionNew或者NSKeyValueObservingOptionOld指定为选项,变更字典中的NSKeyValueChangeOldKeyNSKeyValueChangeNewKey条目是一个包含更改之前和之后的相关对象的值的数组。

以下示例显示了Person观察者的用于记录属性balanceinterestRate的旧值和新值的observeValueForKeyPath:ofObject:change:context:方法实现。

- (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作为上下文,则将通知的键路径与正在观察的键路径相比较来确定更改的内容。如果对所有被观察的键路径使用单独一个上下文,则首先将通知的上下文与其相比较,找到匹配项后再使用键路径字符串比较来确定具体更改的内容。如果为每个键路径提供唯一的上下文,如上所示,一系列简单的指针比较会同时告诉我们通知是否适用于此观察者以及如果是,则哪个键路径已经更改了。

在任何情况下,当观察者不能确定更改通知是否适用于自己(不能识别上下文或者键路径)时,观察者应该总是调用其父类的observeValueForKeyPath:ofObject:change:context:实现。

注意:如果通知传递到类层次结构的顶部,则NSObject会抛出一个NSInternalInconsistencyException

移除观察者

通过向被观察对象发送一个removeObserver:forKeyPath:context:消息并指定观察者对象、键路径和上下文来移除观察者。 以下示例显示了移除balanceinterestRate的观察者Person

- (void)unregisterAsObserverForAccount:(Account*)account 
{
    [account removeObserver:self forKeyPath:@"balance" context:PersonAccountBalanceContext];

    [account removeObserver:self forKeyPath:@"interestRate" context:PersonAccountInterestRateContext];
}

收到removeObserver:forKeyPath:context:消息后,观察者对象将不会接收到指定的键路径和对象的任何observeValueForKeyPath:ofObject:change:context:消息。

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

  • 如果请求移除一个还未被注册的观察者,则会导致一个NSRangeExceptionremoveObserver:forKeyPath:context:addObserver:forKeyPath:options:context:方法的调用应该相对应,或者如果在应用程序中无法这样做,则将removeObserver:forKeyPath:context:调用放在try/catch的block中,以便处理潜在异常。
  • 观察者对象被释放时,不会移除其自身来取消注册观察者。被观察对象会继续发送通知,无视观察者的状态。但是,变更通知与其他任何消息一样,发送给一个已经释放的对象,会触发一个内存访问异常。因此,必须确保观察者在从内存中消失之前将其自身移除。

KVO兼容

为了让特定的属性兼容KVO,一个类必须确保以下内容:

  • 该类的属性必须是兼容KVC的。KVO支持的数据类型与KVC的相同,包括Objective-C对象、标量和结构体。
  • 该类会为属性发出KVO更改通知。
  • 依赖的键已被正确注册(请参看注册依赖的键)。

有两种技术可确保发出通知。默认情况下,NSObject类为一个类的兼容KVC的所有属性提供自动支持。通常,如果我们遵循标准的Cocoa编码和命名约定,则可以使用自动更改通知——不必编写任何其他代码。

手动更改通知提供了对何时发出通知的额外控制,但需要为其编写额外的代码。可以通过实现类方法automaticallyNotifiesObserversForKey:来控制自类属性的自动通知。

自动更改通知

NSObject提供了自动键值更改通知的基本实现。自动键值更改通知告知观察者使用键值兼容的访问器和键值编码方法所做的更改。mutableArrayValueForKey:mutableOrderedSetValueForKey:mutableSetValueForKey:方法返回的集合代理对象也是支持自动通知的。

以下示例代码会让属性的观察者被告知属性的更改。

// 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 = [[Transaction alloc] init];
NSMutableArray *transactions = [account mutableArrayValueForKey:@"transactions"];
[transactions addObject:newTransaction];

手动更改通知

在某些情况下,可能想要控制通知过程,例如,最大程度地减少触发那些对于应用程序特定原因而言是不必要的通知,或者将大量更改合并到单个通知中。手动更改通知提供执行这些操作的方法。

手动和自动通知不是互斥的。除了现有的自动通知之外,还可以自由发出手动通知。更典型的情况是,我们可能想要完全控制特定属性的通知。在这种情况下,需要覆盖NSObjectautomaticallyNotifiesObserversForKey:的实现。对于想要避免自动通知的属性,子类的automaticallyNotifiesObserversForKey:实现应返回NO。子类的实现还应该为任何无法识别的键调用super。以下示例启用了balance属性的手动通知,并允许父类确定所有其他键的通知。

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

为了实现手动观察者通知,请在更改值之前调用willChangeValueForKey:方法和在更改值之后调用didChangeValueForKey:方法。以下示例实现了balance属性的手动通知。

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

可以通过首先检查值是否已更改来最大限度地减少发送不必要的通知。以下示例验证了balance的值,只提供了更改后的通知。

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

如果单个操作导致多个键发生更改,则必须嵌套更改通知,如下所示。

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

在一个有序的 to-many relationship 的情况下,不仅必须指定更改的键,还必须指定更改的类型和所涉及对象的索引。更改的类型是一个NSKeyValueChange,它的值可以为NSKeyValueChangeInsertionNSKeyValueChangeRemoval或者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"];
}

注册依赖的键

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

To-One Relationships

要为一个 to-one relationship 自动触发通知,需要重写keyPathsForValuesAffectingValueForKey:方法或者实现一个合适的方法,该方法遵循它为注册依赖的键定义的模式。

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

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

firstNamelastName属性发生更改时,必须通知观察fullName属性的对象,因为它们会影响属性的值。

一种解决方案是重写keyPathsForValuesAffectingValueForKey:方法来指定PersonfullName属性依赖于firstNamelastName属性。以下示例显示了这种依赖的实现:

+ (NSSet *)keyPathsForValuesAffectingValueForKey:(NSString *)key 
{
    NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];

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

在自定义实现中通常应该调用super并返回一个集合,以免干扰父类中的此方法的实现。

还可以通过实现一个遵循命名约定keyPathsForValuesAffecting<Key>的类方法来实现相同的结果,其中<Key>是依赖于值的属性的名称(首字母大写)。之前示例中的代码可以使用这种格式编写成一个名为keyPathsForValuesAffectingFullName的类方法。

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

当使用类别往现有类中添加一个计算属性时,不能覆盖keyPathsForValuesAffectingValueForKey:方法,因为不支持在类别中覆盖方法。在这种情况下,实现一个keyPathsForValuesAffecting<Key>类方法来使用此机制。

注意:无法通过实现keyPathsForValuesAffectingValueForKey:方法来配置 to-many relationships 的依赖关系。取而代之的是,必须观察 to-many relationships 中每个对象的相应属性,并通过自己更新依赖的键来响应其值的更改。

To-Many Relationships

keyPathsForValuesAffectingValueForKey:方法不支持包含一个 to-many relationship 的键路径。例如,假设存在一个Department对象,该对象有一个与Employee(员工)对象具有 to-many relationship 的employees 集合属性,Employee对象有一个salary(薪水)属性。我们可能希望Department(部门)对象有一个totalSalary(薪水总额)属性,该属性的值取决于employees集合中所有Employee对象的salary。这种情况是无法使用keyPathsForValuesAffectingTotalSalary方法并将employees.salary作为键返回的。

在这种情况下,有两种可能的解决方案:

  1. 使用KVO将父项(在此示例中为Department)注册为所有子项(本示例中的Employee)的相关属性(salary属性)的观察者。在employees集合中添加和删除Employee子对象时,必须注册和取消注册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;
}
  1. 如果正在使用Core Data,则可以将父项作为其 managed object context 的观察者注册到应用程序的通知中心。父项应该以类似于键值观察的方式响应子项们发出的相关变更通知。

KVO实现细节

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

isa指针指向对象的类,类维护着一个调度表,该调度表基本上包含指向该类实现的方法的指针以及其他数据。

当为对象的一个属性注册观察者时,被观察对象的isa指针被修改并指向一个中间类而不是真正的类,这样被观察对象实际上就成为了此中间类的一个实例。这个中间类继承自被观察对象原本的类,其重写了被观察属性的setter以便在被观察属性的值改变时发出更改通知。同时,它还重写了class方法并返回原本的类。

因此,绝不应该依赖isa指针来判断类成员资格。相反,应该使用class方法来判断对象实例的类。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容

  • 关于键值编码 键值编码(KVC)是一种由NSKeyValueCoding非正式协议提供的机制,对象采用该机制来提供...
    渐z阅读 859评论 0 0
  • 1.ios高性能编程 (1).内层 最小的内层平均值和峰值(2).耗电量 高效的算法和数据结构(3).初始化时...
    欧辰_OSR阅读 29,021评论 8 265
  • Swift1> Swift和OC的区别1.1> Swift没有地址/指针的概念1.2> 泛型1.3> 类型严谨 对...
    cosWriter阅读 11,036评论 1 32
  • 开始 关于键值编码 键值编码是一种机制,通过NSKeyValueCoding非正式协议,对象采用这种机制提供对其属...
    影痕残碎阅读 1,130评论 0 2
  • 不曾历尽艰难的人,不会懂得走出沙漠的人,见到一杯清茶的幸福感;没有被风雨侵袭过的花朵,不会感受到在岁月的枝头迎着阳...
    巧手精品手工包阅读 126评论 0 0