KVO是一种机制,它允许对象在其他对象的指定属性发生更改时收到通知。它最常用的一个场景就是viewconroller中监听model属性的变化从而刷新页面展示。
KVO使用过程的细节
基本使用
KVO的基本使用就是三部曲:
注册观察者
addObserver:forKeyPath:options:context1
[self.person addObserver:self forKeyPath:@"nickname" options:NSKeyValueObservingOptionNew context:NULL];
实现
KVO的回调observeValueForKeyPath:ofObject:change:context1
2
3
4
5- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{
if ([keyPath isEqualToString:@"nickname"]) {
NSLog(@"%@",change);
}
}移除观察者
removeObserver:forKeyPath:context1
[self.person removeObserver:self forKeyPath:@"nickname" context:NULL];
context参数的使用
基本使用的过程中我们经常会给context传NULL作为实参,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 specifyNULLand 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.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.
大体意思就是:addObserver:forKeyPath:options:context:方法中的context指针包含任意数据,这些数据将在相应的更改通知中传递回观察者。可以通过指定context为NULL,从而依靠keyPath即键路径字符串传来确定更改通知的来源,但是这种方法可能会导致对象的父类由于不同的原因也观察到相同的键路径而导致问题。所以可以为每个观察到的keyPath创建一个不同的context,从而完全不需要进行字符串比较,从而可以更有效地进行通知解析。
context主要是用于区分不同对象的同名属性,从而在KVO回调方法中可以直接使用context进行区分,可以大大提升性能,以及代码的可读性。
不使用context时,我们通过字符串判断:
1
2
3
4
5
6[self.person addObserver:self forKeyPath:@"nickname" options:NSKeyValueObservingOptionNew context:NULL];
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{
if ([keyPath isEqualToString:@"nickname"]) {
NSLog(@"%@",change);
}
}使用context时,我们通过
context判断:1
2
3
4
5
6
7
8
9
10
11
12
13
14//context定义
static void *PersonNickNameContext = &PersonNickNameContext;
static void *PersonNameContext = &PersonNameContext;
//注册观察者
[self.person addObserver:self forKeyPath:@"nick" options:NSKeyValueObservingOptionNew context:PersonNickNameContext];
[self.person addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:PersonNameContext];
//KVO的回调
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{
if (context == PersonNickNameContext) {
NSLog(@"%@",change);
}else if (context == PersonNameContext){
NSLog(@"%@",change);
}
}
是否有必要移除KVOobserver
官方文档对removeObserver也有说明:
When removing an observer, keep several points in mind:
- Asking to be removed as an observer if not already registered as one results in an
NSRangeException. You either callremoveObserver:forKeyPath:context:exactly once for the corresponding call toaddObserver:forKeyPath:options:context:, or if that is not feasible in your app, place theremoveObserver:forKeyPath:context:call inside a try/catch block to process the potential exception.- An observer does not automatically remove itself when deallocated. The observed object continues to send notifications, oblivious to the state of the observer. However, a change notification, like any other message, sent to a released object, triggers a memory access exception. You therefore ensure that observers remove themselves before disappearing from memory.
- The protocol offers no way to ask an object if it is an observer or being observed. Construct your code to avoid release related errors. A typical pattern is to register as an observer during the observer’s initialization (for example in
initorviewDidLoad) and unregister during deallocation (usually indealloc), ensuring properly paired and ordered add and remove messages, and that the observer is unregistered before it is freed from memory.
翻译过来就是,移除观察者时,注意以下几点:
- 如果未注册为观察者,在移除观察者的时候会导致
NSRangeException异常。removeObserver必须和addObserver对应,且只能调用一次。如果项目中不能保证,就需要在使用的时候使用try/catch来处理异常。 - 观察者在对象销毁的时候不会自动移除观察者。被观察者会继续发送通知,对观察者来说这个状态是感知不到的。但是,向一个已经释放的对象发送通知会引起内存访问异常。所以,我们要保证观察者在内存释放之前移除观察。
- 这个协议没有方法可以判断他是一个观察者还是被观察者,写代码是要避免释放内存相关的错误。一个典型的规范就是在观察者初始画的时候注册观察,在
dealloc的时候移除观察,以确保成对和有序地添加和删除消息,并确保观察者在注册之前被取消注册,从内存中释放出来。
所以,总的来说,KVO注册观察者 和移除观察者是需要成对出现的,如果只注册,不移除,会出现野指针的崩溃。
自动触发与手动触发
KVO观察的自动和手动两种方式
自动开关,
automaticallyNotifiesObserversForKey返回YES的时候标示自动监听,如果是NO表示我们需要手动监听1
2
3
4// 自动开关
+ (BOOL) automaticallyNotifiesObserversForKey:(NSString *)key{
return YES;
}如果是手动,我们需要通过
手动开关监听1
2
3
4
5
6- (void)setNickName:(NSString *)nickName{
//手动开关
[self willChangeValueForKey:@"nickName"];
_nickName = namenickName
[self didChangeValueForKey:@"nickName"];
}
观察多个属性变化
我们以观察两个属性为例,例如我们需要根据速度speed和时间time,取得当前的路程distance。我们用两种方式。
第一种就是分别观察速度
speed和时间time两个属性,当其中一个发生变化计算 当前路程distance。第二种方式就是,通过
keyPathsForValuesAffectingValueForKey方法,将两个观察合为一个观察,即观察当前路程distance1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23//1、合二为一的观察方法
+ (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key{
NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
if ([key isEqualToString:@"distance"]) {
NSArray *affectingKeys = @[@"speed", @"time"];
keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];
}
return keyPaths;
}
//2、注册KVO观察
[self.person addObserver:self forKeyPath:@"distance" options:(NSKeyValueObservingOptionNew) context:NULL];
//3、触发属性值变化
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
self.person.speed += 10;
self.person.time += 1;
}
//4、移除观察者
- (void)dealloc{
[self.person removeObserver:self forKeyPath:@"distance"];
}
可变数组的观察
KVO是基于KVC基础之上的,所以可变数组如果直接添加数据,是不会调用setter方法的,所有对可变数组的KVO观察下面这种方式不生效的,即直接通过[self.person.dateArray addObject:@"1"];向数组添加元素,是不会触发kvo通知回调的,代码如下:
1 | //1、注册可变数组KVO观察者 |
在KVC官方文档中,针对可变数组的集合类型,有如下说明,即访问集合对象需要需要通过mutableArrayValueForKey方法,这样才能将元素添加到可变数组中
The protocol defines three different proxy methods for collection object access, each with a key and a key path variant:
mutableArrayValueForKey:andmutableArrayValueForKeyPath:These return a proxy object that behaves like an
NSMutableArrayobject.
mutableSetValueForKey:andmutableSetValueForKeyPath:These return a proxy object that behaves like an
NSMutableSetobject.
mutableOrderedSetValueForKey:andmutableOrderedSetValueForKeyPath:These return a proxy object that behaves like an
NSMutableOrderedSetobject.
我们代码这样修改:
1 | - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{ |
现在可变数组就可以监听到了。
KVO底层探索
苹果官方文档在Key-Value Observing Implementation Details里有提到KVO的实现:
Automatic key-value observing is implemented using a technique called isa-swizzling.
The
isapointer, 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.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.
You should never rely on the
isapointer to determine class membership. Instead, you should use theclassmethod to determine the class of an object instance.
大概意思就是KVO的实现使用了isa的交换。当我们添加一个observer的时候isa的指向会发生改变,是一个中间类而不是真正的类。我们不能根据isa指针确定类的成员身份,而是用哪个class方法确定。
中间类是什么
看苹果官网文档我们了解了,KVO的实现时通过修改isa指针指向了一个中间类实现的,我们使用lldb探究一下中间类是什么。
添加观察者之前,我们打印实例对象
person的方法是JSPerson。1
2(lldb) po object_getClassName(self.person)
"JSPerson"添加观察者之后,我们打印实例对象
person的方法是NSKVONotifying_JSPerson。1
2(lldb) po object_getClassName(self.person)
"NSKVONotifying_JSPerson"
通过上面的调试,我们看到添加观察者值isa指向了一个名为"NSKVONotifying_JSPerson的中间类。关于这个中间类我们有几个点需要研究一下。
中间类和之前的类是父子类关系吗我们通过一个方法来判断
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20#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);
}
//********以下为调用********
[self printClasses:[JSPerson class]];打印结果为
1
2
3
4classes = (
JSPerson,
"NSKVONotifying_JSPerson"
)通过打印结果我们可以判断中间类
NSKVONotifying_JSPerson是JSPerson的子类。中间类里有什么方法。
同样,我们定义一个方法获取
NSKVONotifying_JSPerson的所有方法1
2
3
4
5
6
7
8
9
10
11
12
13
14
15#pragma mark - 遍历方法-ivar-property
- (void)printClassAllMethod:(Class)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);
}
//********以下为调用********
[self printClassAllMethod:objc_getClass("NSKVONotifying_JSPerson")];打印结果
1
2
3
4setNickName:-0x7fff207bbb57
class-0x7fff207ba662
dealloc-0x7fff207ba40b
_isKVOA-0x7fff207ba403我们看到一共有四个方法
- 重写了父类的
setNickName方法 - 重写了根类的
class、dealloc方法 _isKVO方法,用来判断是否是kvo类
- 重写了父类的
dealloc中移除观察者后,isa会指回来吗
移除观察者之前,我们用
lldb打印1
2(lldb) po object_getClassName(self.person)
"NSKVONotifying_JSPerson"移除观察者之后,我们重新打印
1
2(lldb) po object_getClassName(self.person)
"JSPerson"
说明的确是在移除观察的时候将
isa指回来的。移除观察后中间类会销毁吗
我们返回前一个页面,此时添加观察者的VC已经销毁,我们打印
JSPerson的子类1
[self printClasses:[JSPerson class]];
打印结果
1
2
3
4classes = (
JSPerson,
"NSKVONotifying_JSPerson"
)发现子类并不会被销毁。
小结
- 实例对象
isa的指向在添加KVO观察者之后,由原有类更改为指向中间类 中间类重写了观察属性的setter方法、class、dealloc、_isKVOA方法dealloc方法中,移除KVO观察者之后,实例对象isa指向由中间类更改为原有类中间类从创建后,就一直存在内存中,不会被销毁