iOS底层-KVO原理

1,954 阅读7分钟

KVO的全称是Key-value observing(键值观察),它提供了一种机制,允许对象在其他对象的特定属性发生变化时收到通知。下面我们先从API中去分析他的用法,然后分析他的底层原理。

API分析

我们经常使用KVO方式如下:

// Wushuang.h
@interface Wushuang : NSObject
@property (nonatomic, strong) NSString *name;
@property (nonatomic, strong) NSString *nickName;
@end

// VC
@interface SecondViewController ()
@property (nonatomic, strong) Wushuang *ws;
@end

self.ws = [Wushuang alloc];
self.ws.name = @"Dianji";
self.ws.nickName = @"Iron Man";
// 添加观察
[self.ws addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];

// 观察回调
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    
    NSLog(@" 🎈 change : %@ 🎈 ", change);
}

addObserver时,末尾的context参数往往被忽视了,那么这个参数有什么作用呢?下面我们去分析

1. context的作用

  • 在官方文档 Key-Value Observing Programming Guide 中,是这样描述context的:

    截屏2021-11-24 17.00.27.png

    • 大致意思是context可以用来判断观察属性的路径,这样更安全,可拓展行也更强。
    • 如果观察一个对象中的两个不同属性,我们可以用keyPath来判断,但观察多个不同对象的属性时,就需要先判断object再判断keyPath,这样就比较繁琐,此时context的优势就体现出来了,用法如下:
    static void *wushuangNameContext = &wushuangNameContext;
    static void *teacherNameContext = &teacherNameContext;
    
    [self.ws addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:wushuangNameContext];
    
    [self.teacher addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:teacherNameContext];
    
    - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
        if (context == wushuangNameContext) {
            NSLog(@"1. keyPath == %@  object = %@", keyPath, object);
        } else if (context == teacherNameContext) {
            NSLog(@"2. keyPath == %@  object = %@", keyPath, object);
        }
        NSLog(@" 🎈 change : %@ 🎈 ", change);
    }
    

    context和属性一一对应,这样就更简洁也更安全,也提高了判断的效率

2. 移除观察者

  • 我们知道在iOS9之后KVO就不需要手动移除,所以往往不需要手动处理移除。但在官方文档中有这样一句话介绍KVO移除:

    截屏2021-11-24 17.41.26.png

    • 观察者在释放时不会自动移除自己,如果继续发送消息就会产生空指针异常。平时不移除观察者,是因为一般观察者在销毁时,被观察者也不存在了,所以不会出现异常

    • 也就是说如果被观察者是一个单例,在观察者释放后,再重新添加该类的对象为观察者时就会产生异常。如图所示:

      截屏2021-11-25 10.15.21.png

    • 所以在使用时还是尽量手动移除观察者。

3. 手动触发KVO

  • 我们常用的KVO是自动触发回调通知,也就是观察的属性值改变后,就会收到回调通知。在 KVO Compliance 中有介绍,触发回调是由automaticallyNotifiesObserversForKey方法确定,因为不去实现默认值是YES,也就是自动触发回调通知,如果设置成NO则就需要手动触发,需要在赋值前后设置相应的willChangeValueForKey:didChangeValueForKey:方法。

  • 下面进行案例验证案例验证:

    // Wushuang.m
    + (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key {
        if ([key isEqualToString:@"name"]) {
            return YES;
        } else if ([key isEqualToString:@"nickName"]) {
            return NO;
        }
        return YES;
    }
    
    - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
        self.ws.name = [NSString stringWithFormat:@"%@ +", self.ws.name];
        self.ws.nickName = [NSString stringWithFormat:@"%@ !", self.ws.nickName];
    }
    
    • vc中观察了WushuangnamenickName属性,其中name设置成自动通知,nickName设置成手动通知,点击赋值结果如下:

      截屏2021-11-25 11.17.46.png
      结果只有name值的改变,回调中收到了通知。

    • Wushuang.m中添加一些代码再尝试

    - (void)setNickName:(NSString *)nickName {
        [self willChangeValueForKey:@"nickName"];
        _nickName = nickName;
        [self didChangeValueForKey:@"nickName"];
    }
    

    再次修改属性的值,就会收到nickName值改变的通知了:

    截屏2021-11-25 11.23.06.png

4. 依赖多个Keys

  • 在很多情况下,一个属性的值取决于另一个对象中的一个或多个其他属性的值。这个时候就需要用keyPathsForValuesAffectingValueForKey来设置依赖关系:

    // Wushuang.h
    @interface Wushuang : NSObject
    @property (nonatomic, strong) NSString *food;
    @property (nonatomic, strong) NSString *meat;
    @property (nonatomic, strong) NSString *vegetable;
    @end
    
    // Wushuang.m
    + (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key {
        NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
        if ([key isEqualToString:@"food"]) {
            NSArray *affectingKeys = @[@"meat", @"vegetable"];
            keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];
        }
        return keyPaths;
    }
    
    - (NSString *)food {
        return [NSString stringWithFormat:@"%@ and %@", self.meat, self.vegetable];
    }
    
    // VC代码
    [self.ws addObserver:self forKeyPath:@"food" options:NSKeyValueObservingOptionNew context:NULL];
    
    - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
        self.ws.meat = @"beef";
        self.ws.vegetable = @"radish";
    }
    

    改变值输出结果如下:

    截屏2021-11-25 14.44.35.png

5. 集合类型

  • 对于集合类型的属性的观察就有些不一样了,在KVCAccessing Collection Properties 中有如下介绍:

    截屏2021-11-25 15.30.36.png
    对于集合对象的访问需要使用代理的相关方法,这样会相应的修改底层属性,进而触发KVO的回调。

  • 以数组为例子,具体实现如下:

    // Wushuang.m
    @property (nonatomic, strong) NSMutableArray *dataArray;
    
    // 添加监听
    [self.ws addObserver:self forKeyPath:@"dataArray" options:NSKeyValueObservingOptionNew context:NULL];
    
    - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    
    //    [self.ws.dataArray addObject:@"Tony Stark"]; // 没有作用
    
        [[self.ws mutableArrayValueForKey:@"dataArray"] addObject:@"Steve Rogers"];
    }
    
    • 在想数组里添加object时,以常规的方式访问数组是不会触发KVO通知的。需要使用mutableArrayValueForKey协议方法的方式访问到dataArray,再进行添加object

原理分析

  • KVO的文档最后对它的原理有一些介绍:

    截屏2021-11-25 16.18.35.png

    • 我们知道对象isa指向类,但当注册观察者时,被观察对象的isa指向了中间类,我们可以打印被观察对象在观察前后的isa来对比差异

产生中间类

  • 打印在观察前后ws对象的内容:

    截屏2021-11-25 16.35.05.png

    • 在观察前对象的isa指向Wushuang,但观察后isa就指向了NSKVONotifying_Wushuang,这个中间类名字上也带有Wushuang,那么它和原来的类有什么关系呢?中间类又有哪些内容呢?对象的值改变后为什么会影响原类呢?下面我们去挨个分析

原类和中间类的关系

  • 我们可以使用Runtime获取所以注册类的信息,然后筛选出类Wushuang的子类:

    // 遍历类及其子类
    - (void)printClasses:(Class)class {
        // 获取所以注册类数量
        int count = objc_getClassList(NULL, 0);
        // 创建一个数组, 其中包含给定对象
        NSMutableArray *mutArray = [NSMutableArray arrayWithObject:class];
        // 获取所有已注册的类
        Class *classes = (Class *)malloc(sizeof(class) * count);
      objc_getClassList(classes, count);
      
        for (int i = 0; i < count; i++) {
            if (class == class_getSuperclass(classes[i])) {
                [mutArray addObject:classes[i]];
            }
        }
        free(classes);
        NSLog(@" classes = %@ ", mutArray);
    }
    

    然后在添加观察前后查看与Wushuang类相匹配的信息:

    截屏2021-11-25 17.39.50.png

    • 其中的WSTeacherWushuang的子类,所以可以得出结论:
        1. 中间类是被观察类的子类
        1. 中间类没有其他子类

中间类有什么

  • 现在知道了中间类的名字,那么我们可以使用Runtime方法打印出它的方法列表

    - (void)printMethodesInClass: (Class)class {
        unsigned int count = 0;
        Method *methodList = class_copyMethodList(class, &count);
      
        for (int i = 0; i < count; i++) {
            Method method = methodList[i];
            SEL sel = method_getName(method);
            IMP imp = method_getImplementation(method);
          
            NSLog(@" 🎉 : %@ --- %p ", NSStringFromSelector(sel), imp);
        }
        free(methodList);
    }
    

    分别打印原类和中间类,输出如下:

    截屏2021-11-25 17.18.15.png

    • 原类里打印的是一些属性对应的settergetter方法
    • 中间类打印的是观察属性的setter方法,classdealloc以及_isKVOA这四个:
      • _isKVOA:是一个辨识码,来判断这个类是不是因为KVO产生的动态子类
      • dealloc:判断它是否进行释放
      • class:是类的信息
      • setName:是要变化的属性的setter方法
    • 实际上这四个方法都是重写父类的方法。

isa什么时候指回来

  • 中间类中重写了dealloc,可以在移除观察者前后来观察isa信息:

    截屏2021-11-25 20.06.11.png

    • 从打印结果可以发现,在dealloc中移除观察者后,对象的isa就指回来了。

中间类什么时候销毁

  • 那么当前页面销毁时,这个中间类也会销毁吗?下面来验证下:

    截屏2021-11-25 20.23.53.png

    • 通过几次打印对比发现:
        1. vc对象出栈后,因监听而产生的中间类依然在注册类中,并不会因为页面销毁而销毁
        1. 再次监听相同类时,并不会产生新的中间类

setter方法做了什么

  • 我们知道中间类中有setter方法,那么它做了什么?监听的属性还是成员变量?可我们可以在类中定义一个成员变量来查看改变值后是有回调信息:

    // Wushuang.h
    @interface Wushuang : NSObject {
       @public
        NSString *hobby;
    }
    @property (nonatomic, strong) NSString *name;
    @end
    
    // VC
    [self.ws addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];
    [self.ws addObserver:self forKeyPath:@"hobby" options:NSKeyValueObservingOptionNew context:NULL];
    
    - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
        self.ws.name = [NSString stringWithFormat:@"%@ +", self.ws.name];
        self.ws->hobby = @"Swimming";
    }
    
    • 打印结果如下:

    截屏2021-11-25 22.56.53.png

    • 结果改变成员变量并不会触发监听通知,所以KVO在底层是通过setter方法来监听属性,要分析这个过程就需要借助LLDB的指令watchpoint set variable +变量,在添加观察前来设置内存断点,当变量的值发生改变就会触发断点,如下图:

    截屏2021-11-26 11.40.33.png

    堆栈信息反应了在属性值变化后,底层调用了下面几个函数:

      1. Foundation _NSSetObjectValueAndNotify
      • 结合堆栈和汇编可以得出该方法里会调用willChangeValueForKeydidChangeValueForKey方法:

        截屏2021-11-26 13.41.02.png

      1. Foundation -[NSObject(NSKeyValueObservingPrivate) _changeValueForKey:key:key:usingBlock:]
      1. Foundation -[NSObject(NSKeyValueObservingPrivate) _changeValueForKeys:count:maybeOldValuesDict:maybeNewValuesDict:usingBlock:]

    这三个函数执行完后最终就会执行-(void)observeValueForKeyPath:ofObject:change:context:方法

流程总结

  • 在添加观察时,runtime会产生一个中间类:

      1. 中间类继承于原类
      1. 中间类会重写被观察keysetter方法,
      1. 对象的isa从指向元类,变成指向中间类
  • 当对属性赋值时,对象会根据isa找到中间类对应的setter方法,然后在willChangeValueForKeydidChangeValueForKey方法之间进行赋值,进而触发-(void)observeValueForKeyPath:ofObject:change:context:方法。

  • 当在dealloc中移除通知后,isa会重新指向原来的类,相关实例变量的值不变。dealloc后中间类并不会释放,依然在注册类中。

观察前后,以及赋值流程图如下: 截屏2021-11-26 14.55.16.png