KVO使用进阶和底层原理

KVO使用

KVO(key-value-observing)键值监听常用来监听特定对象中某属性值的变化,日常开发中我们常常监听数据模型的变化从而动态的修改对应视图。当然上述需求用代理和通知机制也可以完成,但它们都有各自的优缺点和适用场景,后面会详细介绍。

常用方法

常用的方法有如下几个,各参数含义见注释:

/*
注册观察者(监听器对象)
观察者对象是observer,被观察者是消息的发送者(方法的调用者),在回调函数中会被回传
观察的属性路径为keyPath,支持点语法的嵌套
观察类型为options,支持按位或来监听多个事件类型
观察上下文context,主要用于对多个观察者对象观察相同keyPath时进行区分
添加观察者只会保留观察者对象的地址,不会增加引用,也不会在对象释放后置空,因此需要自己持有观察者对象的强引用,该参数也会在回调函数中回传
*/
- (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context;

/*
删除观察者
观察者对象为observer,被观察者对象为消息的发送者即方法的调用者,应与addObserver方法匹配
观察的属性路径为keyPath,应与addObserver方法的keyPath匹配
观察上下文context,应与addObserver方法的context匹配
*/
- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath context:(nullable void *)context API_AVAILABLE(macos(10.7), ios(5.0), watchos(2.0), tvos(9.0));

/*
与上一个方法相同,只是少了context参数
推荐使用上一个方法,该方法由于没有传递context可能会产生异常结果
*/
- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;

/*
观察者对象的观察回调方法,被观察对象属性发生变化时,观察者会调用该方法
keyPath即为观察的属性路径
object为被观察的对象
change保存被观察的值产生的变化
context为观察上下文,由add方法回传
*/
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context;

谨记,我们要在对象销毁前删除监听器。这要从KVO注册监听器开始说起。
KVO在注册监听器的时候,不会持有观察者对象的引用,也不会像weak那样,在观察者对象被销毁时置nil,而仅仅保留观察者对象的地址,类似于copy。当观察者对象被销毁而又没有删除监听器时,如果这个时候被观察对象的值发生变化系统会执行监听器的回调函数,这个时候观察者对象已经不存在了,KVO保留的地址就是一个野指针,因此会产生野指针错误。
下面来看一个产生上面情况的例子:

程序界面如图所示


程序界面

操作过程为:先点击紫色按钮,跳转到黄色试图控制器,再点击黑色按钮回到绿色试图控制器,最后点击红色按钮。

// 示例程序共有两个viewController,一个是根控制器viewcontroller,一个是displayViewcontroller。
// 根控制器上有两个按钮,上面的按钮用来跳转到displayViewcontroller。下面的按钮用来改变被观察属性的值。
// displayViewcontroller上有一个按钮,用来退出当前控制器

// viewController.m
- (IBAction)jumpToDisplayButton:(UIButton *)sender {
    HuPerson *person = [[HuPerson alloc] init];// HuPerson是要观察的模型对象
    _person = person;
    HuDisplayViewController *displayVC = [[HuDisplayViewController alloc] initWithModel:person];
    [self presentViewController:displayVC animated:YES completion:nil];
}
- (IBAction)changeAgeButton:(UIButton *)sender {
    _person.age = 20;
}

// displayViewcontroller.m 
-(instancetype)initWithModel:(HuPerson *)person {
    if (self = [super init]) {
        _person = person;
        // 创建监听器
        [_person addObserver:self forKeyPath:@"age" options:NSKeyValueObservingOptionNew context:nil];
    }
    return self;
}
// 监听器的回调方法
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    if (object == self.person && [keyPath  isEqual: @"age"]) {
        NSLog(@"%@", change);
    }
}
// 注释掉该段代码,测试如果不删除监听器会发生什么情况
//- (void)dealloc {
//    [_person removeObserver:self forKeyPath:@"age" context:nil];
//}

- (IBAction)clickButton:(UIButton *)sender {
    [self dismissViewControllerAnimated:self completion:nil];
}

我们在开发中经常会遇到这种需求:在一个页面获取到数据后,使用另一个页面来展示数据,第二个界面很有可能根据需求来监听模型对象。如果我们像上面的代码一样,没有在观察者对象销毁的时候释放监听器,那么在点击viewController第二个按钮的时候,就会产生野指针错误。

因为在第二个按钮的点击方法中,我们改变了被观察对象属性的值,由于前一个视图中没有释放监听器,KVO中仍有监听器的存在,此时会触发监听器的回调方法,但displayViewcontroller已经被销毁了,因此产生野指针。

KVO中一对多 和 多对一

  • KVO支持多个观察者对象观察同一对象的某个属性。上面的例子中,我们在viewController中也添加对person.age的监听。当age属性发生变化的时候,监听器会触发所有监听该属性的回调函数。
  • KVO也支持一个观察者对象观察多个属性。可以按照我们常用的通过keyPath字符串来判断产生回调的具体是哪个属性值,但如果监听很多属性值,这样的方法看起来很凌乱,而且逐一进行字符串判断很浪费资源,并且当我们在后期修改了属性的名称还不能忘记修改监听器的keyPath判断语句,那有什么办法能够取代keyPath吗?答案是context,之前我经常直接将context置为nil,但context才是KVO保证正确运行的关键,context也是苹果推荐我们的做法。

context参数的使用

context是一个id类型的参数,在注册监听器时可以传入该参数,在回调函数中会回传该参数,因此,该参数可以解决KVO中一对多,多对一产生的一些问题。那context这个id类型的参数设置为什么值比较合适呢?可能第一感觉还是设置为NSString类型,但这样仍然可能会产生冲突,苹果推荐的做法是创建一个静态变量然后使用该静态变量的地址作为context,通过这样的方法就能够保证context唯一。
首先来看一个例子:

/*
本例子需要使用三个UIViewController
ViewController是根视图控制器
DisplayViewController 父视图控制器
SubViewController 子视图控制器
ViewController不监听模型,包括一个按钮用于创建SubViewController并展示
DisplayViewController跟上述例子中的一样
SubViewController继承DisplayViewController并且也创建了监听器来监听person.age属性
*/

//ViewController部分代码如下
//该控制器只有一个按钮
- (void)buttonClicked
{
    SubViewController *vc = [[SubViewController alloc] initWithModel:self.person];
    [self presentViewController:vc animated:YES completion:nil];
}

//DisplayViewController的部分代码如下
//为了便于输出这里使用的是NSString类型的context
static void * DisplayViewControllerBalanceObserverContext = @"父类的context";

//在初始化方法中输入上面的变量作为context进行监听器的注册
[self.person addObserver:self forKeyPath:@"age" options:NSKeyValueObservingOptionInitial context:DisplayViewControllerBalanceObserverContext];

//退出按钮方法
- (void)exitButtonClickedHandler
{
    [self dismissViewControllerAnimated:YES completion:nil];
}

//模拟修改模型数据变化的按钮
- (void)changeValueButtonClickedHandler
{
    self.person.age = 110;
}

//监听器回调函数
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
{
    //将void *的context转换为NSString类型
    NSString *d = (__bridge NSString*)context;
    NSLog(@"%@", d);

    if (context == DisplayViewControllerBalanceObserverContext)
    {
        NSLog(@"父类监听的age属性改变了%lf", self.person.age);
    }
}

//删除监听器
- (void)dealloc
{
    [self.model removeObserver:self forKeyPath:@"age" context:DisplayViewControllerBalanceObserverContext];
}

//SubViewController部分代码如下
//为了便于输出使用NSString类型的context
static void * SubViewControllerBalanceObserverContext = @"子类的context";

- (instancetype)initWithModel:(HuPerson *)model;
{
    if (self = [super initWithModel:model])
    {
        //注册监听器
        [self.person addObserver:self forKeyPath:@"age" options:NSKeyValueObservingOptionNew context:SubViewControllerBalanceObserverContext];
    }
    return self;
}

//监听器回调方法
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
{

    NSString *d = (__bridge NSString*)context;
    NSLog(@"%@", d);
    if (context == SubViewControllerBalanceObserverContext)
    {
        NSLog(@"子类监听的age值改变了: %lf", self.person.age);
    }

}

//删除监听器
- (void)dealloc
{
    [self.person removeObserver:self forKeyPath:@"age" context:SubViewControllerBalanceObserverContext];

上述代码运行后,根视图控制器为ViewController展示一个按钮,点击后会创建SubViewController并展示,此时会有两个按钮,一个退出、一个修改模型值,接下来点击修改模型值按钮会发现有如下输出:

子类的context
子类监听的age值改变了:110
父类的context

当我们点击修改模型按钮后会触发监听器的回调函数,然后执行SubViewController的回调方法就会输出上面两行的打印结果,那第三行是什么呢?第三行还是SubViewController的输出结果,但是打印的context却是DisplayViewController注册的,也就是说,KVO在触发回调函数时会向所有注册了的监听器发送回调信息,也就是所有注册了的监听器都会执行回调函数,但由于继承关系的存在没有执行父类的回调函数而是执行了两次子类的回调函数,因此,为了使得父类也能够正确执行监听器的回调函数,在子类的回调函数中应当手动调用,所以子类监听器回调函数正确的写法应是如下代码:

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
{
    if (context == SubViewControllerBalanceObserverContext)
    {
        NSLog(@"SubViewController NewAge: %lf", self.person.age);
    }
    else
    {
        [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
    }
}

这里我们是展示了使用正确使用context会避免很多问题,正如大多数人平常开发的时候那样,如果我们设置context为nil,会发生什么情况呢?

仅仅通过keyPath判断,根本无法得知继承的父类是否也在监听同一对象,如果我们继承的是第三方的框架,很可能就会产生未知的异常。苹果也建议我们针对我们监听的每一个属性都创建一个context,不建议使用keyPath来做字符串的判断,并且字符串判断的效率也很低.

手动触发KVO

有时我们可能有一些需求,在属性值满足要求下才去触发KVO,我们可以直接在回调函数中进行判断就好,但是当我们开发一些供他人使用的框架时我们不能保证其他用户能够按照要求进行条件判断,此时就需要手动触发KVO。

触发监听器回调函数时需要满足一个类方法:

//age属性实现该方法
+ (BOOL)automaticallyNotifiesObserversOfAge

//其他属性按照以下格式实现类方法
+ (BOOL)automaticallyNotifiesObserversOfName

通过函数名就可以判断,该函数是用来判断是否自行进行监听器通知,默认返回true,因此默认情况下都是自动触发KVO的回调函数,如果要手动触发则需要返回false并在需要触发KVO回调函数的地方执行以下方法:

//对需要触发回调函数的属性名称调用如下方法
[self willChangeValueForKey:@"balance"];
//为其赋新值
_balance = balance;
[self didChangeValueForKey:@"balance"];

注意,上面两个函数要写在触发回调函数的前后,即属性的set方法中。

KVO监听NSMutableArray内部的变化

简单理解KVO底层实现原理,其实是重写了观察属性的set方法,详细内容会在下面一节解释。然而对于NSMutableArray来讲,添加、删除元素并没有调用set方法,因此不会触发KVO,我们也就监听不到NSMutableArray的变化,有什么办法呢?
苹果官方为我们提供了一个方法

-(NSMutableArray *)mutableArrayValueForKey:(NSString *)key;

用一个例子来说明该方法的使用:
myItems是我们进行KVO的一个属性,定义如下:

@property(nonatomic, strong) NSMutableArray *myItems;

按照上面所讲的正常方法对其添加观察者,但是在它添加元素时,使用如下方法:

[[self mutableArrayValueForKey:@"myItems"] addObject:@"one"];

这样,我们便用KVO实现了对可变数组的监听。

总结

  • 使用静态变量的地址作为context,并且为每一个监听的属性都创建一个context,尽量不使用keyPath作为区分条件。
  • addObserver与removeObserver必须要成套出现,建议在dealloc方法中删除监听器对象。
  • 如果有继承关系,在监听器回调函数中将不是当前类处理的context调用父类的监听器回调函数进行处理。
  • 删除监听器时需要注意不要重复删除,尽量使用context删除。

KVO底层原理

原理剖析

当对一个对象的属性第一次进行监听器注册后,编译器会默认生成一个名称为NSKVONotifying_原有类名称的派生中间类,该类继承原有类,然后修改原有类对象的isa指针,使其指向新生成的中间类,接着,会在派生类中修改监听属性的setter和getter方法,执行willChangeValueForKey:和didChangeValueForKey:方法和父类的setter方法,并通知所有监听的对象,监听属性被修改了。

因此,对于使用KVO监听的类来说,isa指针的指向并不一定指向对象的实际类。我们不应该依赖isa指针去决定类的成员关系,而应该使用class方法去正确的获取对象的实际类。

实现自定义的KVO监听

此部分后续更新...

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容