[iOS]用代码探究 KVO 原理(真原创)

1,079 阅读7分钟

我在上一篇文章 [iOS]从使用 KVO 监听 readonly 属性说起 中谈了使用 KVO 一些问题。里面谈到了 KVO 的原理,我在网上搜了一下 KVO 原理,发现大家说的都是一样的。但是我在实际使用中发现事实并不是网上说的那样。所以打算用代码的形式来一步一步探究 KVO 的原理。

01.代码场景

说一下我们的代码场景:一个人类 Person,他的朋友把狗 LeLe 寄养在他那里,然后他自己有一只猫 Tom。因为他自己没有狗,所以狗是 readonly 的。

Person 类的 .h 文件是这样的:

@interface Person : NSObject

@property(nonatomic, strong)NSString *aCat;

@property(nonatomic, strong, readonly)NSString *aDog;

// 寄养狗
-(void)careDog:(id)dog;

@end

Person 类的 .m 文件是这样的:

#import "Person.h"

@implementation Person

-(void)careDog:(id)dog{
  [self setValue:dog forKey:@"aDog"];
}

-(void)willChangeValueForKey:(NSString *)key{
  [super willChangeValueForKey:key];
  NSLog(@"willChangeValueForKey");
}

@end

控制器的代码是这样的:

    #import "ViewController.h"
    #import "Person.h"

    @interface ViewController ()

    // 人
    @property(nonatomic, strong)Person *p;

    // 狗
    @property(nonatomic, strong)NSString *d;

    // 猫
    @property(nonatomic, strong)NSString *c;

    @end

    @implementation ViewController

    - (void)viewDidLoad {
        [super viewDidLoad];
    
        self.p = [Person new];
        self.d = @"LeLe";
        self.c = @"Tom";
    
        // 开始监听:第一个断点位置 👇
        [self.p addObserver:self forKeyPath:@"aDog" options:NSKeyValueObservingOptionNew context:nil];
        [self.p addObserver:self forKeyPath:@"aCat" options:NSKeyValueObservingOptionNew context:nil];
    }

    -(void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{
   
        NSLog(@"监听到了 %@ 的改变", keyPath);
    }

    -(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    
        // 寄养狗
        [self.p careDog:self.d];
    
        // 拥有猫
        self.p.aCat = self.c;
    }

    @end

02.开始监听

代码跑起来,停在第一个断点还未添加 KVO 监听的位置,使用 po 命令在控制台打印如下:

(lldb) po self.p.class
Person
(lldb) po object_getClass(self.p)
Person

两个命令拿到的类是一样样的,都是 Person。

注意,代码往下过一行,为人的 aDog 属性添加 KVO,再使用同样的 po 命令打印一次:

(lldb) po self.p.class
Person

(lldb) po object_getClass(self.p)
NSKVONotifying_Person

使用 self.p.class 去拿人 p 的类仍然是 Person,但是用 object_getClass 去拿的时候就露出小尾巴了。其实这个表现在官方文档里已经说得很清楚了。我英语不行,我大概翻译一下,你大概看一下。

Key-Value Observing Implementation Details KVO 实现细节(标题)

Automatic key-value observing is implemented using a technique called isa-swizzling. KVO 采用了一个叫做 isa-swizzling(类指针交换,isa 是指向每个实例对象的类的一个地址) 的技术来实现。

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. isa 指针,顾名思义,表明了对象属于的类,类里保存了对象方法列表。这个方法列表的本质就是对象方法的地址的集合。

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. 当一个对象的属性被监听的时候,这个对象的 isa 指针将被修改,然后指向一个临时的类而不是原来的类。事实上 isa 指针不会影响对象的真实类型(意思当我们用 .class 去取对象的类的时候,系统会处理这个 .class 方法,使返回的类仍然是原来的类,伪装的真好)。

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 指针而影响你的使用,你就当什么都没发生过就好了。

03.监听值的改变

我们的 Person 类中有一个当属性值改变的时候,系统自动调用通知观察者的方法:

-(void)willChangeValueForKey:(NSString *)key{
    // 第二个断点位置 👇
    [super willChangeValueForKey:key];
    NSLog(@"willChangeValueForKey");
}

3.1、给 readwrite 的属性 aCat 赋值

我们 Person 类的 aCat 属性是 readwrite 的。我们先在点击屏幕的时候给猫赋值。

-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
 // 寄养狗
 // [self.p careDog:self.d];
    
 // 拥有猫
    self.p.aCat = self.c;
}

我们在断点的地方查看函数调用栈:

可以看到,当点击屏幕后,系统调用了一个 _NSSetObjectValueAndNotify 的方法,这个方法会调起 willChangeValueForKey: 方法。

3.2、给 readonly 的 aDog 赋值

我们都知道 readonly 的属性是没有 setter 方法的。所以我们尝试采用 KVC 的方式来给属性赋值。 保持断点不要动,这回我们这样写:

-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    // 寄养狗
   [self.p careDog:self.d];
    
    // 拥有猫
    // self.p.aCat = self.c;
}

寄养狗的方法会使用 KVC 给 aDog 属性赋值。我们再来看一下函数调用栈:

进入 careDog: 方法以后,系统先是调用 KVC 的赋值方法,然后再调用 _NSSetValueAndNotifyForKeyIvar,这个方法会调起 willChangeValueForKey: 方法。

04.监听到值的改变

4.1、监听到 readwrite 的 aCat 的值的改变

接下来我们断点打在监听结果方法里:

-(void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{
   // 第三个断点位置 👇
   NSLog(@"监听到了 %@ 的改变", keyPath);
}

同时在这么写:

-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    // 寄养狗
    // [self.p careDog:self.d];
    
    // 拥有猫
    self.p.aCat = self.c;
}

代码跑起来,点击屏幕,程序停在断点位置,我们来看函数调用栈:

先是调用 _NSSetObjectValueAndNotify 方法,接下来这个方法会调起监听者 NSKeyValueNotifyObserver,这个监听者再调起 -observeValueForKeyPath: 方法。

4.2、监听到 readonly 的 aDog 的值的改变

保持断点不要动,现在这么写:

-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    // 寄养狗
    [self.p careDog:self.d];
    
    // 拥有猫
    // self.p.aCat = self.c;
}

代码跑起来,点击屏幕,程序停在断点位置,我们来看函数调用栈:

系统先是调用 KVC 的赋值方法,这个方法会触发 NSKeyValueNotifyObserver,然后调起 -observeValueForKeyPath: 方法。

05.移除 KVO 监听

我们在控制器中添加如下代码来移除所有 KVO 监听:

-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    
   // 寄养狗
   // [self.p careDog:self.d];
    
   // 拥有猫
   // self.p.aCat = self.c;
    
   [self.p removeObserver:self forKeyPath:@"aDog"];
   [self.p removeObserver:self forKeyPath:@"aCat"];
   // 第四个断点位置 👇
}

然后使用 po 命令来查看 p 的类:

(lldb) po self.p.class
Person

(lldb) po object_getClass(self.p)
Person

发现在移除对 p 的所有属性的监听以后,系统会自动将对象的 isa 指针从那个以 NSKVONotifying_ 打头的临时的类又重新调转到 p 的原来的类。

06.总结

  • 01.开始监听。当一个对象的属性被使用 KVO 监听的时候,系统会自动生成一个以 NSKVONotifying_ 打头的临时的类,然后将这个对象的 isa 指针指向这个临时的类;

  • 02.监听过程

  • 2.1.当监听的属性是 readwrite 的时候,并不会往这个属性的 setter 方法里插入 -willChangeValueForKey: 和 -didChangeValueForKey: 等方法,而是系统会在设置属性的值的时候调用 _NSSetObjectValueAndNotify 方法,这个方法会发送一条通知,然后 NSKeyValueNotifyObserver 这个监听者监听到值的改变的时候,会发送一个通知调起  -observeValueForKeyPath:。

  • 2.2.当监听的属性是 readonly 的时候,当使用 KVC 给属性赋值的时候,系统先是调用 KVC 的赋值方法,这个方法会触发 NSKeyValueNotifyObserver,然后调起 -observeValueForKeyPath: 方法。

  • 移除监听。移除所有 KVO 监听的时候,系统会自动将对象的 isa 指针从那个以 NSKVONotifying_ 打头的临时的类又重新调转到 p 的原来的类。

注意:如果你没有重写 -willChangeValueForKey: 和 -didChangeValueForKey: 方法,这两个方法就不会被调起。

NewPan 的文章集合

下面这个链接是我所有文章的一个集合目录。这些文章凡是涉及实现的,每篇文章中都有 Github 地址,Github 上都有源码。

NewPan 的文章集合索引

如果你有问题,除了在文章最后留言,还可以在微博 @盼盼_HKbuy 上给我留言,以及访问我的 Github