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

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

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

一. KVO的定义

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

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

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.objective-c

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,苹果官方文档也作了精彩的注释。bash

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

相关文章
相关标签/搜索