导语:
KVO全称Key Value Observing,直译为键值观察。KVO 作为 iOS 中一种强大并且有效的机制,为 iOS 开发者们提供了很多的便利;可以使用 KVO 来检测对象属性的变化、快速做出响应,这能够为开发者在开发强交互、响应式应用以及实现视图和模型的双向绑定时提供大量的帮助。
Demo源码见KVODemo,主要从以下5个方面来探究KVO:
- KVO基本使用
- KVO触发模式
- KVO属性依赖
- KVO基本原理
- KVO容器观察
1. KVO基本使用
1.1 使用KVO分为三个步骤:
- 通过
addObserver:forKeyPath:options:context:
方法注册观察者,观察者可以接收keyPath属性的变化事件。 - 在观察者中实现
observeValueForKeyPath:ofObject:change:context:
方法,当keyPath属性发生改变后,KVO会回调这个方法来通知观察者。 - 当观察者不需要监听时,可以调用
removeObserver:forKeyPath:
方法将KVO移除。 需注意调用removeObserver需要在观察者消失之前,否则会导致Crash。
1.2 addObserver方法
在注册观察者时,可以传入下列参数:
-
Observer参数,观察者对象。
-
keyPath参数,需要观察的属性。由于是字符串形式,传错容易导致Crash。一般利用系统的反射机制NSStringFromSelector(@selector(keyPath))。
-
options参数,参数是一个枚举类型。
NSKeyValueObservingOptionNew
接收新值,默认为只接收新值
NSKeyValueObservingOptionOld
接收旧值
NSKeyValueObservingOptionInitial
在注册时立即接收一次回调,在改变时也会发送通知
NSKeyValueObservingOptionPrior
改变之 前发一一次,改变之后发-一次 -
Context参数,传入任意类型的对象,在接收消息回调的代码中可以接收到这个对象,是KVO中的一种传值方式。
注意:调用addObserver方法后,KVO并不会对观察者进行强引用,所以需要注意观察者的生命周期,否则会导致观察者被释放带来的Crash。
1.3 监听回调
观察者需要实现observeValueForKeyPath:ofObject:change:context:方法,如果没有实现会导致Crash。
里面参数:
-
keyPath
: 监听属性名称 -
Object
: 被观察对象 -
Change
: 字典,字典中存放KVO属性相关的值,根据options时 传入的枚举来返回。 -
Context
: 传入进来的上下文,一般在添加观察者时,留下一个入口,用于传值。
Demo如下:
NS_ASSUME_NONNULL_BEGIN
@interface KVOModel : NSObject
@property (nonatomic, strong) NSString* name;
@end
NS_ASSUME_NONNULL_END
有个class为KVOModel,需要对类中的name属性进行监听
@interface ViewController ()
@property (nonatomic, strong) KVOModel* model;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
_model = [KVOModel new];
// 注册
[_model addObserver:self forKeyPath:NSStringFromSelector(@selector(name)) options:(NSKeyValueObservingOptionNew) context:nil];
}
/** 监听方法 */
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
{
NSLog(@"%@", change);
}
/** 屏幕touch */
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
static int magicNum;
_model.name = [NSString stringWithFormat:@"name=%d", magicNum++];
}
运行结果:
2018-11-23 00:47:57.035430+0800 KVODemo[40822:260341] {
kind = 1;
new = "name=0";
}
2018-11-23 00:47:58.197815+0800 KVODemo[40822:260341] {
kind = 1;
new = "name=1";
}
2. KVO触发模式
KVO在属性发生改变时的调用是自动的,如果想要手动控制这个调用时机,或想自已实现KVO属性的调用,则可以通过KVO提供的方法进行调用。
@implementation KVOModel
/** 模式调整 */
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key
{
return NO; // 改为手动模式
}
@end
这样在ViewController中改name的值不会进到监听方法中,需要手动调用触发,在更改name地方需要做如下处理:
/** 屏幕touch */
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
static int magicNum;
[_model willChangeValueForKey:NSStringFromSelector(@selector(name))];
_model.name = [NSString stringWithFormat:@"name=%d", magicNum++];
[_model didChangeValueForKey:NSStringFromSelector(@selector(name))];
}
手动模式的好处,可能有一种需求在某些情况下在更改value的时候,不需要通知,有的时候需要通知,这个时候就需要手动模式来处理。
如果把上面代码中对model中的name赋值给注视掉,再次去点击屏幕,会发现还是会进到监听方法中,这种情况下,监听方法调不调用与设置name无关,只是和有没有调用方法willChangeValueForKey:和didChangeValueForKey:有关。
3. KVO属性依赖
在开发过程中,model一般不会那么简单,比如KVOModel中有个Person类的属性,要观察Person属性中的属性变化,就不能上面那样方法进行处理,Person类:
@interface Person : NSObject
@property (nonatomic, assign) int age;
@end
KVOModel改为如下:
@interface KVOModel : NSObject
@property (nonatomic, strong) NSString* name;
@property (nonatomic, strong) Person* person;
@end
在ViewController注册的时候要做如下处理:
// 注册
[_model addObserver:self forKeyPath:@"person.age" options:(NSKeyValueObservingOptionNew) context:nil];
在Person中只有一个age属性,如果还有其他属性,可以在上面注册代码下加入一样的代码,只是更改person.age值。但是如果是Person中的属性很多很多,每个属性更改都要通知观察者,这样写就比较麻烦,这个时候就要通过属性依赖进行处理。需要重写KVOModel中类方法:
+ (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key
{
NSSet* keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
if ([key isEqualToString:@"person"])
{
keyPaths = [[NSSet alloc] initWithObjects:@"_person.age", @"_person.name", nil];
}
return keyPaths;
}
在ViewController注册,只要监听person属性就可以
[_model addObserver:self forKeyPath:@"person" options:(NSKeyValueObservingOptionNew) context:nil];
这样更改person对象的属性值,都会通知到观察者KVOModel,进入到监听方法。
4. KVO基本原理
KVO是通过观察属性的set方法,但是前面demo中不设置属性值,只要调用willChangeValueForKey:和didChangeValueForKey:两个方法也会触发通知,这个可以通过demo验证,比如说KVOModel中有个成员变量value,直接更改value的值看效果。
@interface KVOModel : NSObject
{
@public
int value;
}
@end
注册时keyPath为value,然后去更改值
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
static int magicNum;
_model->value = magicNum++;
}
会发现监听方法没有调用到,KVO实际上还是通过观察属性set的方法达到目的。 那如何当调用KVOModel类对象的set方法能够观察到,首先会想到两种方法,一种是分类去设计,另一种是通过继承,子类实现。
分类实现
可以直接在分类中重写属性对应的set方法,然后在分类set方法去通知外部,但是这种会有弊端,有的时候会有种需求,会重写set方法,然后加上自己的业务,如果KVO通过分类实现,就会覆盖掉原来的set方法,业务逻辑就永远调用不到,这种框架设计就会有问题。
子类实现
KVO底层实现通过子类实现,需要以下步骤:
- 创建一个子类,例如KVOModel,子类名字就会叫做NSKVONotifying_KVOModel。
- 重写set方法,例如观察name,底层就会重写setName方法。
- 外界改变isa指针。
可以通过查看KVOModel验证下系统的实现,在KVOModel创建后查看下对应的信息,如下:
KVOModel对象信息.png 调用完addObserver,再次查看KVOModel对象的信息,如下: KVOModel对象信息.pngisa就会被改为NSKVONotifying_KVOModel,这个肯定是在调用addObserver方法中创建了这个子类,苹果的KVO没有开源,网络上有基于
GNU开源
的代码,会有共通之处,可以查看参考。我的另一篇简书文章《自定义KVO》大致根据原理模拟实现了一个简单的KVO。
5. KVO容器观察
如果KVOModel中有个容器属性,这需要怎么观察到容器中数据改动。
@property (nonatomic, strong) NSMutableArray* arrayValue;
同样通过上述注册方法对arrayValue进行观察,然后每次点击屏幕的时候都给arrayValue添加一个元素,如下:
[_model.arrayValue addObject:@"1"];
会发现回调方法不会触发,这个由于KVO观察的是set方法,这边容器是add,所以就不会触发,KVO给开发者提供了mutableArrayValueForKey去拿容器对象,然后再调用add,这个时候就会观察到元素改变:
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
NSMutableArray* tmpArray = [_model mutableArrayValueForKey:@"arrayValue"];
[tmpArray addObject:@"1"];
}
通过打断点,查看tmpArray信息,刚开始定义的时候结构如下:
tmpArray信息.png
通过mutableArrayValueForKey方法获取赋值,再次查看tmpArray的结构信息,如下:
tmpArray信息.png
tmpArray类型改变了,很明显是个子类,所以应该是系统在子类中重写了add方法,然后调用willChangeValueForKey:和didChangeValueForKey:两个方法通知外部达到目的。
6. 结尾
苹果的KVO技术给我们提供了方便,但是用不适当,可能就会出现crash,观察Demo中,在初始化地方注册,然后用其他方法来监听,明显是分开的,如果代码量很大的时候,这种方式就比较不好,可读性就比较差点,并且在dealloc的时候,一定要remove监听,必须要一一对应,如果注册多了或者remove多了,都会crash,针对KVO这点缺点,可以对其进行封装,比如RAC(函数式响应式编程)框架,在github中有个开源框架ReactiveCocoa,函数式就是AFN,KVO封装可以结合Block去做,addObserver:forKeyPath:options:context:
调用的时候就没有必要传self,因为通过block的时候,就不用根据self去调用监听方法observeValueForKeyPath:ofObject:change:context:
逻辑直接就是调用block,这样也就不用在dealloc的时候去remove观察,很方便使用。
网友评论