目录


1. Key Value Observing 介绍

Observer Pattern 是”GOF”提到的24种面向对象设计模式中的一种, 它可以让subject(被观察者)的iVar改变时通知observer(观察者)。Key Value Observing 是Cocoa对于Observing Pattern的具体实现。 这在Model-View-Controller模式里提供了一种在Controller里,Model和View各自变化实时更新彼此的实现。 KVO实现的原理是subject拥有观察者们的引用(array),在iVar的setter的更改值前后加入通知观察者。令人疑惑的是Cocoa的KVO的API在没有重写subject的setter的同时又实现了通知观察者,这里用到了isa swizzling的方法,我们暂时不讲。本文着重介绍Key Value Observing的API应用, 包括自动观察,手动观察,依赖值观察,以及KVO的各种坑及应对方法。

2. Key Value Observing 三种应用

Key Value Observing 的应用逻辑遵循 ”订阅“-”响应“-”取消订阅“的基本方式。

2.1. 自动观察

先来看一段代码。

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
Bank.h
@class Person;
@interface Bank : NSObject
@property (nonatomic, strong) NSNumber* accountDomestic;
@property (nonatomic, strong) Person* person;
-(instancetype)initWithPerson:(Person *)person;
@end

Bank.m
@implementation Bank
-(instancetype)initWithPerson:(Person *)person{
    if(self = [super init]){
        _person = person;
        [self addObserver:_person forKeyPath:NSStringFromSelector(@selector(accountDomestic)) options:NSKeyValueObservingOptionOld|NSKeyValueObservingOptionNew context:bankContext];
    }
    return self;
}
-(void)dealloc{
    [self removeObserver:_person forKeyPath:NSStringFromSelector(@selector(accountDomestic))];
}
@end


Person.h
@interface Person : NSObject
@end

Person.m
@implementation Person
-(void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSString *,id> *)change context:(void *)context{
    if([object isKindOfClass:[Bank class]]){
        if([keyPath isEqualToString:NSStringFromSelector(@selector(accountDomestic))]){
            NSString *oldValue = change[NSKeyValueChangeOldKey];
            NSString *newValue = change[NSKeyValueChangeNewKey];
            NSLog(@"--------------------------");
            NSLog(@"accountDomestic old value: %@", oldValue);
            NSLog(@"accountDomestic new value: %@", newValue);
        }
    }
}
@end


ViewController.m
@interface ViewController ()
@property (nonatomic, strong) Bank *bank;
@property (nonatomic, strong) Person *person;
@property (nonatomic, strong) NSNumber *delta;
@end

@implementation ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    self.person = [[Person alloc] init];
    self.delta = @10;
    self.bank = [[Bank alloc] initWithPerson:self.person];
}
- (IBAction)accountBalanceIncreaseButtonDidTouch:(id)sender {
    self.bank.accountDomestic = self.delta;
    int temp = [self.delta intValue];
    temp += 10;
    self.delta = [NSNumber numberWithInt:temp]; 
}
//输出:按两次button
//--------------------------
//accountDomestic old value: <null>
//accountDomestic new value: 10
//--------------------------
//accountDomestic old value: 10
//accountDomestic new value: 20
//

我们先来看”订阅”。这里Bank类有一个iVar是accountDomestic,还有一个客户Person,在Bank初始化时, Person订阅了accountDomestic的KVO服务, 被加入到观察者中。

1
2
3
4
- (void)addObserver:(NSObject *)observer 
         forKeyPath:(NSString *)keyPath 
            options:(NSKeyValueObservingOptions)options 
            context:(nullable void *)context;

这里observerperson,观察者;keyPath是iVar的名字, 应该是@"accountDomestic",由于手动输入只能在运行时才能确认对错,用NSStringFromSelecotr(@selector(accountDomestic))替代更为合适; option是一个NSKeyValueObservingOptions的enum,可以有四个值,一般用的是旧值NSKeyValueObservingOptionOld和新值NSKeyValueObservingOptionNew; context通常是null也可以用作唯一的ID来区别这个类的观察和父类的观察,这个我们后面讲。

下面我们看”响应“。Person类实现了以下方法。

1
2
3
4
- (void)observeValueForKeyPath:(NSString *)keyPath 
                      ofObject:(id)object 
                        change:(NSDictionary<NSString*, id> *)change 
                        context:(void *)context;

keyPathPerson类添加的键,本例中是至少有一个为NSStringFromSelecotr(@selector(accountDomestic))object是subject(被观察者),这里是Bank实例。change为一个NSDictionary,当订阅时的option是用的是旧值NSKeyValueObservingOptionOld和新值NSKeyValueObservingOptionNew时,可以分别通过change[NSKeyValueChangeOldKey]change[NSKeyValueChangeNewKey]来访问;context和订阅时的context一样。

本例中我们首先判断响应是否来源于Bank,再判断是否是NSStringFromSelecotr(@selector(accountDomestic)),然后再执行相关业务。由于KVO的响应不能像UIControl的实例方法-addTarget:action:forControlEvents:一样可以添加selector, 因此所有的响应都在-observeValueForKeyPath:ofObject:change:context:中执行,这是KVO广为诟病的一大缺点之一。

最后我们来看取消订阅:

1
2
- (void)removeObserver:(NSObject *)observer 
            forKeyPath:(NSString *)keyPath;

当subjectdealloc时,我们取消订阅。

2.2. 手动观察

有时候观察者需要对被观察的某个键进或者几个键的通知发送进行控制, 比如Bank还有一个属性accountForeign, Person同时也观察这个key。但是现在Person暂时想把accountForeign改变时的发送通知给停止掉,或者暂停掉后又想开启。见如下代码。

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
Bank.m
@implementation Bank
-(instancetype)initWithPerson:(Person *)person{
    if(self = [super init]){
        _person = person;
        [self addObserver:_person forKeyPath:NSStringFromSelector(@selector(accountDomestic)) options:NSKeyValueObservingOptionOld|NSKeyValueObservingOptionNew context:bankContext];
        [self addObserver:_person forKeyPath:NSStringFromSelector(@selector(accountForeign)) options: NSKeyValueObservingOptionOld|NSKeyValueObservingOptionNew context:bankContext];
    }
    return self;
}

+(BOOL)automaticallyNotifiesObserversForKey:(NSString *)key{
//当key时NSStringFromSelector(@selector(accountForeign))停止自动发送通知。
    if([key isEqualToString:NSStringFromSelector(@selector(accountForeign))]){
        return NO;
    }else{
        return YES;
    }
}

-(void)dealloc{
    [self removeObserver:_person forKeyPath:NSStringFromSelector(@selector(accountDomestic))];
    [self removeObserver:_person forKeyPath:NSStringFromSelector(@selector(accountForeign))];
}

Person.m
@implementation Person
-(void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSString *,id> *)change context:(void *)context{
    if([object isKindOfClass:[Bank class]]){
        if([keyPath isEqualToString:NSStringFromSelector(@selector(accountDomestic))]){
            NSString *oldValue = change[NSKeyValueChangeOldKey];
            NSString *newValue = change[NSKeyValueChangeNewKey];
            NSLog(@"--------------------------");
            NSLog(@"accountBalance old value: %@", oldValue);
            NSLog(@"accountBalance new value: %@", newValue);
        }else if([keyPath isEqualToString:NSStringFromSelector(@selector(accountForeign))]){
            NSString *oldValue = change[NSKeyValueChangeOldKey];
            NSString *newValue = change[NSKeyValueChangeNewKey];
            NSLog(@"--------------------------");
            NSLog(@"accountForeign old value: %@", oldValue);
            NSLog(@"accountForeign new value: %@", newValue);
        }
    }
}

ViewController.m
...
@implementation ViewController
...
- (IBAction)accountBalanceIncreaseButtonDidTouch:(id)sender {
    self.bank.accountDomestic = self.delta;
//同时更改accountForeign的值。
    self.bank.accountForeign = self.delta;
    int temp = [self.delta intValue];
    temp += 10;
    self.delta = [NSNumber numberWithInt:temp]; 
}
//输出:按两次button
//--------------------------
//accountDomestic old value: <null>
//accountDomestic new value: 10
//
@end

这里+(BOOL)automaticallyNotifiesObserversForKey:(NSString *)keyaccountForeign返回NO,停止了它的值改变时自动发送通知,虽然它和accountDomestic一样实现了”订阅“-”响应“-”取消订阅“。

如果要开启的话,要么更改+(BOOL)automaticallyNotifiesObserversForKey:(NSString *)keyaccountForeign返回YES; 要么在Bank中插入下列代码:

1
2
3
4
5
-(void)setAccountForeign:(NSNumber *)accountForeign{
    [self willChangeValueForKey:NSStringFromSelector(@selector(accountForeign))];
    _accountForeign = accountForeign;
    [self didChangeValueForKey:NSStringFromSelector(@selector(accountForeign))];
}

- (void)willChangeValueForKey:(NSString *)key- (void)didChangeValueForKey:(NSString *)key分别在setter里面值改变前后通知观察者。这时候在改变它的值得时候,就会收到通知继而触发响应了。

2.3. 依赖值观察

再比如,Bank有一个totalAmountiVar,它是accountDomesticaccountForeign的和, 我们希望accountDomesticaccountForeign任何一个的改变都会使得totalBalance也有一样KVO的功能。这就是所谓的依赖值观察。这里totalAmount的”订阅”-“响应”-“取消订阅”的实现和accountDomestic或者accountForeign一样。唯一不同的是我们在Bank里要加入如下代码:

1
2
3
4
5
6
7
8
//重写totalAmount的getter,体现其数值关系。
-(NSNumber *)totalAmount{
    return [NSNumber numberWithInt:([self.accountDomestic intValue] + [self.accountForeign intValue])];
}
//keyPathsForValuesAffectingTotalAmount里返回两个依赖的key作为NSSet。
+(NSSet *)keyPathsForValuesAffectingTotalAmount{
    return [NSSet setWithObjects:NSStringFromSelector(@selector(accountDomestic)),NSStringFromSelector(@selector(accountForeign)), nil];
}

这样任何一个accountDomestic或者accountForeign的改变,都会发送totalAmount改变的通知。

2.4. KeyPath注意事项

结合ReactiveCocoa

1
RACObserve(targe,keyPath);

self->percolates->N 若想观察self.percolates是否指向新的percolates对象,则RACObserve(self,percolates);若想观察self.percolates.N是否指向新的N对象,则RACObserve(self.percolates, N);

该情况在Algo Project中的PercolateImplementationViewController.m遇到过。

3. 坑及应对

KVO有一些坑需要注意, 否则有时候会产生莫名其妙的bug;

  1. keyPath最好不要用动态输入,如NSStringFromSelector(@selector(accountDomestic))代替@"accountDomestic"

  2. context用来区分本类和super类的通知,例如Person继承自Client(Client还有子类是Company),Client类里已经实现了对Bank某个属性的观察,这个时候在Person里实现响应的时候,应该做如下处理:

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
Person.m
//定义了一个静态常数指针,值为自身的地址,用来表示一个KVO的observer的token
static void *const kPersonConext = &kPersonConext;
@implementation Person
-(void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSString *,id> *)change context:(void *)context{
    //首先判断是不是自身的KVO响应,
    if(context == kPersonConext){
        if([object isKindOfClass:[Bank class]]){
            if([keyPath isEqualToString:NSStringFromSelector(@selector(accountDomestic))]){
                NSString *oldValue = change[NSKeyValueChangeOldKey];
                NSString *newValue = change[NSKeyValueChangeNewKey];
                NSLog(@"--------------------------");
                NSLog(@"accountBalance old value: %@", oldValue);
                NSLog(@"accountBalance new value: %@", newValue);
            }else if([keyPath isEqualToString:NSStringFromSelector(@selector(accountForeign))]){
                NSString *oldValue = change[NSKeyValueChangeOldKey];
                NSString *newValue = change[NSKeyValueChangeNewKey];
                NSLog(@"--------------------------");
                NSLog(@"accountForeign old value: %@", oldValue);
                NSLog(@"accountForeign new value: %@", newValue);
            }
        }
    }else{//不是则调用父类。
        [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
    }
}

static void *const kPersonConext = &kPersonConext用来作为本类observer的token,区分父类。

  1. 在”响应”里-(void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSString *,id> *)change context:(void *)context,由于没有selector参数,因此所有的响应都要写在这个函数的实现里,逻辑比较混乱,要多注意。

4. 总结

KVO是Cocoa对于Observer Pattern的内置实现,需要我们通过”订阅”-”响应“-”取消订阅“来设置自动, 手动或者值依赖KVO。KVO的API有一些坑需要注意,比如keyPath的选择,context的用法, ”响应“的混乱逻辑等。对于KVO的isa swizzle实现原理,我们将在后面OC Runtime系列会详细介绍。

5. 参考资料


Share Post

Twitter Google+

Shunmian

The only programmers in a position to see all the differences in power between the various languages are those who understand the most powerful one.