【OC语法】KVO的底层实现

目录
一、KVO是什么
二、怎么使用KVO
三、KVO的底层实现
四、KVO常见面试题


一、KVO是什么


KVO全称Key-Value Observing,翻译过来是键值观察,是一种用来观察某个对象属性值变化的机制。


二、怎么使用KVO


使用KVO只需要抓住三个关键词就可以了:被观察者是谁——即想要观察哪个对象哪个属性值的变化;观察者是谁——即想要让谁来观察,确定后就可以给对象添加KVO和移除对象的KVO了;观察者的回调方法——即当对象的属性值发生变化后要触发的方法。

举个简单例子:

假设我们有一个Person类,现在想要观察某个Person对象age属性值的变化。

// Person.h
#import <UIKit/UIKit.h>

@interface INEPerson : NSObject

@property (nonatomic, assign) NSInteger age;

@end

又有一个ViewController类,我们想要让ViewController来观察Person对象age属性值的变化,确定好观察者后,就可以给对象添加KVO和移除对象的KVO了。

// ViewController.m
#import "ViewController.h"
#import "INEPerson.h"

@interface ViewController ()

@property (nonatomic, strong) INEPerson *person;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.person = [[INEPerson alloc] init];
    self.person.age = 25;
    
    // 给person对象添加KVO
    [self.person addObserver:self forKeyPath:@"age" options:(NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew) context:nil];
}

- (void)dealloc {
    // 移除person对象的KVO
    [self.person removeObserver:self forKeyPath:@"age"];
}

@end

方法解释:

/**
 *  给对象添加KVO
 *
 *  @param  observer    观察者
 *  @param  keyPath     观察者想要观察对象哪个属性值的变化
 *  @param  options     我们想要得到该属性变化后的新值还是旧值(如果想在添加观察者后,立即触发一次观察者的回调方法,可以在这里添上NSKeyValueObservingOptionInitial这个值)
 *  @param  context     额外信息,通常填nil
 */
- (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context;
/**
 *  移除对象的KVO
 *
 *  @param  observer    观察者
 *  @param  keyPath     观察者观察的属性
 */
- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;

然后我们要为观察者实现一个回调方法,以便被观察对象的属性值发生变化后,观察者能够及时收到回调并做自定义的处理。

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    
    NSLog(@"观察到%@对象的%@属性的值发生了变化:%@", object, keyPath, change);
}

方法解释:

/**
 *  观察者的回调方法
 *
 *  @param  keyPath     观察者观察的属性
 *  @param  object      观察者观察的属性所属的对象
 *  @param  change      属性变化后的新值还是旧值都存在这里(我们还可以通过NSKeyValueChangeKindKey来判断新旧值的变化是重设、新增、替换还是移除)
 *  @param  context     额外信息,addObserver方法传过来是啥这里就是啥,通常是nil
 */
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context;

经过以上对三个关键词的捕捉与实现,我们就完成了KVO的使用。现在模拟修改一下被观察对象的age属性。

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

可以看到控制台打印如下,这说明观察者的回调方法被成功触发了。

// 控制台打印
观察到<INEPerson: 0x600003530f50>对象的age属性的值发生了变化:{
    kind = 1;
    new = 26;
    old = 25;
}


三、KVO的底层实现


继续上面的例子:

现在我们创建两个Person对象,并且给person1添加KVO,person2不添加KVO。

// ViewController.m
#import "ViewController.h"
#import "INEPerson.h"

@interface ViewController ()

@property (nonatomic, strong) INEPerson *person1;
@property (nonatomic, strong) INEPerson *person2;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.person1 = [[INEPerson alloc] init];
    self.person1.age = 25;
    // 给person对象添加KVO
    [self.person1 addObserver:self forKeyPath:@"age" options:(NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld) context:nil];
    
    
    self.person2 = [[INEPerson alloc] init];
    self.person2.age = 26;
}

- (void)dealloc {
    // 移除person对象的KVO
    [self.person1 removeObserver:self forKeyPath:@"age"];
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    
    NSLog(@"观察到%@对象的%@属性的值发生了变化:%@", object, keyPath, change);
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    
    self.person1.age = 26;
    self.person2.age = 27;
}

@end

当我们点击屏幕时,控制台打印如下。

// 控制台打印
观察到<INEPerson: 0x600002f765f0>对象的age属性的值发生了变化:{
    kind = 1;
    new = 26;
    old = 25;
}

于是我们不禁要问,点击屏幕,person1person2同样是调用setAge:方法,为什么person1就能触发观察者的回调方法,person2就不行呢?我们知道person1person2的唯一区别就是person1添加了KVO,而person2没有添加KVO,那KVO到底对person1做了什么?因此我们需要看看KVO的底层实现是什么,或许能找到问题的答案。

1、结论

我们不妨把结论先摆在这里,然后再去验证。

当我们给某个对象添加KVO之后,KVO的底层实现其实就是:

  • 在运行时动态地创建一个类,这个类继承自原来那个类,并且把使用了KVO的对象的isa指针指向这个新类,也就是说这个对象其实已经不是原来那个类的实例了,而是新类的实例。
  • 然后这个新类还会重写原来类和NSObject类的若干个方法,其中我们最关心的就是被观察属性的setter方法被重写了。重写的setter方法内部主要做了三件事:首先调用willChangeValueForKey:方法表明将要修改属性的值,然后调用原来类的setter方法真正去修改成员变量的值,然后再调用didChangeValueForKey:方法表明属性的值修改完毕,而且didChangeValueForKey:方法内部还会让观察者调用观察者的回调方法。

这也就是解释了“点击屏幕,person1person2同样是调用setAge:方法,为什么person1就能触发观察者的回调方法,person2就不行”,就是因为它俩已经不是同一个类了,这两个类的setAge:方法的实现压根儿不一样。

以下是新类以及新类setter方法的伪代码。

// NSKVONotifying_INEPerson.h
#import "INEPerson.h"

@interface NSKVONotifying_INEPerson : INEPerson

@end


// NSKVONotifying_INEPerson.m
#import "NSKVONotifying_INEPerson.h"

@implementation NSKVONotifying_INEPerson

// 我们最关心的就是被观察属性的setter方法被重写了
- (void)setAge:(NSInteger)age {
    // 表明将要修改属性的值
    [self willChangeValueForKey:@"age"];
    
    // 真正去修改成员变量的值
    [super setAge:age];
    
    // 表明属性的值修改完毕
    [self didChangeValueForKey:@"age"];
}

- (void)didChangeValueForKey:(NSString *)key {
    // 让观察者调用回调方法,key就是上面传进来的age
    [observer observeValueForKeyPath:key ofObject:self change:@{@"new": ..., @"old": ..., ...} context:nil];
}

// 重写class方法,我们猜测的原因是苹果为了隐藏KVO的底层实现,让我们开发者感知不到这个新类的存在。这样在开发中,当我们获取某个添加了KVO的对象的类时就不会产生疑惑
- (Class)class {
    return [INEPerson class];
}

- (void)dealloc {
    // 做一些收尾工作
    // ...
}

- (BOOL)_isKVOA {
    // 是否使用了KVO
    return YES;
}

@end

所以在分析KVO的面试题时,我们只需要抓住KVO这两个本质的点就行了,即:

  • KVO会创建一个新类,继承自原来那个类,使用了KVO的对象会指向这个新类。
  • KVO会重写被观察属性的setter方法,里面做了三件事,并且正是在新setter方法里才触发了观察者的回调方法。

2、验证

  • 先验证第一点。
#import <objc/runtime.h>

Class person1Class = object_getClass(self.person1);
Class person1SuperClass = class_getSuperclass(person1Class);
Class person2Class = object_getClass(self.person2);
Class person2SuperClass = class_getSuperclass(person2Class);

NSLog(@"person1所属的类及其父类:%@, %@", person1Class, person1SuperClass);
NSLog(@"person2所属的类及其父类:%@, %@", person2Class, person2SuperClass);

控制台打印如下。

person1所属的类及其父类:NSKVONotifying_INEPerson, INEPerson
person2所属的类及其父类:INEPerson, NSObject

可见我们给person1添加KVO之后,系统确实创建了一个继承自INEPerson的新类NSKVONotifying_INEPerson,并且也确实把person1isa指针指向了NSKVONotifying_INEPerson,也就是说person1已经不是INEPerson的实例了,而是NSKVONotifying_INEPerson的实例。而没添加KVO的person2还是INEPerson的实例,继承自NSObject

  • 再验证第二点。
#import <objc/runtime.h>

- (NSArray *)instanceMethodListOfClass:(Class)cls {
    
    NSMutableArray *instanceMethodList = [@[] mutableCopy];
    
    unsigned int count;
    // 获取类的实例方法列表
    Method *methodList = class_copyMethodList(cls, &count);
    for (NSInteger i = 0; i < count; i++) {
        // 获取方法
        Method method = methodList[i];
        // 获取方法名
        NSString *methodName = NSStringFromSelector(method_getName(method));
        
        [instanceMethodList addObject:methodName];
    }
    // 释放
    free(methodList);
    
    return instanceMethodList;
}

NSArray *person1MethodList = [self instanceMethodListOfClass:(object_getClass(self.person1))];
NSArray *person2MethodList = [self instanceMethodListOfClass:(object_getClass(self.person2))];
NSLog(@"person1的实例方法列表:%@", person1MethodList);
NSLog(@"person2的实例方法列表:%@", person2MethodList);


IMP person1SetAgeImp = class_getMethodImplementation(object_getClass(self.person1), @selector(setAge:));
IMP person2SetAgeImp = class_getMethodImplementation(object_getClass(self.person2), @selector(setAge:));
NSLog(@"person1 setAge:方法实现的地址:%p", person1SetAgeImp);
NSLog(@"person2 setAge:方法实现的地址:%p", person2SetAgeImp);

控制台打印如下。

person1的实例方法列表:(
    "setAge:",
    class,
    dealloc,
    "_isKVOA"
)
person2的实例方法列表:(
    "setAge:",
    age
)


person1 setAge:方法实现的地址:0x10d8a5688
person2 setAge:方法实现的地址:0x10d54a430

可见新类确实重写了原来类和NSObject类的若干个方法,包括setAge:- classdealloc_isKVOA四个,当然其中我们最关心的还是setAge:方法被重写,这从person1person2两者setAge:方法实现的地址不同,可以更加确认。至于重写的setAge:方法的内部实现,我们暂时无法看到它的源码,反编译后倒是可以看到它对应的汇编代码,但我们不一定能看得懂,所以现在只是通过现象来猜测它内部实现的伪代码。


四、KVO常见面试题


1、像person->age = 1这样直接修改成员变量会触发KVO(的回调方法)吗?

答案:

不会。

因为触发KVO本质上是调用重写后的setter方法内部触发的,而直接修改成员变量person->age = 1是不会调用setter方法的,所以不会触发。

2、如何手动触发KVO?(如何手动触发KVO观察者的回调方法?)

答案:

手动调用willChangeValueForKey:didChangeValueForKey:方法。(注意KVO的API里要求这两个方法必须成对调用)

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

推荐阅读更多精彩内容