在开发过程中,KVC支持我们使用字符串作为关联标识为对象的某个实例变量或属性进行赋值,这个字符串可以是对象的某个属性名或实例变量名,本文我们将通过官方文档描述来探寻KVC赋值逻辑。
设置器方法:- (void)setValue:(nullable id)value forKey:(NSString *)key;
此方法根据关联标识字符串 key 以及设置值 value, 方法内部通过以下三步进行赋值操作:
1.查找设置器方法。
根据方法参数key的值去依次匹配以下方法:
// 注意:以下<Key>为 - (void)setValue:(nullable id)value forKey:(NSString *)key; 中提供的key值
// 仅仅只是将首字母大写(如果以字母开头)并替换,并不会对key值做其他额外操作来匹配存取器方法
// 例如key值以“_”下划线开头,例如“_name”,则匹配的方法为 -(void)set_name:(id)value; 2、3同理
// 1.
- (void)set<Key>:(id)value;
// 2.
- (void)_set<Key>:(id)value;
// 3.
- (void)setIs<Key>:(id)value;
如果找到对应方法,则将value作为参数调用此方法。此步骤不关心类中是否拥有相应的属性或成员变量,仅仅只是方法匹配。
2.查找相应的实例变量
第一步中没有找到相关设置器方法并且该类 accessInstanceVariablesDirectly 属性返回YES,那么将按照 _<key>、 _is<Key>、 <key>、 is<Key> 的顺序查找相匹配的实例变量,如果找到相应的实例变量则对变量进行赋值(注意:此过程直接对实例变量进行赋值,不调用setter)。
3.-setValue:forUndefinedKey:
如果经过步骤1和步骤2没有找到相应的属性设置器或者实例变量,-setValue:forUndefinedKey: 会被调用。
-setValue:forUndefinedKey: 方法默认实现为抛出一个 NSUndefinedKeyException 异常。可以通过重写此方法进行特殊处理或者空实现避免抛出异常。
- (void)setValue:(nullable id)value forKey:(NSString *)key 方法赋值过程中针对非OC对象的处理
- 设置器方法value参数是一个OC对象,但是有时我们需要设置的实例变量类型有可能是基本数据类型、结构体等,对于这种情况我们需要将值包装成为NSNumber或者NSValue对象,设置器方法内部会在调用存取器方法或为实例变量赋值之前对value进行逆转换操作。
- 当检查发现存取器方法参数或实例变量类型为非对象类型,并且value为nil则 -setNilValueForKey: 方法会被调用。-setNilValueForKey: 方法默认实现为抛出一个 NSInvalidArgumentException 异常,我们可以通过重写此方法将nil映射为有意义的值。
如果一个集合类型对象调用此方法,则集合中每一个对象都会将 value、 key 作为参数调用设置器方法。
NSArray * arr = [NSArray arrayWithObjects: obj1, obj2, nil];
[arr setValue:@"zh-cn" forKey:@"language"];
集合中每一个对象的 setValue:forKey: 方法都会被调用,等同于 :
NSArray * arr = [NSArray arrayWithObjects: obj1, obj2, nil];
[arr enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
[obj setValue:@"zh-cn" forKey:@"language"];
}];
访问器方法:- (nullable id)valueForKey:(NSString *)key;
此方法根据关联标识字符串 key 取值,方法内部通过以下四个步骤进行取值(仅讨论iOS):
1.查找访问器方法
根据方法参数key的值依次匹配以下方法:
-get<Key>
-<key>
-is<Key>
-_<key>
如果找到对应的访问器方法,则调用此方法获取返回值。此步骤依旧不关心类中是否拥有相应的属性或成员变量,仅仅匹配方法。
2.查找集合访问方法
如果第一步中没有找到相关访问器方法,则匹配查找集合访问器方法
-countOf<Key>
-objectIn<Key>AtIndex:
如果找到以上两个方法则返回一个 NSKeyValueArray 类型的集合代理对象。NSKeyValueArray类继承自NSArray,可以响应NSArray所有消息,发送至集合代理对象的所有NSArray消息会被转换为以上一个方法或两个方法的组合发送至 -valueForKey: 方法的原始接收方。
关于 NSKeyValueArray 的更多内容将在后面的部分讲到,这里我们只需要简单理解为 NSKeyValueArray 可以被当做 NSArray 使用。
3.查找相应的实例变量 (与设置器方法类似)
如果没有找到相关访问器方法并且该类 accessInstanceVariablesDirectly 属性返回YES,那么将按照 _<key>、 _is<Key>、 <key>、 is<Key> 的顺序查找相匹配的实例变量,如果找到相应的实例变量则对变量进行赋值(注意:此过程直接为实例变量赋值,不调用getter)。
4.- (id)valueForUndefinedKey:(NSString *)key
当经过以上步骤没有找到访问方法或实例变量时,- (id)valueForUndefinedKey:(NSString *)key 会被调用,此方法默认实现为抛出 NSUndefinedKeyException 异常。
- (void)setValue:(nullable id)value forKey:(NSString *)key 方法取值过程中针对非OC对象的处理
- 与设置方法同理,当访问方法取得的值为非OC对象类型时,如果结果的类型是NSNumber支持的数据类型之一,则将结果转换为NSNumber对象返回,其他情况则将结果转换为NSValue对象返回。
当访问方法的接收对象为集合时,方法返回值为集合中每一个元素通过访问方法获取的值的集合
NSArray * arr = [NSArray arrayWithObjects: obj1, obj2, nil];
NSArray * value = [arr valueForKey:@"language"];
集合中每一个对象的 valueForKey: 方法都会被调用,等同于 :
NSArray * arr = [NSArray arrayWithObjects: obj1, obj2, nil];
NSMutableArray * arrM = [NSMutableArray array];
[arr enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
[arrM addObject: [obj valueForKey:@"language"]];
}];
NSArray * value = arrM.copy;
NSKeyValueArray 集合代理对象
1. NSKeyValueArray 继承自 NSArray ,NSKeyValueArray 集合代理对象中保存了 -valueForKey: 方法原始消息接收方(_container)以及方法参数( _key )。
当集合代理对象接收到 NSArray 消息时,消息会被转换为 -countOf<Key> 和
-objectIn<Key>AtIndex: 的一个或多个消息组合发送至 -valueForKey: 方法原始接收方(_container 实例变量)。
例如:
__kindof NSArray * value = [self valueForKey:@"list"];
id element = [value objectAtIndex:0];
此时 -objectAtIndex: 会被转换为 - objectInListAtIndex: 并发送至 - valueForKey: 方法的原始接收方self ,相当于:
id element = [self objectInListAtIndex:0];
2. 下标越界问题
通常我们在获取数组中某个下标元素时提供的下标值超出了数组长度会抛出异常导致程序崩溃。
对于 NSKeyValueArray " [ ] " 和 " - objectAtIndex: "消息会被转换为 - objectIn<Key>AtIndex: 发送至原始接收方( _container ),无论下标是否超出了 - countOfList: 方法返回的长度都不会导致程序崩溃。因此在 - objectIn<Key>AtIndex: 方法中我们应该先对 index 值进行越界检查,避免由于下标越界而出现一些匪夷所思的BUG。
3. NSKeyValueArray获取集合元素
在上面的文章中我们提到 NSKeyValueArray 继承自 NSArray 可以响应所有的 NSArray 消息(消息会被转换为集合访问器方法的一个或多个组合发送至 -valueForKey: 的原始接收方),因此每一次获取 NSKeyValueArray 集合中的全部或某个元素时,原始接收方( _container )的 -objectIn<Key>AtIndex: 方法会都被调用,换言之 NSKeyValueArray 对象并不像 NSArray对象一样会对集合中元素进行强引用, NSKeyValueArray 仅仅只是一个代理对象,所有元素均通过 -objectIn<Key>AtIndex: 方法实时获取,最终取得值由原始接收方决定。
此处我们将通过一个简单的例子验证以上结论,仔细思考一下,对于下面的例子element1和element2是同一个对象吗?
- (void)test {
// 注意,此时我们的类中并没有名为_list的成员变量,也没有访问器方法。
__kindof NSArray * value = [self valueForKey:@"list"];
// element1 和 element2 是同一个对象吗?
id element1 = value[0];
id element2 = value[0];
NSLog(@"\nelement1:%@\nelement2:%@", element1, element2);
}
- (NSUInteger)countOfList {
return 1;
}
- (id)objectInListAtIndex:(NSUInteger)index {
if (index < [self countOfList]) {
if (index == 0) {
return [NSObject new];
}
}
return nil;
}
对于上面的例子对value进行的两次取值相当于调用了两遍 _-objectInListAtIndex: 方法,而这个方法每一次调用都会创建一个新的NSObject对象返回,因此element1和element2并不是同一个对象。
获取可变集合器
获取 key 对应的可变集合访问器,访问器可以响应对应集合类型所有方法。该方法必定会返回一个对应的可变集合访问器,只是在使用时有不同的行为。
// 获取可变数组集合访问器
- (NSMutableArray *)mutableArrayValueForKey:(NSString *)key;
// 获取可变有续集和访问器
- (NSMutableOrderedSet *)mutableOrderedSetValueForKey:(NSString *)key;
// 获取可变不重复集合访问器
- (NSMutableSet *)mutableSetValueForKey:(NSString *)key;
以 MutableArray 为例(MutableOrderSet、MutableSet 同理,仅仅只是匹配的响应方法不同,具体可以查看 api 对应的注释说明),获取可变数组集合器经过查找设置器和查找访问器两个步骤步骤:
1.查找设置器
1.1 查找可变集合响应方法
对于 MutableArray 会先查找以下方法:
// 插入元素
-insertObject:in<Key>AtIndex:
// 删除元素
-removeObjectFrom<Key>AtIndex:
// 以下方法为可选,系统会根据需要调用,不实现也不影响功能
// 批量插入,对应 -[NSMutableArray insertObjects:atIndexes:] ,
// 如果不实现系统会将批量操作拆分为单个操作,通过多次调用 -insertObject:in<Key>AtIndex: 实现,以下方法同理
-insert<Key>:atIndexes:
// 批量移除,对应 -[NSMutableArray removeObjectsAtIndexes:]
-remove<Key>AtIndexes:
// 替换, 如果不实现会将操作拆分为 插入/删除 组合
-replaceObjectIn<Key>AtIndex:withObject:
// 批量替换
-replace<Key>AtIndexes:with<Key>:
1.2 查找 key 对应的设置器
在此步骤中,会根据与 -setValue:forKey: 方法相同的方式去查找设置器,并最终使用设置器。需要注意的是如果类中没有对应的设置器方法以及成员变量,最终会使用 -setValue:forKey: 作为设置器,如果此时使用可变集合访问器去修改集合时会执行 -setValue:forUndefinedKey: 。
2. 查找访问器方法
在此步骤中,会根据与 -valueForKey: 方法相同的方式去查找访问器。需要注意的是如果类中没有对应的访问器方法以及成员变量,最终会使用 -valueForKey: 作为访问器,如果此时使用可变集合访问器去获取集合时会执行 -valueForUndefinedKey: 。
KVO和KVC之间有什么联系,使用KVC赋值可以触发KVO回调吗?
说到KVC不得不提KVO,如果对于KVO还是不很熟悉的同学可以移步 KVO 键值观察原理浅析 进行了解。
注意:如果你还不是很清楚KVO的原理建议先了解KVO原理后再阅读此部分内容
KVO方法需要一个 keyPath 参数,keyPath 参数虽然名为 keyPath 但是我们可以提供一个 key (eg: name) 或者 keyPath (eg: dog.name),而我们之前介绍的KVC方法也有对应的 keyPath 存取方法,基于此我们不禁好奇KVO和KVC究竟有什么关系呢?
通过对KVO原理的了解,我们知道KVO之所以能够监听某个属性值改变,是由于其重写了原始类相关设置器方法,并在赋值前后分别调用 -willChangeValueForKey: 和 -didChangeValueForKey: 触发KVO监听回调。
类中有相关设置器方法。
KVO在查找设置器方法时的逻辑是否与KVC查找设置器方法逻辑相同呢?我们不妨写demo来验证一下。
下面的例子 ClassA 中拥有一个名为 -setIsName: 的设置器方法,我们通过 -setValue:forKey: 方法将 name 作为 key 赋值时会调用 -setIsName: 方法,如果我们同时将 name 作为KVO方法的 keyPath KVO回调方法会被执行吗?
@implementation ClassA {
NSString * _name;
}
- (instancetype)init {
if (self = [super init]) {
[self addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:nil];
[self setValue:@"John" forKey:@"name"];
}
return self;
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
if ([keyPath isEqualToString:@"name"]) {
NSLog(@"my name is %@", _name);
}
}
// KVC赋值第一步操作可匹配的设置器方法
- (void)setIsName:(NSString *)name {
_name = name;
}
@end
上面的例子运行起来,我们设置的KVO监听被触发,控制台有如下输出:
现在我们不妨查看一下KVO生成的子类 NSKVONotifying_ClassA 的方法列表:
在其中我们发现了 -setIsName: 这个方法,而这个方法是 ClassA 类中为 _name 提供的供KVC赋值使用的设置器方法,如此我们可以确定,如果类中提供了相关设置器方法(-set<Key>:、- _set<Key>:、- setIs<Key>:),那么当我们设置KVO监听后设置器方法会被重写,设置器方法被调用时KVO会被触发。
类中没有相关设置器方法。
我们将上面的例子中 ClassA 类的设置器方法代码删除运行,运行发现即使没有相关设置器方法,KVO依然会被触发,这又是为什么呢?
我们知道KVO回调是通过 -willChangeValueForKey: 和 -didChangeValueForKey: 这两个方法中触发的,那么我们可以重写 -willChangeValueForKey: 方法设置断点来观察一下从调用KVC方法到第一次触发KVO回调中间的方法调用堆栈。
虽然我们无法直接查看 _NSSetValueAndNotifyForKeyInIvar 函数实现,但是通过字面意思我们能够大致了解,这一步直接为相关成员变量赋值并且通知回调,所以这就是为什么类中没有相关设置器方法的情况下使用KVC赋值依旧能够触发KVO回调的原因。
类中没有相关设置器方法以及成员变量
将上例中 ClassA 类的设置器方法以及成员变量代码删除运行调用堆栈如下:
在运行上例时,我们会得到 valueForUndefinedKey: 方法抛出的异常,由此我们可以大概确定KVO内部利用KVC来获取旧值。需要注意的是,即使我们为类添加了与 key 同名的方法,虽然 KVO 得以正常回调,但是最后程序也会因为执行 -setValue:forUndefinedKey: 崩溃。
实际上通过对KVC赋值逻辑三个步骤以及在每一种情况下的调用堆栈观察,我们可以得出以下结论:
查找到相关设置器方法:
setValue:forKey: -> _NSSetObjectValueAndNotify -> willChangeValueForKey:查找到相关成员变量:
setValue:forKey: -> _NSSetValueAndNotifyForKeyInIvar -> willChangeValueForKey:没有查找到相关设置器方法以及成员变量:
setValue:forKey: -> _NSSetValueAndNotifyForUndefinedKey -> willChangeValueForKey:
也就是说KVC设置器方法实际上会根据每一种情况提供对KVO的处理,所以我们在设置了KVO监听之后使用KVC赋值是可以触发KVO回调的。
那么KVC在什么情况下会去处理KVO监听呢? 不知大家是否还记得我们在之前查看KVO监听后生成的子类方法列表时,其中有一个特殊的方法 _isKVOA,当时我们并没有提到它,那么现在它的作用不言自明。
KVO 监听与可变集合访问器
上文中我们说到了获取可变集合访问器方法,那么如果对一个 key 设置了 KVO 监听,我们在使用可变集合访问器去修改集合的时候,KVO 回调会被触发吗?
答案是取决于可变集合访问器获取的时机
如下的例子
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
if ([keyPath isEqualToString:@"arr"]) {
NSLog(@"arr改变了 %@", change);
}
}
// 添加 KVO 前获取可变集合访问器
NSMutableArray * mutableArr1 = [t mutableArrayValueForKey:@"arr"];
// 添加 KVO 监听
[t addObserver:self forKeyPath:@"arr" options:NSKeyValueObservingOptionNew context:nil];
// 添加 KVO 之后获取可变集合访问器
NSMutableArray * mutableArr2 = [t mutableArrayValueForKey:@"arr"];
// 初始化 t 的 arr 值,触发 KVO 回调
[t setValue:[NSMutableArray array] forKey:@"arr"];
// 通过添加 KVO 前获取的可变集合访问器添加元素,不会触发 KVO 回调
[mutableArr1 addObject:@"1"];
// 通过添加 KVO 后获取的可变集合访问器添加元素,触发 KVO 回调
[mutableArr2 addObject:@"2"];
输出结果如下:
2023-12-09 11:17:34.752834+0800 Demo1[34580:3276019] arr改变了 {
kind = 1;
new = (
);
}
2023-12-09 11:17:54.928467+0800 Demo1[34580:3276019] arr改变了 {
indexes = "<_NSCachedIndexSet: 0x280a343c0>[number of indexes: 1 (in 1 ranges), indexes: (1)]";
kind = 2;
new = (
2
);
}
根据结果可知,在添加 KVO 前获取的可变集合访问器修改元素不会触发 KVO,而之后获取的可以触发 KVO 且 KVO 回调中 change 包含改变的元素和改变的元素下标。