iOS进阶之路 (十四)KVO 原理 & 缺陷

3,087

Important: In order to understand key-value observing, you must first understand key-value coding. -- 官方文档

想要理解KVO,必须先理解KVC,因为键值观察是建立在键值编码 的基础上。观众老爷们可以参考笔者的上篇文章iOS进阶之路 (十三)KVC

一. KVO的定义

Key-value observing is a mechanism that allows objects to be notified of changes to specified properties of other objects.

KVO (Key-value observing) 是一个非正式协议,允许对象在其他对象的指定属性发生更改时得到通知。iOS开发者可以使用KVO 来检测对象属性的变化、快速做出响应,这能够为我们在开发强交互、响应式应用以及实现视图和模型的双向绑定时提供大量的帮助。

KVO’s primary benefit is that you don’t have to implement your own scheme to send notifications every time a property changes. Its well-defined infrastructure has framework-level support that makes it easy to adopt—typically you do not have to add any code to your project. In addition, the infrastructure is already full-featured, which makes it easy to support multiple observers for a single property, as well as dependent values.

KVO的主要好处是,不必在每次属性更改时都实现自己的方案来发送通知。这个过程大部分是内建的,自动的,透明的。这使得采用它很容易,通常您不必向项目中添加任何代码。此外,KVO支持单个属性添加多个观察者以及依赖值。

二. 注册KVO -- Registering for Key-Value Observing

2.1 注册观察者 -- Registering as an Observer

- (void)addObserver:(NSObject *)observer
         forKeyPath:(NSString *)keyPath
            options:(NSKeyValueObservingOptions)options
            context:(void *)context;
  • observer: 注册 KVO 通知的对象。观察者必须实现 key-value observing 方法 - observeValueForKeyPath:ofObject:change:context:
  • keyPath: 被观察者的属性的 keypath,相对于接受者,值不能是 nil。
  • options: 代表 NSKeyValueObservingOptions 的位掩码,它指定了观察通知中包含了什么
  • context :在 observeValueForKeyPath:ofObject:change:context: 传给 observer 参数的上下文

2.1.1 更好的 keyPath

传字符串做为 keypath 比直接使用属性更糟糕,因为任何错字或者拼写错误都不会被编译器察觉,最终导致不能正常工作。 一个聪明的解决方案是使用 NSStringFromSelector 和一个 @selector 字面值:

NSStringFromSelector(@selector(isFinished))

因为 @selector 检查目标中的所有可用 selector,这并不能阻止所有的错误,但它可以用来捕获大部分改变。

2.1.2 更好的 context

关于context,苹果官方文档也做了精彩的注释。

The context pointer in the addObserver:forKeyPath:options:context: message contains arbitrary data that will be passed back to the observer in the corresponding change notifications. You may specify NULL and rely entirely on the key path string to determine the origin of a change notification, but this approach may cause problems for an object whose superclass is also observing the same key path for different reasons.

context 中包含着将在相应的更改通知中传递回观察员的任意数据。您可以指定NULL并完全依赖于keyPath确定更改通知的来源。但是这种方法可能出现问题:尤其是处理那些继承自同一个父类的子类,并且这些子类有相同的 keypath

A safer and more extensible approach is to use the context to ensure notifications you receive are destined for your observer and not a superclass.

一种更安全、更可扩展的方法是:使用context来确保接收到的通知是发送给观察者的,而不是发送给超类。 如何设置一个好的 content 呢?苹果官方文档也给出了推荐的方法。

//  a Person instance registers itself as an observer for an Account instance’s
// 大体意思就是:一个静态变量存着它自己的指针。这意味着它自己什么也没有。
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];
}

2.2 接受变化 -- Receiving Notification of a Change

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

2.3 移除观察者 -- Removing an Object as an Observer

苹果官方文档推荐在 initviewDidLoad add观察者,在 dealloc 里移除观察者, 保证 addremove 是成对出现的。

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

更好的remove

调用 –removeObserver:forKeyPath:context:时, 当这个对象没有被注册为观察者(因为它已经解注册了或者开始没有注册),会抛出一个异常。有意思的是,没有一个内建的方式来检查对象是否注册。 苹果官方文档推荐使用: @try / @catch 移除观察者。

- (void)dealloc {
    @try {
        // 3. unsubscribe
        [_account removeObserver:self
                      forKeyPath:NSStringFromSelector(@selector(contentSize))
                         context:ContentSizeContext];
    }
    @catch (NSException *exception) {
        
    }
}

三. 集合属性的监听

  1. 集合属性(这里指 NSArray 和 NSSet),只有在赋值时会触发KVO,改变集合属性里的元素是不会触发KVO的(比如添加、删除、修改元素)。

2. 直接操作:通过使用KVC的集合代理对象(collection proxy object)来处理集合相关的操作,使集合对象内部元素改变时也能触发KVO。关于集合代理对象,可以参考OC由浅入深系列 之 KVC (二):集合代理对象

  • 有序集合

ttps://user-gold-cdn.xitu.io/2020/4/24/171ab2ba83b7cd21?w=1362&h=436&f=png&s=202396)

  • 无需集合 还要额外实现如下的方法:

  1. 间接操作:在进行可变集合对象操作时,先通过key或者keyPath获取集合对象,然后再对集合对象进行add或remove等操作时,就会触发KVO的消息通知了。这种方式是实际开发中最常用到的。
// key 方法
- (NSMutableArray *)mutableArrayValueForKey:(NSString *)key;
- (NSMutableOrderedSet *)mutableOrderedSetValueForKey:(NSString *)key;
- (NSMutableSet *)mutableSetValueForKey:(NSString *)key;

// keyPath方法:
- (NSMutableArray *)mutableArrayValueForKeyPath:(NSString *)keyPath;
- (NSMutableOrderedSet *)mutableOrderedSetValueForKeyPath:(NSString *)keyPath;
- (NSMutableSet *)mutableSetValueForKeyPath:(NSString *)keyPath;

例子:

四. KVO兼容 -- KVO Compliance

To use KVO, first you must ensure that the observed object, the Account in this case, is KVO compliant. Typically, if your objects inherit from NSObject and you create properties in the usual way, your objects and their properties will automatically be KVO Compliant. It is also possible to implement compliance manually. KVO Compliance describes the difference between automatic and manual key-value observing, and how to implement both.

要使用KVO,首先必须确保被观察的对象符合KVO。通常,如果您的对象继承自NSObject,并且您以通常的方式创建属性,那么您的对象及其属性将自动与KVO兼容。当然,也可以手动实现遵从性。KVO Compliance讲述述了自动和手动键值观察之间的区别,以及如何实现两者。

可以通过复写 automaticallyNotifiesObserversForKey: 的返回值,选择自动退出 KVO。同时该类方法还能完成控制特定属性的目的。

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

4.1 自动KVO -- Automatic Change Notification

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

4.2 手动KVO -- Manual Change Notification

手动 KVO 可以帮助我们将多个属性值的更改合并成一个,这样在回调的时候就有一次了,同时也能最大程度地减少处于应用程序特定原因而导致的通知发生。

  • 要实现手动观察者通知,请在更改值之前调用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"];
    }
}
  • 如果一个操作导致多个键发生更改,应该这样更改
- (void)setBalance:(double)theBalance {
    [self willChangeValueForKey:@"balance"];
    [self willChangeValueForKey:@"itemChanged"];
    _balance = theBalance;
    _itemChanged = _itemChanged+1;
    [self didChangeValueForKey:@"itemChanged"];
    [self didChangeValueForKey:@"balance"];
}
  • 对于有序的一对多关系属性,不仅必须指定已更改的键,还必须指定更改的类型和所涉及对象的索引。 更改的类型是 NSKeyValueChangeNSKeyValueChangeInsertionNSKeyValueChangeRemovalNSKeyValueChangeReplacement),受影响的对象的索引用 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"];
}

五. 注册依赖键 -- Registering Dependent Keys

有一些属性的值取决于一个或者多个其他对象的属性值,一旦某个被依赖的属性值变了,依赖它的属性的变化也需要被通知。

5.1 To-One Relationships

要自动触发 To-One 关系,有两种方法:

  • 重写 keyPathsForValuesAffectingValueForKey:方法
  • 定义名称为 keyPathsForValuesAffecting<Key> 的方法。

举个例子: 一个人的全名 fullName 是由 firstName 和 lastName 组成的,一个观察 fullName 的程序在 firstName 或者 lastName 变化时也应该接收到通知。

- (NSString *)fullName
{
    return [NSString stringWithFormat:@"%@ %@",firstName, lastName];
}
  1. 方法一:重写 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;
}

相当于在影响 fullName 值的 keypath 中新加了两个key:lastName 和 firstName,很容易理解。

值得注意的是:需要先对父类发送 keyPathsForValuesAffectingValueForKey 消息,以免干扰父类中对此方法的重写

  1. 方法二:实现一个遵循命名方式为keyPathsForValuesAffecting的类方法,是依赖于其他值的属性名(首字母大写)
+ (NSSet *)keyPathsForValuesAffectingFullName
{
    return [NSSet setWithObjects:@"lastName", @"firstName", nil];
}

如果在分类中,使用 keyPathsForValuesAffectingFullName 更合理,因为分类中是不允许重载方法的,所以 keyPathsForValuesAffectingValueForKey 方法肯定是不能在分类中使用的。

5.2 To-many Relationships

keyPathsForValuesAffectingValueForKey:方法不支持包含 to-many 关系的 keypath

比如,有一个 Department(部门) 类,它有一个针对 Employee(雇员) 类的 to-many 关系,Employee类有 salary(薪资)属性。你希望 Department类有一个 totalSalary 属性来计算所有员工的薪水,也就是 Department的totalSalary 依赖于所有 Employee的salary 属性。你不能通过实现 keyPathsForValuesAffectingTotalSalary 方法并返回 employees.salary

你可以用KVO将parent(比如Department)作为所有children(比如Employee)相关属性的观察者。你必须在把child添加或删除到parent时也把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:@"employees.@sum.salary"]];
}
 
- (void)setTotalSalary:(NSNumber *)newTotalSalary {
 
    if (totalSalary != newTotalSalary) {
        [self willChangeValueForKey:@"totalSalary"];
        _totalSalary = newTotalSalary;
        [self didChangeValueForKey:@"totalSalary"];
    }
}
 
- (NSNumber *)totalSalary {
    return _totalSalary;
}

将 Department 实例对象注册为观察者,然后观察对象为 totalSalary 属性,但是在通知回调中会手动调用 totalSalary 属性的 setter 方法,并且传入值是通过 KVC 的集合运算符的方式取出 employees 属性所对应的集合中所有 sum 值之和。然后在 totalSalary 属性的 setter 方法中,会相应的调用 willChangeValueForKey: 和 didChangeValueForKey: 方法。

六. KVO的原理

先看看官方文档的解释

Automatic key-value observing is implemented using a technique called isa-swizzling.

  • 自动的键值观察的实现基于 isa-swizzling

The isa pointer, as the name suggests, points to the object's class which maintains a dispatch table. This dispatch table essentially contains pointers to the methods the class implements, among other data.

  • 顾名思义,isa指针指向维护分配表的对象的类,该分派表实质上包含指向该类实现的方法的指针以及其他数据

When an observer is registered for an attribute of an object the isa pointer of the observed object is modified, pointing to an intermediate class rather than at the true class. As a result the value of the isa pointer does not necessarily reflect the actual class of the instance.

  • 当一个观察者注册了对一个对象的某个属性键值观察之后,被观察对象的 isa 指针所指向的内容发生了变化,指向了一个中间类而不是真正的类。这也导致 isa 指针并不一定是指向实例所属的真正的类。

You should never rely on the isa pointer to determine class membership. Instead, you should use the class method to determine the class of an object instance.

  • 你永远不应依靠 isa 指针来确定类成员身份。相反,你应该使用 class 方法来确定对象实例所属的类。

6.1 动态生成中间子类:NSKVONotifying_XXX

根据官网文档,我们初步判断,在 KVO 底层会有一个中间类生成,中间类会让对象的 isa 指针发生变化。测试一下:

  • 添加观察者之前:类对象为AKPerson,实例对象 self.person 的isa 指向 AKPerson类
  • 添加观察者之后:类对象为AKPerson,实例对象 self.person 的isa 指向 NSKVONotifying_AKPerson类

结论1:添加观察者后,系统动态生成了中间类NSKVONotifying_AKPerson,实例对象self.person 的isa由指向 原始AKPerson类, 修改为指向中间类NSKVONotifying_AKPerson

那么这个中间类和原来的类是什么关系呢? 遍历下两者的类以及子类,测试一下

#pragma mark - 遍历类以及子类
- (void)printClasses:(Class)cls {
    // 注册类的总数
    int count = objc_getClassList(NULL, 0);
    // 创建一个数组, 其中包含给定对象
    NSMutableArray *mArray = [NSMutableArray arrayWithObject:cls];
    // 获取所有已注册的类
    Class* classes = (Class*)malloc(sizeof(Class)*count);
    objc_getClassList(classes, count);
    for (int i = 0; i<count; i++) {
        if (cls == class_getSuperclass(classes[i])) {
            [mArray addObject:classes[i]];
        }
    }
    free(classes);
    NSLog(@"classes = %@", mArray);
}

结论2:添加观察者之后,中间类 NSKVONotifying_AKPerson 是原始类 AKPerson 的子类.

6.2 动态子类观察 setter 方法

KVO关注的是属性值的变化,而 属性 = 成员变量 + getter + setter,显然只有 setter 和成员变量赋值两种方式可以改变属性值。

我们做下测试:AKPerson 添加成员变量name属性nickName, 添加观察者后同时修改两者的值:

@interface AKPerson : NSObject{
    @public
    NSString *name;
}
@property (nonatomic, copy) NSString *nickName;

@end

  • 属性nickName的setter方法,触发了KVO的回调函数
  • 成员变量name的赋值操作,不触发了KVO的回调函数

结论3: 动态子类观察的是setter方法

6.3 动态子类内部策略

6.3.1 动态子类重写了什么方法?

我们可以遍历原始类和中间类的方法列表, 观察下中间类重写了哪些方法?

#pragma mark - 遍历方法-ivar-property
- (void)printClassAllMethod:(Class)cls{
    NSLog(@"*****   %@  *****", cls);
    unsigned int count = 0;
    Method *methodList = class_copyMethodList(cls, &count);
    for (int i = 0; i<count; i++) {
        Method method = methodList[i];
        SEL sel = method_getName(method);
        IMP imp = class_getMethodImplementation(cls, sel);
        NSLog(@"%@-%p",NSStringFromSelector(sel),imp);
    }
    free(methodList);
}

  • AKPerson类 中的方法没有改变(imp实现地址没有变化)
  • NSKVONotifying_AKPerson中间类 重写了 父类AKPersondealloc方法
  • NSKVONotifying_AKPerson中间类 重写了 基类NSObjectclass方法_isKVOA方法
  • _isKVOA 方法:做个标识
  • 根据上面的推测,中间类重写的 class方法 仍然是返回的是原始类,目的就是隐藏中间类的存在,让调用者调用 class方法 结果前后一致

6.3.2 移除观察后,动态子类何去何从?

  • 移除观察者后,原始类的isa由 NSKVONotifying_AKPerson中间类 指会 AKPerson类
  • 移除观察者后,动态子类依然存在。

6.4 KVO调试

通过observationInfo命令,可以在 lldb 里查看一个被观察对象的所有观察信息。

这会打印出有关谁观察谁之类的很多信息。 这个信息的格式不是公开的,我们不能让任何东西依赖它,因为苹果随时都可以改变它。不过这是一个很强大的排错工具。

七. KVO的缺陷

KVO 是 Cocoa 为我们提供的一种模式,用于订阅其他对象属性的更改。 KVO 很强大,没错。知道它内部实现,或许能帮助更好地使用它,或在它出错时更方便调试。但官方 KVO 提供的 API 并不好用。甚至我们的日常开发中,除非万不得已,很少去使用官方 KVO 的 API。

在 Soroush Khanlou 的 Key-Value Observing Done Right 文章中,KVO 被批判的体无完肤。比如:

  • KVO all comes through one method: 只能通过重写 -observeValueForKeyPath:ofObject:change:context: 方法来获得通知,想要提供自定义的 selector ,想要传一个 block ,没门!
  • KVO is string-ly typed: 传递的 keyPath 是字符串类型的,任何错字或者拼写错误都不会被编译器察觉,最终导致不能正常工作。
  • KVO requires you to handle superclasses yourself: KVO要求我们自己处理超类的问题。尤其是处理那些继承自同一个父类的子类,并且这些子类有相同的 keypath。
  • KVO can crash when deregistering:KVO 需要手动移除,移除的时候还可能引起崩溃,而且 KVO 没有一个内建的方式来检查对象是否注册或销毁。例如:一个超类也观察同一个对象上的同一个参数,会发生什么情况?它会被移除两次,第二次会导致崩溃。

如果想完美的解决这些问题,建议阅读下强大的自定义KVO框架 facebookarchive/KVOController

八. 总结

Objective-CCocoa 中,有许多事件之间进行通信的方式,并且每个都有不同程度的形式和耦合

  1. NSNotification
  • 提供了一个中央枢纽,应用的任何部分都可能通知或者被通知应用的其他部分的变化。唯一需要做的是要知道在寻找什么,主要靠通知的名字。
  • 例如,UIApplicationDidReceiveMemoryWarningNotification 是给应用发了一个内存不足的信号。
  • 通知的接收方线程取决于通知的发送方(子线程发送通知,会在同一子线程接收到,此时更新UI需要切回祝线程)。
  1. Key-Value Observing
  • 允许 ad-hoc,通过在特定对象之间监听一个特定的 keypath 的改变进行事件内省。
  • 例如:一个 ProgressView 可以观察 网络请求的 numberOfBytesRead 来更新它自己的 progress 属性。
  1. Delegate
  • 是一个流行的传递事件的设计模式,通过定义一系列的方法来传递给指定的处理对象。
  • 例如:UIScrollView 每次它的 scroll offset 改变的时候都会发送 scrollViewDidScroll: 到它的代理
  1. Callbacks
  • 不管是像 NSOperation 里的 completionBlock(当 isFinished==YES 的时候会触发),还是 C 里边的函数指针,传递一个函数钩子
  • 例如:NetworkReachabilitySetCallback(3)。

最后附上KVO的流程图:

参考资料

Key-Value Observing Programming Guide - Apple 官方文档

iOS 底层探索KVO - leejunhui_ios

Objective-C中的KVC和KVO

Key-Value Observing - NSHipster