美文网首页
KVO使用进阶和底层原理

KVO使用进阶和底层原理

作者: Gintok | 来源:发表于2018-05-23 10:31 被阅读34次

    KVO使用

    KVO(key-value-observing)键值监听常用来监听特定对象中某属性值的变化,日常开发中我们常常监听数据模型的变化从而动态的修改对应视图。当然上述需求用代理和通知机制也可以完成,但它们都有各自的优缺点和适用场景,后面会详细介绍。

    常用方法

    常用的方法有如下几个,各参数含义见注释:

    /*
    注册观察者(监听器对象)
    观察者对象是observer,被观察者是消息的发送者(方法的调用者),在回调函数中会被回传
    观察的属性路径为keyPath,支持点语法的嵌套
    观察类型为options,支持按位或来监听多个事件类型
    观察上下文context,主要用于对多个观察者对象观察相同keyPath时进行区分
    添加观察者只会保留观察者对象的地址,不会增加引用,也不会在对象释放后置空,因此需要自己持有观察者对象的强引用,该参数也会在回调函数中回传
    */
    - (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context;
    
    /*
    删除观察者
    观察者对象为observer,被观察者对象为消息的发送者即方法的调用者,应与addObserver方法匹配
    观察的属性路径为keyPath,应与addObserver方法的keyPath匹配
    观察上下文context,应与addObserver方法的context匹配
    */
    - (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath context:(nullable void *)context API_AVAILABLE(macos(10.7), ios(5.0), watchos(2.0), tvos(9.0));
    
    /*
    与上一个方法相同,只是少了context参数
    推荐使用上一个方法,该方法由于没有传递context可能会产生异常结果
    */
    - (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;
    
    /*
    观察者对象的观察回调方法,被观察对象属性发生变化时,观察者会调用该方法
    keyPath即为观察的属性路径
    object为被观察的对象
    change保存被观察的值产生的变化
    context为观察上下文,由add方法回传
    */
    - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context;
    
    

    谨记,我们要在对象销毁前删除监听器。这要从KVO注册监听器开始说起。
    KVO在注册监听器的时候,不会持有观察者对象的引用,也不会像weak那样,在观察者对象被销毁时置nil,而仅仅保留观察者对象的地址,类似于copy。当观察者对象被销毁而又没有删除监听器时,如果这个时候被观察对象的值发生变化系统会执行监听器的回调函数,这个时候观察者对象已经不存在了,KVO保留的地址就是一个野指针,因此会产生野指针错误。
    下面来看一个产生上面情况的例子:

    程序界面如图所示


    程序界面

    操作过程为:先点击紫色按钮,跳转到黄色试图控制器,再点击黑色按钮回到绿色试图控制器,最后点击红色按钮。

    // 示例程序共有两个viewController,一个是根控制器viewcontroller,一个是displayViewcontroller。
    // 根控制器上有两个按钮,上面的按钮用来跳转到displayViewcontroller。下面的按钮用来改变被观察属性的值。
    // displayViewcontroller上有一个按钮,用来退出当前控制器
    
    // viewController.m
    - (IBAction)jumpToDisplayButton:(UIButton *)sender {
        HuPerson *person = [[HuPerson alloc] init];// HuPerson是要观察的模型对象
        _person = person;
        HuDisplayViewController *displayVC = [[HuDisplayViewController alloc] initWithModel:person];
        [self presentViewController:displayVC animated:YES completion:nil];
    }
    - (IBAction)changeAgeButton:(UIButton *)sender {
        _person.age = 20;
    }
    
    // displayViewcontroller.m 
    -(instancetype)initWithModel:(HuPerson *)person {
        if (self = [super init]) {
            _person = person;
            // 创建监听器
            [_person addObserver:self forKeyPath:@"age" options:NSKeyValueObservingOptionNew context:nil];
        }
        return self;
    }
    // 监听器的回调方法
    - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
        if (object == self.person && [keyPath  isEqual: @"age"]) {
            NSLog(@"%@", change);
        }
    }
    // 注释掉该段代码,测试如果不删除监听器会发生什么情况
    //- (void)dealloc {
    //    [_person removeObserver:self forKeyPath:@"age" context:nil];
    //}
    
    - (IBAction)clickButton:(UIButton *)sender {
        [self dismissViewControllerAnimated:self completion:nil];
    }
    

    我们在开发中经常会遇到这种需求:在一个页面获取到数据后,使用另一个页面来展示数据,第二个界面很有可能根据需求来监听模型对象。如果我们像上面的代码一样,没有在观察者对象销毁的时候释放监听器,那么在点击viewController第二个按钮的时候,就会产生野指针错误。

    因为在第二个按钮的点击方法中,我们改变了被观察对象属性的值,由于前一个视图中没有释放监听器,KVO中仍有监听器的存在,此时会触发监听器的回调方法,但displayViewcontroller已经被销毁了,因此产生野指针。

    KVO中一对多 和 多对一

    • KVO支持多个观察者对象观察同一对象的某个属性。上面的例子中,我们在viewController中也添加对person.age的监听。当age属性发生变化的时候,监听器会触发所有监听该属性的回调函数。
    • KVO也支持一个观察者对象观察多个属性。可以按照我们常用的通过keyPath字符串来判断产生回调的具体是哪个属性值,但如果监听很多属性值,这样的方法看起来很凌乱,而且逐一进行字符串判断很浪费资源,并且当我们在后期修改了属性的名称还不能忘记修改监听器的keyPath判断语句,那有什么办法能够取代keyPath吗?答案是context,之前我经常直接将context置为nil,但context才是KVO保证正确运行的关键,context也是苹果推荐我们的做法。

    context参数的使用

    context是一个id类型的参数,在注册监听器时可以传入该参数,在回调函数中会回传该参数,因此,该参数可以解决KVO中一对多,多对一产生的一些问题。那context这个id类型的参数设置为什么值比较合适呢?可能第一感觉还是设置为NSString类型,但这样仍然可能会产生冲突,苹果推荐的做法是创建一个静态变量然后使用该静态变量的地址作为context,通过这样的方法就能够保证context唯一。
    首先来看一个例子:

    /*
    本例子需要使用三个UIViewController
    ViewController是根视图控制器
    DisplayViewController 父视图控制器
    SubViewController 子视图控制器
    ViewController不监听模型,包括一个按钮用于创建SubViewController并展示
    DisplayViewController跟上述例子中的一样
    SubViewController继承DisplayViewController并且也创建了监听器来监听person.age属性
    */
    
    //ViewController部分代码如下
    //该控制器只有一个按钮
    - (void)buttonClicked
    {
        SubViewController *vc = [[SubViewController alloc] initWithModel:self.person];
        [self presentViewController:vc animated:YES completion:nil];
    }
    
    //DisplayViewController的部分代码如下
    //为了便于输出这里使用的是NSString类型的context
    static void * DisplayViewControllerBalanceObserverContext = @"父类的context";
    
    //在初始化方法中输入上面的变量作为context进行监听器的注册
    [self.person addObserver:self forKeyPath:@"age" options:NSKeyValueObservingOptionInitial context:DisplayViewControllerBalanceObserverContext];
    
    //退出按钮方法
    - (void)exitButtonClickedHandler
    {
        [self dismissViewControllerAnimated:YES completion:nil];
    }
    
    //模拟修改模型数据变化的按钮
    - (void)changeValueButtonClickedHandler
    {
        self.person.age = 110;
    }
    
    //监听器回调函数
    - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
    {
        //将void *的context转换为NSString类型
        NSString *d = (__bridge NSString*)context;
        NSLog(@"%@", d);
    
        if (context == DisplayViewControllerBalanceObserverContext)
        {
            NSLog(@"父类监听的age属性改变了%lf", self.person.age);
        }
    }
    
    //删除监听器
    - (void)dealloc
    {
        [self.model removeObserver:self forKeyPath:@"age" context:DisplayViewControllerBalanceObserverContext];
    }
    
    //SubViewController部分代码如下
    //为了便于输出使用NSString类型的context
    static void * SubViewControllerBalanceObserverContext = @"子类的context";
    
    - (instancetype)initWithModel:(HuPerson *)model;
    {
        if (self = [super initWithModel:model])
        {
            //注册监听器
            [self.person addObserver:self forKeyPath:@"age" options:NSKeyValueObservingOptionNew context:SubViewControllerBalanceObserverContext];
        }
        return self;
    }
    
    //监听器回调方法
    - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
    {
    
        NSString *d = (__bridge NSString*)context;
        NSLog(@"%@", d);
        if (context == SubViewControllerBalanceObserverContext)
        {
            NSLog(@"子类监听的age值改变了: %lf", self.person.age);
        }
    
    }
    
    //删除监听器
    - (void)dealloc
    {
        [self.person removeObserver:self forKeyPath:@"age" context:SubViewControllerBalanceObserverContext];
    

    上述代码运行后,根视图控制器为ViewController展示一个按钮,点击后会创建SubViewController并展示,此时会有两个按钮,一个退出、一个修改模型值,接下来点击修改模型值按钮会发现有如下输出:

    子类的context
    子类监听的age值改变了:110
    父类的context
    

    当我们点击修改模型按钮后会触发监听器的回调函数,然后执行SubViewController的回调方法就会输出上面两行的打印结果,那第三行是什么呢?第三行还是SubViewController的输出结果,但是打印的context却是DisplayViewController注册的,也就是说,KVO在触发回调函数时会向所有注册了的监听器发送回调信息,也就是所有注册了的监听器都会执行回调函数,但由于继承关系的存在没有执行父类的回调函数而是执行了两次子类的回调函数,因此,为了使得父类也能够正确执行监听器的回调函数,在子类的回调函数中应当手动调用,所以子类监听器回调函数正确的写法应是如下代码:

    - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
    {
        if (context == SubViewControllerBalanceObserverContext)
        {
            NSLog(@"SubViewController NewAge: %lf", self.person.age);
        }
        else
        {
            [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
        }
    }
    

    这里我们是展示了使用正确使用context会避免很多问题,正如大多数人平常开发的时候那样,如果我们设置context为nil,会发生什么情况呢?

    仅仅通过keyPath判断,根本无法得知继承的父类是否也在监听同一对象,如果我们继承的是第三方的框架,很可能就会产生未知的异常。苹果也建议我们针对我们监听的每一个属性都创建一个context,不建议使用keyPath来做字符串的判断,并且字符串判断的效率也很低.

    手动触发KVO

    有时我们可能有一些需求,在属性值满足要求下才去触发KVO,我们可以直接在回调函数中进行判断就好,但是当我们开发一些供他人使用的框架时我们不能保证其他用户能够按照要求进行条件判断,此时就需要手动触发KVO。

    触发监听器回调函数时需要满足一个类方法:
    
    //age属性实现该方法
    + (BOOL)automaticallyNotifiesObserversOfAge
    
    //其他属性按照以下格式实现类方法
    + (BOOL)automaticallyNotifiesObserversOfName
    

    通过函数名就可以判断,该函数是用来判断是否自行进行监听器通知,默认返回true,因此默认情况下都是自动触发KVO的回调函数,如果要手动触发则需要返回false并在需要触发KVO回调函数的地方执行以下方法:

    //对需要触发回调函数的属性名称调用如下方法
    [self willChangeValueForKey:@"balance"];
    //为其赋新值
    _balance = balance;
    [self didChangeValueForKey:@"balance"];
    

    注意,上面两个函数要写在触发回调函数的前后,即属性的set方法中。

    KVO监听NSMutableArray内部的变化

    简单理解KVO底层实现原理,其实是重写了观察属性的set方法,详细内容会在下面一节解释。然而对于NSMutableArray来讲,添加、删除元素并没有调用set方法,因此不会触发KVO,我们也就监听不到NSMutableArray的变化,有什么办法呢?
    苹果官方为我们提供了一个方法

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

    用一个例子来说明该方法的使用:
    myItems是我们进行KVO的一个属性,定义如下:

    @property(nonatomic, strong) NSMutableArray *myItems;
    

    按照上面所讲的正常方法对其添加观察者,但是在它添加元素时,使用如下方法:

    [[self mutableArrayValueForKey:@"myItems"] addObject:@"one"];
    

    这样,我们便用KVO实现了对可变数组的监听。

    总结

    • 使用静态变量的地址作为context,并且为每一个监听的属性都创建一个context,尽量不使用keyPath作为区分条件。
    • addObserver与removeObserver必须要成套出现,建议在dealloc方法中删除监听器对象。
    • 如果有继承关系,在监听器回调函数中将不是当前类处理的context调用父类的监听器回调函数进行处理。
    • 删除监听器时需要注意不要重复删除,尽量使用context删除。

    KVO底层原理

    原理剖析

    当对一个对象的属性第一次进行监听器注册后,编译器会默认生成一个名称为NSKVONotifying_原有类名称的派生中间类,该类继承原有类,然后修改原有类对象的isa指针,使其指向新生成的中间类,接着,会在派生类中修改监听属性的setter和getter方法,执行willChangeValueForKey:和didChangeValueForKey:方法和父类的setter方法,并通知所有监听的对象,监听属性被修改了。

    因此,对于使用KVO监听的类来说,isa指针的指向并不一定指向对象的实际类。我们不应该依赖isa指针去决定类的成员关系,而应该使用class方法去正确的获取对象的实际类。

    实现自定义的KVO监听

    此部分后续更新...

    相关文章

      网友评论

          本文标题:KVO使用进阶和底层原理

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