前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >KVO详解(一)

KVO详解(一)

作者头像
拉维
发布2021-03-25 14:52:52
6860
发布2021-03-25 14:52:52
举报
文章被收录于专栏:iOS小生活iOS小生活

我在之前的文章iOS开发中的设计模式--观察者模式中有介绍过KVO的简单使用,大家可以先去了解一下。今天呢,我们来详细分析下KVO。

跟KVC的分析一样,我们首先去查看一下KVO的官方文档,打开如下网址:

代码语言:javascript
复制
https://developer.apple.com/library/archive/navigation/

然后输入“Key value observing”关键字,就可以找到KVO的官方文档了:

文档如下:

KVO初探

KVO三部曲

我们知道,实现一个KVO有三个步骤:添加观察者、响应观察到的变化、移除观察者。

我们先来看看如何添加一个观察者。现在我想观察student对象的name属性变化:

代码语言:javascript
复制
[self.student addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];

我们点进addObserver方法去看看其定义:

代码语言:javascript
复制
- (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context;

我们需要注意第四个参数context,很多同学在写代码的时候会直接将其赋值为nil,实际上,这是错误的!我们在定义中可以看到,context的类型是void *,这是一个C语言中的指针类型,而C语言中的空指针是使用NULL来表示的。nil表示的是OC中的实例对象的空指针。关于这块内容的详细对比说明,可以查看我之前的文章OC中的nil、Nil、NULL、NSNull的区别

这个context是干什么用的呢?我们来看一下文档说明:

通过文档说明我们可以得知,context实际上是一个确定更改通知来源的标识,如果将其设置为NULL,那么在响应所观察的变化的时候就需要通过keyPath和keyPath来共同确定变化的来源,如下:

代码语言:javascript
复制
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    // 在这里响应所观察的属性的变化
    // 如果在添加观察者的时候将context设置为NULL,那么在这里就需要通过keyPath和object共同来确定变化的来源
}

通过keyPath和object来确定变化的来源其实是不优雅的。比如现在有一个LVPerson类:

代码语言:javascript
复制
#import <Foundation/Foundation.h>
@class LVStudent;

NS_ASSUME_NONNULL_BEGIN

@interface LVPerson : NSObject

@property (nonatomic, copy) NSString *name;
@property (nonatomic, copy) NSString *nick;
@property (nonatomic, copy) NSString *downloadProgress;
@property (nonatomic, assign) double writtenData;
@property (nonatomic, assign) double totalData;
@property (nonatomic, strong) NSMutableArray *dateArray;
@property (nonatomic, strong) LVStudent *st;

@end

还有一个LVStudent类,他继承自LVPerson类:

代码语言:javascript
复制
@interface LVStudent : LVPerson

+ (instancetype)shareInstance;

@end

而我在某个VC中定义了这两个类的属性:

代码语言:javascript
复制
@interface LVViewController ()

@property (nonatomic, strong) LVPerson  *person;
@property (nonatomic, strong) LVStudent *student;

@end

我在这个VC中观察student对象的name属性的变化:

代码语言:javascript
复制
[self.student addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];

响应如下:

代码语言:javascript
复制
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    // 在这里响应所观察的属性的变化
}

此时,由于我在添加观察者的时候将contxt设置为NULL了,所以我需要通过keyPath来确定变化的来源,只有当keyPath与字符串"name"匹配的时候才会响应;此时还有一个极大的问题,由于LVStudent是继承自LVPerson,因此LVStudent会拥有LVPerson的所有属性,name就是其中之一,也就是说,student和person这两个实例对象的很多内部属性都是相同的,那么我怎么就知道这里监听到的"name"的变化是student中name属性的变化还是person中name属性的变化呢?答案是通过object来确定变化来源自哪个对象

然后我就第一层if-else来判断变化是来自哪一个对象;第二层if-else来判断变化是来自对象中的哪一个变量。这样的写法能实现功能,但是也有很多问题:多层if-else嵌套的写法不优雅,代码可读性较差,增加编译时间

因此,苹果是建议我们使用context来标识变化的来源,这样会更加安全、更加方便、更加优雅。至于如何使用context来标识变化来源,可以去参考苹果官方文档,地址如下:

代码语言:javascript
复制
https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/KeyValueObserving/Articles/KVOBasics.html#//apple_ref/doc/uid/20002252-SW4

文档很详细,我这里摘录一下:

接下来聊聊KVO三部曲中的最后一曲:移除观察者。一定不要切记,观察者务必在销毁的时候记得移除

举个例子,A页面跳转到B页面,A、B页面中都有一个对象student,该student是同一个的单例对象。我在A、B页面都通过KVO监听了student单例对象的name属性的变化,然后分别进行了响应。现在我从A页面跳转到B页面,此时student单例对象的name属性的变化就有A和B两个观察者了,然后我返回A,但是在B的dealloc中并没有移除KVO的观察。返回到A页面后,针对student单例对象的name属性的变化,仍旧有A和B两个观察者,然后我在A页面改变了student单例对象的name属性的值,此时在A页面的观察和响应都没有问题,但是此时观察者B已经被销毁了,因此再使用原来的B的指针去找对应的相应方法,就会导致野指针调用,程序就会崩溃

因此,在观察者被销毁的时候,一定要移除对应的KVO

控制是否自动发送变化通知

其核心方法是下面的方法:

代码语言:javascript
复制
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key;

该方法的定义如下:

我们看到,上面有一段注释,根据这段注释我们可以分析出如下要点:

  • 下面?这几个方法是触发KVO通知的源头。
代码语言:javascript
复制
-willChangeValueForKey:/-didChangeValueForKey:
-willChange:valuesAtIndexes:forKey:-didChange:valuesAtIndexes:forKey:
-willChangeValueForKey:withSetMutation:usingObjects:/-didChangeValueForKey:withSetMutation:usingObjects:
  • 当类的实例对象接收到KVC消息时,如果你想要自动调用上面的几个方法,进而自动触发KVO通知的话,那么就在+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key方法中返回YES。默认就是返回YES的,这也就解释了为什么默认情况下KVC能够自动触发KVO 。
  • 如果你不想自动发送KVO通知,那么就应该在+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key方法中返回NO。

如果我们想要手动触发KVO,那么就需要在被观察的类里面复写+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key方法,然后返回NO。

这还不算完,你此时只是禁掉了KVO通知的自动触发,但是你还没有手动触发KVO啊,那么如何手动触发KVO呢?前面我们已经说了,是通过如下的几个方法:

代码语言:javascript
复制
-willChangeValueForKey:/-didChangeValueForKey:
-willChange:valuesAtIndexes:forKey:-didChange:valuesAtIndexes:forKey:
-willChangeValueForKey:withSetMutation:usingObjects:/-didChangeValueForKey:withSetMutation:usingObjects:

接下来我以一个例子来进行说明:

多因素复合观察

其核心方法是:

代码语言:javascript
复制
+ (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key

我们通过一个例子来进行简单介绍。

代码语言:javascript
复制
@interface LVPerson : NSObject

@property (nonatomic, copy) NSString *downloadProgress;
@property (nonatomic, assign) double writtenData;
@property (nonatomic, assign) double totalData;

@end

LVPerson类中有3个属性。writtenData和totalData分别表示已经下载下来的文件大小、总共需要下载的文件大小,这两个属性都是动态变化的。downloadProgress表示总的下载比例,它是根据writtenData/totalData计算得来。现在的要求是在writtenData和totalData改变的时候,downloadProgress也会动态改变,实现如下。

在被观察的类LVPerson中实现keyPathsForValuesAffectingValueForKey方法,如下:

代码语言:javascript
复制
+ (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key{
    NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
    // downloadProgress是被影响的因素
    if ([key isEqualToString:@"downloadProgress"]) {
        // affectingKeys记录影响downloadProgress的所有子因素的集合
        NSArray *affectingKeys = @[@"totalData", @"writtenData"];
        keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];
    }
    
    return keyPaths;
}

然后在外界给LVPerson实例添加一个变量downloadProgress的观察者,就可以在totalData或者writtenData发生变化的时候,观察到downloadProgress的变化了:

代码语言:javascript
复制
[self.person addObserver:self forKeyPath:@"downloadProgress" options:(NSKeyValueObservingOptionNew) context:NULL];
代码语言:javascript
复制
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    // 在这里响应所观察的属性的变化
    // 如果在添加观察者的时候将context设置为NULL,那么在这里就需要通过keyPath和object共同来确定变化的来源
    NSLog(@"LVViewController :%@",change);
}

观察可变数组的变化

代码语言:javascript
复制
@interface LVPerson : NSObject

@property (nonatomic, strong) NSMutableArray *dateArray;

@end

LVPerson类中有一个可变数组属性dateArray。

添加观察者的代码如下:

代码语言:javascript
复制
self.person  = [LVPerson new];

self.person.dateArray = [NSMutableArray arrayWithCapacity:1];

[self.person addObserver:self forKeyPath:@"dateArray" options:(NSKeyValueObservingOptionNew) context:NULL];

响应变化的代码如下:

代码语言:javascript
复制
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    // 在这里响应所观察的属性的变化
    // 如果在添加观察者的时候将context设置为NULL,那么在这里就需要通过keyPath和object共同来确定变化的来源
    NSLog(@"LVViewController :%@",change);
}

外界的变化的写法如下:

代码语言:javascript
复制
// 这里不能触发KVO
[self.person.dateArray addObject:@"1"];

// 只有KVC才能触发KVO
[[self.person mutableArrayValueForKey:@"dateArray"] addObject:@"2"];

其中,[self.person.dateArray addObject:@"1"];是不能触发KVO的,因为它没有走到KVC的方法。而KVO是建立在KVC的基础上的,所以,对于可变数组类型的属性,要使用如下方式进行监听:

代码语言:javascript
复制
// 只有KVC才能触发KVO
[[self.person mutableArrayValueForKey:@"dateArray"] addObject:@"2"];

只有KVC才能触发KVO

看下面这个例子。

代码语言:javascript
复制
@interface LVPerson : NSObject {
    @public
    NSString *nickName;
}

@property (nonatomic, copy) NSString *name;

@end

LVPerson类里面定义了一个实例变量nickName,和一个属性name。

给LVPerson类的实例对象self.person添加观察者,监听name和nickName的变化:

代码语言:javascript
复制
self.person  = [LVPerson new];

[self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];
[self.person addObserver:self forKeyPath:@"nickName" options:(NSKeyValueObservingOptionNew) context:NULL];

改变name和nickName的值:

代码语言:javascript
复制
self.person.name  = @"lavie";
self.person->nickName = @"norman";

运行后,经过验证,只能观察到self.person.name属性的变化,self.person->nickName的变化是兼听不到的。原因就在于,self.person.name属性的变化是走了Setter方法,这是KVC的演变,是可以监听到的;而self.person->nickName的变化是直接修改成员变量值,因此KVO是兼听不到的。

KVO的实现细节

动态生成中间类

在KVO的官方文档(https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/KeyValueObserving/Articles/KVOImplementation.html#//apple_ref/doc/uid/20002307-BAJEAIEE)中,我们可以找到下面这一个章节,该章节用一段话讲述了KVO实现的细节,如下:

有几个要点我这边概括一下:

  1. KVO键值观测的实现使用了一种被称为 isa-swizzling的技术
  2. 我们知道,isa指针会指向其对应的类对象的内存地址。但是当一个实例对象被使用KVO观测之后,这个被观测的实例对象中的isa指针就会被修改,被修改后的isa指针就不再指向原来真正的类的内存地址了,而是指向了一个中间类的内存
  3. 因此,决不能使用isa指针来确定实例对象的类,而是使用class方法来确定实例对象的类到底是什么。

接下来我们验证一下。

看下面这几行代码:

我在给self.person实例对象添加KVO观察者之前打了个断点,在给self.person实例对象添加KVO观察者之后也打了个断点。

然后运行程序,当跑到第一个断点处的时候,此时还没有给self.person实例对象添加KVO观察者,我使用llvm指令调试如下:

通过打印结果我们可以知道,此时self.person.classobject_getClassName(self.person)都是LVPerson。

需要说明一下的是,通过object_getClassName(self.person)获取到的就是self.person这个实例对象里面的isa指针指向的那个类;而通过self.person.class获取到的是创建self.person实例对象的那个类。

好,接下来断点往下走,来到添加观察者之后:

此时,self.person.class是LVPerson,而object_getClassName(self.person)是NSKVONotifying_LVPerson。这个NSKVONotifying_LVPerson就是生成的中间类。这也就验证了,在被KVO观察之后,实例对象的isa指针就被修改了

现在再来考虑一个问题,NSKVONotifying_LVPerson这个中间类与真实的类LVPerson有什么关系呢?其实,NSKVONotifying_LVPerson是LVPerson的子类

那么如何来证明NSKVONotifying_LVPerson是LVPerson的子类呢?我们可以在给self.person实例对象添加KVO观察者之前和之后都打印一下LVPerson的子类,通过对比,看看之后是不是比之前多了个NSKVONotifying_LVPerson

获取一个类的所有子类的代码如下:

代码语言:javascript
复制
- (void)printSubClasses:(Class)cls {
    // 获取到当前注册的所有的类的总数
    int count = objc_getClassList(NULL, 0);
    // 创建一个数组,其中包含给定对象
    NSMutableArray *mArray = [NSMutableArray arrayWithObject:cls];
    // 获取所有已注册的类
    Class* classes = (Class*)malloc(sizeof(Class)*count);
    objc_getClassList(classes, count);
    // 将传入类的所有子类筛选出来,存入数组mArray中
    for (int i = 0; i<count; i++) {
        if (cls == class_getSuperclass(classes[i])) {
            [mArray addObject:classes[i]];
        }
    }
    free(classes);
    
    NSLog(@"subClasses = %@", mArray);
}

验证代码如下:

代码语言:javascript
复制
NSLog(@"添加KVO观察者之前");
[self printSubClasses:LVPerson.class];
[self.person addObserver:self forKeyPath:@"dateArray" options:(NSKeyValueObservingOptionNew) context:NULL];
NSLog(@"添加KVO观察者之后");
[self printSubClasses:LVPerson.class];

打印结果如下:

2021-03-16 12:23:40.474829+0800 001---KVO初探[8993:1173846] 添加KVO观察者之前

2021-03-16 12:23:40.601446+0800 001---KVO初探[8993:1173846] subClasses = (

LVPerson,

LVStudent

)

2021-03-16 12:23:40.602012+0800 001---KVO初探[8993:1173846] 添加KVO观察者之后

2021-03-16 12:23:40.606003+0800 001---KVO初探[8993:1173846] subClasses = (

LVPerson,

"NSKVONotifying_LVPerson",

LVStudent

)

我们发现,添加KVO观察者之后确实比之前多了一个NSKVONotifying_LVPerson

中间类中做了什么?

现在我们知道了,当一个实例对象被KVO观察之后,该对象的isa指针会被改变,指向一个动态生成的新的类,这个新的类继承自原类。

那么这个动态类里面做了什么事情呢?我们接下来分析一下。

获取一个类中的所有方法并打印:

代码语言:javascript
复制
- (void)printClassAllMethod:(Class)cls {
    NSLog(@"**********Start***********");
    unsigned int count = 0;
    Method *methodList = class_copyMethodList(cls, &count);
    for (int i = 0; i<count; i++) {
        Method method = methodList[i];
        SEL sel = method_getName(method);
        IMP imp = class_getMethodImplementation(cls, sel);
        NSLog(@"%@-%p",NSStringFromSelector(sel),imp);
    }
    free(methodList);
    NSLog(@"**********End***********");
}

外界调用:

代码语言:javascript
复制
[self printSubClasses:LVPerson.class];
[self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];
[self printSubClasses:LVPerson.class];

[self printClassAllMethod:LVPerson.class];
[self printClassAllMethod:NSClassFromString(@"NSKVONotifying_LVPerson")];

运行之后打印结果如下:

首先,我们对比了给实例对象self.person添加KVO观察者前后LVPerson类的子类列表,发现后比前多了一个NSKVONotifying_LVPerson,这说明新生成的动态中间类就是NSKVONotifying_LVPerson

然后我在给实例对象self.person添加KVO观察者之后,先后打印了LVPersonNSKVONotifying_LVPerson类的方法列表。

通过比较打印出来的LVPersonNSKVONotifying_LVPerson类的方法列表结果,不知道诸位是否有一个疑问:不是说子类可以继承父类所有的方法吗?为什么NSKVONotifying_LVPerson继承自LVPerson,但是LVPerson中的有些方法在NSKVONotifying_LVPerson中却没有打印出来呢

子类可以继承自父类中的所有方法没有错,但是这种继承体现在子类的实例对象可以去调用父类中的方法,在方法查找的过程中通过superClass一层一层往上去找。父类的方法自然是存放在父类的methodlist中,子类的methodlist中是没有的,查找的时候,在子类的methodlist中没找到,就到父类的methodlist去找。这才是所谓的子类继承父类的所有方法的真正含义。

但是,在上面的LVPersonNSKVONotifying_LVPerson类的方法列表结果比较中,我又发现,LVPersonNSKVONotifying_LVPerson类的方法列表结果中都有setName:方法,这又是为什么呢?

这是因为,子类NSKVONotifying_LVPerson中复写了setName:方法。如果一个子类复写了父类中的某个方法,那么在子类和父类的methodlist中都有该方法,只不过在方法查找过程中先在子类的methodlist中找到了该方法,找到之后就不再往上继续查找了而已

我们现在来看一下NSKVONotifying_LVPerson类中的几个方法:

代码语言:javascript
复制
setName:-0x10f8317ae
class-0x10f830271
dealloc-0x10f82ffd6
_isKVOA-0x10f82ffce

我发现,除了_isKVOA之外,其余的三个方法都是自父类中继承而来的方法,所以,我现在知道了,NSKVONotifying_LVPerson类对setName、class、dealloc这三个方法都进行了重写。

前面我不是有提到,要通过对象的class方法来获取对象的类,而不是通过isa指针:通过isa指针有可能会获取到中间的类,而通过class方法获取到的,肯定是最初创建该实例对象的那个类。为什么通过class就能获取到最初的那个类呢?这里就解释了原因了,因为在动态子类中对class方法进行了重写,它指向的就是动态子类的父类,即最初的那个类

除了重写class方法之外,NSKVONotifying_LVPerson类中还重写了setName,大家可以想一下,为什么需要重写setName?

我们根据前面的分析也已经知道了,真正触发KVO的源头是下面这几个方法:

代码语言:javascript
复制
-willChangeValueForKey:/-didChangeValueForKey:
-willChange:valuesAtIndexes:forKey:-didChange:valuesAtIndexes:forKey:
-willChangeValueForKey:withSetMutation:usingObjects:/-didChangeValueForKey:withSetMutation:usingObjects:

所以我猜想,NSKVONotifying_LVPerson类中重写setName的原因可能就是加上了-willChangeValueForKey:/-didChangeValueForKey:,从而触发KVO

isa的指回以及动态子类的销毁

在某个对象被KVO观测之后,该对象的isa指针会被修改。那么,这个isa指针的修改会被一致保留吗?isa指针被修改了之后会再被改回来吗?

答案是会的。当我们为对象移除了KVO观察之后,该对象的isa指针就会恢复最初始的样子了

一般而言,我们都会在观察者的dealloc方法中移除该观察者观察的所有的对象。为了测试,我暂且不移除,并且在dealloc方法的最后打个断点,当走到断点处的时候,我再使用llvm指令获取被观测对象的isa指向,如下:

这说明,如果没有移除观察者,那么被观测对象的isa指针将永远指向动态中间类

然后我们再来看一下移除了观察者的情形:

这说明,移除了观察者之后,会再次调整被观测对象的isa的指向,将其指向最初的原类

现在在考虑一个问题,既然isa又被指回最初的原类了,那么那个中间子类是否会被销毁呢?答案是不会的。一旦中间子类被创建了,那么他将会一直存在缓存中,即便观察者已经被移除

以上。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2021-03-16,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 iOS小生活 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档