iOS KVC赋值原理

在开发过程中,KVC支持我们使用字符串作为关联标识为对象的某个实例变量或属性进行赋值,这个字符串可以是对象的某个属性名或实例变量名,本文我们将通过官方文档描述来探寻KVC赋值逻辑。


设置器方法:- (void)setValue:(nullable id)value forKey:(NSString *)key;

此方法根据关联标识字符串 key 以及设置值 value, 方法内部通过以下三步进行赋值操作:

1.查找设置器方法。

根据方法参数key的值去依次匹配以下方法:

// 注意:以下为 - (void)setValue:(nullable id)value forKey:(NSString *)key; 中提供的key值
// 仅仅只是将首字母大写(如果以字母开头)并替换,并不会对key值做其他额外操作来匹配存取器方法
// 例如key值以“_”下划线开头,例如“_name”,则匹配的方法为 -(void)set_name:(id)value; 2、3同理
// 1.
- (void)set:(id)value;
// 2.
- (void)_set:(id)value;
// 3.
- (void)setIs:(id)value;

如果找到对应方法,则将value作为参数调用此方法。此步骤不关心类中是否拥有相应的属性或成员变量,仅仅只是方法匹配。

2.查找相应的实例变量

第一步中没有找到相关设置器方法并且该类 accessInstanceVariablesDirectly 属性返回YES,那么将按照 _、 _isis 的顺序查找相匹配的实例变量,如果找到相应的实例变量则对变量进行赋值(注意:此过程直接对实例变量进行赋值,不调用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映射为有意义的值。

如果一个集合类型对象调用此方法,则集合中每一个对象都会将 valuekey 作为参数调用设置器方法。
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
-
-is
-_

如果找到对应的访问器方法,则调用此方法获取返回值。此步骤依旧不关心类中是否拥有相应的属性或成员变量,仅仅匹配方法。

2.查找集合访问方法

如果第一步中没有找到相关访问器方法,则匹配查找集合访问器方法

-countOf
-objectInAtIndex:

如果找到以上两个方法则返回一个 NSKeyValueArray 类型的集合代理对象。NSKeyValueArray类继承自NSArray,可以响应NSArray所有消息,发送至集合代理对象的所有NSArray消息会被转换为以上一个方法或两个方法的组合发送至 -valueForKey: 方法的原始接收方。
关于 NSKeyValueArray 的更多内容将在后面的部分讲到,这里我们只需要简单理解为 NSKeyValueArray 可以被当做 NSArray 使用。

3.查找相应的实例变量 (与设置器方法类似)

如果没有找到相关访问器方法并且该类 accessInstanceVariablesDirectly 属性返回YES,那么将按照 _、 _is、 is 的顺序查找相匹配的实例变量,如果找到相应的实例变量则对变量进行赋值(注意:此过程直接为实例变量赋值,不调用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 集合代理对象
NSKeyValueArray
1. NSKeyValueArray 继承自 NSArrayNSKeyValueArray 集合代理对象中保存了 -valueForKey: 方法原始消息接收方(_container)以及方法参数( _key )。

当集合代理对象接收到 NSArray 消息时,消息会被转换为 -countOf
-objectInAtIndex: 的一个或多个消息组合发送至 -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: "消息会被转换为 - objectInAtIndex: 发送至原始接收方( _container ),无论下标是否超出了 - countOfList: 方法返回的长度都不会导致程序崩溃。因此在 - objectInAtIndex: 方法中我们应该先对 index 值进行越界检查,避免由于下标越界而出现一些匪夷所思的BUG。

3. NSKeyValueArray获取集合元素

在上面的文章中我们提到 NSKeyValueArray 继承自 NSArray 可以响应所有的 NSArray 消息(消息会被转换为集合访问器方法的一个或多个组合发送至 -valueForKey: 的原始接收方),因此每一次获取 NSKeyValueArray 集合中的全部或某个元素时,原始接收方( _container )的 -objectInAtIndex: 方法会都被调用,换言之 NSKeyValueArray 对象并不像 NSArray对象一样会对集合中元素进行强引用, NSKeyValueArray 仅仅只是一个代理对象,所有元素均通过 -objectInAtIndex: 方法实时获取,最终取得值由原始接收方决定。

此处我们将通过一个简单的例子验证以上结论,仔细思考一下,对于下面的例子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并不是同一个对象。

element1与element2

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 *)change context:(void *)context {
    if ([keyPath isEqualToString:@"name"]) {
        NSLog(@"my name is %@", _name);
    }
}

// KVC赋值第一步操作可匹配的设置器方法
- (void)setIsName:(NSString *)name {
    _name = name;
}

@end

上面的例子运行起来,我们设置的KVO监听被触发,控制台有如下输出:

KVC与KVO

现在我们不妨查看一下KVO生成的子类 NSKVONotifying_ClassA 的方法列表:
image.png

在其中我们发现了 -setIsName: 这个方法,而这个方法是 ClassA 类中为 _name 提供的供KVC赋值使用的设置器方法,如此我们可以确定,如果类中提供了相关设置器方法(-set:、- _set:、- setIs:),那么当我们设置KVO监听后设置器方法会被重写,设置器方法被调用时KVO会被触发。

类中没有相关设置器方法。

我们将上面的例子中 ClassA 类的设置器方法代码删除运行,运行发现即使没有相关设置器方法,KVO依然会被触发,这又是为什么呢?

我们知道KVO回调是通过 -willChangeValueForKey:-didChangeValueForKey: 这两个方法中触发的,那么我们可以重写 -willChangeValueForKey: 方法设置断点来观察一下从调用KVC方法到第一次触发KVO回调中间的方法调用堆栈。

图1.png

虽然我们无法直接查看 _NSSetValueAndNotifyForKeyInIvar 函数实现,但是通过字面意思我们能够大致了解,这一步直接为相关成员变量赋值并且通知回调,所以这就是为什么类中没有相关设置器方法的情况下使用KVC赋值依旧能够触发KVO回调的原因。

类中没有相关设置器方法以及成员变量

将上例中 ClassA 类的设置器方法以及成员变量代码删除运行调用堆栈如下:

图2.png

在运行上例时,我们会得到 valueForUndefinedKey: 方法抛出的异常,由此我们可以大概确定KVO内部利用KVC来获取旧值。


实际上通过对KVC赋值逻辑三个步骤以及在每一种情况下的调用堆栈观察,我们可以得出以下结论:

  1. 查找到相关设置器方法:
    setValue:forKey: -> _NSSetObjectValueAndNotify -> willChangeValueForKey:

  2. 查找到相关成员变量:
    setValue:forKey: -> _NSSetValueAndNotifyForKeyInIvar -> willChangeValueForKey:

  3. 没有查找到相关设置器方法以及成员变量:
    setValue:forKey: -> _NSSetValueAndNotifyForUndefinedKey -> willChangeValueForKey:

也就是说KVC设置器方法实际上会根据每一种情况提供对KVO的处理,所以我们在设置了KVO监听之后使用KVC赋值是可以触发KVO回调的。

那么KVC在什么情况下会去处理KVO监听呢? 不知大家是否还记得我们在之前查看KVO监听后生成的子类方法列表时,其中有一个特殊的方法 _isKVOA,当时我们并没有提到它,那么现在它的作用不言自明。

想必大家一定在很多文章中看到过一句话“KVO是基于KVC实现的”,现在大家应该对这句话有了更深层次的理解了吧。(温馨提示:如果没有的话切忌在任何面试官面前挥舞这把大刀,听我的盖好被子好好睡一觉,然后忘了它。)

文章中如果有任何问题,或者讲述不是很容易理解的地方,大家可以私信我或者在评论区提出,后续我会根据大家的反馈对文章进行补充修改。

感谢!!!

你可能感兴趣的:(iOS KVC赋值原理)