OC之KVO原理分析

1,178 阅读10分钟

和谐学习!不急不躁!!我是你们的老朋友小青龙~

前言

前面文章,我们探究了KVC的原理,本篇文章就KVO进行一番探究。虽然KVC和KVO长得很像,但是大家不能记混淆哦~

  • KVC:Key-Value Coding
  • KVO:Key-value observing

老规矩,先上KVO的案例:

image.png

KVO的使用很简单:

  1. 注册(addObserver:forKeyPath:options:context:

  2. 回调代理方法(observeValueForKeyPath:ofObject:change:context:

  3. 移除观察者(removeObserver:forKeyPath:context:,这个需要自己移除,不然就会因为向一个被释放的对象发送消息而发送异常)

原理探究

虽然大家都是这么用的,但是当我们想看具体实现的时候,发现不能点进去看下一步:

image.png

别急,这时候我们可以打开苹果爸爸提供的KVO官方文档

image.png

图片上大概意思是:

  1. KVO提供了一种机制,它允许当其它对象的指定属性发生改变时,这个对象被通知到。

  2. 可以是控制器观察数据模型的值变化;

  3. 可以是模型数据A观察模型数据B的变化(比如模型B是模型A其中一个属性);

  4. 也可以是模型数据对自己本身的数据变化做监听; 关于第2点,文档上还给出一个案例:Account作为Person的其中一个属性,它有余额和利率的属性,Person对象可以监听Account对象的余额和利率变化情况。

当然,关于账户信息的变更,可以通过定时去查询来知道信息的变化情况。但是这样做效率不高,而且不够及时

这时候KVO的出现就像是账户边上占了一个人,24小时盯着账户的情况,一旦发生改变就立马发送一个通知告诉你“兄嘚儿,你的账户发生变动了,变动信息是XXX”。

最后,在你不需要监听的时候,你要手动取消监听。

KVO的使用流程和注意事项

image.png 上图讲述了KVO的实现步骤(注册、回调代理、移除):

  • addObserver:forKeyPath:options:context:

  • observeValueForKeyPath:ofObject:change:context:

  • removeObserver:forKeyPath:

需要注意:并非所有类对所有属性都符合KVO。通过遵循KVO Compliance中描述的步骤,您可以确保自己的类符合KVO。通常,苹果提供的框架中的属性只有在有文档记录的情况下才符合KVO。

关于 KVO Compliance

image.png

  • 该类必须符合属性的键值编码,如确保KVC符合性中所述。KVO支持与KVC相同的数据类型,包括Objective-C对象以及标量和结构支持中列出的标量和结构。

  • 该类发出属性的KVO更改通知。

  • 相关密钥已正确注册(请参阅Registering Dependent Keys)。

查看 Registering Dependent Keys

image.png

在许多情况下,一个属性的值取决于另一个对象中一个或多个其他属性的值。如果某个属性的值发生更改,则派生属性的值也应标记为更改。如何确保为这些依赖属性发布键值观察通知取决于关系的基数。

关于addObserver:forKeyPath:options:context:

image.png

注册观察者对象以接收与接收此消息的对象相关的密钥路径的KVO通知。

参数Options
  • NSKeyValueObservingOptionNew //只包含改变后的状态信息
  • NSKeyValueObservingOptionOld //只包含改变前的状态信息
  • NSKeyValueObservingOptionInitial //这种模式下,会走两遍observeValueForKeyPath代理,且不同步
  • NSKeyValueObservingOptionPrior //这种模式下,会走两遍observeValueForKeyPath代理,且同步

一般情况下,我们会使用NSKeyValueObservingOptionNew和NSKeyValueObservingOptionOld,这两个大家比较熟悉了,这里不做阐述。

接下来测试一下NSKeyValueObservingOptionInitial

- (void)viewDidLoad {
    [super viewDidLoad];
    self.person = [SSJPerson new];
    self.person.weight = @"50kg";
    // 注册观察者
    [self.person addObserver:self forKeyPath:@"weight" options:(NSKeyValueObservingOptionInitial) context:SSJPersonWeightContext];
    __block typeof(self)blockSelf = self;
    // 2秒后改变内容
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        NSLog(@"开始改变~");
        blockSelf.person.weight = @"52kg";
    });
    NSLog(@"\n\n~~~~~~~ 间隔 ~~~~~~~\n");
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{
    NSLog(@"打印change:%@",change);
    NSLog(@"演示结束~");
}

运行:

image.png

第一次进断点: image.png

第二次进断点: image.png

接下来测试一下NSKeyValueObservingOptionPrior

image.png

第一次进断点: image.png

第二次进断点: image.png

说明:

  • NSKeyValueObservingOptionInitial模式下,会在注册完观察模式后立刻执行一次代理方法,返回被观察者person最初的状态,当观察属性发生变化的时候,会再调用一次,并返回改变后的状态。

  • NSKeyValueObservingOptionInitial模式下,当观察属性发生变化的时候,才会调用两次代理方法,第一次返回旧数据,第二次返回新数据。

参数Context

image.png

context:上下文标识符,传递给观察者的任意数据。

一种更安全、更可扩展的方法是使用上下文context来确保您收到的通知是针对您的观察者而不是超类的。简单来说,如果对同一个消息接收者观察了不同的SSJPerson对象的不同属性值变化,那么在observeValueForKeyPath里就需要进行多次判断才能找到那个需要处理的消息,这时候可以通过设置不同的context作为唯一标识符,可以更高效的定位不同的消息。比如:


SSJPerson *personA;
SSJPerson *personB;
static void *SSJPersonAWeightContext = &SSJPersonWeightContext;
static void *SSJPersonBNameContext = &SSJPersonBNameContext;;
- (void)viewDidLoad {
    [super viewDidLoad];

    personA = [SSJPerson new];
    personB = [SSJPerson new];
    [personA addObserver:self forKeyPath:@"weight" options:(NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld) context:SSJPersonAWeightContext];
    [personB addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld) context:SSJPersonBNameContext];
    personA.weight = @"12";
    personB.name = @"王五";
}

image.png

参数observer、参数keyPath

image.png

  • observer:即要注册KVO通知的对象。注册观察之后,observer必须实现observeValueForKeyPath:ofObject:change:context:代理方法

  • keyPath:要观察的属性的键值对路径,且不能为nil

关于 observeValueForKeyPath:ofObject:change:context:

观察的对象的指定关键点路径(keyPath)上的值发生更改时,通知观察的对象。

参数

image.png

  • keyPath:键值对路径。

  • object:被观察的对象。

  • change:字典类型,包含关于keyPath指定路径值的变化情况。

  • context:注册观察者的时候传进来的内容。

关于 removeObserver:forKeyPath:

image.png

通过向观察对象发送removeObserver:forKeyPath:context:message,指定观察对象、键路径和上下文,可以删除键值观察者。

  • 如果尚未注册为观察员,则请求以观察员身份删除会导致nsrange异常。可以放在try/catch块中以处理潜在异常。

案例:

image.png

添加try/catche处理后,成功捕获错误信息:

image.png

  • 取消分配时,观察者不会自动删除自身。被观察对象继续发送通知,而不理会观察者的状态。但是,与任何其他消息一样,发送到已发布对象的更改通知会触发内存访问异常。因此,您可以确保观察者在从内存中消失之前将自己移除。

案例:

IMB_vcSSpD.GIF

创建了一个SSJPerson单例作为被观察者,并且把当前控制器设为观察者,第一次返回上一层,控制器销毁了;但是由于单里的原因,再加上没有没有remove第一次的观察者,被观察者会继续给这个被销毁的控制器发消息(改变的时候才会发,也就是第二次进入控制层,进入test03方法的时候,这时候它会给两个控制器发消息)。这边花了一个图方便大家理解:

image.png

  • 协议没有提供询问对象是观察者还是被观察者的方法。构造代码以避免与发布相关的错误。典型的模式是在观察者初始化期间(例如在init或viewDidLoad中)注册为观察者,并在解除分配期间(通常在dealloc中)取消注册,以确保正确配对和有序的添加和删除消息,并确保观察者在从内存中释放之前取消注册。

按照上文所说,remove一个不存在的观察模式,会报异常,官方给的建议是使用try/cache避免程序因为异常信息而崩落。问题是,错误终究还是存在,我们如何去避免这种错误呢?或者说,能否提前知道可不可以remove呢??

  • 写个NSObject分类,替换系统的addObserver:forKeyPath:options:context:方法,在新方法里将obserkeypath保存到自己的单例里。remove之前先从单例里获取对应的信息,根据读取信息判断是否可以remove,remove之后单例不要忘记移除对应数据

观察模式的几种应用场景

当张三的上班时间发生变化时,需要被通知到。

  • 控制器监听数据模型的变化:

image.png

  • 模型之间的监听

image.png

KVO的实现详情

image.png

翻译如下:

使用称为`isa swizzling`的技术实现自动键值观察。

顾名思义,isa指针指向维护分派表的对象类。这个分派表本质上包含指向类实现的方法的指针以及其他数据。

当一个观察者为一个对象的属性注册时,被观察对象的isa指针被修改,指向一个中间类而不是真正的类。因此,isa指针的值不一定反映实例的实际类。

决不能依赖isa指针来确定类成员身份。相反,您应该使用class方法来确定对象实例的类。
验证isa知否真的被修改:

image.png

查看添加注册之后self.person的isa指向:

image.png

事实证明,经过addObserver这一步的注册,self.person的isa由原来的指向SSJPerson改为指向NSKVONotifying_SSJPerson这个中间类。

中间类NSKVONotifying_SSJPerson跟原来的SSJPerson类有什么关系呢?

image.png

我们知道,isKindOfClass可以判断某个对象是否属于某个类,或者这个类的子类。
经过上面的打印,我们发现NSKVONotifying_SSJPersonSSJPerson的子类。

removeObserver之后,「isa」的指向是否会重新指向「SSJPerson」呢?原来的NSKVONotifying_SSJPerson是否已经销毁了呢?

继续控制台打印:

image.png

这边提供一个方法,可以获取运行时指定类的所有子类:

//获取指定类的子类
+ (NSArray *)findSubClass:(Class)defaultClass
{
    //注册类的总数
    int count = objc_getClassList(NULL,0);
    
    //创建一个数组,其中包含给定对象
    NSMutableArray * array = [NSMutableArray arrayWithObject:defaultClass];
    
    //获取所有已注册的类
    Class *classes = (Class *)malloc(sizeof(Class) * count);
    objc_getClassList(classes, count);
    //遍历
    for (int i = 0; i < count; i++) {
        if (defaultClass == class_getSuperclass(classes[i])) {
            [array addObject:classes[i]];
        }
    }
    free(classes);
    return array;
}

image.png

dealloc里remove观察模式之后再看看:

image.png

说明经过addObserver这一步产生的中间类,并不会因为观察模式的移除而销毁。

我们发现
  • 经过 addObserver:forKeyPath:options:context:这一步,会让被观察对象的isa指向一个动态生成的中间类(继承自原来的类);

  • 经过removeObserver:forKeyPath:context:这一步,会让被观察对象的isa指向原来的类,且中间类并不会被销毁。

关于中间类是原来就有,还是后面动态生成的,可以通过lldb打印一下:

image.png

继续打印:

image.png

说明NSKVONotifying_SSJPerson类是在addObserver:forKeyPath:options:context:之后生成的。

NSKVONotifying_SSJPerson类有哪些属性
// 遍历所有的属性
- (NSArray *)getAllProperties:(id)instanceOjb{
    u_int count = 0;
    //传递count的地址
    objc_property_t *properties = class_copyPropertyList([instanceOjb class], &count);
    NSMutableArray *propertyArray = [NSMutableArray arrayWithCapacity:count];
    for (int i = 0; i < count; i++) {
        //得到的propertyName为C语言的字符串
        const char *propertyName = property_getName(properties[i]);
        [propertyArray addObject:[NSString stringWithUTF8String:propertyName]];
// NSLog(@"%@",[NSString stringWithUTF8String:propertyName]);
    }
    free(properties);
    return propertyArray;
}

打印SSJPerson实例对象的属性:

image.png

打印的NSKVONotifying_SSJPerson实例对象属性:

image.png

NSKVONotifying_SSJPerson类有哪些方法

这边提供了一个打印类所有实例方法的函数:

//遍历所有的方法
//遍历所有的方法
- (NSArray *)gainMethodList:(id)instanceOjb
{
    unsigned int methodCount = 0;
    Method *methodList = class_copyMethodList([instanceOjb class], &methodCount);
    NSMutableArray *methodArray = [NSMutableArray arrayWithCapacity:methodCount];
    for (int i = 0; i < methodCount; i++) {
        Method temp = methodList[i];
        SEL name_A = method_getName(temp);
        const char *name_sel = sel_getName(name_A);
        [methodArray addObject:[NSString stringWithUTF8String:name_sel]];
    }
    free(methodList);
    return methodArray;
}

这里对 image.png

打印SSJPerson类实例方法: image.png

打印NSKVONotifying_SSJPerson实例方法

image.png

class_copyMethodList获取的是到底是自己本身的实例方法,还是从父类继承的方法?

image.png

从API的注释可以看出,「class_copyMethodList」返回的是自己本身实现的方法。

由此可见,作为子类的NSKVONotifying_SSJPerson实例对象,重写了父类SSJPerson的实例方法。

中间类的setter方法做了什么事情

到目前为止,我们知道注册观察模式之后,被观察对象的isa会指向新的中间类(原来类的子类),这个中间类重写了原来类的setter方法,那么这个setter方法干了什么事情呢?

对属性和成员变量设置观察模式:

image.png

运行结果:

image.png

由此可知,观察模式只对属性生效,对成员变量无效。

接下来通过lldb符号断点查看底层的函数走向:

image.png

通过堆栈信息可以看到,经过103行对name的赋值,会进行一系列的函数调用:

  1. _NSSetObjectValueAndNotify

  2. _changeValueForKey:key:key:usingBlock:

  3. _changeValueForKeys:count:maybeOldValuesDict:maybeNewValuesDict:usingBlock:

  4. setName: 注意,对name的赋值实际上是对中间类的赋值,因为那时候的isa已经不再指向原来的类,而经过这一系列的函数,最终还是会调用一次原来类的setName:方法。

那么通知回调observeValueForKeyPath:ofObject:change:context:是什么时候发起的呢? 下断点:

image.png

我们发现它是自NSKeyValueDidChange之后发起的。

梳理下KVO整个流程

部分代码

- (void)viewDidLoad {
    ...
    // 创建SSJPerson的实例变量
    self.person = [SSJPerson new];
    // 注册观察模式
    [self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld) context:SSJPersonNameContext];
    // 赋值
    self.person.name = @"王大大";
}

// 观察模式回调方法
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{
    /** 具体业务代码 */
}

- (void)dealloc{
    ...
    [self.person removeObserver:self forKeyPath:@"name" context:SSJPersonBFriendsNameArrayContext];
}
  1. 调用addObserver:forKeyPath:options:context:方法,
  • 底层动态生成「NSKVONotifying_SSJPerson」类(是SSJPerson的子类),并且person的isa指向了「NSKVONotifying_SSJPerson」,

  • NSKVONotifying_SSJPerson重写了「SSJPerson」类的实例方法(比如setter、getter等方法);

  1. 对属性值name进行赋值(本质上是对NSKVONotifying_SSJPerson的name赋值),会调用以下函数
  • _NSSetObjectValueAndNotify

  • _changeValueForKey:key:key:usingBlock:

  • _changeValueForKeys:count:maybeOldValuesDict:maybeNewValuesDict:usingBlock: (这里会调用NSKeyValueDidChange,然后发起「observeValueForKeyPath:ofObject:change:context:」回调)

  • setName:(这一步会对SSJPerson的name进行赋值)

  1. 调用removeObserver:forKeyPath:context:移除观察模式
  • person的isa重新指向SSJPerson

  • NSKVONotifying_SSJPerson不会销毁,依旧在内存中