阅读 80

iOS探索底层-KVO

前言

在日常的开发过程中,我们经常会用到KVO来进行一些开发,让我们的开发更加简便、直接。那么KVO到底是什么?他在底层的原理是什么呢?我们今天就来探索一下它

概念

KVO的概念描述

KVO 是 Objective-C 对 观察者模式(Observer Pattern)的实现。当被观察对象的某个属性发生更改时,观察者对象会获得通知。一般继承自NSObject的对象都默认支持KVO。KVO是响应式编程的代表。

KVO的作用

  1. 监听带有状态的基础控件,如开关、按钮等;
  2. 监听字符串的改变,当监听的字符串改变时,来做一些自定义的操作;
  3. 当数据模型的数据发生改变时,视图组件能动态的更新,及时显示数据模型更新后的数据,比如tableview中数据发生变化进行刷新列表操作,监听scrollViewcontentOffset属性监听页面的滑动.

简单的使用KVO

我们来看个例子,这是最简单的KVO的使用方法

- (void)viewDidLoad {
    [super viewDidLoad];
    self.person = [DMPerson new];
    [self.person addObserver:self forKeyPath:@"nick" options:NSKeyValueObservingOptionNew context:NULL];
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    self.person.nick = [NSString stringWithFormat:@"%@+",self.person.nick];
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{
    NSLog(@"%@",change);
}

- (void)dealloc {
    [self.person removeObserver:self forKeyPath:@"nick"];
}
复制代码

运行代码并简单点击屏幕点击结果如下 image.png 我们创建了一个DMPerson的实例对象,对它的nick属性进行了观察,当每次nick赋予新值时,就会调用observeValueForKeyPath方法告诉我们。同时我们在touchesBegan方法中给nick赋值,每次我们触摸屏幕,就会进行一次赋值。

KVO中的context

我们在看上面的例子的时候,可以看到addObserver方法需要传递几个参数

  • 第一个是观察者是谁,也就是我们调用谁的observeValueForKeyPath方法
  • 第二个是被观察的属性是什么,一般来说是被观察者的属性
  • 第三个是options也就是我们可以设置的参数,通常有四种
  1. NSKeyValueObservingOptionNew 把更改之前的值提供给处理方法
  2. NSKeyValueObservingOptionOld 把更改之后的值提供给处理方法
  3. NSKeyValueObservingOptionInitial 把初始化的值提供给处理方法,一旦注册,立马就会调用一次。通常它会带有新值,而不会带有旧值。
  4. NSKeyValueObservingOptionPrior 分2次调用。在值改变之前和值改变之后
  • 第四个是context 上下文

其他的我们都很好理解,但是这个context我们平时使用的时候,一般都是直接传NULL,那么他有什么用呢?我相信苹果肯定不会写一些没用的东西放在这里。打开苹果的KVO文档,我们可以看到下面这样一段话

image.png 这里的意思大致就是如果我们传了这个参数,会使性能安全,更加直接,这又是为什么呢?

- (void)viewDidLoad {
    [super viewDidLoad];
    self.person = [DMPerson new];
    [self.person addObserver:self forKeyPath:@"nick" options:NSKeyValueObservingOptionNew context:NULL];
    [self.person addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:NULL];
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    self.person.nick = [NSString stringWithFormat:@"%@+",self.person.nick];
    self.person.name = [NSString stringWithFormat:@"%@*",self.person.name];
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
{
    if (object == self.person) {
        if ([keyPath isEqualToString:@"nick"]) {
            NSLog(@"nick 改变了\n%@",change);
        } else if ([keyPath isEqualToString:@"name"]) {
            NSLog(@"name 改变了\n%@",change);
        }
    }
}
复制代码

我们把上面的例子修改一下,我现在需要同时观察DMPerson对象的两个属性namenick,并且根据不同的属性变化进行不同的操作,在observeValueForKeyPath就会写的比较复杂,我们需要先判断object是不是DMPerson,再判断keyPathnick还是name

image.png 这个时候,我们就可以使用context这个参数

image.png 更改下注册观察者的代码

[self.person addObserver:self forKeyPath:@"nick" options:NSKeyValueObservingOptionNew context:PersonNickContext];
[self.person addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:PersonNameContext];
复制代码

再把回调中的代码判断改下

    if (context == PersonNickContext) {
        NSLog(@"通过context nick 改变了\n%@",change);
    } else if (context == PersonNameContext) {
        NSLog(@"通过context name 改变了\n%@",change);
    }
复制代码

看看结果

image.png 没错,context实际上就是一个标志,让我们能够更加简单,直接的判断是哪个属性的改变回调的。

移除观察者

我们在使用KVO的时候,一般会需要在不需要使用的时候,移除观察者,而这一般都是放在dealloc方法中。那么我们不移除,是不是可以的呢?会有什么问题呢?实践才是唯一真理,我们来试试就知道

- (void)viewDidLoad {
    [super viewDidLoad];
    self.person = [DMPerson new];
    [self.person addObserver:self forKeyPath:@"nick" options:NSKeyValueObservingOptionNew context:NULL];
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    self.person.nick = @"mantou";
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    NSLog(@"DetailViewController :%@",change);
}
复制代码

我们在一个二级页面写下这段代码,然后每次每次进入点击一下屏幕后就退出该页面,重复几次。结果如下

image.png 似乎并没有什么问题,接下来我们修改下代码,观察DMWorker对象的属性改变,DMWorker是一个单例

self.worker = [DMWorker shareInstance];
[self.worker addObserver:self forKeyPath:@"nick" options:NSKeyValueObservingOptionNew context:NULL];
复制代码

同样的操作,来看看结果

image.png 在第二次进入并点击触摸后,应用发生了异常崩溃。为什么呢?我们去苹果官方文档中找到了一段答案

An observer does not automatically remove itself when deallocated. The observed object continues to send notifications, oblivious to the state of the observer. However, a change notification, like any other message, sent to a released object, triggers a memory access exception. You therefore ensure that observers remove themselves before disappearing from memory.

大意是当dealloced时,观察者不会自动删除自己,当被观察者继续发送通知的时候,可能会给已经释放掉的观察者发送消息,最终造成了内存的访问异常。因此,需要保证当观察者被销毁时,将观察者移除。

  • 当我们第一次进入DetailViewController的时候,我们注册了观察者(用D1来作为称呼),然后触摸触发了回调,没有任何问题,接着我们推出页面
  • 当我们第二次进入DetailViewController的时候,我们第一次进入注册的观察者D1已经被销毁,但是由于DMWorker是个单例,所以依旧会像D1发送回调消息,最终导致内存访问异常,应用崩溃。

结论:当我们的观察者dealloc时,一定要移除观察者

自动/手动监听KVO

当时我们使用最基本的方法来使用KVO时,默认是自动监听模式,而当我们想改变成手动监听模式的时候,我们需要在被监听的对象中实现automaticallyNotifiesObserversForKey方法

+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key {
    //可以根据不同的key值,来区分使用自动还是手动监听
    if ([key isEqualToString:@"nick"]) {
        return YES;
    }
    return NO;
}
复制代码

如果直接return NO则表示全部使用手动监听,这是我们在上面的案例中直接去触摸屏幕就没有任何响应了,如果想要响应则需要实现下面的方法

[self willChangeValueForKey:@"nick"];
_nick = nick;
[self didChangeValueForKey:@"nick"];
复制代码

willChangeValueForKeydidChangeValueForKey中间进行赋值,则会进行手动监听到。

观察多个因素影响的属性

我们有时候需要观察一个属性,但是这个属性是由多个其他的因素共同影响而变化的。举个例子,例如我们的下载文件的过程,下载进度 = 已下载 / 总数。如果已下载和总数都是在不断变化的,那么我们该怎么做才能对下载进度进行观察呢?请看下面的例子

- (void)viewDidLoad {
    [super viewDidLoad];
    self.person = [DMPerson new];
    [self.person addObserver:self forKeyPath:@"downloadProgress" options:NSKeyValueObservingOptionNew context:NULL];
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    self.person.writtenData += 10;
    self.person.totalData += 30;
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    NSLog(@"DetailViewController :%@",change);
}
- (void)dealloc {
    [self.person removeObserver:self forKeyPath:@"downloadProgress"];
}
复制代码

我们一共触摸了三次,接下来看看打印结果

image.png 我们可以看到,每次触摸都打印了多条数据,为什么呢?我们来看看DMPerson中的实现

+ (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key{
    NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
    if ([key isEqualToString:@"downloadProgress"]) {
        NSArray *affectingKeys = @[@"totalData", @"writtenData"];
        keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];
    }
    return keyPaths;
}
- (NSString *)downloadProgress {
    if (self.writtenData == 0) {
        self.writtenData = 10;
    }
    if (self.totalData == 0) {
        self.totalData = 100;
    }
    return [[NSString alloc] initWithFormat:@"%f",1.0f*self.writtenData/self.totalData];
}
复制代码

其实很简单,其实只要在keyPathsForValuesAffectingValueForKey方法中,将downloadProgress关联的两个因素totalDatawrittenData通过setByAddingObjectsFromArray关联起来,那么每次totalData或者writtenData改变时,系统会自动通知我们downloadProgress改变了。而第一次打印了三次的原因只是因为我们当totalData为0时,设置他为100,多调用了一次而已。

可变数组的观察

如果我们需要观察可变数组,我们该怎么办呢?继续开始试验

- (void)viewDidLoad {
    [super viewDidLoad];
    self.person = [DMPerson new];
    self.person.dateArray = [NSMutableArray array];
    [self.person addObserver:self forKeyPath:@"dateArray" options:NSKeyValueObservingOptionNew context:NULL];
    [self.person.dateArray addObject:@"mantou"];
}
复制代码

viewDidLoad中实现这些代码,理论上进入页面后就会观察到并回调,而实际上并没有任何响应。我们在苹果的文档中,又找到了这样一行

In order to understand key-value observing, you must first understand key-value coding

这句话的意思,要想理解KVO就先要理解KVC,也就是说KVO是建立在KVC上的。

而在KVC的官方文档中,有这么一段话

image.png 意思就是,如果你需要使用KVO去观察集合类型的数据变化,那么就需要使用对应的api来获取这个集合,这样在你进行设置值的时候,系统就能够通知到你。那么我们修改下我们的代码

- (void)viewDidLoad {
    [super viewDidLoad];
    self.person = [DMPerson new];
    [self.person addObserver:self forKeyPath:@"dateArray" options:NSKeyValueObservingOptionNew context:NULL];
    self.person.dateArray = [NSMutableArray array];
    [[self.person mutableArrayValueForKey:@"dateArray"] addObject:@"mantou"];
}
复制代码

结果打印出来了,第一次是dateArray初始化的打印,第二次是addObjcet的打印 image.png 细心的你肯定已经发现,两次打印的kind值并不一样,那么他们代表什么呢?

// *NSKeyValueChange*
typedef NS_ENUM(NSUInteger, NSKeyValueChange) {
    NSKeyValueChangeSetting = 1,
    NSKeyValueChangeInsertion = 2,
    NSKeyValueChangeRemoval = 3,
    NSKeyValueChangeReplacement = 4,
};
复制代码

这个枚举,告诉了我们答案。

KVO底层原理探索

在上面我们举例了很多KVO的应用,那么他的在底层的原理是什么样的呢?我们现在就来探索一下他

修改isa指向

- (void)viewDidLoad {
    [super viewDidLoad];
    self.person = [[DMPerson alloc] init];
    [self.person addObserver:self forKeyPath:@"nickName" options:NSKeyValueObservingOptionNew context:NULL];
}
复制代码

首先我们实现一个简单的KVO,然后再addObserver方法处打上断点,我们想要探究一下,addObserver方法调用以后,系统进行了什么操作呢,先来看看他的isa指向

image.png 我们发现,当调用addObserver之后,self.personisa指向已经变成了NSKVONotifying_DMPerson。在之前的探索中,我们知道,实例对象的和类的关系实际上就是实例对象的isa指向了类对象。所以这里我们可以简单粗暴的认为,self.person在调用addObserver方法后,已经从DMPerson类的实例对象,变成了NSKVONotifying_DMPerson的实例对象。

NSKVONotifying_DMPerson

那么这个NSKVONotifying_DMPerson是什么东西?他是不是一开始就直接存在的呢?他跟DMPerson又有什么关系呢?我们接下来继续探索。依旧是刚刚的代码,我们首先来看看NSKVONotifying_DMPerson是不是一开始就存在的

image.png 从这个结果我们可以很明显的看到NSKVONotifying_DMPerson这个类,是在调用addObserver方法后,系统动态添加生成的一个类。接下来我们发现这两个类名字这么相似,而系统动态生成的那个类,有没有可能是DMPerson的子类呢?我们打印一下NSKVONotifying_DMPerson的父类

image.png 很惊奇的发现NSKVONotifying_DMPerson是继承自DMPerson的。那么这个中间类,有没有可能存在自己的子类呢?我们通过下面这段代码来看看

#pragma mark **- 遍历类以及子类**
- (void)printClasses:(Class)cls{
    // 注册类的总数
    int count = objc_getClassList(NULL, 0);
    // 创建一个数组, 其中包含给定对象
    NSMutableArray *mArray = [NSMutableArray arrayWithObject:cls];
    // 获取所有已注册的类
    Class* classes = (Class*)malloc(sizeof(Class)*count);
    objc_getClassList(classes, count);
    for (int i = 0; i<count; i++) {
        if (cls == class_getSuperclass(classes[i])) {
            [mArray addObject:classes[i]];
        }
    }
    free(classes);
    NSLog(@"classes = %@", mArray);
}
复制代码

image.png 结果比较明显,也从另外一个方面印证了NSKVONotifying_DMPersonDMPerson的子类。

既然如此,那么NSKVONotifying_DMPerson里面到底有什么东西呢?我们知道类里面无非就存储了成员变量、方法、协议等等,我们就通过下面这段代码来探索一下他里面的方法都有什么

#pragma mark **- 遍历方法-ivar-property**
- (void)printClassAllMethod:(Class)cls{
    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);
}
复制代码

这段代码是遍历类中的方法然后输出打印出来。我们知道,如果是继承的方法,自己并没有重写,那么这个方法实际上存储是在父类中,所以通过这个方法,我们是打印不出来的。那么我们来看看打印结果

image.png 从这里我们看到了,系统重写了setNickNameclassdealloc这几个方法,并且添加了一个叫_isKVOA的方法,来区分是不是系统通过KVO自动生成的类。

移除观察者修改回isa指向

我们之前强调过,在不使用观察者时,需要移除观察者,否则可能会造成内存访问异常。既然调用addObserver方法会修改isa指向,系统新生成的中间类,那么移除观察者后系统会怎么做呢?我们在dealloc方法中移除观察者这里打上断点,然后继续观察self.personisa指向

image.png 我们看到移除观察者之后,self.personisa又指回了DMPerson类。那么之前生成的中间类,是否会释放呢?我们继续看看

image.png 通过lldb我们发现,他还存在,没有进行销毁。原因是如果下次继续进行观察者添加,系统就不会再生成新的中间类,而是直接使用这个类了,防止资源的浪费。这一步实际上也是我们再刚刚看到的NSKVONotifying_DMPerson中的dealloc方法重写后里面所做的。

重写的class方法

我们研究过了重写的dealloc方法,那么重写的这个class方法有什么意义呢?

image.png 通过对addObserver方法调用前后的class方法的打印结果,我们可以看到,虽然self.personisa已经指向NSKVONotifying_DMPerson了,但是,由于NSKVONotifying_DMPerson重写了class方法,最后打印输出的还是DMPerson,目的是为了隐藏系统在背后做的一系列动作,让开发者更少的关注底层。

重写的setter方法

NSKVONotifying_DMPerson重写的其他几个方法我们都分析过了,只剩下了最后的也是最重要的setter方法的重写了。在这之前,我们先思考一个问题,KVO针对的是某个对象的属性的观察,那么成员变量能不能观察呢?我们实验一下就知道了

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

- (void)viewDidLoad {
    [super viewDidLoad];
    self.person = [[DMPerson alloc] init];
    [self.person addObserver:self forKeyPath:@"nickName" options:NSKeyValueObservingOptionNew context:NULL];
    [self.person addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:NULL];
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSLog(@"实际情况:%@ - %@",self.person.nickName, self.person->name);
    self.person.nickName = @"mantou";
    self.person->name = @"tong";
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    NSLog(@"%@",change);
}
复制代码

这里我们在DMPerson中声明了一个属性nickName和一个成员变量name,并且分别设置了KVO观察,我们来看看运行结果

image.png 很明显nickNamename的赋值都生效了,但是只有nickName的赋值触发了KVO的回调,所以我们可以得到一个结论

KVO的观察只针对属性,对于成员变量没有效果

我们知道设置KVO以后,self.person实际上已经是NSKVONotifying_DMPerson的实例了,那么DMPerson类里面nickName的属性,在我们调用self.person.nickName = @"mantou"时会不会发生改变呢?我们在dealloc中移除KVO以后的地方打上断点

image.png 我们发现,移除观察者以后isa指向了DMPerson,所以DMPerson中的nickName属性实际上也已经改变了,这就很神奇了,我们调用的NSKVONotifying_DMPersonsetNickName方法,但是最后DMPerson中的属性值改变了,这是为什么呢?我们在调用setNickName的地方打上断点

image.png 然后看看堆栈信息

image.png 这里可以看出,在我们调用NSKVONotifying_DMPersonsetNickName方法后,系统通过Foundation框架中的一些底层处理,最终调用了DMPersonsetNickName方法。这里就真相大白了。

KVO流程总结

我们来总结一下整个KVO的流程

  1. 只针对属性观察,实际上是观察setter方法
  2. 设置观察者后,会自动生成一个中间类,一般命名为NSKVONotifying_xxxx,并将实例对象的isa指向中间类,中间类是被观察对象的子类。
  3. NSKVONotifying_xxxx中重写了class方法、dealloc方法和被观察的属性的setter方法,并且生成了一个_isKOVA的标识方法
  4. 在调用中间类NSKVONotifying_xxxxsetter方法后,实际上会在其中调用被观察者的属性的setter方法
  5. 最后当观察者释放时,需要移除观察者,这时isa会重新指回被观察者并且中间类并不会移除,如果下次继续添加观察,则直接使用这个中间类不用重新生成
文章分类
iOS
文章标签