KVO

Key-value observing是一种机制,允许对象在其他对象的指定属性发生更改时得到通知。它对于应用程序中 模型层 和 控制器层 之间的通信特别有用。

控制器对象 通常观察 模型对象 的属性,视图对象 通过 控制器 观察 模型对象 的属性。除此之外,模型对象 可以观察其他 模型对象(通常用于确定从属值何时更改),甚至可以观察自身(再次确定从属值何时更改)。

要使用KVO,首先必须确保所观察的对象是符合KVO的(实现NSKeyValueObServing协议)。通常,如果你的对象继承自NSObject,并且您以通常的方式创建属性,则您的对象及其属性将自动符合KVO标准。也可以手动实现遵从性。KVO遵从性描述了 自动 和 手动键值观察之间的区别,以及如何实现这两者。

只有在.h中暴露出来的属性,才能被KVO监听到,.m中的属性,是不能够被监听到。

KVO的基本使用

定义被观察的对象:

1
2
3
4
5
6
7
@interface MyModel : NSObject

@property (nonatomic, assign)CGFloat amount;
@property (nonatomic, strong)NSMutableArray *arr;
@property (nonatomic, strong)NSMutableDictionary *dict;

@end

注册观察者进行监听操作:

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
// 定义context标识通知的来源
static void *VCMyPersonAmountContext = &VCMyPersonAmountContext;

- (void)viewDidLoad {
[super viewDidLoad];

self.model = [[MyModel alloc] init];

self.model.amount = 100;

self.model.amount = 200;

/// 1、向被观察对象注册观察者
/// @param observer 观察者
/// @param keyPath 被观察属性的Key-Path
/// @param options 影响接收到通知中的change字典的内容 和 生成通知的方法。
/// New 和 Old 表示通知内容中包含新值和旧值;
/// Initial 表示被观察的对象在调用 addObserver 注册观察者方法返回之前立即发送通知,使得观察者能够获取被观察对象属性的初始值
/// Prior 表示被观察对象在属性更改前会发送一个预更改通知,然后再发送更改的常规通知。预更改通知的字典内容包括notificationIsPrior = 1。
/// @param context 表示在属性更改通知中传递给观察者的数据。
/// 通常,把context=nil,并在接受通知的方法里通过 keyPath 来确定通知的来源,但这会导致观察者的父类观察到相同的键路径而产生问题。因此,必要时可以通过定义 context 来确定通知的来源。
/// 注意:addObserver:... 方法不会对观察者、被观察对象、context进行强引用
[self.model addObserver:self forKeyPath:@"amount" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld | NSKeyValueObservingOptionPrior context:VCMyPersonAmountContext];
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
self.model.amount = 102.23;
}

/// 2、在观察者内部实现接收被观察对象属性发生更改的通知的方法
/// @param keyPath 触发通知的keyPath
/// @param object 被观察的对象
/// @param change 属性发生更改的信息,如果属性是对象,则直接提供值。如果属性是标量或C结构体,则值被包装在NSValue对象中。这和KVC一致。
/// @param context 注册观察者时提供的context
- (void)observeValueForKeyPath:(nullable NSString *)keyPath ofObject:(nullable id)object change:(nullable NSDictionary<NSKeyValueChangeKey, id> *)change context:(nullable void *)context {

if (context == VCMyPersonAmountContext) {
NSLog(@"change = %@", change);

/*
1、当注册观察者的 options 参数为 Initial 时:
注册观察者前就打印输出:change = { "kind" : 1, "new" : 200 }
点击touch后更改通知: change = { "kind" : 1, "old" : 200, "new" : 102.23 }

2、当注册观察者的 options 参数为 Prior 时:
点击 touch 会打印输出两次:
先预更改通知:change = { "kind" : 1, "old" : 200, "notificationIsPrior" : true }
接着常规通知:change = { "kind" : 1, "old" : 200, "new" : 102.23 }
*/

} else {
// 当无法识别通知时,意味着超类也已经注册了通知,需要调用父类的方法
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}

}

// 3、移除观察者
// 当观察者不需要接收通知时,至少需要在它释放之前,移除观察者
- (void)dealloc{
// 如果删除未注册的观察者,则会导致NSRangeException异常
[self.model removeObserver:self forKeyPath:@"amount" context:VCMyPersonAmountContext];
}

KVO的合规性

为了让类特定属性是符合KVO,类必须确保以下各项:

  • 该类必须使得该属性支持KVC
  • 该类为更改属性发出KVO通知。
  • 当有依赖关系的时候,注册合适的依赖键。

更改通知

有两种技术可以确保发出更改通知:

  • 自动更改通知,由NSObject提供,默认情况下,它适用于符合KVC类的所有属性。通常,如果遵循标准的Cocoa编码和命名约定,就可以使用自动更改通知,而不必编写任何其他代码。
  • 手动更改通知,它提供了对何时发出通知的额外控制,需要额外的编码,通过实现类方法automaticallyNotifiesObserversForKey:可以控制子类属性的自动通知。

自动更改通知

NSObject提供了自动键值更改通知的基本实现。自动键值更改通知 通知观察者 使用 符合Key-Value访问器 以及 KVC方法所做的更改。

通过如下方法更改被观察者的属性的值,都会发送通知:

1
2
3
4
5
6
[account setName:@"Savings"];

[account setValue:@"Savings" forKey:@"name"];

NSMutableArray *transactions = [account mutableArrayValueForKey:@"transactions"];
[transactions addObject:newTransaction];

手动更改通知

通过手动通知你可以控制特定属性通知的过程,例如:尽量减少因特定原因而不必要触发通知,或者将多个更改分组为单个通知。

手动通知和自动通知不是互斥的,可以同时存在,在这种情况下,通过重写NSObjectautomaticallyNotifiesObserversForKey:类方法来实现。

如下代码实现手动更改通知:

1
2
3
4
5
6
7
@interface MyBus : NSObject

@property (nonatomic, copy)NSString *name;
@property (nonatomic, assign)NSInteger numberID;
@property (nonatomic, strong)NSArray *arr;

@end
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
@implementation MyBus

// 根据 key 让特定的属性采用手动通知
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key {
BOOL automatic = NO;
if ([key isEqualToString:@"arr"]) {
automatic = NO;
} else if ([key isEqualToString:@"numberID"]) {
automatic = NO;
} else {
// 为任何未识别的键调用super
automatic = [super automaticallyNotifiesObserversForKey:key];
}
return automatic;
}

// 在值发生更改时实现手动观察器通知
- (void)setArr:(NSMutableArray *)arr {
if (![arr isEqual:@[]]) {
[self willChangeValueForKey:@"arr"];
_arr = arr;
[self didChangeValueForKey:@"arr"];
}
}

// 如果一个操作导致多个键发生更改,则必须嵌套更改通知
- (void)setNumberID:(NSInteger)numberID {
[self willChangeValueForKey:@"numberID"];
[self willChangeValueForKey:@"name"];
_numberID = numberID;
_name = [NSString stringWithFormat:@"%@_%ld", _name, numberID];
[self didChangeValueForKey:@"name"];
[self didChangeValueForKey:@"numberID"];
}

@end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 1、注册观察者
self.bus = [[MyBus alloc] init];
self.bus.name = @"新能源公交车";
[self.bus addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:nil];
[self.bus addObserver:self forKeyPath:@"arr" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:nil];
[self.bus addObserver:self forKeyPath:@"numberID" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:nil];

// 更改监听属性的值:
self.bus.arr = @[];
self.bus.numberID = 100;

// 2、接收通知
- (void)observeValueForKeyPath:(nullable NSString *)keyPath ofObject:(nullable id)object change:(nullable NSDictionary<NSKeyValueChangeKey, id> *)change context:(nullable void *)context {
NSLog(@"%@, %@", keyPath, change);
// 打印结果:
// name, { kind = 1; old = 新能源公交车; new = 新能源公交车_100; }
// numberID, { kind = 1; old = 0; new = 100; }
}

注册依赖键

在许多情况下,一个属性的值取决于另一对象中一个或多个其他属性的值。如果一个属性的值发生更改,则派生属性的值也应标记为更改。如何确保为这些依赖属性发布KVO通知取决于关系的基数。

一对一关系

为了让一对一关系自动触发通知,需要重写 keyPathsForValuesAffectingValueForKey: 或者 实现一个合适的方法,该方法遵循它为 注册依赖键 定义的模式。

1
2
3
4
5
6
@interface MyCar : NSObject

@property (nonatomic, copy)NSString *name;
@property (nonatomic, assign)CGFloat price;

@end
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
@interface MyToy : NSObject

@property (nonatomic, strong)MyCar *car;
@property (nonatomic, copy)NSString *info;

@end

// 定义依赖键的方法
- (NSString *)info {
return [NSString stringWithFormat:@"我的玩具车名为:%@,花了我%f元钱", self.car.name, self.car.price];
}

// 指定 info 依赖于 car.name 和 car.price
+ (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key {

// 避免干扰父类中的方法
NSSet *set = [super keyPathsForValuesAffectingValueForKey:key];

if ([key isEqualToString:@"info"]) {
NSArray *affectingKeys = @[@"car.name", @"car.price"];
set = [set setByAddingObjectsFromArray:affectingKeys];
}
return set;
}

// keyPathsForValuesAffecting<Key> 与 keyPathsForValuesAffectingValueForKey: 作用一致,可以替代它
// 当你在 Category 中添加计算属性时,不能重写 keyPathsForValuesAffectingValueForKey:,而需要采用 keyPathsForValuesAffecting<Key>
//+ (NSSet<NSString *> *)keyPathsForValuesAffectingInfo {
// return [NSSet setWithObjects:@"car.name", @"car.price", nil];
//}

@end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
self.toy = [[MyToy alloc] init];
MyCar *car = [[MyCar alloc] init];
car.name = @"赛车";
car.price = 50.5;
self.toy.car = car;

// 1、注册观察者
[self.toy addObserver:self forKeyPath:@"info" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:nil];

// 改变依赖键的值
self.toy.car.name = @"超人赛车";

// 2、接收更改通知

- (void)observeValueForKeyPath:(nullable NSString *)keyPath ofObject:(nullable id)object change:(nullable NSDictionary<NSKeyValueChangeKey, id> *)change context:(nullable void *)context {
NSLog(@"%@, %@", keyPath, change);
// 打印: info, { kind = 1; old = 我的玩具车名为:赛车,花了我50.500000元钱; new = 我的玩具车名为:超人赛车,花了我50.500000元钱; }
}

一对多关系

keyPathsForValuesAffectingValueForKey: 不支持包含一对多关系的key-Path。例如如下例子:一个Department的对象包含 多个Employee的对象,且Department对象的totalSalarys属性依赖多个Employee对象的salary属性的总和。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@interface Employee : NSObject

@property (nonatomic, strong)NSNumber *salary;

- (instancetype)initWithSalary:(NSNumber *)salary;

@end

@interface Department : NSObject

@property (nonatomic, strong)NSArray <Employee *>*employees;
@property (nonatomic, strong)NSNumber *totalSalarys;

@end

不能使用 keyPathsForValuesAffecting<Key> 方法返回 @"employee.salary" 去实现其依赖关系,从而自动发出通知。

当出现 一对多的依赖关系时,有一下两种方法解决:

方法一

将 父级 注册为所有 子级 的观察者。具体实现如下:

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
static void *totalSalaryContext = &totalSalaryContext;

@implementation Department

- (instancetype)init {
if (self = [super init]) {

// 将 父级(Department) 注册为 子级(employees) 的观察者
[self addObserver:self forKeyPath:@"employees" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:totalSalaryContext];
}
return self;
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
if (context == totalSalaryContext) {
// 当 子级(employees) 值发生改变时,更新父级中依赖的属性,并手动发送通知
[self updateTotalSalary];
} else {
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}

- (void)updateTotalSalary {
NSNumber *num = [self valueForKeyPath:@"employees.@sum.salary"];
[self setTotalSalary:num];
}

- (void)setTotalSalary:(NSNumber *)newTotalSalary {

if (_totalSalarys != newTotalSalary) {
[self willChangeValueForKey:@"totalSalarys"];
_totalSalarys = newTotalSalary;
[self didChangeValueForKey:@"totalSalarys"];
}
}

- (NSNumber *)totalSalary {
return _totalSalarys;
}

- (void)dealloc {
[self removeObserver:self forKeyPath:@"totalSalary" context:totalSalaryContext];
}

@end

监听一对多依赖关系中属性的更改通知:

1
2
3
4
5
6
7
8
9
10
11
self.dept = [[Department alloc] init];
[self.dept addObserver:self forKeyPath:@"totalSalarys" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:nil];

// 更改属性
self.dept.employees = @[[[Employee alloc] initWithSalary:@10], [[Employee alloc] initWithSalary:@20]];

// 接收通知
- (void)observeValueForKeyPath:(nullable NSString *)keyPath ofObject:(nullable id)object change:(nullable NSDictionary<NSKeyValueChangeKey, id> *)change context:(nullable void *)context {
NSLog(@"👍👍👍 %@, %@", keyPath, change);
// 打印:👍👍👍 totalSalarys, { kind = 1; old = <null>; new = 30; }
}

方法二

如果您正在使用Core Data,可以向应用程序的notification center注册父对象,作为其托管对象上下文的观察者。父级应以类似于KVO的方式响应子级发布的相关更改通知。

KVO 监听 NSAarry 和 NSSet 集合对象的某个元素

1
2
3
4
5
6
7
8
9
10
11
self.dept.employees = @[[[Employee alloc] initWithSalary:@10], [[Employee alloc] initWithSalary:@20]];

// 注意:监听的元素必须存在,否则会崩溃报错
[self.dept.employees addObserver:self toObjectsAtIndexes:[NSIndexSet indexSetWithIndex:0] forKeyPath:@"salary" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:nil];

self.dept.employees.firstObject.salary = @1000;

- (void)observeValueForKeyPath:(nullable NSString *)keyPath ofObject:(nullable id)object change:(nullable NSDictionary<NSKeyValueChangeKey, id> *)change context:(nullable void *)context {
NSLog(@"👍👍👍 %@, %@", keyPath, change);
// 👍👍👍 salary, { kind = 1; old = 10; new = 1000;
}

KVO的原理

Automatic key-value observing是使用一种称为isa-swizzling的技术实现的。

isa 指针,指向对象的类,该类维护一个调度表。这个调度表实际上包含 指向该类实现的方法的指针 和 其他数据。

当为 对象的属性 注册 观察者 时,将修改 被观察对象 的 isa指针,指向 中间类 而不是 真正类。因此,isa指针的值不一定反映实例的实际类。

决不能依赖isa指针来确定类成员身份。应该使用class方法来确定对象实例的类。

  • 当添加KVO监听一个A类的属性时,系统会在runtime动态地创建该类的一个子类NSKVONotifyig_A,并且在NSKVONotifyig_A中重写基类中任何被观察属性的 setter 方法。

  • 当我们修改A类的一个被观察的属性时,系统其实将A类isa指针指向了子类NSKVONotifyig_A,走NSKVONotifyig_Asetter方法,setter方法随后负责 通知观察对象属性的改变状况 以及 调用A所属类的setter方法,从而激活键值通知机制。

1
2
3
4
5
- (void)setName:(NSString *)newName {
[self willChangeValueForKey:@"name"]; // KVO在调用存取方法之前总调用
[super setValue:newName forKey:@"name"]; // 调用父类的存取方法
[self didChangeValueForKey:@"name"]; // KVO在调用存取方法之后总调用
}

学习博客

Key-Value Observing Programming Guide

Key-Value Coding Programming Guide

iOS大解密:玄之又玄的KVO

sunnyxx * objc kvo简单探索

Runtime窥探 (五)| KVO底层实现

如何优雅地使用 KVO

iOS 底层探索 - KVO

文章作者: Czm
文章链接: http://yoursite.com/2020/09/15/KVO/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Czm