1.认识KVO
KVO
类似于观察者模式,我们利用简单的代码来了解什么是KVO
。
1 | // 注册一个Person类 |
我们在ViewController
中引入头文件,并创建两个全局的属性。我们希望Person
作为Dog
的观察者,当Dog
的name
属性发生变化的时候,Person
可以第一时间知道。这时我们就可以运用KVO
的技术。
1 | Person *p = [Person new]; |
监听选项Options
是由枚举NSKeyValueObservingOptions
定义的,他决定了哪些值可以被传入到观察者内部实现的方法中。
定义如下:
1 | enum { |
注册之后,我们要在观察者内部实现如下方法
1 | // 此时,当被观察者的属性发生变更,观察者就会自动调用如下方法 |
Change
选项,它记录了被监听属性的变化情况。可以通过key来获取值:
1 |
|
NSKeyValueChangeKindKey
的值取自于NSKeyValueChange
,这是一个枚举值,定义如下
1 | enum { |
注意,观察者在不需要使用的时候一定要移除,否则会产生崩溃
1 | - (void)dealloc { |
通过上面简要的代码示例,我们可以得知,时运观察者只需要实现简单的几步。
- 注册观察者
- 观察者实现相应的方法
- 移除观察者
2.KVC和KVO的实现原理
KVC
和KVO
是基于强大的Runtime
来实现的。其中使用到的技术就是isa-swilling
,isa-swilling
这项技术也是一个重点,我们会在后续的Runtime
部分会讲到。如果有看到此处不明白的同学也请保持耐心。
网上有一篇文章针对实现原理写的很好,链接在此。
整体来说就是,当某个类的对象第一次被观察时,系统会在运行期间动态的为这个类创建一个派生类,假如被监听类名为ClassA
,那么派生类的名称就为NSKVONotifying_ClassA
。
1.原有对象的isa
指针会指向全新的派生类,派生类为了混淆,避免别人知道他不是原来的类,所以派生类重写了Class
的类方法。
2.同时重写了Dealloc
方法,用于资源的销毁处理。
3.还重写了_isKVOA
,这个是一个标记,用于标示这个类是遵守KVO
机制的。
4.最关键的是重写了被监听属性的Setter
方法,这是实现KVO
的关键。至于为什么,后面会讲解到。
简单的画了张图,可能会有助于理解。
我们上面讲重写了被观察对象属性的Setter
方法是十分关键的,这就要说起另外两个十分重要的方法
1 | // 在属性值即将被修改的时候,会调用这个方法 |
其实我个人猜测,重写Setter
方法内部应该这样实现的
1 | [self willChangeValueForKey:@"name"]; |
说到这里,相信你应该完整的明白KVO的实现机制了。
1 | // 这才是KVO机制触发的关键 |
3.调用KVO的三种方法
综合上面KVO的实现原理,我们可以得出如下结论:
1.使用了KVC
使用了KVC
,如果有访问器方法
,则运行时会在访问器方法中调用will/didChangeValueForKey:
方法;
没用访问器方法,运行时会在setValue:forKey
方法中调用will/didChangeValueForKey:
方法。
2.有访问器方法
运行时会重写访问器方法调用will/didChangeValueForKey:
方法。
因此,直接调用访问器方法改变属性值时,KVO也能监听到。
3.直接调用
显式调用will/didChangeValueForKey:
方法。
4.KVO自动通知、手动通知
通常意义下我们使用的都是自动通知,注册观察者之后,当触发will/didChangeValueForKey:
方法后,观察者对象的- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context { }
方法会被调用。
如果像实现手动通知,我们需要借助一个额外的方法
1 | + (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key |
这个方法默认返回YES
,用来标记Key
指定的属性是否支持KVO
,如果返回值为NO
,则需要我们手动更新。
我们还是用我们最上面的例子,监听Person
的name
属性,不过这次我们采取手动通知的方式。
1 |
|
这样我们就已经标记好当Person
的name
属性发生改变时,手动发送通知,代码如下:
1 | @implementation Person |
手动发送通知一对一的操作方法如上,如果是一对多的案例,则可以使用如下方法
1 | - (void)willChange:(NSKeyValueChange)change valuesAtIndexes:(NSIndexSet *)indexes forKey:(NSString *)key |
5.注册依赖键
实际开发过程中可能会遇到这种场景,某个变量的值取决于其他的值。
我们还是看一个例子吧:
1 | // 声明一个Person类,有三个属性 |
回到Controller:
1 | - (void)viewDidLoad { |
6.KVO使用中的”坑”
最近我在看这方面资料的时候,发现大家都以
tableView
和ContentOffset
作为例子。咱们就用这个最常见的控件来说明一下吧。
1.keyPath为字符串
众所周知,KVO里面的KeyPath
是NSString
类型,结合Obj-C
动态语言的特性,在编译时是不做检查的,只有运行到执行的时候,才会动态的去方法列表
、实例变量列表
中去查找,所以一旦我们写错了KeyPath
,不运行的时候很难发现。
基于这个问题,我们用以下的方法规避
1 | // 这样就不会写错了 |
2.多层继承、共用同一个回调方法
假如父类的控制器监听了tableView
的ContentOffset
属性,同时该控制器还监听了其他控件的一些属性,但是同一个对象或者控制器作为多个对象属性的观察者,实际上最后调用的都是同一个回调方法- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context { }
,这样写极其容易混淆,所以我们为了解决这个问题,把代码写成如下的样子
1 | - (void)observeValueForKeyPath:(NSString *)keyPath |
但是光这样写是不全面的,因为当前的这个类很可能有父类,并且它的父类可能绑定了一些其他的KVO,上面的代码只有一个条件判断,一旦不成立,此次KVO的触发操作也就断了。而当前类无法捕捉的这个KVO事件很可能就在它的父类里,或者是父类的父类,上述操作,将这一链条截断,所以正确的方法应该如下:
1 | - (void)observeValueForKeyPath:(NSString *)keyPath |
这样做这一链条就完整的保留了。
3.观察者的注销
上面的方法做完之后还是有隐患的。我们知道KVO不用的时候是需要注销的。我们知道当你对同一个KVO注销两次的时候,系统默认是抛出异常的。
你可能会好奇,什么时候我会对同一个Observer
注销多次呢?
这个时候我们可以想一下我们注销Observer
的时机,是不是多在Dealloc
方法中?
在Obj-C
中,有很多系统的方法被重写时需要调用super xxxxxxx
等方法,这是Obj-C
的继承关系决定的。
例如:
1 | // 在重写init方法时,我们要调用一下父类的init方法 |
还有些方法,不需要调用父类的方法,自动就会帮你调用,就如我们所说的Dealloc
。其实只有在ARC
模式下才不需要调用父类,MRC
下的Dealloc
还是要手动调用super dealloc
的。
所以我们在注销观察者的时候就这么写
1 | - (void)dealloc { |
假设我们有三个类 ClassA(父类)
,ClassB(子类)
,ClassC(孙子类)
。这三个类都作为观察者,观察tableView
的contentOffset
属性。
如果我们在ClassC(孙子类)
的Dealloc
方法中释放观察者
1 | - (void)dealloc { |
当ClassC(孙子类)
的Dealloc
执行完毕后,就会自动去ClassB(子类)
的Dealloc
方法中,释放观察者
1 | - (void)dealloc { |
这个时候就出现崩溃了,因为我们在前面提到过这样会导致相同的removeObserver被执行两次,于是导致crash。
4.正确写法
针对这种类型的Crash,我们就要谈一下在注册Observer
似的一个关键的参数Context
,之前我是不知道这个Context
是做啥用的,对于KVO
的使用只是流于表面,所以对于这个神秘的Context
的作用一直没有深究,现在我们将使用Context
来为每一个Observer
做区分,避免多次调用相同的removeObserver
。
KVO的三个关键方法
1 | // 注册观察者 |
相比细心的同学已经看出来了,我们在注册、响应、移除的三个步骤里,都可以找到Context
这个关键字。所以为了保持注册、响应、移除的一致性,正确的写法应该如下:
1 | // 首先我们应在使用KVO的类中,创建一个独一无二的Context,用来和其他类进行区分 |
如果还不放心,也可以使用@try @catch
去捕获异常
7.总结
KVO
这套 API 真麻烦~