【底层探索】- KVO(上)


一、KVO 初探

1.1 概念

Key-value observing is a mechanism that allows objects to be notified of changes to specified properties of other objects.

键值观察(KVO)是一种机制,允许对象在其他对象的特定属性改变后得到通知。

KVO 概念,参看 苹果开放文档

1.2 使用

  • 注册监听

    func addObserver(_ observer: NSObject, 
          forKeyPath keyPath: String, 
             options: NSKeyValueObservingOptions = [], 
             context: UnsafeMutableRawPointer?)
  • 实现键值变换的通知

    - (void)observeValueForKeyPath:(NSString *)keyPath 
                          ofObject:(id)object 
                            change:(NSDictionary<NSKeyValueChangeKey, id> *)change 
                           context:(void *)context;
  • 移除监听,当不再需要监听事件时

    func removeObserver(_ observer: NSObject, 
             forKeyPath keyPath: String)

1.1 context 的作用

1.1.1 介绍

添加KVO 的关键方法为

- (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context;
  1. objserver - 观察者
  2. keyPath - 关键路径,即观察的重点,如属性
  3. options - 键值观察的策略选项。
  4. context - 观察上下文。

1.1.2 原因:

苹果文档 在context 这一小节 写到:

A safer and more extensible approach is to use the context to ensure notifications you receive are destined for your observer and not a superclass.

更安全和扩展性更好的获取方式,就是使用context来确保你收到的通知被指向你的观察者,而不是父类。

可见上下文指针(context) 是用来区分多个对象同时使用使用相同的keyPath,这样无需区分不同对象,减少判断嵌套,提高性能。

可以快速定位观察键,是的区分观察对象更加便利、安全、直接。

类似标签区别

1.1.3 使用

context 使用静态地址指针,用法如下:

  • 创建 context 指针

    static void *PersonAccountBalanceContext = &PersonAccountBalanceContext;
    static void *PersonAccountInterestRateContext = &PersonAccountInterestRateContext;
  • 将不同的context 注册在相同对象的观察者中

    - (void)registerAsObserverForAccount:(Account*)account {
        [account addObserver:self
                  forKeyPath:@"balance"
                     options:(NSKeyValueObservingOptionNew |
                              NSKeyValueObservingOptionOld)
                     context:PersonAccountBalanceContext];
    
        [account addObserver:self
                  forKeyPath:@"interestRate"
                     options:(NSKeyValueObservingOptionNew |
                              NSKeyValueObservingOptionOld)
                      context:PersonAccountInterestRateContext];
    }

1.2 移除观察者的重要性

会触发隐藏的崩溃

移除观察者,通常会使用这个方法

- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;

如果不移除,当被观察者被dealloc 后,观察者还在苦苦的观察它,长此以往,找不到对象

会形成野指针,造成崩溃。

类似女同事们默默喜欢关注吴尊好多年,结果突然你告诉我他早就脱单结婚了,女儿都八岁了,能不崩溃吗。

1.3 手动/自动观察开关

有些业务场景,经常需要变换对键的观察,由于需求的变换,可能之前一直在观察的值,这个版本就不需要在观察了,那么再去删除大量的观察代码肯定不是一件明智的事情(谁知道产品经理下一步会不会回退需求呢?)。

可以通过开关自动观察指定键的对象按钮来实现

+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key;
  • 当需要手动来操作时,返回NO

    此时对需要改动的键值,手动添加willChangeValueForKeydidChangeValueForKey 两个方法,这样是的被观察对象的观察策略生效。

    代码如下:

    业务代码对对象进行观察以及改变

    - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    
        [self.person willChangeValueForKey:@"name"];
        self.person.name  = @"haha";
        [self.person didChangeValueForKey:@"name"];
    }

    类的实现下,关闭自动

    // 自动开关
    + (BOOL) automaticallyNotifiesObserversForKey:(NSString *)key{
        return NO;
    }
  • 当需要自动观察指定的键时,返回YES

    // 自动开关
    + (BOOL) automaticallyNotifiesObserversForKey:(NSString *)key{
        return YES;
    }

1.4 路径集合

在某些场景下,观察对象手多个外接属性/变量影响,也可以使用KVO。

一个典型的场景,是在音乐/电视剧下载时,由于某些专辑有多首歌曲,某些时候下载过程中,会有新的关联专辑加入进来,此时下载进度条可能满了之后会会退(奇怪的需求)。

  • 给对象添加一个下载进度的KVO 监测

    [self.person addObserver:self forKeyPath:@"downloadProgress" options:(NSKeyValueObservingOptionNew) context:NULL];
  • 业务层,对简洁相关联的属性,进行增减。比如这里,使用到的是下载进度相关的”已写入数据”和”总数据”,当点击时,他们会依次添加

    self.person.writtenData += 10;
    self.person.totalData += 20;
  • 最后,在集合关联键值方法里,给他们绑定上关系

    此时有3个关键键了,分别上:downloadProgresstotalDatawrittenData

    他们之间的关系是: 下载进度 = 当前下载量 / 总量 (downloadProgress = writtenData / totalData)

  + (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key{

      NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
      if ([key isEqualToString:@"downloadProgress"]) {
          NSArray *affectingKeys = @[@"totalData", @"writtenData"];
          keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];
      }
      return keyPaths;
  }

1.5 集合数组观察

  • 比如对某个数组添加观察。

    [self.person addObserver:self forKeyPath:@"dateArray" options:(NSKeyValueObservingOptionNew) context:NULL];
  • 对数组进行改动。此时需要将其先改成可变数组,才能改动

    [[self.person mutableArrayValueForKey: @"dateArray"] addObject: @"hello"];

原因:因为数组没有遵循KVC,并没有使用setter 方法

1.6 观察类型枚举

  • NSKeyValueChangeSetting
  • NSKeyValueChangeInsertion
  • NSKeyValueChangeRemoval
  • NSKeyValueChangeReplacement

二、KVO 原理

2.1 概念

Automatic key-value observing is implemented using a technique called isa-swizzling.

自动KVO 使用isa 改变的技术来实现

2.2 动态生成子类

为了分析所谓的动态生成子类,先写一段代码,生成一个Dog 的类,对其name 属性添加观察,并打印实例d 观察前和观察后的类名。代码如下:

    Dog *d = [[Dog alloc] init];
    NSLog(@"添加前-:    %s\n", object_getClassName(d));
    [d addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];
    NSLog(@"添加后-:    %s", object_getClassName(d));

运行,打印结果如下:

-

如何判断是子类呢,可以写个方法来打印当前Dog 类的子类列表

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

打印结果如下:

2020-05-07 16:54:32.770527+0800 MyKVO[8567:500364] 添加前-:    Dog

2020-05-07 16:54:32.771302+0800 MyKVO[8567:500364] 添加后-:    NSKVONotifying_Dog
2020-05-07 16:54:32.788208+0800 MyKVO[8567:500364] classes = (
    Dog,
    "NSKVONotifying_Dog"
)

果然生成了一个NSKVONotifying_Dog 基于Dog 类的子类

当对某个对象实用KVO,会动态生成子类 NSKeyValueNotifying__XXX,这个类的isa 指向对象的类

2.3 键值观察的是setter方法

我们知道KVO 是对属性做的键值观察,那么为什么不是成员变量呢?为了解决这个问题,不妨生成一个成员变量,对他做一下检测试试。

  1. 先给类Dog 新增一个成员变量age,记得添加@public, 这样才能在外部访问

    @interface Dog : NSObject
    {
        @public
        NSString *age;
    }
    @property (copy, nonatomic) NSString *petName;
  2. 接下来给狗狗对象d 添加属性petName、成员变量age 的KVO方法

    [self.d addObserver:self forKeyPath:@"petName" options:(NSKeyValueObservingOptionNew) context:NULL];
    [self.d addObserver:self forKeyPath:@"age" options:(NSKeyValueObservingOptionNew) context:NULL];
  3. 添加一个改变属性的点击事件。当点击屏幕,给d 的属性和成员变量赋值

    - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
        self.d.petName = @"Jacob";
        NSLog(@"self.petName-%@", self.d.petName);
    
        self.d->age = @"18";
        NSLog(@"self.age-%@", self.d->age);
    }
  4. 运行,点击屏幕,监控键值变化如下:

可见打印结果中,self.petName 的值变化,得到了打印验证;而 self.d->age 作为公开出来的成员变量,并不会在其值发生改变打印。为什么呢?

我们都知道属性创建之后,系统会自动配置setter 方法和getter 方法;

而成员变量,则是需要手动添加setter/getter 方法,区别就在setter 方法未实现。

所以我们得知,KVO 监控的是变量的setter 方法

2.4 动态子类会重写方法

在上文得知,会动态生成NSKeyValueNotifying__XXX 的子类后,不免想要看看这个新的类,究竟实现了那些内容。

先看看类里面有哪些方法,写一个遍历类的方法:

#pragma mark - 遍历方法-ivar-property
- (void)printClassAllMethod:(Class)cls{
    NSLog(@"*********************");
    unsigned int count = 0;
    Method *methodList = class_copyMethodList(cls, &count);
    for (int i = 0; i<count; i++) {
        Method method = methodList[i];
        SEL sel = method_getName(method);
        IMP imp = class_getMethodImplementation(cls, sel);
        NSLog(@"%@-%p",NSStringFromSelector(sel),imp);
    }
    free(methodList);
}

输入这个新的类:

[self printClassAllMethod: NSClassFromString(@"NSKVONotifying_Dog")];

打印结果如下:

可见实现的类有如下:

  • set 方法 ——setPetName
  • class 方法
  • dealloc 方法
  • _isKVOA 键的标识

2.5 KVO移除时操作

当对一个对象的监控结束后,会移除KVO 监控,这时候会发生什么呢?

2.5.1 isa 指回原类

试验一下,在dealloc 方法,添加移除方法,并在前后打印类的名字

    NSLog(@"移除前,类名为:    %s", object_getClassName(self.d));
    [self.d removeObserver:self  forKeyPath:@"petName"];
    [self.d removeObserver:self  forKeyPath:@"age"];
    NSLog(@"移除后,类名为:    %s\n", object_getClassName(self.d));

在这里可以清晰的看到,当移除KVO 观测属性值后,这个动态子类,又变回了Dog类,即isa 指向又从动态子类,指向了原来的类

2.5.2 动态子类会缓存

那么移除KVO 后,动态子类是否销毁?

打印一下当前d 的类的子类即可,先定义一个打印方法

- (void)printClasses:(Class)cls{

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

执行对self.d 的打印

- (void)dealloc{
    NSLog(@"*************移除前");
    [self printClasses:[self.d class]];
    [self.d removeObserver:self  forKeyPath:@"petName"];
    [self.d removeObserver:self  forKeyPath:@"age"];
    NSLog(@"*************移除后");
    [self printClasses:[self.d class]];
}

得到结果如下:

很显然,这里的NSKVONotifying_Dog, 依旧存在,我们可以大胆推断,这里应该是缓存下来,方便下次再进行调用。

原理图如下:


文章作者: 李佳
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 李佳 !
评论
 上一篇
【数据结构与算法】-(4)双向链表和双向循环链表 【数据结构与算法】-(4)双向链表和双向循环链表
【数据结构与算法】-(1)基础篇 【数据结构与算法】-(2)线性表基础 【数据结构与算法】-(3)循环链表(单向) 【数据结构与算法】-(4)双向链表和双向循环链表 【数据结构与算法】-(5)链表面试题解析 【数据结构与算法】
2020-04-03 李佳
下一篇 
【数据结构与算法】-(3)循环链表(单向) 【数据结构与算法】-(3)循环链表(单向)
【数据结构与算法】-(1)基础篇 【数据结构与算法】-(2)线性表基础 【数据结构与算法】-(3)循环链表(单向) 【数据结构与算法】-(4)双向链表和双向循环链表 【数据结构与算法】-(5)链表面试题解析 【数据结构与算法】-(6)栈
2020-04-02
  目录