OC底层原理18 - KVO

简介

KVO,全称为Key-Value observing,中文名为键值观察,KVO是一种机制,它允许将其他对象的指定属性的更改通知给对象

Key-Value Observing Programming Guide官方文档中,又这么一句话:理解KVO之前,必须先理解KVC(即KVO是基于KVC基础之上)。

KVC是键值编码,在对象创建完成后,可以动态的给对象属性赋值,而KVO是键值观察,提供了一种监听机制,当指定的对象的属性被修改后,则对象会收到通知,所以可以看出KVO是基于KVC的基础上对属性动态变化的监听。

KVO与NSNotificatioCenter的区别

  • 相同点
    • 两者的设计模式都使用观察者模式
    • 都是用于对象的监听
    • 都能实现一对多的操作
  • 不同点
    • KVO只能用于监听对象属性的变化,并且属性名都是通过NSString来查找,编译器不会帮你检测对错和补全,纯手敲会比较容易出错
    • NSNotification的发送监听(post)的操作我们可以控制,KVO由系统控制
    • KVO可以记录新旧值变化

KVO 使用

基本操作

  • 注册观察者,addObserver:forKeyPath:options:context
    • observer参数:注册KVO通知的对象,即观察者;观察者必须实现key-value观察方法:observeValueForKeyPath:ofObject:change:context:
    • keyPath参数:被观察对象的键值路径,这个值不允许为nil
    • options参数,这是一个NSKeyValueObservingOptions枚举类型。
      • NSKeyValueObservingOptionNew:当告知观察者对象发生变化时,提供新的属性值
      • NSKeyValueObservingOptionOld:当告知观察者对象发生变化时,提供旧的属性值
      • NSKeyValueObservingOptionInitial:通知应该立即发送给观察者,在观察者注册方法甚至返回之前
      • NSKeyValueObservingOptionPrior:在每次更改之前之后分别向观察者发送通知,而不是在更改之后发送单个通知。这与-willChangeValueForKey:被触发的时间是相对应的。在每次修改属性时,实际上是会发送两条通知。
    • context参数:任意的额外数据,我们可以将这些数据作为上下文数据并传递至observeValueForKeyPath:ofObject:change:context方法中。这个参数的意义在于区分同一对象监听同一属性(从属于同一对象)的多个不同的监听。
  • 实现KVO回调,observeValueForKeyPath:ofObject:change:context
    • keyPath参数:被观察对象的属性的键值路径,这个值不允许为nil
    • object参数:被观察对象。
    • change参数:这是一个字典,它包含了属性被修改的一些信息。这个字典中包含的值会根据我们在添加观察者时设置的options参数的不同而有所不同。change中的内容可通过以下key进行获取。官方提供了以下五种Key。
      1. NSKeyValueChangeKindKey,提供了发生变化类型的信息。
        1. 返回NSKeyValueChangeSetting,表示观察对象被设置了一个新值
        2. 返回NSKeyValueChangeInsertion,表示一对多关系的对象中有值被插入
        3. 返回NSKeyValueChangeRemoval,表示一对多关系的对象中有值被移除
        4. 返回NSKeyValueChangeReplacement,表示一对多关系的对象中有值被替换
      2. NSKeyValueChangeNewKey:观察对象被设置的新值,需要将options设置成NSKeyValueObservingOptionNew
      3. NSKeyValueChangeOldKey:观察对象被设置新值之前的旧值,需要将options设置成NSKeyValueObservingOptionOld
      4. NSKeyValueChangeIndexesKey:当一对多关系的对象中有值被插入、移除或替换,则通过该获取插入、移除或替换索引
      5. NSKeyValueChangeNotificationIsPriorKey:当options设置成NSKeyValueObservingOptionPrior选项时,可以使用NSKeyValueChangeNotificationIsPriorKey来获取到通知是否是预先发送的,如果是,获取到的值总是@(YES)
    • context参数:这个值即是添加观察者时提供的上下文信息。
  • 移除观察者,removeObserver:forKeyPath:contextremoveObserver:forKeyPath
    • observer参数:注册KVO通知的对象,即观察者。
    • keyPath参数:被观察对象的键值路径,这个值不允许为nil
    • context参数:这个值即是添加观察者时提供的上下文信息。

移除观察者注意点:

  • 如果还没有注册成观察者,就要求被移除会导致NSRangeException异常。正常情况,一次removeObserver:forKeyPath:context:对应于addObserver:forKeyPath:options:context:。或者在removeObserver:forKeyPath:context:中增加try/catch块处理异常。
  • 当对象被释放时,观察者不会自动移除。被观察对象继续发送通知,发送给已释放对象的更改通知会触发内存访问异常。因此,可以确保观察者在从内存中消失之前删除自己。
  • KVO协议并不提供接口获取当前对象是否是观察者或被观察者。在构建代码时为了避免相关错误,一个典型方法是在对象初始化的时候注册成观察者(例如在init或viewDidLoad中),在对象释放的时候移除观察者(通常在dealloc中)。

KVO自动触发和手动触发

  • 自动触发
    当类方法automaticallyNotifiesObserversForKey返回YES时,表示自动触发KVO。当返回NO时,关闭自动触发KVO
    NSObject提供了一个基本的自动键值变化通知的实现。但不是所有的方法都能自动触发KVO,以下方法能自动触发KVO

    // Call the accessor method.
    [account setName:@"Savings"];
    
    //Use setValue:forKey:.
    [account setValue:@"Savings" forKey:@"name"];
    
    //Use a key path, where 'account' is a kvc-compliant property of 'document'.
    [document setValue:@"Savings" forKeyPath:@"account.name"];
    
    //Use mutableArrayValueForKey: to retrieve a relationship proxy object.
    Transaction *newTransaction = <#Create a new transaction for the account#>;
    NSMutableArray *transactions = [account mutableArrayValueForKey:@"transactions"];
    [transactions addObject:newTransaction];
    
  • 手动触发
    automaticallyNotifiesObserversForKey返回NO时,可手动触发KVO。如下:属性的setter方法中,在改变value之前调用willChangeValueForKey,在改变value之后调用didChangeValueForKey

    - (void)setName:(NSString *)name{
        if (name != _name) {
            [self willChangeValueForKey:@"name"];
            _name = name;
            [self didChangeValueForKey:@"name"];
        }
    }
    

    如果单个操作导致多个属性发生更改,则必须嵌套更改通知。如:

    - (void)setBalance:(double)theBalance {
        [self willChangeValueForKey:@"balance"];
        [self willChangeValueForKey:@"itemChanged"];
        _balance = theBalance;
        _itemChanged = _itemChanged+1;
        [self didChangeValueForKey:@"itemChanged"];
        [self didChangeValueForKey:@"balance"];
    }
    

    如果集合类型的属性,则在手动触发KVO时,不仅必须指定更改的键,还必须指定更改的类型和涉及的对象的索引。如:

    - (void)removeTransactionsAtIndexes:(NSIndexSet *)indexes {
        [self willChange:NSKeyValueChangeRemoval
            valuesAtIndexes:indexes forKey:@"transactions"];
        [self didChange:NSKeyValueChangeRemoval
            valuesAtIndexes:indexes forKey:@"transactions"];
    }
    

注册 Dependent Keys

在许多情况下,一个属性的值依赖于另一个对象中的一个或多个其他属性的值。如果一个属性的值发生了变化,那么派生属性的值也应该被标记为发生了变化。如何确保为这些依赖属性触发键-值观察通知,取决于关系的基数。

  • 一对一情况
  1. 当属性发生更改时,手动触发派生属性的KVO。
  2. 重写keyPathsForValuesAffectingValueForKey:方法,指明派生属性依赖于哪个属性。
  3. 实现类方法keyPathsForValuesAffecting<Key>,指明派生属性依赖于哪个属性。
  4. 实现类方法keyPathsForValuesAffectingValueForKey:,指明派生属性依赖于哪个属性。
  • 一对多情况
    keyPathsForValuesAffectingValueForKey:方法不能支持to-many的关系。举个例子,比如你有一个 Department 对象,和很多个 Employee 对象。而 Employee 有一个 salary 属性。你可能希望 Department 对象有一个 totalSalary 的属性,依赖于所有的 Employee 的 salary 。
    你可以注册 Department 成为所有 Employee 的观察者。当 Employee 被添加或者被移除时,你必须要添加和移除观察者。然后在 observeValueForKeyPath:ofObject:change:context: 方法中,根据改变做出反馈。

    - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
    if (context == totalSalaryContext) {
        [self updateTotalSalary];
    }
    else
    // deal with other observations and/or invoke super...
    }
    - (void)updateTotalSalary {
    [self setTotalSalary:[self valueForKeyPath:@"employees.@sum.salary"]];
    }
    - (void)setTotalSalary:(NSNumber *)newTotalSalary {
        if (totalSalary != newTotalSalary) {
            [self willChangeValueForKey:@"totalSalary"];
            _totalSalary = newTotalSalary;
            [self didChangeValueForKey:@"totalSalary"];
        }
    }
    - (NSNumber *)totalSalary {
        return _totalSalary;
    }
    

KVO中的isa-swizzling

KVO 的实现用了一种叫isa-swizzling的技术。isa就是指向类的指针,当一个对象的一个属性注册了观察者后,被观察对象的isa就指向了一个系统为我们生成的中间类,而不是我们自己创建的类。在这个类中,系统为我们重写了被观察属性setter方法。

  • 中间类
    注册KVO观察者后,观察对象的isa指向会发生改变,指向了一个NSKVONotifying_xxx的类。带着这个观点,接下来对代码进行调试分析。
    【1】验证注册观察者之后,观察对象的isa是否会发生改变,是否会指向一个NSKVONotifying_xxx的类。

    //注册观察者之前
    (lldb) po object_getClassName(self)
    "PersonSwizzling"
    
    //注册观察者之后
    (lldb) po object_getClassName(self)
    "NSKVONotifying_PersonSwizzling"
    
    //object_getClassName
    const char *object_getClassName(id obj) {
        return class_getName(obj ? obj->getIsa() : nil);
    }
    

    通过lldb打印信息可以验证,当注册观察者之后,观察对象的isa发生了变化。并且指向了NSKVONotifying_PersonSwizzling的类。

    【2】PersonSwizzling 类和 NSKVONotifying_PersonSwizzling 类的关系

    //1. 在注册观察者之前,查看当前类的isa以及supperclass
    //读取对象地址中的内容
    (lldb) x/4g self
    0x6000038f8410: 0x0000000106a23e80 0x0000000000000000
    0x6000038f8420: 0x00007f984be06dc0 0x0000000000000000
    //通过对象的isa找到当前实例对象所指向的类
    (lldb) p/x 0x0000000106a23e80 & 0x00007ffffffffff8ULL
    (unsigned long long) $1 = 0x0000000106a23e80
    //实例对象指向的类:Person类
    (lldb) po $1
    PersonSwizzling
    //读取类地址中的内容
    (lldb) x/4g $1
    0x106a23e80: 0x0000000106a23ea8 0x00007fff86d54660
    0x106a23e90: 0x0000600002ff4740 0x0001801c00000003
    //类结构中,第一个参数是类的isa指向,此时指向的是 PersonSwizzling 元类
    (lldb) po 0x0000000106a23ea8
    PersonSwizzling
    //类结构中,第二参数是类的父类,此时指向的是 NSObject 类
    (lldb) po 0x00007fff86d54660
    NSObject
    
    注册观察者之前
    //2. 在注册观察者之后,查看当前类的isa以及supperclass
    //读取对象地址中的内容
    (lldb) x/4g self
    0x6000038f8410: 0x00006000008f81b0 0x0000000000000000
    0x6000038f8420: 0x00007f984be06dc0 0x0000000000000000
    //通过对象的isa找到当前实例对象所指向的类
    (lldb) p/x 0x00006000008f81b0 & 0x00007ffffffffff8ULL
    (unsigned long long) $5 = 0x00006000008f81b0
    //当前实例对象指向的类:NSKVONotifying_PersonSwizzling 类
    (lldb) po $5
    NSKVONotifying_PersonSwizzling
    //读取类地址中的内容
    (lldb) x/4g $5
    0x6000008f81b0: 0x00006000008f8240 0x0000000106a23e80
    0x6000008f81c0: 0x00007fff20193d20 0x0000000400000000
    //类结构中,第一个参数是类的isa指向,此时指向的是 NSKVONotifying_PersonSwizzling 元类
    (lldb) po 0x00006000008f8240
    NSKVONotifying_PersonSwizzling
    //类结构中,第二参数是类的父类,此时指向的是 PersonSwizzling 类
    (lldb) po 0x0000000106a23e80
    PersonSwizzling
    
    注册观察者之后

    由lldb中可以看出,当注册观察后,原本实例对象的isa由指向PersonSwizzling类修改成指向NSKVONotifying_PersonSwizzling类,而NSKVONotifying_PersonSwizzling类的父类PersonSwizzling类。

    【3】NSKVONotifying_PersonSwizzling 类中有什么方法?
    1、通过class_copyMethodList方法获取类的方法列表
    2、遍历方法列表,获取每个方法的SEL和IMP

    - (void)printClassAllMethod:(Class)cls{
        unsigned int outCount = 0;
        Method* methodList = class_copyMethodList(cls, &outCount);
        for(int i = 0; i<outCount; i++){
            Method method = methodList[i];
            SEL sel = method_getName(method);
            IMP imp = method_getImplementation(method);
            NSLog(@"sel:%@, imp:%p", NSStringFromSelector(sel), imp);
        }
        free(methodList);
    }
    

    通过lldb打印输出可查看到NSKVONotifying_PersonSwizzling类中方法的SEL和IMP。

    2021-03-19 15:47:34.199551+0800 KVODemo[93742:5454700] sel:setName:, imp:0x7fff207bbb57(Foundation`_NSSetObjectValueAndNotify)
    2021-03-19 15:47:39.892224+0800 KVODemo[93742:5454700] sel:class, imp:0x7fff207ba662(Foundation`NSKVOClass)
    2021-03-19 15:47:45.109027+0800 KVODemo[93742:5454700] sel:dealloc, imp:0x7fff207ba40b(Foundation`NSKVODeallocate)
    2021-03-19 15:47:50.819430+0800 KVODemo[93742:5454700] sel:_isKVOA, imp:0x7fff207ba403(Foundation`NSKVOIsAutonotifying)
    
    • setName:观察属性的setter方法
    • class:获取类的方法
    • dealloc:对象释放的方法
    • _isKVOA:判断是否自动触发KVO的方法
  • 中间类总结

    • 在注册观察者之后,实例对象的isa会指向一个由系统生成的NSKVONotifying_PersonSwizzling类,NSKVONotifying_PersonSwizzling继承自NSKVONotifying_PersonSwizzling
    • 在移除观察者之后,实例对象的isa会修改回指向PersonSwizzling
    • NSKVONotifying_PersonSwizzling类中会重写观察属性的setter方法、class方法、dealloc方法、_isKVOA方法。
    • NSKVONotifying_PersonSwizzling类一旦注册到内存中,为了考虑后续的重用问题,中间类将一直存在内存中。
  • 自定义KVO
    大概了解了KVO的过程,接下来根据了解的KVO的三步骤自定义VKO流程。

    • 注册观察者:

      1. 判断对象属性是否有Setter方法,若没有的话,则不允许继续执行KVO
      2. 动态生成子类(重点
        2.1 获取当前类的类名,拼接当前类名,生成一个新的类名HQKVONotifying_xxx
        2.2 根据新的类名,通过NSClassFromString函数获取一个类结构的指针
        2.3 使用objc_allocateClassPair完善新的类
        2.4 通过objc_registerClassPair函数将类注册至内存
        2.5 重写新类的class方法,如果没有重映射,自定的KVO在注册前后的实例对象person的class就会看到是不一致的,返回的isa更改后的类,即中间类
        2.6 重写新类的setter方法
        2.7 重写新类的delloc方法
      3. 修改实例对象的isa,使它指向新生成的类
      4. 保存当前观察者信息
    • 当观察属性被设值时

      1. 将设值的信息先发送给父类,让父类完全属性值的设置
      2. 发送通知hq_observeValueForKeyPath:ofObject:change:context:,通知观察者当前属性的值发生了变化。
    • 移除观察者

      1. 将关联对象中保存的观察者信息删掉
      2. 将实例对象的isa重新指回原来的类

自定义KVO Demo

本文中的示例,及自定义KVO 请见Demo地址

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念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

推荐阅读更多精彩内容