KVO的本质

面试问题:

  • iOS用什么方式实现对一个对象的KVO?
  • 如何手动触发KVO?

KVO简介

KVO就是键值观测。有时候有这种需求,就是需要知道一个对象的属性的任何变化来改变做出相应的响应,这时候就可以使用KVO。
KVO中有两个关键的方法。

  • 添加观测者
   /***************
     @observer:就是观察者,是谁想要观测对象的值的改变。
     @keyPath:就是想要观察的对象属性。
     @options:options一般选择NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld,这样当属性值发生改变时我们可以同时获得旧值和新值,如果我们只填NSKeyValueObservingOptionNew则属性发生改变时只会获得新值。
     @context:想要携带的其他信息,比如一个字符串或者字典什么的。
     **************/
    - (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context;
  • 当所观测的属性值发生改变时调用的函数
    /********************
     @keyPath:观察的属性
     @object:观察的是哪个对象的属性
     @change:这是一个字典类型的值,通过键值对显示新的属性值和旧的属性值
     @context:上面添加观察者时携带的信息
     *******************/
    - (void)observeValueForKeyPath:(nullable NSString *)keyPath ofObject:(nullable id)object change:(nullable NSDictionary<NSKeyValueChangeKey, id> *)change context:(nullable void *)context;

下面通过一个实例来展示KVO的用法:
Person类有一个属性为age

@interface ViewController ()

@property (nonatomic, strong)Person *person;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.person = [[Person alloc] init];
    self.person.age = 5;
    NSKeyValueObservingOptions options = NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld;
    [self.person addObserver:self forKeyPath:@"age" options:options context:@"测试信息"];
    
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    
    self.person.age = 10;
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{
    
    NSLog(@"被观测对象:%@, 被观测的属性:%@, 值的改变: %@\n, 携带信息:%@", object, keyPath, change, context);
}

当触摸屏幕的时候就改变age属性值,查看打印结果:

2018-07-03 22:07:21.747831+0800 interview-KVO[16684:548659] 被观测对象:<Person: 0x60400001a700>
, 被观测的属性:age
, 值的改变: {
    kind = 1;
    new = 10;
    old = 5;
}
, 携带信息:测试信息

KVO本质分析

我们创建两个Person对象person1和person2,监听person1的age属性而不监听person2,触摸屏幕的时候同时改变person1和person2的age属性。

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.person1 = [[Person alloc] init];
    self.person1.age = 5;
    self.person2 = [[Person alloc] init];
    self.person2.age = 6;
    NSKeyValueObservingOptions options = NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld;
    [self.person1 addObserver:self forKeyPath:@"age" options:options context:@"测试信息"];
    
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    
    self.person1.age = 10;
    self.person2.age = 11;
}

触摸屏幕之后通过打印值发现只能监听到person1对象的age属性值发生了改变,而不能监听person2。这个也很好理解,因为我们没有监听person2的属性。但是我们想一下

self.person1.age = 10;
self.person2.age = 11;

就是调用了Person类的set方法:

[self.person1 setAge:10];
[self.person2 setAge:11];

同样是调用set方法,为什么加了观察者效果就不一样呢?
问题就出自对象本身。
我们打断点打印一下person1和person2的isa,看看isa是什么。

6321899B-D82C-4F53-9D5F-A070E83FD0B1.png

通过这个打印的结果我们可以清晰的看到,person1的isa指针竟然指向NSKVONotifying_Person这个陌生的类,而person2的isa指针则是正常的指向Person类。我们知道实例对象的isa指针指向的是类对象,所以正常而言person1的isa指针指向的是Person类对象,由于加了观测者,导致其isa指向了NSKVONotifying_Person
我们看一下person2的结构:
未添加监听.png

为了弄清person1的结构,我们打印看一下NSKVONotifying_Person的类对象的superclass指针指向哪里:

NSKVONotifying_Person的类对象的isa指针

这个打印结果就说明NSKVONotifying_Person这个类是Person类的子类。据此我们猜测出person1对象的结构图:
添加监听.png

接下来我们可以写一下这个NSKVONotifying_Person这个类的伪代码:

@implementation NSKVONotifying_Person

- (void)setAge:(int)age{
    
    _NSSetIntValueAndNotify();
}

void __NSSetIntValueAndNotify()
{
    
    [self willChangeValueForKey:@"age"];
    [super setAge:age];
    [self didChangeValueForKey:@"age"];
}

- (void)didChangeValueForKey:(NSString *)key{
    
    [observer observeValueForKeyPath:key ofObject:self change:nil context:nil];
}

@end

NSKVONotifying_Person这个子类的setAge:方法中主要是实现了一个C方法_NSSetIntValueAndNotify(),这个方法的实现分三步,首先是属性将要改变时调用willChangeValueForKey:,然后是调用父类即Person类的setAge:方法来真正的改变age属性的值,当age属性的值改变完成之后再调用didChangeValueForKey:这个方法来通知监听者属性值已经改变。

验证

  • 1.打印类对象
    在person1添加监听者之后我们打印一下person1和person2对应的类对象:
NSLog(@"person1添加KVO监听之后:-%@ %@", object_getClass(self.person1), object_getClass(self.person2));

打印结果:

person1添加KVO监听之后:-NSKVONotifying_Person Person
  • 2.查看添加监听前后setAge:方法的实现
    - (IMP)methodForSelector:(SEL)aSelector;这个方法是传入一个selector返回一个方法的实现即imp,这里我们打印一下person1添加监听前后person1和person2的setAge:方法的实现的地址来判断这两个对象调用的的setAge:方法是否发生了改变:
NSLog(@"person1添加监听之前:- %p %p", [self.person1 methodForSelector:@selector(setAge:)], [self.person2 methodForSelector:@selector(setAge:)]);
    
[self.person1 addObserver:self forKeyPath:@"age" options:options context:@"测试信息"];
    
NSLog(@"person1添加监听之后:- %p %p", [self.person1 methodForSelector:@selector(setAge:)], [self.person2 methodForSelector:@selector(setAge:)]);

打印结果:

person1添加监听之前:- 0x10f5c84d0 0x10f5c84d0
person1添加监听之后:- 0x10f96df8e 0x10f5c84d0

然后我们使用LLDB打印一下0x10f5c84d00x10f96df8e这两个地址的IMP,我们把地址强制转化为IMP然后转化出来:

(lldb) p (IMP)0x10678a4d0
(IMP) $0 = 0x000000010678a4d0 (interview-KVO`-[Person setAge:] at Person.m:13)
(lldb) p (IMP)0x106b2ff8e
(IMP) $1 = 0x0000000106b2ff8e (Foundation`_NSSetIntValueAndNotify)

这样我们就看的很清晰了。
0x10678a4d0这个地址的setAge:实现是调用Person类的setAge:方法,并且是在Person.m的第13行。
0x106b2ff8e这个地址的setAge:实现是调用_NSSetIntValueAndNotify这样一个C函数。
所以person2则没有发生变化,它一直是调用Person类的setAge:方法。而person1添加监听前后person1的setAge:方法发生了变化,添加监听前它是调用的Person类的setAge:方法,添加监听后变成了调用_NSSetIntValueAndNotify这样一个C函数。

KVO内部调用顺序

KVO内部调用顺序也就是_NSSetIntValueAndNotify这样一个C函数的执行过程。前面的伪代码写过这个C函数的执行过程大概分三步:

[self willChangeValueForKey:@"age"];
[super setAge:age];
[self didChangeValueForKey:@"age"];

由于我们无法去窥探_NSSetIntValueAndNotify的真实结构,也无法去重写NSKVONotifying_Person这个类,所以我们只能利用它的父类Person类来分析其执行过程。
在Person类里面重写willChangeValueForKey:didChangeValueForKey:这两个方法,但是只是简单的调用父类的方法,除此之外不做其他的有效处理,这样不会影响其执行。

@implementation Person

- (void)setAge:(int)age{
    
    _age = age;
    
    NSLog(@"setAge:");
}

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

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

@end

然后我们点击屏幕查看执行过程:


调用过程

通过这个图我们可以看到:

  • 1.首先调用willChangeValueForKey:方法。
  • 2.然后调用setAge:方法真正的改变属性的值。
  • 3.开始调用didChangeValueForKey:这个方法,调用[super didChangeValueForKey:key]时会通知监听者属性值已经改变,然后监听者执行自己的- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context这个方法。

NSKVONotifying_Person这个类的内部方法

在上面贴出的图中NSKVONotifying_Person这个类的结构是这样的:

NSKVONotifying_Perso的结构.png

这个图里面有一些我们很熟悉,比如这个isasuperclasssetAge:这些方法。而-dealloc则是主要做一些收尾工作,比如移除监听器等等。那么这个class方法主要是干什么的呢?
首先我们通过两种方式来打印一下person1和person2的类对象,一种是使用runtime的object_getClass()方法,另外一种是直接调用实例对象的class方法:

NSLog(@"%@ %@", object_getClass(self.person1), object_getClass(self.person2));
NSLog(@"%@ %@", [self.person1 class], [self.person2 class]);

看打印结果:

NSKVONotifying_Person Person
Person Person

奇怪了,为什么使用两种方式的打印结果不一样呢?通过前面的分析我们已经知道了,person1对象的类对象是NSKVONotifying_Person,我们通过runtime打印出来的是对的,但是通过[self.person1 class]这种方式打印出来的结果是错误的。原因就是NSKVONotifying_Person这个类重写了class方法,很可能就是直接返回了[Person class]

- (Class)class{
    
    return [Person class];
}
为什么要重写class这个方法呢?

苹果并不希望把NSKVONotifying_Person这个类暴露出来,屏蔽内部实现,隐藏这个类的存在。

打印NSKVONotifying_Person这个类的方法名

前面提到NSKVONotifying_Person这个类中有isa,superclass,setAge:,dealloc,class这些方法都还只是我们的猜想,俺么怎么证明这个类中有这些方法呢?我们使用runtime打印NSKVONotifying_Person这个类中的方法名。

- (void)printClassMethodNamesOfClass:(Class)cls{
    
    unsigned int count;
    //获得方法数组
    Method *methodList = class_copyMethodList(cls, &count);
    //遍历所有的方法
    for(int i = 0; i < count; i++){
        
        //获得方法
        Method method = methodList[i];
        //获得方法名
        NSString *methodName = NSStringFromSelector(method_getName(method));
        NSLog(@"方法名:%@ \n", methodName);
    }
    
    free(methodList);
}

我们写这个函数,通过传入一个类对象来打印这个类的所有函数名。
调用:

[self printClassMethodNamesOfClass:object_getClass(self.person1)];

查看打印结果:

2018-07-08 16:56:12.115606+0800 interview-KVO[4433:314695] setAge:
2018-07-08 16:56:12.115719+0800 interview-KVO[4433:314695] setAge:
2018-07-08 16:56:12.116083+0800 interview-KVO[4433:314695] 方法名:setAge: 

2018-07-08 16:56:12.116178+0800 interview-KVO[4433:314695] 方法名:class 

2018-07-08 16:56:12.116259+0800 interview-KVO[4433:314695] 方法名:dealloc 

2018-07-08 16:56:12.116349+0800 interview-KVO[4433:314695] 方法名:_isKVOA 

通过打印结果也就验证了我们的猜测。

直接修改成员变量会触发KVO吗?

不会,KVO的本质是set方法,只有调用了set方法才会触发KVO。

如何手动触发KVO

手动调用willChangeValueForKeydidChangeValueForKey方法。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,560评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,104评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,297评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,869评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,275评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,563评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,833评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,543评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,245评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,512评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,011评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,359评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,006评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,062评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,825评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,590评论 2 273
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,501评论 2 268

推荐阅读更多精彩内容