美文网首页
KVO 正确使用姿势进阶及底层实现

KVO 正确使用姿势进阶及底层实现

作者: pengshuangta | 来源:发表于2018-07-19 14:53 被阅读38次

    转载:原文链接

    本系列文章主要通过讲解KVC、KVO、Delegate、Notification的使用方法,来探讨KVO、Delegate、Notification的区别以及相关使用场景,本系列文章将分一下几篇文章进行讲解,读者可按需查阅。

    KVO 正确使用姿势进阶及底层实现

    KVO(key value observing)键值监听是我们在开发中常使用的用于监听特定对象属性值变化的方法,常用于监听数据模型的变化从而可以动态的修改对应视图。能够上述需求的方法有很多,后面要讲的DelegateNotification都可以实现,但都有各自的优缺点和适用场景,需要根据实际情况按需选择,但三者都很重要,在开发中都会使用。

    KVC相同,OC在实现KVO时没有采用实现接口的方式,而是针对NSObject创建了一个类别,通过这样的方式使得NSObject的子类可以自行实现NSKeyValueObserving类别定义的相关方法,其他的如NSArrayNSSet这样的集合类也都定义了相关的类别,因此也可以对集合类型进行KVO的监听。本文主要进行KVO进阶讲解,基础知识还需读者自行查阅。

    学习KVO最好的方法就是阅读官方文档:Key-Value Observing Programming Guide

    KVO基础方法详解进阶

    KVO常用的方法有如下几个:

    /*
    注册监听器
    监听器对象为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;
    

    举一个简单的栗子:

    #import <Foundation/Foundation.h>
    
    @interface Account: NSObject
    
    @property (nonatomic, copy) NSString *accountNumber;
    @property (nonatomic, assign) double balance;
    
    @end
    
    @implementation Account
    
    @synthesize accountNumber = _accountNumber;
    @synthesize balance = _balance;
    
    @end
    
    @interface Person : NSObject
    
    @property (nonatomic, copy) NSString *name;
    @property (nonatomic, assign) NSUInteger age;
    @property (nonatomic, strong) Account *account;
    
    - (void)setObserver;
    
    @end
    
    @implementation Person
    
    @synthesize name = _name;
    @synthesize age = _age;
    
    //添加监听器
    - (void)setObserver
    {
        /*
        监听器对象为Person类的对象本身,被监听的对象为Person类对象持有的account
        监听的属性路径为account的balance,可以监听嵌套的对象比如account有一个对象是bank可以监听bank是否营业,可以写"bank.isOpen"
        监听上下文设置为nil,相信很多人在使用的时候都会这么写
        */
        [self.account addObserver:self forKeyPath:@"balance" options:NSKeyValueObservingOptionNew context:nil];
    }
    
    //监听器回调方法
    - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
    {
        //判断被监听对象是否为account,并且通过NSString来判断监听属性路径是否一致
        if (object == self.account && [keyPath isEqualToString:@"balance"])
        {
            NSLog(@"NewBalance: %lf", self.account.balance);
        }
    }
    
    //Person销毁时调用的方法
    - (void)dealloc
    {
        /*
        切记,当我们添加监听器时一定要在对象被销毁前删除该监听器
        删除监听器传递的参数要与添加监听器传参一致
        监听器也不可以重复删除,如果没有注册监听器而去执行删除操作也会抛出异常
        */
        [self.account removeObserver:self forKeyPath:@"balance" context:nil];
    }
    
    @end
    
    int main(int argc, const char * argv[]) {
        @autoreleasepool {
    
            Person *p = [[Person alloc] init];
    
            p.account = [[Account alloc] init];
            p.account.balance = 100.0;
            //添加监听器
            [p setObserver];
            //重新对account的balance赋值后会触发回调函数
            //输出: NewBalance: 200.0
            p.account.balance = 200.0;        
        }
        return 0;
    }
    

    上面的例子很简单,运行结果也很正常,在Person类对象被销毁前也进行了监听器的删除操作,并且运行结果也很正常,相信很多人在实际的开发过程中也都是按照这样方式实现的KVO,不幸的是,上面的写法有很多缺陷。

    首先,讲解一下为什么要在对象被销毁前删除监听器,我们在开发中使用KVO时很可能会遇到因为没有删除监听器而产生的野指针错误。

    KVO在注册监听器的时候不会持有监听器对象的引用,也不会像weak那样在监听器对象被销毁时置nil,而是仅仅保留监听器对象的地址,类似于copy修饰符,当监听器对象被销毁而又没有删除监听器时,如果这个时候被监听对象的值发生变化系统会执行监听器的回调函数,这个时候监听器对象已经不存在了,KVO保留的地址就是一个野指针,因此会产生野指针错误。上面的栗子由于在对象被销毁前没有修改account.balance的值,因此哪怕不删除监听器也不会产生野指针异常,但我们需要注意的是,要时刻保证addObserverremoveObserver成对出现,避免野指针错误的产生。

    接下来举一个会产生野指针异常的栗子:

    /*
    首先实现两个UIViewController
    以下代码为ViewController代码,在ViewController中添加两个按钮,并分别添加两个点击事件。其他代码不再展示,读者可自行完善
    */
    
    //第一个按钮点击处理器
    - (void)buttonClicked
    {
        /*
        另一个UIViewController为DisplayViewController
        在开发中经常会遇到这样的情形,需要创建一个VC来展示Model的数据
        以下两行代码就是用来创建并展示该VC
        */
        DisplayViewController *vc = [[DisplayViewController alloc] initWithModel:self.model];
        [self presentViewController:vc animated:YES completion:nil];
    }
    
    //第二个按钮点击处理器
    - (void)button2Clicked
    {
        //模拟模型数据发生变化
        self.model.balance = 8888;
    }
    
    
    /*
    接下来实现DisplayViewController
    假设DisplayViewController中需要对Model进行进一步处理,所以需要监听Model的balance属性,并在initWithModel:初始化方法中添加监听器
    */
    //初始化方法,添加一个退出按钮,并添加model的balance属性监听器
    - (instancetype)initWithModel:(Model*)model;
    {
        if (self = [super init])
        {
            self.view.backgroundColor = [UIColor whiteColor];
    
            self.model = model;
    
            //创建监听器
            [self.model addObserver:self forKeyPath:@"balance" options:NSKeyValueObservingOptionInitial context:nil];
    
            self.exitButton = [UIButton buttonWithType:UIButtonTypeCustom];
            self.exitButton.frame = CGRectMake(150, 200, 80, 80);
            self.exitButton.backgroundColor = [UIColor blackColor];
            [self.exitButton addTarget:self action:@selector(exitButtonClickedHandler) forControlEvents:UIControlEventTouchUpInside];
            [self.view addSubview:self.exitButton];
        }
        return self;
    }
    //退出按钮处理器
    - (void)exitButtonClickedHandler
    {
        //直接退出当前页面
        [self dismissViewControllerAnimated:YES completion:nil];
    }
    
    //监听model的balance属性
    - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
    {
        if (object == self.model && [keyPath isEqualToString:@"balance"])
        {
            NSLog(@"New Balance %lf", self.model.balance);
        }
    }
    
    //以下dealloc方法注释,因此当DisplayViewController销毁时不会删除监听器
    //- (void)dealloc
    //{
    //    [self.model removeObserver:self forKeyPath:@"balance" context:nil];
    //}
    

    上述代码完成后,运行程序,ViewController页面如下:

    ViewController页面

    该视图只有两个按钮,Click Me为第一个按钮,点击后触发buttonClicked方法,该方法创建DisplayViewController后直接展示出来,DisplayViewController页面如下:

    DisplayViewController页面

    该视图只有一个按钮,点击黑色按钮后退出页面,回到ViewController视图中,此时并没有任何错误产生,尽管我们在DisplayViewController销毁后也没有删除其监听器,这个逻辑在开发中经常遇到,在一个页面获取到数据后使用另一个页面来展示相关数据,另一个页面很有可能会根据需求来监听模型对象。此时如果点击第二个按钮BTN2不幸的事情就会产生,在button2Clicked方法中会产生野指针错误,因为在该方法中修改了model.balance的值,由于前一个视图中没有删除监听器,KVO中仍然有监听器的存在,此时会触发监听器的回调方法,但DisplayViewController早已销毁,因此产生野指针错误,当我们把DisplayViewControllerdealloc方法去掉注释后一切运行正常,因为在DisplayViewController销毁时也删除了监听器。

    上面这个栗子产生的野指针错误正是因为KVO使用不正确,可能有些读者没有在监听器销毁前删除监听器也没有发生过任何异常,因此不太注意,但KVO正确使用姿势一定是在监听器对象销毁前删除监听器。

    上面的例子看似解决了一个问题,需要注意的是上面的栗子在创建监听器时传入的contextnil,可能很多初学者都会这么写,接下来继续看一个栗子:

    /*
    本示例与上一个栗子相同,只是在ViewController中注册了model.balance的监听器
    */
    //ViewController.m
    //在初始化时注册model.balance监听器
    
    /*
    DisplayViewController与上一个栗子一样,但多添加一个按钮
    */
    - (void)changeValueButtonClickedHandler
    {
        self.model.balance = 8989;
    }
    

    上面这个栗子与前一个类似,只不过在ViewController中同样添加了对model.balance的监听,也就是说两个ViewControllerDisplayViewController都监听了同一个对象的属性值,这在开发中也很常见,在DisplayViewController中添加了一个按钮用于模拟在DisplayViewController中修改model.balance值的操作,现在两个视图都监听了同一对象的属性值,那当我们展示DisplayViewController后修改了model.balance的值,此时会触发哪个视图的回调函数呢?实验一下就能发现两个视图的监听器回调函数都触发了。

    KVO还有一个可能会产生错误的地方,在看下一个栗子之前有一点需要说明,有时候我们可能在一个视图中监听很多模型对象,当然了可以按照我们常用的通过keyPath字符串来判断产生回调的具体是哪个属性值,但如果监听很多属性值,这样的方法似乎看起来很凌乱,而且逐一进行字符串判断感觉很浪费资源,并且当我们在后期修改了属性的名称还不能忘记修改监听器的keyPath判断语句,那有什么办法能够取代keyPath吗?答案是context,初学者经常直接将context置为nil,但context才是KVO保证正确运行的关键。

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

    接下来看下一个栗子:

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

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

    SUB CCCCCCCAAAA
    SubViewController NewBalance: 8989.000000
    SUB DDDDDDDD
    

    这个结果是不是有点出乎意料,当我们点击修改模型按钮后会触发监听器的回调函数,然后执行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 NewBalance: %lf", self.model.balance);
        }
        else
        {
            [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
        }
    }
    

    context不属于子类定义时应当调用父类的监听器回调函数,其实这里还少了一个栗子,就是不使用context,当我们不使用context仅仅通过keyPath判断,根本无法得知继承的父类是否也在监听同一对象,如果我们继承的是第三方的框架,很可能就会产生未知的异常。苹果也建议我们针对我们监听的每一个属性都创建一个context,不建议使用keyPath来做字符串的判断,并且字符串判断的效率也很低,正确的context写法如下:

    //静态变量的地址可以保证context的独一无二
    static void * SubViewControllerBalanceObserverContext = &SubViewControllerBalanceObserverContext;
    

    手动触发KVO

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

    触发监听器回调函数时需要满足一个类方法:

    //balance属性实现该方法
    + (BOOL)automaticallyNotifiesObserversOfBalance
    
    //其他属性按照以下格式实现类方法
    + (BOOL)automaticallyNotifiesObserversOfXXXX
    

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

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

    举个栗子如下:

    #import <Foundation/Foundation.h>
    
    @interface Account: NSObject
    
    @property (nonatomic, copy) NSString *accountNumber;
    @property (nonatomic, assign) double balance;
    
    @end
    
    @implementation Account
    
    @synthesize accountNumber = _accountNumber;
    @synthesize balance = _balance;
    
    - (void)setBalance:(double)balance
    {
        //如果新值小于0不触发KVO
        if (balance < 0)
        {
            _balance = balance;
        }
        else
        {
            //新值大于0才触发KVO回调函数
            [self willChangeValueForKey:@"balance"];
            _balance = balance;
            [self didChangeValueForKey:@"balance"];
        }
    }
    
    + (BOOL)automaticallyNotifiesObserversOfBalance
    {
        return NO;
    }
    
    
    @end
    
    @interface Person : NSObject
    
    @property (nonatomic, copy) NSString *name;
    @property (nonatomic, assign) NSUInteger age;
    @property (nonatomic, strong) Account *account;
    
    - (void)setObserver;
    
    @end
    
    @implementation Person
    
    @synthesize name = _name;
    @synthesize age = _age;
    
    - (void)setObserver
    {
        [self.account addObserver:self forKeyPath:@"balance" options:NSKeyValueObservingOptionNew context:nil];
    }
    
    - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
    {
        if (object == self.account && [keyPath isEqualToString:@"balance"])
        {
            NSLog(@"NewBalance: %lf", self.account.balance);
        }
    }
    
    - (void)dealloc
    {
        [self.account removeObserver:self forKeyPath:@"balance" context:nil];
    }
    
    @end
    
    
    int main(int argc, const char * argv[]) {
        @autoreleasepool {
    
            Person *p = [[Person alloc] init];
    
            p.account = [[Account alloc] init];
            p.account.balance = 100.0;
            [p setObserver];
            //执行下面的代码不会触发KVO回调函数
            p.account.balance = -1000;
            //执行下面这行代码会输出 NewBalance: 220.000000
            p.account.balance = 220.0;
        }
        return 0;
    }
    

    总结

    通过上面一系列的例子可以发现KVO的坑挺多的,虽然基本的使用方法很简单,但是需要注意的地方也有很多。正确的使用姿势应当如下:

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

    KVO底层实现

    在官方文档中有一点简介如下:

    Key-Value Observing Implementation Details
    
    Automatic key-value observing is implemented using a technique called isa-swizzling.
    
    The isa pointer, as the name suggests, points to the object's class which maintains a dispatch table. This dispatch table essentially contains pointers to the methods the class implements, among other data.
    
    When an observer is registered for an attribute of an object the isa pointer of the observed object is modified, pointing to an intermediate class rather than at the true class. As a result the value of the isa pointer does not necessarily reflect the actual class of the instance.
    
    You should never rely on the isa pointer to determine class membership. Instead, you should use the class method to determine the class of an object instance.
    

    关于isa指针、isa-swizzling本博客都有详细介绍,有兴趣的读者可以自行查阅: iOS runtime探究(一): 从runtime开始理解面向对象的类到面向过程的结构体

    KVO的实现使用了isa-swizzling技术以及观察者模式。
    isa指针指向了对象的类对象,这个类对象维护着一个分发表,分发表保存了类方法、成员方法实现的指针。

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

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

    相关文章

      网友评论

          本文标题:KVO 正确使用姿势进阶及底层实现

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