iOS底层学习——KVO的使用和原理

2,056 阅读14分钟

上一篇文章学习了键值编码KVCKVC是由NSKeyValueCoding非正式协议启用的一种机制,对象采用该机制来提供对其属性的间接访问。本篇文章重点学习KVOKVO实现的基础就是KVC键值编码

1.KVO协议定义

KVO全称是Key-value Observing,翻译过来就是:键值观察。提供了一种当其它对象属性被修改的时候能通知当前对象的机制。

  • KVO的定义

    KVO的定义都是对NSObject的扩展来实现的(Objective-C中有个显式的NSKeyValueObserving类别名-分类)。KVO的定义在Foundation里面,而Foundation框架是不开源的,只能在苹果官方文档查找。见下图:

    image.png

    键值观察编程指南中对KVO进行了详细介绍,键值观察是一种机制,它允许对象在其他对象的指定属性发生更改时收到通知。它对于应用程序中模型层和控制器层之间的通信特别有用。

    要使用KVO,首先必须确保被观察对象符合KVO。通常,如果您的对象继承自NSObject并且您以通常的方式创建属性,则您的对象及其属性将自动符合KVO

  • KVO提供的API

    • 监听注册

      使用方法addObserver:forKeyPath:options:context:向被观察对象注册观察者。必须执行以下步骤才能使对象能够接收KVO兼容属性的键值观察通知:

      - (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context;
      

      观察者指定一个选项参数options和一个上下文指针context来管理通知的各个方面。

    • 接收通知

      在观察者内部实现observeValueForKeyPath:ofObject:change:context:以接受更改通知消息。

       - (void)observeValueForKeyPath:(nullable NSString *)keyPath ofObject:(nullable id)object change:(nullable NSDictionary<NSKeyValueChangeKey, id> *)change context:(nullable void *)context;
      
    • 移除监听

      使用方法- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;移除观察者

      - (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;
      

2.KVO的使用

1.监听选项option

监听选项是由枚举NSKeyValueObservingOptions定义的:

    typedef NS_OPTIONS(NSUInteger, NSKeyValueObservingOptions) {
            NSKeyValueObservingOptionNew = 0x01,
            NSKeyValueObservingOptionOld = 0x02,
            NSKeyValueObservingOptionInitial API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0)) = 0x04,
            NSKeyValueObservingOptionPrior API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0)) = 0x08
    }

option会影响通知中,提供的更改字典的内容以及生成通知的方式,下面分别分析不同选项的使用特点:

  • NSKeyValueObservingOptionNew

    监听获取属性的新值,见下图:

    image.png

  • NSKeyValueObservingOptionOld

    监听获取属性的旧值,见下图:

    image.png

  • NSKeyValueObservingOptionInitial

    在添加观察者的时候立即发送一个通知给观察者,见下图:

    image.png

  • NSKeyValueObservingOptionInitial

    在每次修改属性时,会在修改通知被发送之前预先发送一条通知给观察者,这与-willChangeValueForKey:被触发的时间是相对应的。这样,在每次修改属性时,实际上是会发送两条通知

    image.png

2.上下文指针Context

addObserver:forKeyPath:options:context:消息中的上下文指针包含任意数据,这些数据将在相应的更改通知中传递回观察者。您可以指定NULL并完全依赖键路径字符串来确定更改通知的来源,但这种方法可能会导致超类由于不同原因也在观察相同键路径的对象出现问题。

如下面的案例,LGStudent继承自LGPerson,同时对两个对象的name属行进行设置,通过添加上下文指针context,可以在接收通知的地方进行过滤。见下图:

image.png

3.使用技巧

  • 同一个对象重复注册为同一属

    可以多次调用addObserver:forKeyPath:options:context:这个方法,将同一个对象注册为同一属性的观察者(所有参数可以完全相同)。这时,即便在所有参数一致的情况下,新注册的观察者并不会替换原来观察者,而是会并存。这样,当属性被修改时,两次监听都会响应。

    参考下面的案例:

    image.png

    可以看到KVO为每次注册都调用了一次监听处理操作。所以多次调用同样的注册操作会产生多个观察者。

  • 移除观察者

    通过上面的案例,可以得出,在观察者不再需要监听属性变化时,必须调用removeObserver:forKeyPath:removeObserver:forKeyPath:context:方法来移除观察者,这两个方法的声明如下:

    - (void)removeObserver:(NSObject *)anObserver
                forKeyPath:(NSString *)keyPath
    
    - (void)removeObserver:(NSObject *)observer
                forKeyPath:(NSString *)keyPath
                   context:(void *)context
    

    这两个方法会根据传入的参数(主要是keyPathcontext)来移除观察者。移除观察者可以避免监听回调的混乱,保持良好的代码质量。

    需要注意的是,如果observer没有监听keyPath属性,依然调用上面两个方法会抛出异常,见下图:

    image.png

    所以,我们必须确保先注册了观察者,才能调用移除方法。那如果我们忘记调用移除观察者方法,会怎么样呢?会崩溃。

    添加观察时,两个对象(即观察者对象及属性所属的对象)都不会被retain,然而在这些对象被释放后,相关的监听信息却还存在,KVO做的处理是直接让程序崩溃。其实苹果官网也给出了相关说明,见下图:

    image.png

    • 如果尚未注册为观察者,则要求将其移除为观察者会导致NSRangeException
    • 解除分配时,观察者不会自动删除自己。被观察的对象继续发送通知,而忽略了观察者的状态。但是,发送到已释放对象的更改通知与任何其他消息一样,会触发内存访问异常。因此,您要确保观察者在从记忆中消失之前将自己移除。
    • 该协议没有提供询问对象是观察者还是被观察者的方法。所以在构建代码时,避免与发布相关的错误。
  • 自动监听和手动监听

    + (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key
    

    默认情况下,该方法返回YES,即表示默认可以对任何类中的所有属性进行监听,可以理解为自动监听。在这种模式下,当我们修改属性的值时,KVO会自动调用以下两个方法:

    - (void)willChangeValueForKey:(NSString *)key
    - (void)didChangeValueForKey:(NSString *)key
    

    开发过程中,可能不需要对所有属性进行监听,只要求选择性的观察部分属性。此时+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key方法返回NO,那么就需要对属性进行手动监听。见下面代码:

    // 自动监听开关-关闭
    + (BOOL) automaticallyNotifiesObserversForKey:(NSString *)key{
        return NO;
    }
    
    - (void)setName:(NSString *)name{
        [self willChangeValueForKey:@"name"];
        _name = name;
        [self didChangeValueForKey:@"name"];
    }
    

    此时自动监听开关已经关闭,如果需要监听person对象的name属性的变化,就需要在setter方法中添加willChangeValueForKeydidChangeValueForKey方法,两个方法必须成对出现,否则无效。

    但如果我们希望自己控制通知发送的一些细节,则可以启用手动控制模式。手动控制通知提供了对KVO更精确控制,它可以控制通知如何以及何时被发送给观察者。采用这种方式可以减少不必要的通知,或者可以将多个修改组合到一个修改中。

    同时通过+automaticallyNotifiesObserversForKey:方法可以设置对象中哪些属性需要手动处理,那么可以自动处理。见下图案例:

    image.png

  • 确保属性发生变化发送通知

    如果希望只有当属性值实际被修改时发送通知,以尽量减少不必要的通知,则可以如下实现:

        - (void)setNick:(NSString *)nick{
            if (nick != _nick){
                [self willChangeValueForKey:@"nick"];
                _nick = nick;
                [self didChangeValueForKey:@"nick"];
            }
        }
    

    如果我们在setter方法之外改变了实例变量(如_nick),且希望这种修改被观察者监听到,则需要像在setter方法里面做一样的处理。这也涉及到我们通常会遇到的一个问题,在类的内部,对于一个属性值,何时用属性(self.nick)访问,而何时用实例变量(_nick)访问。一般的建议是,在获取属性值时,可以用实例变量;在设置属性值时,尽量用setter方法,以保证属性的KVO特性。当然,性能也是一个考量,在设置值时,使用实例变量比使用属性设置值的性能高不少。

  • 多属性依赖

    有些场景,我们监听的某个属性可能会依赖于其它多个属性的变化,不管所依赖的哪个属性发生了变化,都会导致计算属性的变化。对于这种一对一(To-one)的关系,我们需要做两步操作,首先是确定计算属性与所依赖属性的关系。如我们在Person类中定义一个fullName属性,其getter方法定义如下:

        - (NSString *)fullName {
            return [[NSString alloc] initWithFormat:@"he's full name is :%@ , %@", self.name, self.nick];
        }
    

    定义了这种依赖关系后,需要以某种方式告诉KVO,当我们的被依赖属性修改时,会发送fullName属性被修改的通知。此时,我们需要重写NSKeyValueObserving协议的keyPathsForValuesAffectingValueForKey:方法,这个方法返回的是一个集合对象,包含了哪些影响key指定的属性依赖的属性所对应的字符串。所以对于fullName属性,该方法的实现如下:

        + (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key{
            NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
            if ([key isEqualToString:@"fullName"]) {
                NSArray *affectingKeys = @[@"name", @"nick"];
                keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];
            }
            return keyPaths;
        }
    

    下面看看fullName监听效果,见下图:

    image.png

  • 集合属性的监听

    对于集合的KVO,我们需要了解的一点是,KVO旨在观察关系(relationship)而不是集合。对于不可变集合属性,我们更多的是把它当成一个整体来监听,而无法去监听集合中的某个元素的变化;对于可变集合属性,实际上也是当成一个整体,去监听它整体的变化,如添加、删除和替换元素。

    监听集合整体的变化,见下图案例:

    image.png

    如果想监听集合中数据的变化,如添加、删除和替换元素该如何处理呢?向可变数组中添加元素,这种处理方式没有效果。见下图:

    image.png

    我们知道KVO键值监听实现的基础是KVC。我们以数组为例,在我们的Person类中有一个dateArray数组属性,如果我们希望响应dateArray所有的方法,则需要实现以下方法:

    image.png

    所以对于可变集合,我们不使用valueForKey:来获取对象,而是使用以下方法:

    - (NSMutableArray *)mutableArrayValueForKey:(NSString *)key;
    

    分别对可变数组中的元素进行添加、修改、替换,运行结果,见下图:

    image.png

    发现了一个问题,kind发生了变化,输出值为234。这是因为,KVO机制能在集合改变的时候把详细的变化放进change字典中。

    补充:集合(Set)也有一套对应的方法来实现集合代理对象,包括无序集合与有序集合;而字典则没有,对于字典属性的监听,还是只能作为一个整理来处理。

    如果我们想到手动控制集合属性消息的发送,则可以使用上面提到的几个方法,即:

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

    不过得先保证把自动通知关闭,否则每次改变KVO都会被发送两次。

  • 变化字典

    观察者对象必须实现 -observeValueForKeyPath:ofObject:change:context:方法,来对属性修改通知做相应的处理。这个方法的声明如下:

    - (void)observeValueForKeyPath:(NSString *)keyPath
                          ofObject:(id)object
                            change:(NSDictionary *)change
                           context:(void *)context
    

    第三个参数,通常称之为变化字典(Change Dictionary),它记录了被监听属性的变化情况。这个字典中包含的值,会根据我们在添加观察者时设置的options参数的不同而有所不同,它包含了属性被修改的一些信息。我们可以通过以下key来获取我们想要的信息:

    
    typedef NSString * NSKeyValueChangeKey NS_STRING_ENUM;
    
    /* 
    Keys for entries in change dictionaries. See the comments for -observeValueForKeyPath:ofObject:change:context: for more information.
    */
    FOUNDATION_EXPORT NSKeyValueChangeKey const NSKeyValueChangeKindKey;
    FOUNDATION_EXPORT NSKeyValueChangeKey const NSKeyValueChangeNewKey;
    FOUNDATION_EXPORT NSKeyValueChangeKey const NSKeyValueChangeOldKey;
    FOUNDATION_EXPORT NSKeyValueChangeKey const NSKeyValueChangeIndexesKey;
    FOUNDATION_EXPORT NSKeyValueChangeKey const NSKeyValueChangeNotificationIsPriorKey API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0));
    

    其中,NSKeyValueChangeKindKey的值取自于NSKeyValueChange,它的值是由以下枚举定义的:

    enum {
         // 设置一个新值。被监听的属性可以是一个对象,也可以是一对一关系的属性或一对多关系的属性。
         NSKeyValueChangeSetting = 1,
         // 表示一个对象被插入到一对多关系的属性。
         NSKeyValueChangeInsertion = 2,
         // 表示一个对象被从一对多关系的属性中移除。
         NSKeyValueChangeRemoval = 3,
         // 表示一个对象在一对多的关系的属性中被替换
         NSKeyValueChangeReplacement = 4
     };
     typedef NSUInteger NSKeyValueChange;
    

3.KVO实现原理

了解了NSKeyValueObserving所提供的功能后,我们再来看看KVO的实现机制,以便更深入地的理解KVOKVO没有开源,所以我们无法从源代码的层面来分析它的实现。

我们从苹果官网的的说明中解开了一些谜团。见下图:

image.png

翻译过来:自动键值观察是使用一种称为isa-swizzling的技术实现的。isa指针指向维护调度表的对象的类。 该调度表主要包含指向类实现的方法的指针,以及其他数据。当观察者为对象的属性注册时,被观察对象的isa指针被修改,指向中间类而不是真正的类。 因此,isa指针的值不一定反映实例的实际类。

所以这里就有了探索目标:这个isa指向的中间类是什么?kvo观察的是setter方法,setter方法做了什么,调用的又是谁的setter方法?移除监听后这个中间类是否销毁呢?

带着这些问题,进行KVO原理探索。

  • 寻找中间类NSKVONotifying_LGPerson

    首先我们通过设置断点,来逐步跟踪person对象isa指针所指向的类,见下图:

    image.png

    在添加监听之前,person对象对应的类是LGPerson,添加过监听之后,person对象isa指向的类是NSKVONotifying_LGPerson。这个类应该就是官网中说到的中间类

    那么这个中间类是何时创建的呢?我们在调用addObserver:forKeyPath:options:context:方法之前,获取NSKVONotifying_LGPerson这个类,发现这个类并不存在。见下图:

    image.png

    说明这个类应该是通过runtime在运行时动态生成的。

  • NSKVONotifying_LGPersonLGPerson的关系

    这个中间类NSKVONotifying_LGPerson,与LGPerson有什么关系呢?通过lldb调试,打印NSKVONotifying_LGPerson类的地址,获取其内存空间,发现NSKVONotifying_LGPerson的父类是LGPerson类。

    image.png

    所以,NSKVONotifying_LGPersonLGPerson的子类。

  • 中间类提供的方法

    NSKVONotifying_LGPerson中间类找到了,并且是LGPeron的子类,那么NSKVONotifying_LGPerson提供了哪些方法呢?提供下面一个辅助方法,用来获取类中的方法列表。

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

    在调用addObserver:forKeyPath:options:context:方法之后,调用该辅助方法,查看NSKVONotifying_LGPerson类中有哪些功能。见下图:

    image.png

    发现中间类重写了父类的四个方法。分别是setNickNameclassdealloc_isKVOA

  • 对象的isa何时修复

    通过上面的分析,我们发现在调用addObserver:forKeyPath:options:context:方法之后,对象的isa指向了一个中间类,那么isa和在重新执行LGPerson类呢?

    这里我们很容易联想到- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;方法,也就是移除监听的时候。下面验证一下,见下图:

    image.png

    在调用- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;方法之后,对象的isa指针重新指向了LGPerson类。

    同时发现,在完成观察者的销毁之后,这个中间类依然存在,并没有被销毁。(为下次使用做准备,性能的考虑,避免重复创建)见下图:

    image.png

  • 中间类中setter方法的作用

    这里setter方法做了什么,监听的是属性还是成员变量呢?我们做个监听分别采用操作属性和访问成员变量的方式,分别变更nickNamename,见下图:

    image.png

    说明KVO实际是通过setter方法监听的是属性。我们可以通过监听nickName成员变量来分析底层调用过程。见下图:

    image.png

    通过堆栈我们可以发现,在调用setNickName方法是,底层实际是调用了下面的流程:

    1. Foundation _NSSetObjectValueAndNotify
    2. Foundation -[NSObject(NSKeyValueObservingPrivate) _changeValueForKey:key:key:usingBlock:]
    3. Foundation -[NSObject(NSKeyValueObservingPrivate) _changeValueForKeys:count:maybeOldValuesDict:maybeNewValuesDict:usingBlock:]
  • 总结

    Objective-C依托于强大的run time机制来实现KVO。当我们第一次观察某个对象的属性时,run time会创建一个新的继承自这个对象的classsubclass。在这个新的subclass中,它会重写所有被观察的keysetter,然后将对象的isa指针指向新创建的class(这个指针告诉Objective-C运行时某个对象到底是什么类型的)。所以实例对象神奇地变成了新的子类的实例。完成以上操作后,通过调用setter方法进行相关属性的变化时,操作的就是这个中间的子类。但是底层依然会将对中间类操作的状态,同步到原对象中。在进行监听移除后,对象的isa回复到原来的类上。