KVC、KVO的本质

这篇文章介绍KVC、KVO的本质。如果你对KVC、KVO不了解,推荐先查看其用法:KVC和KVO学习笔记

1. KVO的本质

KVO 是 Key Value Observing 的缩写,称为健值观察。用于监听对象属性值的改变。

1.1 KVO的实现

观察者模式使用示例如下:

@interface ViewController ()
@property (nonatomic, strong) Child *child1;
@property (nonatomic, strong) Child *child2;
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.child1 = [[Child alloc] init];
    self.child1.age = 1;
    
    self.child2 = [[Child alloc] init];
    self.child2.age = 2;
    
    // 添加观察者
    [self.child1 addObserver:self
                  forKeyPath:@"age"
                     options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld
                     context:@"123context"];
}

// 观察到键值发生改变
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
    NSLog(@"监听到 %@ 的 %@ 属性值发生改变 - %@ - %@", object, keyPath, change, context);
}

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

1.2 runtime动态创建NSKVONotifying_XXX类

通过上述代码可以看到,age属性发生改变后,会通知监听者,即触发observeValueForKeyPath:ofObject:change:context:方法。我们知道赋值操作是通过调用set方法实现,进入Child类,重写setAge:方法,查看KVO是否通过修改set方法实现。

@implementation Child
- (void)setAge:(int)age {
    _age = age;
    
    NSLog(@"KVO是否通过重写setAge:方法实现?age:%d", age);
}
@end

测试后发现,修改child1child2都会触发setAge:方法,但child1会额外触发KVO。说明KVO在运行时对child1进行了修改,使得child1在调用setAge:时,进行了额外的操作。

根据 runtime 的原理,向实例对象发送消息时,先根据实例对象的 isa 查找到类对象,在类对象的方法列表中查找方法实现。因此,可以查看child1child2是否指向同一个类对象:

    // 打印添加观察者前实例对象的isa
    NSLog(@"添加Observer前 child1: %@ - child2: %@", object_getClass(self.child1), object_getClass(self.child2));
    
    // 添加观察者
    [self.child1 addObserver:self
                  forKeyPath:@"age"
                     options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld
                     context:@"123context"];
    
    // 打印添加观察者后实例对象的isa
    NSLog(@"添加Observer后 child1: %@ - child2: %@", object_getClass(self.child1), object_getClass(self.child2));

打印结果如下:

添加Observer前 child1: Child - child2: Child
添加Observer后 child1: NSKVONotifying_Child - child2: Child

如果你对 runtime、isa不了解,可以查看我的另一篇文章:Runtime从入门到进阶一

child1添加观察者后,isa指针由之前的指向Child,变为了NSKVONotifying_Child,而child2实例对象 isa 的指向没有发生改变。

添加观察者之前,child1实例在内存中的结构如下:

KVO&KVCBeforeAddObserver.png

添加观察者后,child1对象的isa指针指向了NSKVONotifying_Child类。NSKVONotifying_Childsuperclass指针指向Child类。

NSKVONotifying_ChildsetAge:方法调用了_NSSetIntValueAndNotify函数,该函数依次执行以下三步:

  1. 先调用willChangeValueForKey:
  2. 后调用[super setAge:]
  3. 最后调用didChangeValueForKey:触发监听

下面打印添加观察者前后setAge:方法实现的变化:

    NSLog(@"添加Observer前 child1: %p - child2: %p", [self.child1 methodForSelector:@selector(setAge:)], [self.child2 methodForSelector:@selector(setAge:)]);
    
    // 添加观察者
    ...
    
    NSLog(@"添加Observer后 child1: %p - child2: %p", [self.child1 methodForSelector:@selector(setAge:)], [self.child2 methodForSelector:@selector(setAge:)]);

控制台输出如下:

添加Observer前 child1: 0x10b247190 - child2: 0x10b247190
添加Observer后 child1: 0x7fff207bb2b7 - child2: 0x10b247190

可以看到,添加观察者后,child1setAge:地址发生了变化,child2setAge:地址没有发生变化。继续打印其具体实现:

(lldb) p (IMP)0x10b247190
(IMP) $0 = 0x000000010b247190 (KVC&KVO的本质`-[Child setAge:] at Child.m:12)
(lldb) p (IMP)0x7fff207bb2b7
(IMP) $1 = 0x00007fff207bb2b7 (Foundation`_NSSetIntValueAndNotify)

可以看到,添加观察者后,child1setAge:方法实现转换成了C语言Foundation框架中的_NSSetIntValueAndNotify函数。如果setAge:参数类型是double,其会自动调用_NSSetDoubleValueAndNotify函数,也就是会根据类型自动转换。

1.3 NSKVONotifying_Child具体实现

NSKVONotifying_Child继承自ChildNSKVONotifying_Child内部重写了setAge:方法,通过 runtime 的class_copyMethodList()函数可以查看对象的方法列表:

如下所示:

- (void)printMethodList:(Class)cls {
    unsigned int methodCount;
    // 获取方法数组
    Method *methodList = class_copyMethodList(cls, &methodCount);
    
    NSMutableString *name = [NSMutableString string];
    for (int i=0; i<methodCount; ++i) {
        // 获得方法
        Method method = methodList[I];
        // 获得方法名
        NSString *methodName = NSStringFromSelector(method_getName(method));
        
        [name appendString:methodName];
        [name appendString:@", "];
    }
    
    // C语言中使用copy、create创建的对象需释放。
    free(methodList);
    NSLog(@"%@ %@", cls, name);
}

添加观察者后,分别打印child1child2

NSKVONotifying_Child setAge:, class, dealloc, _isKVOA,
Child setAge:, age,

可以看到,NSKVONotifying_Child对象有四个方法,分别为

  • setAge::触发观察者的具体逻辑。
  • class:重写class方法,直接返回父类名称。这样可以屏蔽KVO内部实现,隐藏NSKVONOtifying_Child类的存在。
  • dealloc:释放时进行收尾工作。
  • _isKVOA:当前类是否是添加KVO后系统使用runtime创建的。

添加观察者之后,child1实例在内存中的结构如下:

KVO&KVCNSKVONotifying_XXX.png

另外,还可以手动创建名称为NSKVONotifying_Child、继承自Child的类。添加后,注册观察者时会报以下错误:

[general] KVO failed to allocate class pair for name NSKVONotifying_Child, automatic key-value observing will not work for this class

这也从侧面证明了runtime会动态创建NSKVONOtifying_Child类。

1.4 验证didChangeValueForKey:内部会调用observeValueForKeyPath:ofObject:change:context:方法

Child类中重写willChangeValueForKey:didChangeValueForKey:,查看哪一步调用observeValueForKeyPath:ofObject:change:context:方法:

- (void)setAge:(int)age {
    NSLog(@"begin - setAge:%d", age);
    _age = age;
    NSLog(@"end - setAge:%d", age);
}

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

// 验证didChangeValueForKey:调用了KVO
- (void)didChangeValueForKey:(NSString *)key {
    NSLog(@"didChangeValueForKey: - begin");
    [super didChangeValueForKey:key];
    NSLog(@"didChangeValueForKey: - end");
}

控制台输出如下:

begin - setAge:11
end - setAge:11
didChangeValueForKey: - begin
监听到 <Child: 0x600000168250> 的 age 属性值发生改变 - {
    kind = 1;
    new = 11;
    old = 1;
} - 123context
didChangeValueForKey: - end

可以看到,didChangeValueForKey:方法内部调用了observer的observeValueForKeyPath:ofObject:change:context:方法。

如果没有实现willChangeValueForKey:,只调用didChangeValueForKey:,其并不会调用observer的observeValueForKeyPath:ofObject:change:context:方法。

1.5 KVO 面试题

1.5.1 iOS 使用什么方式实现对一个对象的kvo?即KVO的本质是什么。

KVO是利用runtime API 动态生成一个子类,并且让 instance 对象的isa指向这个全新的子类,该子类的superclass指针指向原来的类。

当修改instance对象的属性时,会调用Foundation的_NSSetXXValueAndNotify函数。其内部会依次执行以下方法:

  1. 调用willChangeValueForKey:
  2. 调用父类的 setter。
  3. 调用didChangeValueForKey:,该方法内部会触发监听器的observeValueForKeyPath:ofObject:change:context:
1.5.2 如何手动触发KVO?

手动调用willChangeValueForKey:didChangeValueForKey:

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

由于 runtime 是通过创建子类,重写setter方法实现的监听值改变,直接修改成员变量并不会调用setter方法。因此,直接修改成员变量不会触发KVO。

2. KVC的本质

KVC 是 Key Value Coding 的缩写,称为健值编码。

2.1 设值原理setValue:forKey:

KVC设值方法有以下两个API:

- (void)setValue:(id)value forKeyPath:(NSString *)keyPath;
- (void)setValue:(id)value forKey:(NSString *)key;

setValue:forKey:原理如下图所示:

KVO&KVCsetValue.png

setValue:forKey:原理如下:

  • 先查找setKey:_setKey:方法,如果找到了,直接传递参数,调用方法;如果找不到,进入下一步。
  • 查看accessInstanceVariableDirectly方法返回值,如果返回NO,即不允许访问成员变量,则调用setValue:forUndefinedKey:方法,并抛出NSUnknownKeyException异常;如果返回YES,进入下一步。
  • 按照_key_isKeykeyisKey顺序查找成员变量,如果找到了成员变量,直接赋值。如果找不到,则调用setValue:forUndefinedKey:方法,并抛出NSUnknownKeyException异常。
2.1.1 有set方法

有set方法时,直接调用set方法:

@interface Person : NSObject
@property (nonatomic, assign) int age;
@end

@implementation Person
- (void)setAge:(int)age {
    _age = age;
    NSLog(@"%s %d", __PRETTY_FUNCTION__, age);
}
@end

执行后,控制台输出如下:

-[Person setAge:] 10
-[Observer observeValueForKeyPath:ofObject:change:context:] - {
    kind = 1;
    new = 10;
    old = 0;
}

可以看到,KVC先调用了set方法,然后触发了KVO。

如果将属性设置为readonly,在其他类中将不能通过访问器方法修改属性值,但可以使用KVC修改。这是因为设置为readonly后,虽然不会生成set方法,但会生成_key成员变量。此时,KVC直接为_key赋值,具体原理可以查看后面部分内容。

KVO&KVCreadonly.png
2.1.2 没有set方法

没有set方法时,会先查看accessInstanceVariableDirectly方法返回值,如果返回NO,则调用setValue:forUndefinedKey:方法,并抛出NSUnknownKeyException异常;如果返回YES,则按照_key_isKeykeyisKey顺序查找成员变量。如果找到了成员变量,直接赋值;如果找不到,则调用setValue:forUndefinedKey:方法,并抛出NSUnknownKeyException异常。

添加以下成员变量,并移除属性:

{
    @public
    // 如果没有set方法,按照下面顺序查找。
    int _age;
    int _isAge;
    int age;
    int isAge;
}

使用setValue:forKey:设置成员变量值后,会按顺序查找上述成员变量,找到后直接赋值。其顺序是固定的,与声明成员变量的先后次序无关。

KVO&KVC_key.png

2.2 KVC 内部实现willChangeValueForKey: 和didChangeValueForKey:方法

Person类添加以下代码:

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

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

再次为age设值,控制台输出如下:

-[Person willChangeValueForKey:] age
-[Person didChangeValueForKey:] - begin - age
- {
    kind = 1;
    new = 10;
    old = 0;
}
-[Person didChangeValueForKey:] - end - age

与KVO类似,其内部也实现了willChangeValueForKey:didChangeValueForKey:方法,并且是在didChangeValueForKey:后触发观察者的observeValueForKeyPath:ofObject:change:context:方法。

2.3 取值原理valueForKey:

KVC取值方法有以下两个API:

- (id)valueForKeyPath:(NSString *)keyPath;
- (id)valueForKey:(NSString *)key;

valueForKey:原理如下图所示:

KVO&KVCValueForKey.png

valueForKey:原理如下:

  • 按照getKey:keyisKey_key顺序查找方法,找到后直接调用方法;如果找不到,则进入下一步。
  • 查看accessInstanceVariableDirectly方法返回值,如果返回NO,则调用setValue:forUndefinedKey:方法,并抛出NSUnknownKeyException异常;如果返回YES,则进入下一步。
  • 按照_key_isKeykeyisKey顺序查找成员变量。如果找到了,直接取值;如果找不到,则调用setValue:forUndefinedKey:方法,并抛出NSUnknownKeyException异常。

Person类中,添加以下方法:

- (int)getAge {
    return 22;
}

使用以下方法取值:

NSLog(@"age: %@", [self.person valueForKey:@"age"]);

可以看到其取出的是getAge的值。你可以自行添加ageisAge_age_isAge方法,验证其取值顺序。

2.4 KVC面试题

2.4.1 通过KVC修改属性会触发KVO吗?

会触发。其内部调用了willChangeValueForKey:didChangeValueForKey:,并在调用didChangeValueForKey:方法后触发观察者的observeValueForKeyPath:ofObject:change:context:方法。

2.4.2 KVC取值、赋值过程是怎么样的?原理是什么?

取值、赋值过程和原理即为上面两个图片内容。

Demo名称:KVC&KVO的本质
源码地址:https://github.com/pro648/BasicDemos-iOS/tree/master/KVC&KVO的本质

欢迎更多指正:https://github.com/pro648/tips

本文地址:https://github.com/pro648/tips/blob/master/sources/KVC、KVO的本质.md

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

推荐阅读更多精彩内容