KVO原理分析及使用进阶

作者: 刘小壮 | 来源:发表于2018-03-18 19:06 被阅读2986次
    该文章属于<简书 — 刘小壮>原创,转载请注明:

    <简书 — 刘小壮> https://www.jianshu.com/p/badf5cac0130


    我们在工作中经常会用到KVO,但是系统原生的KVO并不好用,很容易导致Crash。而且编写代码时,需要编写大量KVO相关的代码,由于不支持block的形式,代码会写的很分散。

    本篇文章对KVO的实现原理进行了详细的分析,并且简单的实现了一个KVO,来当做技术交流。由于系统提供的KVO存在很多问题,在文章的最下面给出了解决方案。


    博客配图

    概述

    KVO全称KeyValueObserving,是苹果提供的一套事件通知机制。允许对象监听另一个对象特定属性的改变,并在改变时接收到事件。由于KVO的实现机制,所以对属性才会发生作用,一般继承自NSObject的对象都默认支持KVO

    KVONSNotificationCenter都是iOS中观察者模式的一种实现。区别在于,相对于被观察者和观察者之间的关系,KVO是一对一的,而一对多的。KVO对被监听对象无侵入性,不需要修改其内部代码即可实现监听。

    KVO可以监听单个属性的变化,也可以监听集合对象的变化。通过KVCmutableArrayValueForKey:等方法获得代理对象,当代理对象的内部对象发生改变时,会回调KVO监听的方法。集合对象包含NSArrayNSSet

    基础使用

    使用KVO分为三个步骤:

    1. 通过addObserver:forKeyPath:options:context:方法注册观察者,观察者可以接收keyPath属性的变化事件。
    2. 在观察者中实现observeValueForKeyPath:ofObject:change:context:方法,当keyPath属性发生改变后,KVO会回调这个方法来通知观察者。
    3. 当观察者不需要监听时,可以调用removeObserver:forKeyPath:方法将KVO移除。需要注意的是,调用removeObserver需要在观察者消失之前,否则会导致Crash

    注册方法

    在注册观察者时,可以传入options参数,参数是一个枚举类型。如果传入NSKeyValueObservingOptionNewNSKeyValueObservingOptionOld表示接收新值和旧值,默认为只接收新值。如果想在注册观察者后,立即接收一次回调,则可以加入NSKeyValueObservingOptionInitial枚举。

    还可以通过方法context传入任意类型的对象,在接收消息回调的代码中可以接收到这个对象,是KVO中的一种传值方式。

    在调用addObserver方法后,KVO并不会对观察者进行强引用,所以需要注意观察者的生命周期,否则会导致观察者被释放带来的Crash

    监听方法

    观察者需要实现observeValueForKeyPath:ofObject:change:context:方法,当KVO事件到来时会调用这个方法,如果没有实现会导致Crashchange字典中存放KVO属性相关的值,根据options时传入的枚举来返回。枚举会对应相应key来从字典中取出值,例如有NSKeyValueChangeOldKey字段,存储改变之前的旧值。

    change中还有NSKeyValueChangeKindKey字段,和NSKeyValueChangeOldKey是平级的关系,来提供本次更改的信息,对应NSKeyValueChange枚举类型的value。例如被观察属性发生改变时,字段为NSKeyValueChangeSetting

    如果被观察对象是集合对象,在NSKeyValueChangeKindKey字段中会包含NSKeyValueChangeInsertionNSKeyValueChangeRemovalNSKeyValueChangeReplacement的信息,表示集合对象的操作方式。

    兼容的调用方式

    调用KVO属性对象时,不仅可以通过点语法和set语法进行调用,KVO兼容很多种调用方式。

    // 直接调用set方法,或者通过属性的点语法间接调用
    [account setName:@"Savings"];
     
    // 使用KVC的setValue:forKey:方法
    [account setValue:@"Savings" forKey:@"name"];
     
    // 使用KVC的setValue:forKeyPath:方法
    [document setValue:@"Savings" forKeyPath:@"account.name"];
    
    // 通过mutableArrayValueForKey:方法获取到代理对象,并使用代理对象进行操作
    Transaction *newTransaction = <#Create a new transaction for the account#>;
    NSMutableArray *transactions = [account mutableArrayValueForKey:@"transactions"];
    [transactions addObject:newTransaction];
    

    实际应用

    KVO主要用来做键值观察操作,想要一个值发生改变后通知另一个对象,则用KVO实现最为合适。斯坦福大学的iOS教程中有一个很经典的案例,通过KVOModelController之间进行通信。

    斯坦福大学 KVO示例

    注意点

    KVOaddObserverremoveObserver需要是成对的,如果重复remove则会导致NSRangeException类型的Crash,如果忘记remove则会在观察者释放后再次接收到KVO回调时Crash

    苹果官方推荐的方式是,在init的时候进行addObserver,在deallocremoveObserver,这样可以保证addremove是成对出现的,是一种比较理想的使用方式。

    手动调用KVO

    KVO在属性发生改变时的调用是自动的,如果想要手动控制这个调用时机,或想自己实现KVO属性的调用,则可以通过KVO提供的方法进行调用。

    - (void)setBalance:(double)theBalance {
        if (theBalance != _balance) {
            [self willChangeValueForKey:@"balance"];
            _balance = theBalance;
            [self didChangeValueForKey:@"balance"];
        }
    }
    

    可以看到调用KVO主要依靠两个方法,在属性发生改变之前调用willChangeValueForKey:方法,在发生改变之后调用didChangeValueForKey:方法。

    如果想控制当前对象的自动调用过程,也就是由上面两个方法发起的KVO调用,则可以重写下面方法。方法返回YES则表示可以调用,如果返回NO则表示不可以调用。

    + (BOOL)automaticallyNotifiesObserversForKey:(NSString *)theKey {
        BOOL automatic = NO;
        if ([theKey isEqualToString:@"balance"]) {
            automatic = NO;
        }
        else {
            automatic = [super automaticallyNotifiesObserversForKey:theKey];
        }
        return automatic;
    }
    

    实现原理

    KVO是通过isa-swizzling技术实现的(这句话是整个KVO实现的重点)。在运行时根据原类创建一个中间类,这个中间类是原类的子类,并动态修改当前对象的isa指向中间类。并且将class方法重写,返回原类的Class。所以苹果建议在开发中不应该依赖isa指针,而是通过class实例方法来获取对象类型。

    测试代码

    为了测试KVO的实现方式,我们加入下面的测试代码。首先创建一个KVOObject类,并在里面加入两个属性,然后重写description方法,并在内部打印一些关键参数。

    @interface KVOObject : NSObject
    @property (nonatomic, copy  ) NSString *name;
    @property (nonatomic, assign) NSInteger age;
    @end
    
    @implementation KVOObject
    
    - (NSString *)description {
        NSLog(@"object address : %p \n", self);
        
        IMP nameIMP = class_getMethodImplementation(object_getClass(self), @selector(setName:));
        IMP ageIMP = class_getMethodImplementation(object_getClass(self), @selector(setAge:));
        NSLog(@"object setName: IMP %p object setAge: IMP %p \n", nameIMP, ageIMP);
        
        Class objectMethodClass = [self class];
        Class objectRuntimeClass = object_getClass(self);
        Class superClass = class_getSuperclass(objectRuntimeClass);
        NSLog(@"objectMethodClass : %@, ObjectRuntimeClass : %@, superClass : %@ \n", objectMethodClass, objectRuntimeClass, superClass);
        
        NSLog(@"object method list \n");
        unsigned int count;
        Method *methodList = class_copyMethodList(objectRuntimeClass, &count);
        for (NSInteger i = 0; i < count; i++) {
            Method method = methodList[i];
            NSString *methodName = NSStringFromSelector(method_getName(method));
            NSLog(@"method Name = %@\n", methodName);
        }
        
        return @"";
    }
    

    在另一个类中分别创建两个KVOObject对象,其中一个对象被观察者通过KVO的方式监听,另一个对象则始终没有被监听。在KVO前后分别打印两个对象的关键信息,看KVO前后有什么变化。

    @property (nonatomic, strong) KVOObject *object1;
    @property (nonatomic, strong) KVOObject *object2;
    
    self.object1 = [[KVOObject alloc] init];
    self.object2 = [[KVOObject alloc] init];
    [self.object1 description];
    [self.object2 description];
    
    [self.object1 addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:nil];
    [self.object1 addObserver:self forKeyPath:@"age" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:nil];
    
    [self.object1 description];
    [self.object2 description];
    
    self.object1.name = @"lxz";
    self.object1.age = 20;
    

    下面是KVO前后打印的关键信息,我们在下面做详细分析。

    // 第一次
    object address : 0x604000239340
    object setName: IMP 0x10ddc2770 object setAge: IMP 0x10ddc27d0
    objectMethodClass : KVOObject, ObjectRuntimeClass : KVOObject, superClass : NSObject
    object method list
    method Name = .cxx_destruct
    method Name = description
    method Name = name
    method Name = setName:
    method Name = setAge:
    method Name = age
    
    object address : 0x604000237920
    object setName: IMP 0x10ddc2770 object setAge: IMP 0x10ddc27d0
    objectMethodClass : KVOObject, ObjectRuntimeClass : KVOObject, superClass : NSObject
    object method list
    method Name = .cxx_destruct
    method Name = description
    method Name = name
    method Name = setName:
    method Name = setAge:
    method Name = age
    
    // 第二次
    object address : 0x604000239340
    object setName: IMP 0x10ea8defe object setAge: IMP 0x10ea94106
    objectMethodClass : KVOObject, ObjectRuntimeClass : NSKVONotifying_KVOObject, superClass : KVOObject
    object method list
    method Name = setAge:
    method Name = setName:
    method Name = class
    method Name = dealloc
    method Name = _isKVOA
    
    object address : 0x604000237920
    object setName: IMP 0x10ddc2770 object setAge: IMP 0x10ddc27d0
    objectMethodClass : KVOObject, ObjectRuntimeClass : KVOObject, superClass : NSObject
    object method list
    method Name = .cxx_destruct
    method Name = description
    method Name = name
    method Name = setName:
    method Name = setAge:
    method Name = age
    

    我们发现对象被KVO后,其真正类型变为了NSKVONotifying_KVOObject类,已经不是之前的类了。KVO会在运行时动态创建一个新类,将对象的isa指向新创建的类,新类是原类的子类,命名规则是NSKVONotifying_xxx的格式。KVO为了使其更像之前的类,还会将对象的class实例方法重写,使其更像原类。

    在上面的代码中还发现了_isKVOA方法,这个方法可以当做使用了KVO的一个标记,系统可能也是这么用的。如果我们想判断当前类是否是KVO动态生成的类,就可以从方法列表中搜索这个方法。

    重写setter方法

    KVO调用栈

    KVO会重写keyPath对应属性的setter方法,没有被KVO的属性则不会重写其setter方法。在重写的setter方法中,修改值之前会调用willChangeValueForKey:方法,修改值之后会调用didChangeValueForKey:方法,这两个方法最终都会被调用到observeValueForKeyPath:ofObject:change:context:方法中。

    object_getClass

    为什么上面调用runtimeobject_getClass函数,就可以获取到真正的类呢?

    调用object_getClass函数后其返回的是一个Class类型,Classobjc_class定义的一个typedef别名,通过objc_class就可以获取到对象的isa指针指向的Class,也就是对象的类对象。

    由此可以推测,object_getClass函数内部返回的是对象的isa指针。

    typedef struct objc_class *Class;
    
    struct objc_class {
        Class _Nonnull isa  OBJC_ISA_AVAILABILITY;
    
    #if !__OBJC2__
        Class _Nullable super_class                              OBJC2_UNAVAILABLE;
        const char * _Nonnull name                               OBJC2_UNAVAILABLE;
        long version                                             OBJC2_UNAVAILABLE;
        long info                                                OBJC2_UNAVAILABLE;
        long instance_size                                       OBJC2_UNAVAILABLE;
        struct objc_ivar_list * _Nullable ivars                  OBJC2_UNAVAILABLE;
        struct objc_method_list * _Nullable * _Nullable methodLists                    OBJC2_UNAVAILABLE;
        struct objc_cache * _Nonnull cache                       OBJC2_UNAVAILABLE;
        struct objc_protocol_list * _Nullable protocols          OBJC2_UNAVAILABLE;
    #endif
    }
    

    缺点

    苹果提供的KVO自身存在很多问题,首要问题在于,KVO如果使用不当很容易崩溃。例如重复addremove导致的CrashObserver被释放导致的崩溃,keyPath传错导致的崩溃等。

    在调用KVO时需要传入一个keyPath,由于keyPath是字符串的形式,所以其对应的属性发生改变后,字符串没有改变容易导致Crash。我们可以利用系统的反射机制将keyPath反射出来,这样编译器可以在@selector()中进行合法性检查。

    NSStringFromSelector(@selector(isFinished))
    

    KVO是一种事件绑定机制的实现,在keyPath对应的值发生改变后会回调对应的方法。这种数据绑定机制,在对象关系很复杂的情况下,很容易导致不好排查的bug。例如keyPath对应的属性被调用的关系很复杂,就不太建议对这个属性进行KVO,可以想一下RAC的信号脑补一下。

    自己实现KVO

    除了上面的缺点,KVO还不支持block语法,需要单独重写父类方法,这样加上addremove方法就会导致代码很分散。所以,我通过runtime简单的实现了一个KVO,源码放在我的Github上,叫做EasyKVO

    self.object1 = [[KVOObject alloc] init];
    [self.object1 lxz_addObserver:self originalSelector:@selector(name) callback:^(id observedObject, NSString *observedKey, id oldValue, id newValue) {
        // callback
    }];
    
    self.object1.name = @"lxz";
    [self.object1 lxz_removeObserver:self originalSelector:@selector(name)];
    

    调用代码很简单,直接通过lxz_addObserver:originalSelector:callback:方法就可以添加KVO的监听,可以通过callbackblock接收属性发生改变后的回调,而且方法的keyPath接收的是一个SEL类型参数,所以可以通过@selector()传入参数时进行方法合法性检查,如果是未实现的方法直接就会报警告。

    通过lxz_removeObserver:originalSelector:方法传入观察者和keyPath,当观察者所有keyPath都移除后则从KVO中移除观察者对象。

    如果重复addObserverremoveObserver也没事,内部有判断逻辑。EasyKVO内部通过weak对观察者做引用,并不会影响观察者的生命周期,并且在观察者释放后不会导致Crash。一次add方法调用对应一个block,如果观察者监听多个keyPath属性,不需要在block回调中判断keyPath

    注意

    需要注意的是,EasyKVO只是做技术交流,不建议在项目中使用。因为KVO实现需要考虑很多情况,继承关系、多个观察者等很多问题。

    KVOController

    想在项目中安全便捷的使用KVO的话,推荐Facebook的一个KVO开源第三方框架-KVOControllerKVOController本质上是对系统KVO的封装,具有原生KVO所有的功能,而且规避了原生KVO的很多问题,兼容blockaction两种回调方式。

    源码分析

    从源码来看还是比较简单的,主要分为NSObjectCategoryFBKVOController两部分。

    FBKVOController

    Category中提供了KVOControllerKVOControllerNonRetaining两个属性,顾名思义第一个会对observer产生强引用,第二个则不会。其内部代码就是创建FBKVOController对象的代码,并将创建出来的对象赋值给Category的属性,直接通过这个Category就可以懒加载创建FBKVOController对象。

    - (FBKVOController *)KVOControllerNonRetaining
    {
      id controller = objc_getAssociatedObject(self, NSObjectKVOControllerNonRetainingKey);
      
      if (nil == controller) {
        controller = [[FBKVOController alloc] initWithObserver:self retainObserved:NO];
        self.KVOControllerNonRetaining = controller;
      }
      
      return controller;
    }
    

    FBKVOController部分

    FBKVOController中分为三部分,_FBKVOInfo是一个私有类,这个类的功能很简单,就是以结构化的形式保存FBKVOController所需的各个对象,类似于模型类的功能。

    还有一个私有类_FBKVOSharedController,这是FBKVOController框架实现的关键。从命名上可以看出其是一个单例,所有通过FBKVOController实现的KVO,观察者都是它。每次通过FBKVOController添加一个KVO时,_FBKVOSharedController都会将自己设为观察者,并在其内部实现observeValueForKeyPath:ofObject:change:context:方法,将接收到的消息通过blockaction进行转发。

    其功能很简单,通过observe:info:方法添加KVO监听,并用一个NSHashTable保存_FBKVOInfo信息。通过unobserve:info:方法移除监听,并从NSHashTable中将对应的_FBKVOInfo移除。这两个方法内部都会调用系统的KVO方法。

    在外界使用时需要用FBKVOController类,其内部实现了初始化以及添加和移除监听的操作。在调用添加监听方法后,其内部会创建一个_FBKVOInfo对象,并通过一个NSMapTable对象进行持有,然后会调用_FBKVOSharedController来进行注册监听。

    使用FBKVOController的话,不需要手动调用removeObserver方法,在被监听对象消失的时候,会在dealloc中调用remove方法。如果因为业务需求,可以手动调用remove方法,重复调用remove方法不会有问题。

    - (void)_observe:(id)object info:(_FBKVOInfo *)info
    {
        NSMutableSet *infos = [_objectInfosMap objectForKey:object];
    
        _FBKVOInfo *existingInfo = [infos member:info];
        if (nil != existingInfo) {
          return;
        }
    
        if (nil == infos) {
          infos = [NSMutableSet set];
          [_objectInfosMap setObject:infos forKey:object];
        }
    
        [infos addObject:info];
    
        [[_FBKVOSharedController sharedController] observe:object info:info];
    }
    

    因为FBKVOController的实现很简单,所以这里就很简单的讲讲,具体实现可以去Github下载源码仔细分析一下。

    相关文章

      网友评论

      • 小包包包:楼主可以深入点配合内存机制讲,不如为什么不成对出现就会有崩溃
        小包包包:@刘小壮 为NSObject添加了一个名为NSKeyValueObserverRegistration的Category,不是会为这个被观察着生成一个子类吗?还会生成一个类别?
        小包包包:@刘小壮 👍
        刘小壮:系统为了实现KVO,为NSObject添加了一个名为NSKeyValueObserverRegistration的Category,KVO的add和remove的实现都在里面。
        在移除的时候,系统会判断当前KVO的key是否已经被移除,如果已经被移除,则主动抛出一个NSException的异常。
        上面的这些,从KVO的崩溃堆栈中都可以看出来。
      • Maj_sunshine:我想问个问题 为什么对一个实例变量不是属性调用KVC,也能触发KVO操作。
        刘小壮:@郭仁磊 磊哥:blush:
        Maj_sunshine:@郭仁磊 我知道了
        郭仁磊:对一个实例变量调用KVC,KVC内部主动调用了对象的willChangeValueForKey:和didChangeValueForKey: 这两个方法,所以会触发KVO操作
      • CoderXY:概述中 KVO是一对一的,而一对多的 是不是漏写了NSNotificationCenter:stuck_out_tongue_closed_eyes:
        刘小壮:是的,大意了,多谢指出:+1:
      • Calvin_Shen:研究的好深入,自觉实力与楼主相差悬殊。学习~
        刘小壮:共同学习,共同进步。
      • Qinz:请问下文章最后这个点击“目录”有个弹窗怎么做的?
        刘小壮:@Qinz 如果是iOS的话tableView吧,但是这是Web,展示方式不太一样。
        Qinz:就是上一篇,目录,下一篇那个,请教下实现思路是怎样的?

      本文标题:KVO原理分析及使用进阶

      本文链接:https://www.haomeiwen.com/subject/rpqyqftx.html