KVO详解(一)

2021/03/16 16:17
阅读数 64

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


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

https://developer.apple.com/library/archive/navigation/

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

文档如下:


KVO初探


KVO三部曲


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


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

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

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

- (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,那么在响应所观察的变化的时候就需要通过keyPathkeyPath来共同确定变化的来源,如下:

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


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

#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类:

@interface LVStudent : LVPerson
+ (instancetype)shareInstance;
@end


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

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


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

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


响应如下:

- (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就是其中之一,也就是说,studentperson这两个实例对象的很多内部属性都是相同的,那么我怎么就知道这里监听到的"name"的变化是studentname属性的变化还是personname属性的变化呢?答案是通过object来确定变化来源自哪个对象


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


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

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


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


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

+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key;

该方法的定义如下:


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

  • 下面👇这几个方法是触发KVO通知的源头。

  1. -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呢?前面我们已经说了,是通过如下的几个方法:

-willChangeValueForKey:/-didChangeValueForKey:-willChange:valuesAtIndexes:forKey:-didChange:valuesAtIndexes:forKey:-willChangeValueForKey:withSetMutation:usingObjects:/-didChangeValueForKey:withSetMutation:usingObjects:


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


多因素复合观察


其核心方法是:

+ (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key


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


@interface LVPerson : NSObject
@property (nonatomic, copy) NSString *downloadProgress;@property (nonatomic, assign) double writtenData;@property (nonatomicassigndouble totalData;
@end

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


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

+ (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的变化了:

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


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


观察可变数组的变化


@interface LVPerson : NSObject
@property (nonatomicstrongNSMutableArray *dateArray;
@end

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


添加观察者的代码如下:

self.person  = [LVPerson new];
self.person.dateArray = [NSMutableArray arrayWithCapacity:1];
[self.person addObserver:self forKeyPath:@"dateArray" options:(NSKeyValueObservingOptionNew) context:NULL];


响应变化的代码如下:

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


外界的变化的写法如下:

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

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

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


只有KVC才能触发KVO


看下面这个例子。


@interface LVPerson : NSObject {    @public    NSString *nickName;}
@property (nonatomic, copy) NSString *name;
@end

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


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

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


改变namenickName的值:

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.classLVPerson,而object_getClassName(self.person)NSKVONotifying_LVPerson。这个NSKVONotifying_LVPerson就是生成的中间类。这也就验证了,在被KVO观察之后,实例对象的isa指针就被修改了


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


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


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

- (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);}


验证代码如下:

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指针会被改变,指向一个动态生成的新的类,这个新的类继承自原类。


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


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

- (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***********");}


外界调用:

[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类中的几个方法:

setName:-0x10f8317aeclass-0x10f830271dealloc-0x10f82ffd6_isKVOA-0x10f82ffce

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


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


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

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

  1. -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又被指回最初的原类了,那么那个中间子类是否会被销毁呢?答案是不会的。一旦中间子类被创建了,那么他将会一直存在缓存中,即便观察者已经被移除


以上。

本文分享自微信公众号 - iOS小生活(iOSHappyLife)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

展开阅读全文
加载中

作者的其它热门文章

打赏
0
0 收藏
分享
打赏
0 评论
0 收藏
0
分享
返回顶部
顶部
返回顶部
顶部