KVO的原理

翻译自Mike Ash大神的blog:How Key-Value Oberserving Works

KVO是什么?

尽管大多数读者可能已经知道KVO,但是这里做一个快速总结:KVO是构成Cocoa Bindings基础的技术;当对象的属性变化时,它可以通知对象。
一个对象观察另一个对象的key。当被观察的对象改变那个key的值时,观察者就会得到通知。非常简单,对不对?微妙所在是通常情况下,KVO不需要在被观察者的类中添加代码。

概况

既然不需要在被观察者的类中添加代码,那KVO是怎样工作的呢?这就是Objective-C Runtime的威力所在。当你第一次观察一个特定类的对象时,KVO会在运行时构建一个继承该类的新类。新类中,KVO会重写所有被观察key值的set方法,然后切换对象的isa指针(告诉Objective-C Runtime所指向的内存块实际是什么对象类型的指针),以致你的对象会神奇地变为新类的实例对象。
那些被覆盖重写的set方法就是KVO可以通知观察者的真实原因所在:改变一个key值是需要检查该key的set方法;因为KVO重写了set方法,所以它可以拦截set方法。任何时候,只要set方法被调用,KVO就会向观察者发送通知。(当然,如果你直接对实例变量进行修改,是不会检查set方法。KVO要求兼容的类不能做直接修改,而是在手动通知调用中必须封装实例变量的直接访问(译者注:不能直接访问实例变量,而是要使用self.property访问))。
实际上苹果不想这块功能暴露出来,这变得十分微妙。除了setter方法外,动态生成的子类也会重写-class方法以达到欺骗使用者并且返回原始的类。如果你不仔细观察,KVO生成的子类对象就和它们原来未被观察的副本一模一样。

深挖

说了这么多,让我们看看KVO到底是怎么运作的。我写了一个程序来证明KVO的原理。因为动态KVO子类尝试隐藏自己的存在证据,我主要用Objective-C Runtime方法来获得我们在寻找的信息。
代码如下:

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// gcc -o kvoexplorer -framework Foundation kvoexplorer.m

#import <Foundation/Foundation.h>
#import <objc/runtime.h>

@interface TestClass : NSObject
{
int x;
int y;
int z;
}
@property int x;
@property int y;
@property int z;
@end
@implementation TestClass
@synthesize x, y, z;
@end

static NSArray *ClassMethodNames(Class c)
{
NSMutableArray *array = [NSMutableArray array];

unsigned int methodCount = 0;
Method *methodList = class_copyMethodList(c, &methodCount);
unsigned int i;
for(i = 0; i < methodCount; i++)
[array addObject: NSStringFromSelector(method_getName(methodList[i]))];
free(methodList);

return array;
}

static void PrintDescription(NSString *name, id obj)
{
NSString *str = [NSString stringWithFormat:
@"%@: %@\n\tNSObject class %s\n\tlibobjc class %s\n\timplements methods <%@>",
name,
obj,
class_getName([obj class]),
class_getName(obj->isa),
[ClassMethodNames(obj->isa) componentsJoinedByString:@", "]];
printf("%s\n", [str UTF8String]);
}

int main(int argc, char **argv)
{
[NSAutoreleasePool new];

TestClass *x = [[TestClass alloc] init];
TestClass *y = [[TestClass alloc] init];
TestClass *xy = [[TestClass alloc] init];
TestClass *control = [[TestClass alloc] init];

[x addObserver:x forKeyPath:@"x" options:0 context:NULL];
[xy addObserver:xy forKeyPath:@"x" options:0 context:NULL];
[y addObserver:y forKeyPath:@"y" options:0 context:NULL];
[xy addObserver:xy forKeyPath:@"y" options:0 context:NULL];

PrintDescription(@"control", control);
PrintDescription(@"x", x);
PrintDescription(@"y", y);
PrintDescription(@"xy", xy);

printf("Using NSObject methods, normal setX: is %p, overridden setX: is %p\n",
[control methodForSelector:@selector(setX:)],
[x methodForSelector:@selector(setX:)]);
printf("Using libobjc functions, normal setX: is %p, overridden setX: is %p\n",
method_getImplementation(class_getInstanceMethod(object_getClass(control),
@selector(setX:))),
method_getImplementation(class_getInstanceMethod(object_getClass(x),
@selector(setX:))));

return 0;
}

我们从上到下简单的浏览一下。

首先,我们定义一个有3个属性的TestClass类(KVO对没有@property的类也起作用,但这是定义setter和getter最简单的方式)。

其次,我们定义一对功能性函数。ClassMethodNames使用Objective-C Runtime函数来遍历一个类,获得类中实现的方法的列表。注意它只获得当前类直接实现的方法,不获得父类中的。PrintDescription打印一份传递给它的对象的完整描述, 好像这个对象的类调用-class方法一样调用Objective-C Runtime的函数;同时,打印出那个类中实现的方法。

然后,我们用这些函数进行实验。我们创建了4个以不同方式观察的TestClass实例变量。x实例变量观察x的key值;y同理;xy观察x、y的key值。z不被观察以用来对比。最后的control变量完全不会被观察,充当实验的控制开关。

下一步,我们打印出所有4个对象的完整描述。

接着,我们对重写的setter方法进行深挖。打印出control对象的-setX:方法的实际实现部分的地址,同时用一个被观察的对象来做对比。重复2遍,因为调用-methroForSelector:不能成功展现出重写的方法。KVO试图隐藏动态子类的同时,甚至利用这个技术隐藏被重写的方法。然而,换成调用Objective-C Runtime函数可以理所当然地获得正确结果。

运行代码

我们看一下例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
control: <TestClass: 0x104b20>
NSObject class TestClass
libobjc class TestClass
implements methods <setX:, x, setY:, y, setZ:, z>
x: <TestClass: 0x103280>
NSObject class TestClass
libobjc class NSKVONotifying_TestClass
implements methods <setY:, setX:, class, dealloc, _isKVOA>
y: <TestClass: 0x104b00>
NSObject class TestClass
libobjc class NSKVONotifying_TestClass
implements methods <setY:, setX:, class, dealloc, _isKVOA>
xy: <TestClass: 0x104b10>
NSObject class TestClass
libobjc class NSKVONotifying_TestClass
implements methods <setY:, setX:, class, dealloc, _isKVOA>
Using NSObject methods, normal setX: is 0x195e, overridden setX: is 0x195e
Using libobjc functions, normal setX: is 0x195e, overridden setX: is 0x96a1a550

首先,打印得到控制对象。正如预想的一样,它是TestClass类,并且实现了从类的属性合成的6个方法。

然后,打印得到3个被观察的对象。注意,调用-class方法仍然显示TestClass的同时,调用*object_getClass方法则会展现这个对象的真实面目:*NSKVONotifying_TestClass的实例变量。这个类就是动态子类。

请注意它是怎样实现2个被观察的setter方法的。有意思了,因为你会注意到不重写-setZ:方法是明智的:尽管它也是一个setter方法,但是没人观察它。假设我们对z增加观察者,那么NSKVONotifying_TestClass会马上重写-setZ:。但是也要注意到,3个实例变量是同一个类的;意思是尽管其中的2个对象只有一个被观察的属性,但是它们仍然会有对x、y2个属性的重写方法。因为没被观察的属性会牵连到已被观察的属性,所以会有一定性能的牺牲。但是,苹果认为如果一个对象有不同的属性被观察,就要生成大量的动态子类,这样明显是不好的处理方式。我也这样认为。

同时,你也会注意到另外3个方法。试图用来隐藏动态子类的被重写的-class方法;用来做清理工作的-dealloc方法;神秘的-_isKVOA方法,看起来像苹果代码用来判断一个对象是否属于这个动态子类的私有方法。

下一步,我们通过调用-methodForSelector:打印出-setX:的实现方法,2个方法返回相同的结果。因为动态子类没有对它进行重写覆盖,这也说明-methodForSelector:内部调用-class方法,所以才会得到和我们预想不同的答案。

当然,我们可以完全绕开,用Objective-C Runtime方法打印出实现方法,这样我们就会看到不同之处。第一个和-methodForSelector:一致(当然也应该这样),第二个则完全不同。

我们在debugger中运行代码,可以确切的知道第二个函数是什么:

1
2
(gdb) print (IMP)0x96a1a550
$1 = (IMP) 0x96a1a550 <_NSSetIntValueAndNotify>

是某种实现观察者通知的私有函数。在Foundation调用nm -a命令,我们可以得到这些私有函数的完整列表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
0013df80 t __NSSetBoolValueAndNotify
000a0480 t __NSSetCharValueAndNotify
0013e120 t __NSSetDoubleValueAndNotify
0013e1f0 t __NSSetFloatValueAndNotify
000e3550 t __NSSetIntValueAndNotify
0013e390 t __NSSetLongLongValueAndNotify
0013e2c0 t __NSSetLongValueAndNotify
00089df0 t __NSSetObjectValueAndNotify
0013e6f0 t __NSSetPointValueAndNotify
0013e7d0 t __NSSetRangeValueAndNotify
0013e8b0 t __NSSetRectValueAndNotify
0013e550 t __NSSetShortValueAndNotify
0008ab20 t __NSSetSizeValueAndNotify
0013e050 t __NSSetUnsignedCharValueAndNotify
0009fcd0 t __NSSetUnsignedIntValueAndNotify
0013e470 t __NSSetUnsignedLongLongValueAndNotify
0009fc00 t __NSSetUnsignedLongValueAndNotify
0013e620 t __NSSetUnsignedShortValueAndNotify

在这个列表中,我们可以发现一些有意思的东西。首先,你会注意到苹果为每个想要支持的基本数据类型实现了一个单独函数。所有的Objective-C对象只需要一个(_NSSetObjectValueAndNotify),其他的类型则需要大量的函数。同时,这个集合还是不完整的:缺少long double_Bool的函数。没有对泛型支持,例如当你有个CFTypeRef属性时就会遇到。然而,却有支持多个通用Cocoa structs的函数,虽然剩下大量的structs不会支持。意思是任何这些类型对象的属性是不能得到自动KVO通知的。

KVO是一个有威力的技术,有时候它显得过于威力了,尤其涉及到自动通知。现在你知道KVO内部是怎样工作的,你可以觉得什么时候使用它、出错时怎样调试它。

如果你打算在自己的应用中使用KVO,可以看一下我的另外一篇文章Key-Value Observing Done Right