iOS中KVO的底层实现原理

1. KVO的使用

KVO(Key-Value Observing),也就是我们常说的键值监听,可以用于监听某个对象属性值的改变。KVO使用比较简单,如下所示定义了一个含有2个属性的Student类,然后声明一个实例对象,并添加一个观察者监听某个属性,当被监听的属性发生变化时就会调用观察者的observeValueForKeyPath: ofObject: change: context:方法。当不需要监听的时候需要移除观察者。

// Student.h文件
@interface Student : NSObject
@property (nonatomic , strong) NSString *name;
@property (nonatomic , strong) NSMutableArray booksArr;
@end
// 使用Student类的文件
- (void)test{
    self.stu1 = [[Student alloc] init];
    // 添加观察者监听name的变化
    [self.stu1 addObserver:self
                forKeyPath:@"name"
                   options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld
                   context:NULL];

    NSLog(@"name改变前");
    self.stu1.name = @"Jack";
    NSLog(@"name改变后");
}

// 当监听属性发生变化时的回调
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{
    NSLog(@"keyPath:%@,change-->%@",keyPath,change);
}

- (void)dealloc{
    // 移除观察者
    [self.stu1 removeObserver:self forKeyPath:@"name"];
}

// ********************打印结果********************
2020-01-05 09:42:32.371008+0800 GCDDemo[13375:567451] name改变前
2020-01-05 09:42:32.371618+0800 GCDDemo[13375:567451] keyPath:name,change-->{
    kind = 1;
    new = Jack;
    old = "<null>";
}
2020-01-05 09:42:32.371895+0800 GCDDemo[13375:567451] name改变后

2. KVO底层实现原理

KVO的实现过程实际上是利用了OC的runtime机制,当一个实例对象(比如上面的self.stu1)添加观察者时,底层根据该实例对象所属的类动态添加了一个类(动态添加的类名就是在原来类的类名前加上NSKVONotifying_前缀),这个类是继承自原来的类的。上面实例的底层实现过程如下:

  • self.stu1添加观察者时,底层就利用runtime动态生成一个叫NSKVONotifying_Student的类,这个类继承自Student类,并重写了以下实例方法:
    • 重写class方法,不重写的话调用这个方法返回的是NSKVONotifying_Student这个类,重写后返回的是原本的Student类。苹果这么做的目的是为了隐藏KVO的实现细节。
    • 重写dealloc方法,在这个方法里面做一些收尾的工作。
    • 重写_isKVOA方法,这是一个私有方法,我们不必关心。
    • 重写被监听属性的setter方法,上面案例只监听了name属性,所以只需重写setName:方法。重写setter是实现KVO的关键,在setter方法里面实际是调用的Foundation框架下的_NSSet***ValueAndNotify方法(***表示不是一个固定的,这个和监听的属性的类型有关,比如是属性是int类型的话这里就是__NSSetIntValueAndNotify,所包含的类型会在后面列出来)。
  • 然后将self.stu1这个实例对象的isa改为指向NSKVONotifying_Student(原本是指向Student类的)。
  • 当我们设置被监听属性的值时self.stu1.name = @"Jack",是调用的setName:方法,前面说了setName:方法被重写了,所以实际上调用的是_NSSetObjectValueAndNotify这个方法。这个方法实现苹果是没有开源的,无法得知其具体实现,不过可以猜出其实现流程大致如下:
    • 首先调用[self willChangeValueForKey:@"name"];这个方法。
    • 然后调用原先的setter方法的实现(比如_name = name;);
    • 再调用[self didChangeValueForKey:@"name"];这个方法。
    • 最后在didChangeValueForKey:这个方法中调用观察者的observeValueForKeyPath: ofObject: change: context:方法来通知观察者属性值发生了变化。

Foundation框架下的_NSSet***ValueAndNotify系列方法列表如下:

 _NSSetBoolValueAndNotify 
 _NSSetCharValueAndNotify 
 _NSSetDoubleValueAndNotify 
 _NSSetFloatValueAndNotify 
 _NSSetIntValueAndNotify 
 _NSSetLongLongValueAndNotify 
 _NSSetLongValueAndNotify 
 _NSSetObjectValueAndNotify 
 _NSSetPointValueAndNotify 
 _NSSetRangeValueAndNotify 
 _NSSetRectValueAndNotify 
 _NSSetShortValueAndNotify 
 _NSSetSizeValueAndNotify 
 _NSSetUnsignedCharValueAndNotify 
 _NSSetUnsignedIntValueAndNotify 
 _NSSetUnsignedLongLongValueAndNotify 
 _NSSetUnsignedLongValueAndNotify 
 _NSSetUnsignedShortValueAndNotify 

3. KVO底层实现的验证

3.1 我们怎么知道添加观察者时动态添加了一个类?

这个其实我们只需要打印一下再添加观察者之前和之后实例对象所属的类就知道了。不过前面已经说过了,动态添加的类重写了class方法,所以我们不能通过这个方法来获取一个实例对象的类,而要通过runtimeobject_getClass()这个API来获取:

- (void)test1{
    self.stu1 = [[Student alloc] init];
    
    NSLog(@"观察前- [self.stu1 class] -->%@",[self.stu1 class]);
    NSLog(@"观察前- object_getClass(self.stu1) -->%@",object_getClass(self.stu1));

    [self.stu1 addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:NULL];

    NSLog(@"观察后- [self.stu1 class] -->%@",[self.stu1 class]);
    NSLog(@"观察后- object_getClass(self.stu1) -->%@",object_getClass(self.stu1));
}

// ********************打印结果********************
2020-01-05 10:51:00.584299+0800 GCDDemo[14497:600230] 观察前- [self.stu1 class] -->Student
2020-01-05 10:51:00.584690+0800 GCDDemo[14497:600230] 观察前- object_getClass(self.stu1) -->Student
2020-01-05 10:51:00.592797+0800 GCDDemo[14497:600230] 观察后- [self.stu1 class] -->Student
2020-01-05 10:51:00.593064+0800 GCDDemo[14497:600230] 观察后- object_getClass(self.stu1) -->NSKVONotifying_Student

3.2 如何知道重写了哪些方法?

这里我们需要用到runtime的一些API来获取一个类对象里面存储的方法列表信息,下面我们先封装一个方法来获取这些信息,然后把监听前和监听后的方法列表打印出来。

- (void)test2{
    self.stu1 = [[Student alloc] init];

    NSLog(@"观察前方法列表-->%@",[self methodNamesOfClass:object_getClass(self.stu1)]);

    [self.stu1 addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:NULL];

    NSLog(@"观察后方法列表-->%@",[self methodNamesOfClass:object_getClass(self.stu1)]);
  
}

// 传入一个类,将类中方法列表的方法名拼接换成字符串返回
- (NSString *)methodNamesOfClass:(Class)cls{
    unsigned int count;
    // 获取方法列表
    Method *methodList = class_copyMethodList(cls, &count);
    NSString *methodNamesStr = @"";

    // 遍历方法列表将方法名拼接成字符串
    for (int i = 0; i < count; i++) {
        Method method = methodList[i];
        NSString *methodName = NSStringFromSelector(method_getName(method));
        methodNamesStr = [methodNamesStr stringByAppendingFormat:@"%@ ,",methodName];
    }
    
    // 释放
    free(methodList);
    
    return methodNamesStr;
}

// ********************打印结果********************
2020-01-05 10:56:43.077817+0800 GCDDemo[14606:603376] 观察前方法列表-->.cxx_destruct ,name ,setName: ,age ,setAge: ,
2020-01-05 10:56:43.078483+0800 GCDDemo[14606:603376] 观察后方法列表-->setName: ,class ,dealloc ,_isKVOA ,

3.3 怎么知道重写setter方法是调用的哪个方法?

这里我们同样需要用到runtime的API,首先通过class_getInstanceMethod()函数来获取setter方法的Method,然后再调用method_getImplementation()来得到setter方法的IMP

不过我们首先打印的是IMP的地址,想要看IMP的具体信息我们需要打一个断点调出LLDB,然后借助LLDB来打印具体信息。比如在监听前的IMP地址是0x10967d4c0,就可以在LLDB中输入p (IMP)0x10967d4c0来打印具体信息。从下面可以看出监听前setter方法就是正常的,监听后就变成了_NSSetObjectValueAndNotify

- (void)test1{
    self.stu1 = [[Student alloc] init];

    NSLog(@"监听前的setter方法IMP-->%p",[self IMPWithSelector:@selector(setName:)]);

    [self.stu1 addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:NULL];

    NSLog(@"监听后的setter方法IMP-->%p",[self IMPWithSelector:@selector(setName:)]);
}

// 获取一个方法的IMP
- (IMP)IMPWithSelector:(SEL)selector{
    Class cls = object_getClass(self.stu1);
    Method methon = class_getInstanceMethod(cls, selector);
    IMP imp = method_getImplementation(methon);
    return imp;
}

// ********************打印结果********************
2020-01-05 11:25:40.485792+0800 GCDDemo[15032:617260] 监听前的setter方法IMP-->0x10967d4c0
2020-01-05 11:25:40.489656+0800 GCDDemo[15032:617260] 监听后的setter方法IMP-->0x7fff25701c8a
(lldb) p (IMP)0x10967d4c0
(IMP) $0 = 0x000000010967d4c0 (GCDDemo`-[Student setName:] at Student.h:15)
(lldb) p (IMP)0x7fff25701c8a
(IMP) $1 = 0x00007fff25701c8a (Foundation`_NSSetObjectValueAndNotify)

4. KVO小结

KVO的核心是动态生成一个继承自原类的类,然后将实例对象的isa指向这个类。然后重写了监听属性的setter方法,在原有setter方法的前面调用willChangeValueForKey方法,在原有setter方法的后面调用didChangeValueForKey

所以我们要判断某个操作是否会触发KVO关键在于它是否调用了监听属性的setter方法。比如上面的例子,self.stu1.name = @"Jack";这种方式就是调用setter方法,所以它会触发KVO。但是下面这几种方式是不会触发KVO的:

  • 采用给成员变量赋值的方式,self.stu1->_name = @"Jack";(前提是需要将成员变量_name给暴露出去才能在外面访问),这种方式是不会触发KVO的,因为它没有调用setter方法。
  • 对于集合类型,集合里面数据的更新是不会触发KVO的。比如[self.stu1.booksArr addObject:@"book1"]这样的操作,它同样没有调用setBooksArr:方法,所以不会触发KVO
  • 如果所监听的属性是一个自定义的OC对象,比如有个Dog类里面有个age属性,Student类里面有个Dog类型的属性dog,如果我们监听dog这个属性,当dogage发生变化时并不会触发KVO,因为它不会调用setDog:方法。

上面这几种情况,如果我们也想触发KVO的话,我们可以手动触发,也就是在原有方法的前面和后面分别加上willChangeValueForKeydidChangeValueForKey这两个方法。就比如最后这个例子,我们可以这样写:

[self.stu1 willChangeValueForKey:@"dog"];
self.stu1.dog.age = 3;
[self.stu1 didChangeValueForKey:@"dog"];

最后还有一点要说明,通过KVC方式设置属性值也是会触发KVO的。比如[self.stu1 setValue:@"Jack" forKey:@"name"];这样写是可以触发KVO的,这应该是苹果在KVC实现中调用了willChangeValueForKeydidChangeValueForKey这两个方法。

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