iOS KVO原理探究

作者: xianminxiao | 来源:发表于2018-11-27 20:02 被阅读2次
    KVO原理探究.jpeg

    导语:

    KVO全称Key Value Observing,直译为键值观察。KVO 作为 iOS 中一种强大并且有效的机制,为 iOS 开发者们提供了很多的便利;可以使用 KVO 来检测对象属性的变化、快速做出响应,这能够为开发者在开发强交互、响应式应用以及实现视图和模型的双向绑定时提供大量的帮助。

    Demo源码见KVODemo,主要从以下5个方面来探究KVO:

    1. KVO基本使用
    2. KVO触发模式
    3. KVO属性依赖
    4. KVO基本原理
    5. KVO容器观察

    1. KVO基本使用

    1.1 使用KVO分为三个步骤:

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

    1.2 addObserver方法

    在注册观察者时,可以传入下列参数:

    • Observer参数,观察者对象。

    • keyPath参数,需要观察的属性。由于是字符串形式,传错容易导致Crash。一般利用系统的反射机制NSStringFromSelector(@selector(keyPath))。

    • options参数,参数是一个枚举类型。

      NSKeyValueObservingOptionNew 接收新值,默认为只接收新值
      NSKeyValueObservingOptionOld 接收旧值
      NSKeyValueObservingOptionInitial 在注册时立即接收一次回调,在改变时也会发送通知
      NSKeyValueObservingOptionPrior 改变之 前发一一次,改变之后发-一次

    • Context参数,传入任意类型的对象,在接收消息回调的代码中可以接收到这个对象,是KVO中的一种传值方式。
      注意:调用addObserver方法后,KVO并不会对观察者进行强引用,所以需要注意观察者的生命周期,否则会导致观察者被释放带来的Crash。

    1.3 监听回调

    观察者需要实现observeValueForKeyPath:ofObject:change:context:方法,如果没有实现会导致Crash。
    里面参数:

    • keyPath : 监听属性名称
    • Object : 被观察对象
    • Change : 字典,字典中存放KVO属性相关的值,根据options时 传入的枚举来返回。
    • Context : 传入进来的上下文,一般在添加观察者时,留下一个入口,用于传值。

    Demo如下:

    NS_ASSUME_NONNULL_BEGIN
    @interface KVOModel : NSObject
    @property (nonatomic, strong) NSString* name;
    @end
    NS_ASSUME_NONNULL_END
    

    有个class为KVOModel,需要对类中的name属性进行监听

    @interface ViewController ()
    @property (nonatomic, strong) KVOModel* model;
    @end
    
    @implementation ViewController
    
    - (void)viewDidLoad {
        [super viewDidLoad];
        _model = [KVOModel new];
        // 注册
        [_model addObserver:self forKeyPath:NSStringFromSelector(@selector(name)) options:(NSKeyValueObservingOptionNew) context:nil];
    }
    /** 监听方法 */
    - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
    {
        NSLog(@"%@", change);
    }
    /** 屏幕touch */
    - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
    {
        static int magicNum;
        _model.name = [NSString stringWithFormat:@"name=%d", magicNum++];
    }
    

    运行结果:

    2018-11-23 00:47:57.035430+0800 KVODemo[40822:260341] {
        kind = 1;
        new = "name=0";
    }
    2018-11-23 00:47:58.197815+0800 KVODemo[40822:260341] {
        kind = 1;
        new = "name=1";
    }
    

    2. KVO触发模式

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

    @implementation KVOModel
    /** 模式调整 */
    + (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key
    {
        return NO;  // 改为手动模式
    }
    @end
    

    这样在ViewController中改name的值不会进到监听方法中,需要手动调用触发,在更改name地方需要做如下处理:

    /** 屏幕touch */
    - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
    {
        static int magicNum;
        [_model willChangeValueForKey:NSStringFromSelector(@selector(name))];
        _model.name = [NSString stringWithFormat:@"name=%d", magicNum++];
        [_model didChangeValueForKey:NSStringFromSelector(@selector(name))];
    }
    

    手动模式的好处,可能有一种需求在某些情况下在更改value的时候,不需要通知,有的时候需要通知,这个时候就需要手动模式来处理。
    如果把上面代码中对model中的name赋值给注视掉,再次去点击屏幕,会发现还是会进到监听方法中,这种情况下,监听方法调不调用与设置name无关,只是和有没有调用方法willChangeValueForKey:和didChangeValueForKey:有关。

    3. KVO属性依赖

    在开发过程中,model一般不会那么简单,比如KVOModel中有个Person类的属性,要观察Person属性中的属性变化,就不能上面那样方法进行处理,Person类:

    @interface Person : NSObject
    @property (nonatomic, assign) int age;
    @end
    

    KVOModel改为如下:

    @interface KVOModel : NSObject
    @property (nonatomic, strong) NSString* name;
    @property (nonatomic, strong) Person* person;
    @end
    

    在ViewController注册的时候要做如下处理:

    // 注册
    [_model addObserver:self forKeyPath:@"person.age" options:(NSKeyValueObservingOptionNew) context:nil];
    

    在Person中只有一个age属性,如果还有其他属性,可以在上面注册代码下加入一样的代码,只是更改person.age值。但是如果是Person中的属性很多很多,每个属性更改都要通知观察者,这样写就比较麻烦,这个时候就要通过属性依赖进行处理。需要重写KVOModel中类方法:

    + (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key
    {
        NSSet* keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
        if ([key isEqualToString:@"person"])
        {
            keyPaths = [[NSSet alloc] initWithObjects:@"_person.age", @"_person.name", nil];
        }
        return keyPaths;
    }
    

    在ViewController注册,只要监听person属性就可以

    [_model addObserver:self forKeyPath:@"person" options:(NSKeyValueObservingOptionNew) context:nil];
    

    这样更改person对象的属性值,都会通知到观察者KVOModel,进入到监听方法。

    4. KVO基本原理

    KVO是通过观察属性的set方法,但是前面demo中不设置属性值,只要调用willChangeValueForKey:和didChangeValueForKey:两个方法也会触发通知,这个可以通过demo验证,比如说KVOModel中有个成员变量value,直接更改value的值看效果。

    @interface KVOModel : NSObject
    {
        @public
        int value;
    }
    @end
    

    注册时keyPath为value,然后去更改值

    - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
    {
        static int magicNum;
        _model->value = magicNum++;
    }
    

    会发现监听方法没有调用到,KVO实际上还是通过观察属性set的方法达到目的。 那如何当调用KVOModel类对象的set方法能够观察到,首先会想到两种方法,一种是分类去设计,另一种是通过继承,子类实现。
    分类实现 可以直接在分类中重写属性对应的set方法,然后在分类set方法去通知外部,但是这种会有弊端,有的时候会有种需求,会重写set方法,然后加上自己的业务,如果KVO通过分类实现,就会覆盖掉原来的set方法,业务逻辑就永远调用不到,这种框架设计就会有问题。
    子类实现 KVO底层实现通过子类实现,需要以下步骤:

    1. 创建一个子类,例如KVOModel,子类名字就会叫做NSKVONotifying_KVOModel。
    2. 重写set方法,例如观察name,底层就会重写setName方法。
    3. 外界改变isa指针。

    可以通过查看KVOModel验证下系统的实现,在KVOModel创建后查看下对应的信息,如下:

    KVOModel对象信息.png 调用完addObserver,再次查看KVOModel对象的信息,如下: KVOModel对象信息.png
    isa就会被改为NSKVONotifying_KVOModel,这个肯定是在调用addObserver方法中创建了这个子类,苹果的KVO没有开源,网络上有基于GNU开源的代码,会有共通之处,可以查看参考。
    我的另一篇简书文章《自定义KVO》大致根据原理模拟实现了一个简单的KVO。

    5. KVO容器观察

    如果KVOModel中有个容器属性,这需要怎么观察到容器中数据改动。

    @property (nonatomic, strong) NSMutableArray* arrayValue;
    

    同样通过上述注册方法对arrayValue进行观察,然后每次点击屏幕的时候都给arrayValue添加一个元素,如下:

    [_model.arrayValue addObject:@"1"];
    

    会发现回调方法不会触发,这个由于KVO观察的是set方法,这边容器是add,所以就不会触发,KVO给开发者提供了mutableArrayValueForKey去拿容器对象,然后再调用add,这个时候就会观察到元素改变:

    - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
    {
        NSMutableArray* tmpArray = [_model mutableArrayValueForKey:@"arrayValue"];
        [tmpArray addObject:@"1"];
    }
    
    通过打断点,查看tmpArray信息,刚开始定义的时候结构如下: tmpArray信息.png 通过mutableArrayValueForKey方法获取赋值,再次查看tmpArray的结构信息,如下: tmpArray信息.png tmpArray类型改变了,很明显是个子类,所以应该是系统在子类中重写了add方法,然后调用willChangeValueForKey:和didChangeValueForKey:两个方法通知外部达到目的。

    6. 结尾

    苹果的KVO技术给我们提供了方便,但是用不适当,可能就会出现crash,观察Demo中,在初始化地方注册,然后用其他方法来监听,明显是分开的,如果代码量很大的时候,这种方式就比较不好,可读性就比较差点,并且在dealloc的时候,一定要remove监听,必须要一一对应,如果注册多了或者remove多了,都会crash,针对KVO这点缺点,可以对其进行封装,比如RAC(函数式响应式编程)框架,在github中有个开源框架ReactiveCocoa,函数式就是AFN,KVO封装可以结合Block去做,addObserver:forKeyPath:options:context:调用的时候就没有必要传self,因为通过block的时候,就不用根据self去调用监听方法observeValueForKeyPath:ofObject:change:context: 逻辑直接就是调用block,这样也就不用在dealloc的时候去remove观察,很方便使用。

    相关文章

      网友评论

        本文标题:iOS KVO原理探究

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