键值观察KVO

KVO及其实现机理

Posted by JT on October 22, 2017

前言

Objective-C 中的键(key)-值(value)观察(KVO)并不是什么新鲜事物,它来源于设计模式中的观察者模式,其基本思想就是:

一个目标对象管理所有依赖于它的观察者对象,并在它自身的状态改变时主动通知观察者对象。这个主动通知通常是通过调用各观察者对象所提供的接口方法来实现的。观察者模式较完美地将目标对象与观察者对象解耦。

在 Objective-C 中有两种使用键值观察的方式:手动或自动,此外还支持注册依赖键(即一个键依赖于其他键,其他键的变化也会作用到该键)。

下面将一一讲述这些,并会深入 Objective-C 内部一窥键值观察是如何实现的。

运用键值观察

注册与解除注册

- (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(void *)context;
- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;

这两个方法的定义在 Foundation/NSKeyValueObserving.h 中,NSObject,NSArray,NSSet均实现了以上方法,因此我们不仅可以观察普通对象,还可以观察数组或结合类对象。

不要忘记解除注册,否则会导致资源泄露。

设置属性

将观察者与被观察者注册好之后,就可以对观察者对象的属性进行操作,这些变更操作就会被通知给观察者对象。注意,只有遵循 KVO 方式来设置属性,观察者对象才会获取通知,也就是说遵循使用属性的 setter 方法,或通过 key-path 来设置:

[target setAge:30]; 
[target setValue:[NSNumber numberWithInt:30] forKey:@"age"];

处理变更通知

观察者需要实现名为 NSKeyValueObserving 的 category 方法来处理收到的变更通知:

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context;

在这里,change 这个字典保存了变更信息,具体是哪些信息取决于注册时的 NSKeyValueObservingOptions

在实现处理变更通知方法 observeValueForKeyPath 时,要将不能处理的 key 转发给 super 的 observeValueForKeyPath 来处理。

手动实现键值观察

  • 首先,需要手动实现属性的 setter 方法,并在设置操作的前后分别调用 willChangeValueForKey: 和 didChangeValueForKey方法,这两个方法用于通知系统该 key 的属性值即将和已经变更了;

  • 其次,要实现类方法 automaticallyNotifiesObserversForKey,并在其中设置对该 key 不自动发送通知(返回 NO 即可)。这里要注意,对其它非手动实现的 key,要转交给 super 来处理。

- (void)setAge:(int)age
{
    [self willChangeValueForKey:@"age"];
    _age = age;
    [self didChangeValueForKey:@"age"];
}

+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key {
    if ([key isEqualToString:@"age"]) {
        return NO;
    }

    return [super automaticallyNotifiesObserversForKey:key];
}

键值观察依赖键

有时候一个属性的值依赖于另一对象中的一个或多个属性,如果这些属性中任一属性的值发生变更,被依赖的属性值也应当为其变更进行标记。因此,object 引入了依赖键。

观察依赖键

1、 添加属性观察

[self addObserver:self forKeyPath:@"infoMessage" options:NSKeyValueObservingOptionNew context:nil];

2、 重写观察属性的get方法

- (NSString *)infoMessage {
    return [NSString stringWithFormat:@"age = %ld,grade = %ld",self.age, self.grade];
}

3、 实现属性变化回调

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context;

4、 实现keyPathsForValuesAffectingInfoMessage方法,确定依赖关系

+ (NSSet<NSString *> *)keyPathsForValuesAffectingInfoMessage
{
    NSSet * keyPaths = [NSSet setWithObjects:@"target.key1", @"target.key2", nil];
    return keyPaths;
}

键值观察是如何实现的

当某个类的对象第一次被观察时,系统就会在运行期动态地创建该类的一个派生类,在这个派生类中重写基类中任何被观察属性的 setter 方法。

派生类在被重写的 setter 方法实现真正的通知机制,就如前面手动实现键值观察那样。这么做是基于设置属性会调用 setter 方法,而通过重写就获得了 KVO 需要的通知机制。当然前提是要通过遵循 KVO 的属性设置方式来变更属性值,如果仅是直接修改属性对应的成员变量,是无法实现 KVO 的。

同时派生类还重写了 class 方法以“欺骗”外部调用者它就是起初的那个类。然后系统将这个对象的 isa 指针指向这个新诞生的派生类,因此这个对象就成为该派生类的对象了,因而在该对象上对 setter 的调用就会调用重写的 setter,从而激活键值通知机制。此外,派生类还重写了 dealloc 方法来释放资源。

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\t%@: %@\n\tNSObject class %s\n\tlibobjc class %s\n\timplements methods <%@>",
                      name,
                      obj,
                      class_getName([obj class]),
                      class_getName(object_getClass(obj)),
                      [ClassMethodNames(object_getClass(obj)) componentsJoinedByString:@", "]];
    NSLog(@"%@", str);
}

@interface ViewController ()

@property (nonatomic, strong) Foo *anything;
@property (nonatomic, strong) Foo *x;
@property (nonatomic, strong) Foo *y;
@property (nonatomic, strong) Foo *xy;
@property (nonatomic, strong) Foo *control;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view, typically from a nib.
    
    Foo * anything = [[Foo alloc] init];
    self.anything = anything;
    Foo * x = [[Foo alloc] init];
    self.x = x;
    Foo * y = [[Foo alloc] init];
    self.y = y;
    Foo * xy = [[Foo alloc] init];
    self.xy = xy;
    Foo * control = [[Foo alloc] init];
    self.control = control;
    
    [x addObserver:anything forKeyPath:@"x" options:0 context:NULL];
    [y addObserver:anything forKeyPath:@"y" options:0 context:NULL];
    
    [xy addObserver:anything forKeyPath:@"x" options:0 context:NULL];
    [xy addObserver:anything forKeyPath:@"y" options:0 context:NULL];
    
    PrintDescription(@"control", control);
    PrintDescription(@"x", x);
    PrintDescription(@"y", y);
    PrintDescription(@"xy", xy);
    
    NSLog(@"\n\tUsing NSObject methods, normal setX: is %p, overridden setX: is %p\n",
          [control methodForSelector:@selector(setX:)],
          [x methodForSelector:@selector(setX:)]);
    NSLog(@"\n\tUsing 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:))));
    
}

在上面的代码中,辅助函数 ClassMethodNames 使用 runtime 函数来获取类的方法列表,PrintDescription 打印对象的信息,包括通过 -class 获取的类名, isa 指针指向的类的名字以及其中方法列表。

在这里,我创建了四个对象,x 对象的 x 属性被观察,y 对象的 y 属性被观察,xy 对象的 x 和 y 属性均被观察,参照对象 control 没有属性被观察。在代码的最后部分,分别通过两种方式(对象方法和 runtime 方法)打印出参数对象 control 和被观察对象 x 对象的 setX 方面的实现地址,来对比显示正常情况下 setter 实现以及派生类中重写的 setter 实现。

编译运行,输出如下:

2017-10-19 19:54:48.641 afndemo[85739:1914033] 
	control: <Foo: 0x608000024260>
	NSObject class Foo
	libobjc class Foo
	implements methods <z, x, setX:, y, setY:, setZ:>
2017-10-19 19:54:48.642 afndemo[85739:1914033] 
	x: <Foo: 0x6080000238c0>
	NSObject class Foo
	libobjc class NSKVONotifying_Foo
	implements methods <setY:, setX:, class, dealloc, _isKVOA>
2017-10-19 19:54:48.642 afndemo[85739:1914033] 
	y: <Foo: 0x608000024140>
	NSObject class Foo
	libobjc class NSKVONotifying_Foo
	implements methods <setY:, setX:, class, dealloc, _isKVOA>
2017-10-19 19:54:48.642 afndemo[85739:1914033] 
	xy: <Foo: 0x6080000241c0>
	NSObject class Foo
	libobjc class NSKVONotifying_Foo
	implements methods <setY:, setX:, class, dealloc, _isKVOA>
2017-10-19 19:54:48.642 afndemo[85739:1914033] 
	Using NSObject methods, normal setX: is 0x1076aeaf0, overridden setX: is 0x1077bb3b6
2017-10-19 19:54:48.643 afndemo[85739:1914033] 
	Using libobjc functions, normal setX: is 0x1076aeaf0, overridden setX: is 0x1077bb3b6

从上面的输出可以看到,如果使用对象的 -class 方面输出类名始终为:Foo,这是因为新诞生的派生类重写了 -class 方法声称它就是起初的基类,只有使用 runtime 函数 object_getClass 才能一睹芳容:NSKVONotifying_Foo。注意看:x,y 以及 xy 三个被观察对象真正的类型都是 NSKVONotifying_Foo,而且该类实现了:setY:, setX:, class, dealloc, _isKVOA 这些方法。其中 setX:, setY:, class 和 dealloc 前面已经讲到过,私有方法 _isKVOA 估计是用来标示该类是一个 KVO 机制声称的类。在这里 Objective C 做了一些优化,它对所有被观察对象只生成一个派生类,该派生类实现所有被观察对象的 setter 方法,这样就减少了派生类的数量,提供了效率。所有 NSKVONotifying_Foo 这个派生类重写了 setX,setY方法(留意:没有必要重写 setZ 方法)。