KVO 原理详解

KVO(Key-Value Observing)是 iOS 开发中常用的一种用于监听某个对象属性值变化的技术。下文将以一段示例代码来分析 KVO 的底层原理。源码地址

示例源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
- (void)viewDidLoad {
[super viewDidLoad];
[self setupSubviews];

BAOPerson *p1 = [[BAOPerson alloc] init];
BAOPerson *p2 = [[BAOPerson alloc] init];
p1.age = 1;
p1.age = 2;
p2.age = 2;

// self 监听 p1 的 age 属性
NSKeyValueObservingOptions options = NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld;
[p1 addObserver:self forKeyPath:@"age" options:options context:nil];
p1.age = 10;
[p1 removeObserver:self forKeyPath:@"age"];
}

- (void)setupSubviews {
[self setupHeaderView];
}

- (void)setupHeaderView {
self.headerView.title = @"KVO";
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
NSLog(@"监听到 %@ 的 %@ 改变了 %@", object, keyPath,change);
}

示例对 p1 进行了 KVO 监听,当 p1 发生改变,即调用 observeValueForKeyPath 方法,从而打印以下信息。

1
2
3
4
5
监听到 <BAOPerson: 0x600003750200> 的 age 改变了 {
kind = 1;
new = 10;
old = 2;
}

KVO 实现原理

通过上述代码可以发现,一旦 age 属性的值发生改变,就会通知到监听者。我们知道赋值操作都是调用 set 方法,我们可以重写 BAOPerson 类中 ageset 方法,观察 KVO 是否是在 set 方法内部做了一些操作来通知监听者。

1
2
3
4
- (void)setAge:(NSInteger)age {
NSLog(@"override setAge");
_age = age;
}

我们发现即使重写了 set 方法,p1 除了调用 set 方法之外还会执行监听者的 observeValueForKeyPath 方法。

根据上述实验推测:KVO 在运行时对 p1 对象进行了改动,使 p1 对象在调用 setAge 方法时做了一些额外的操作。所以问题出在对象身上,两个对象可能本质上并不一样。下面我们来探索一下 KVO 内部是如何实现的。

KVO 实现分析

首先分别在添加 KVO 前后打上断点,以观察添加 KVO 前后 p1 对象有何不同。

通过打印对象的 isa 指针,我们发现,p1 对象的 isa 指针由之前的指向类对象 BAOPerson 变成了指向类对象 NSKVONotifying_BAOPerson。相应地,p2 对象没有改变。因此我们可以推测,p1 对象的 isa 发生改变后,其执行的 setAge 也发生了改变。

我们知道,p2 在调用 setAge 方法时,首先会通过 p2 对象的 isa 指针找到 BAOPerson 类对象,然后在类对象中找到 setAge 方法,最终找到方法对应的实现。如下图所示:

但是,p1 对象的 isa 在添加 KVO 之后已经指向了 NSKVONotifying_BAOPerson 类对象,NSKVONotifying_BAOPerson 则是 BAOPerson 的子类。NSKVONotifying_BAOPerson 是 runtime 在运行时生成的。因此,p1 对象在调用 setAge 方法时必然会根据 p1isa 找到 NSKVONotifying_BAOPerson,并在 NSKVONotifying_BAOPerson 中找到 setAge 方法及其实现。

经查阅资料了解到,NSKVONotifying_BAOPerson 中的 setAge 方法中其实调用了 Foundation 框架中 C 语言函数 _NSsetIntValueAndNotify_NSsetIntValueAndNotify 内部的操作大致是:首先调用 willChangeValueForKey 方法,然后调用父类的 setAge 方法对成员变量赋值,最后调用 didChangeValueForKey 方法。didChangeValueForKey 方法中会调用监听者的监听方法,最终调用监听者的 observeValueForKeyPath 方法。

KVO 原理验证

前面我们已经通过断点打印 isa 指针的方式验证了:p1 对象在添加 KVO 后,其 isa 指针会指向一个通过 runtime 创建的 BAOPerson 的子类 NSKVONotifying_BAOPerson

下面我们可以通过打印方法实现的地址来看一下 p1p2setAge 方法实现的地址在添加 KVO 前后有什么变化。

1
2
3
4
5
6
7
// 通过methodForSelector找到方法实现的地址
NSLog(@"添加 KVO 之前 - p1 = %p, p2 = %p", [p1 methodForSelector: @selector(setAge:)], [p2 methodForSelector: @selector(setAge:)]);

NSKeyValueObservingOptions options = NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld;
[p1 addObserver:self forKeyPath:@"age" options:options context:nil];

NSLog(@"添加 KVO 之后 - p1 = %p, p2 = %p", [p1 methodForSelector: @selector(setAge:)], [p2 methodForSelector: @selector(setAge:)]);

执行上述代码,可以发现:在添加 KVO 之前,p1p2setAge 方法实现的地址是相同的;在添加 KVO 之后, p1setAge 方法实现的地址发生了改变。通过打印方法实现可以证明,p1setAge 方法的实现由 BAOPerson 类方法中的 setAge 方法转换成了 Foundation 框架中的 C 函数 _NSSetIntValueAndNotify

事实上,Foundation 框架中很多例如 _NSSetBoolValueAndNotify_NSSetCharValueAndNotify_NSSetFloatValueAndNotify_NSSetLongValueAndNotify 等函数。

为了查看 Foundation 框架中的相关函数,我们找到 Foundation 文件,通过命令行查询:

1
nm Foundation | grep ValueAndNotify

中间类内部结构

NSKVONotifying_BAOPerson 作为 BAOPerson 的子类,其 superclass 指针指向 BAOPerson 类,其内部对 setAge 方法做了单独的实现,那么 NSKVONotifying_BAOPersonBAOPerson 类的差别可能就在于其内存储的对象方法及实现不同。我们通过 runtime 分别打印 BAOPerson 类对象和 NSKVONotifying_BAOPerson 类对象内存储的对象方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
- (void)viewDidLoad {
[super viewDidLoad];

BAOPerson *p1 = [[BAOPerson alloc] init];
BAOPerson *p2 = [[BAOPerson alloc] init];
p1.age = 1;
p1.age = 2;
p2.age = 2;

NSKeyValueObservingOptions options = NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld;
[p1 addObserver:self forKeyPath:@"age" options:options context:nil];

[self printMethods: object_getClass(p2)];
[self printMethods: object_getClass(p1)];

p1.age = 10;
[p1 removeObserver:self forKeyPath:@"age"];
}

- (void)printMethods:(Class)cls {
unsigned int count;
Method *methods = class_copyMethodList(cls, &count);
NSMutableString *methodNames = [NSMutableString string];
[methodNames appendFormat:@"%@ - ", cls];

for (int i = 0 ; i < count; i++) {
Method method = methods[i];
NSString *methodName = NSStringFromSelector(method_getName(method));

[methodNames appendString:methodName];
[methodNames appendString:@" "];

}

NSLog(@"%@", methodNames);
free(methods);
}

上述代码的打印结果如下:

可以发现,NSKVONotifying_BAOPerson 中有 4 个对象方法,分别是:

1
2
3
4
setAge:
class
dealloc
_isKVOA

NSKVONotifying_BAOPerson 重写 class 方法是为了隐藏 NSKVONotifying_BAOPerson 不被外界看到。我们在 p1 添加 KVO 之后,分别打印 p1p2 对象的 class,可以发现它们都返回 BAOPerson

1
2
NSLog(@"%@, %@", [p1 class], [p2 class]);
// 打印结果 BAOPerson, BAOPerson

综上,我们可以画出 NSKVONotifying_BAOPerson 的内部结构及方法调用顺序。

验证

didChangeValueForKey: 内部调用 observeValueForKeyPath:ofObject:change:context: 方法 在 BAOPerson 类中重写 willChangeValueForKey:didChangeValueForKey: 方法,模拟它们的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- (void)setAge:(NSInteger)age
{
NSLog(@"override setAge");
_age = age;
}

- (void)willChangeValueForKey:(NSString *)key {
NSLog(@"willChangeValueForKey: - begin");
[super willChangeValueForKey:key];
NSLog(@"willChangeValueForKey: - end");
}

- (void)didChangeValueForKey:(NSString *)key {
NSLog(@"didChangeValueForKey: - begin");
[super didChangeValueForKey:key];
NSLog(@"didChangeValueForKey: - end");
}

通过运行上述代码,可以确定是在 didChangeValueForKey: 方法内部调用了监听者的 observeValueForKeyPath:ofObject:change:context: 方法。

根据上述原理,可以通过调用 willChangeValueForKey:didChangeValueForKey: 来手动触发 KVO。

参考

  1. iOS底层原理总结 - 探寻KVO本质