在工作中经常会使用到 KVC, 但是很多人对于 KVC 的实现原理并不太清楚比如说 KVC 在进行存取时, 是怎么进行查找并赋值的
网上有很多讲 KVC 的文章, 但是有很多质量并不高这两天抽空把我所理解的 KVC 写出来, 当做学习交流, 正好也让各位大神帮我指正一下, 十分感谢!
协议定义
KVC 全称是 Key Value Coding, 定义在 NSKeyValueCoding.h 文件中, 是一个非正式协议 KVC 提供了一种间接访问其属性方法或成员变量的机制, 可以通过字符串来访问对应的属性方法或成员变量
在 NSKeyValueCoding 中提供了 KVC 通用的访问方法, 分别是 getter 方法 valueForKey: 和 setter 方法 setValue:forKey:, 以及其衍生的 keyPath 方法, 这两个方法各个类通用的并且由 KVC 提供默认的实现, 我们也可以自己重写对应的方法来改变实现
基础操作
KVC 主要对三种类型进行操作, 基础数据类型及常量对象类型集合类型
- @interface BankAccount : NSObject
- @property (nonatomic, strong) NSNumber *currentBalance;
- @property (nonatomic, strong) Person *owner;
- @property (nonatomic, strong) NSArray<Transaction *> *transactions;
- @end
在使用 KVC 时, 直接将属性名当做 key, 并设置 value, 即可对属性进行赋值
- [myAccount setValue:@(100.0) forKey:@"currentBalance"];
- keyPath
除了对当前对象的属性进行赋值外, 还可以对其更深层的对象进行赋值例如对当前对象的 address 属性的 street 属性进行赋值 KVC 进行多级访问时, 直接类似于属性调用一样用点语法进行访问即可
[myAccount setValue:@"中关村大街" forKeyPath:@"address.street"];
通过 keyPath 对数组进行取值时, 并且数组中存储的对象类型都相同, 可以通过 valueForKeyPath: 方法指定取出数组中所有对象的某个字段例如下面例子中, 通过 valueForKeyPath: 将数组中所有对象的 name 属性值取出, 并放入一个数组中返回
NSArray *names = [array valueForKeyPath:@"name"];
多值操作
需要注意的是, 虽然看到 dictionary 的字样, 下面两个方法并不是字典的方法
KVC 还有更强大的功能, 可以根据给定的一组 key, 获取到一组 value, 并且以字典的形式返回, 获取到字典后可以通过 key 从字典中获取到 value
- (NSDictionary<NSString *, id> *)dictionaryWithValuesForKeys:(NSArray<NSString *> *)keys;
同样, 也可以通过 KVC 进行批量赋值在对象调用
setValuesForKeysWithDictionary:
方法时, 可以传入一个包含 keyvalue 的字典进去, KVC 可以将所有数据按照属性名和字典的 key 进行匹配, 并将 value 给 User 对象的属性赋值
- (void)setValuesForKeysWithDictionary:(NSDictionary<NSString *, id> *)keyedValues;
实用技巧
在项目中经常会遇到字典转模型的情况, 如果在自定义的 init 方法里逐个赋值, 这样每次数据发生改变还需要改赋值语句然而通过 KVC 为我们提供的赋值 API, 可以对数据进行批量赋值假设有以下 JSON 数据并定义 User 类, 在外界通过
setValuesForKeysWithDictionary:
方法对 User 进行赋值
JSON 数据:
- {
- "username": "lxz",
- "age": 25,
- "id": 100
- }
- @interface User : NSObject
- @property (nonatomic, copy) NSString *name;
- @property (nonatomic, assign) NSString age;
- @property (nonatomic, assign) NSInteger userId;
- @end
- @implementation User
- - (void)setValue:(id)value forUndefinedKey:(NSString *)key {
- if ([key isEqualToString:@"id"]) {
- self.userId = [value integerValue];
- }
- }
- @end
赋值时会遇到一些问题, 例如服务器会返回一个 id 字段, 但是对于客户端来说 id 是系统保留字段, 可以重写
setValue:forUndefinedKey:
方法并在内部处理 id 参数的赋值
转换时需要服务器数据和类定义匹配, 字段数量和字段名都应该匹配如果 User 比服务器数据多, 则服务器没传的字段为空如果服务端传递的数据 User 中没有定义, 则会导致崩溃
在 KVC 进行属性赋值时, 内部会对基础数据类型做处理, 不需要手动做 NSNumber 的转换需要注意的是, NSArray 和 NSDictionary 等集合对象, value 都不能是 nil, 否则会导致 Crash
异常信息
当根据 KVC 搜索规则, 没有搜索到对应的 key 或者 keyPath, 则会调用对应的异常方法异常方法的默认实现, 在异常发生时会抛出一个
NSUndefinedKeyException
的异常, 并且应用程序 Crash
我们可以重写下面两个方法, 根据业务需求合理的处理 KVC 导致的异常
- - (nullable id)valueForUndefinedKey:(NSString *)key;
- - (void)setValue:(nullable id)value forUndefinedKey:(NSString *)key;
异常处理
当通过 KVC 给某个非对象的属性赋值为 nil 时, 此时 KVC 会调用属性所属对象的 setNilValueForKey: 方法, 并抛出
NSInvalidArgumentException
的异常, 并使应用程序 Crash
我们可以通过重写下面方法, 在发生这种异常时进行处理例如给 name 赋值为 nil 的时候, 就可以重写 setNilValueForKey: 方法并表示 name 是空的
- - (void)setNilValueForKey:(NSString *)key {
- if ([key isEqualToString:@"name"]) {
- [self setValue:@"" forKey:@age];
- } else {
- [super setNilValueForKey:key];
- }
- }
集合属性操作
根据 KVO 的实现原理, 是在运行时生成新的子类并重写其 setter 方法, 在其内容发生改变时发送消息但这只是对属性直接进行赋值会触发, 如果属性是容器对象, 对容器对象进行 add 或 remove 操作, 则不会调用 KVO 的方法可以通过 KVC 对应的 API 来配合使用, 使容器对象内部发生改变时也能触发 KVO
在进行容器对象操作时, 先调用下面方法通过 key 或者 keyPath 获取集合对象, 然后再对容器对象进行 add 或 remove 等操作时, 就会触发 KVO 的消息通知了
- - (NSMutableArray *)mutableArrayValueForKey:(NSString *)key;
- - (NSMutableOrderedSet *)mutableOrderedSetValueForKey:(NSString *)key API_AVAILABLE(macos(10.7), ios(5.0), watchos(2.0), tvos(9.0));
- - (NSMutableSet *)mutableSetValueForKey:(NSString *)key;
keyPath 方法:
- - (NSMutableArray *)mutableArrayValueForKeyPath:(NSString *)keyPath;
- - (NSMutableOrderedSet *)mutableOrderedSetValueForKeyPath:(NSString *)keyPath API_AVAILABLE(macos(10.7), ios(5.0), watchos(2.0), tvos(9.0));
- - (NSMutableSet *)mutableSetValueForKeyPath:(NSString *)keyPath;
集合运算符
KVC 提供的 valueForKeyPath: 方法非常强大, 可以通过该方法对集合对象进行深入操作, 在其 keyPath 中嵌套集合运算符, 例如求一个数组中对象某个属性的 count(集合对象主要指 NSArray 和 NSSet, 但不包括 NSDictionary)
上面表达式主要分为三部分, left 部分是要操作的集合对象, 如果调用 KVC 的对象本来就是集合对象, 则 left 可以为空中间部分是表达式, 表达式一般以 @符号开头后面是进行运算的属性
集合运算符主要分为三类:
集合操作符: 处理集合包含的对象, 并根据操作符的不同返回不同的类型, 返回值以 NSNumber 为主
数组操作符: 根据操作符的条件, 将符合条件的对象包含在数组中返回
嵌套操作符: 处理集合对象中嵌套其他集合对象的情况, 返回结果也是一个集合对象
example
下面是为了方便模拟 KVC 操作, 而创建的测试代码定义 Transaction 类为模型类, 类中包含三种类型的属性并定义 BankAccount 类, 其中包含一个数组, 下面的代码示例就都是操作这个数组的, 并且数组包含所有 Transaction 对象
- @interface Transaction : NSObject
- @property (nonatomic, strong) NSString *payee;
- @property (nonatomic, strong) NSNumber *amount;
- @property (nonatomic, strong) NSDate *date;
- @end
- @interface BankAccount : NSObject
- @property (nonatomic, strong) NSArray *transactions;
- @end
集合操作符
集合操作符处理 NSArray 和 NSSet 及其子类这样的集合对象, 并根据不同的操作符返回不同类型的对象, 返回值一般都是 NSNumber
@avg 用来计算集合中 right keyPath 指定的属性的平均值
NSNumber *transactionAverage = [self.transactions valueForKeyPath:@"@avg.amount"];
@count 用来计算集合的总数
NSNumber *numberOfTransactions = [self.transactions valueForKeyPath:@"@count"];
备注:@count 操作符比较特殊, 它不需要写 right keyPath, 即使写了也会被忽略
@sum 用来计算集合中 right keyPath 指定的属性的总和
NSNumber *amountSum = [self.transactions valueForKeyPath:@"@sum.amount"];
@max 用来查找集合中 right keyPath 指定的属性的最大值
NSDate *latestDate = [self.transactions valueForKeyPath:@"@max.date"];
@min 用来查找集合中 right keyPath 指定的属性的最小值
NSDate *earliestDate = [self.transactions valueForKeyPath:@"@min.date"];
备注:@max 和 @min 在进行判断时, 都是通过调用 compare: 方法进行判断, 所以可以通过重写该方法对判断过程进行控制
数组操作符
@unionOfObjects 将集合对象中, 所有 payee 对象放在一个数组中并返回
- NSArray *payees = [self.transactions valueForKeyPath:@"@unionOfObjects.payee"];
- @distinctUnionOfObjects
将集合对象中, 所有 payee 对象放在一个数组中, 并将数组进行去重后返回
NSArray *distinctPayees = [self.transactions valueForKeyPath:@"@distinctUnionOfObjects.payee"];
注意: 以上两个方法中, 如果操作的属性为 nil, 在添加到数组中时会导致 Crash
嵌套操作符
由于嵌套操作符是需要对嵌套的集合对象进行操作, 所以新建一个 arrayOfArrays 对象, 其中包含两个数组, 数组中存储的都是 Transaction 类型对象
- NSArray *moreTransactions = ....;
- NSArray *arrayOfArrays = @[self.transactions, moreTransactions];
@unionOfArrays 是用来操作集合内部的集合对象, 将所有 right keyPath 对应的对象放在一个数组中返回
- NSArray *collectedPayees = [arrayOfArrays valueForKeyPath:@"@unionOfArrays.payee"];
- @distinctUnionOfArrays
是用来操作集合内部的集合对象, 将所有 right keyPath 对应的对象放在一个数组中, 并进行排重
- NSArray *collectedDistinctPayees = [arrayOfArrays valueForKeyPath:@"@distinctUnionOfArrays.payee"];
- @distinctUnionOfSets
是用来操作集合内部的集合对象, 将所有 right keyPath 对应的对象放在一个 set 中, 并进行排重
NSSet *collectedPayees = [arrayOfArrays valueForKeyPath:@"@distinctUnionOfSets.payee"];
小技巧
如果在集合对象中操作的属性, 本来就是 NSNumber 类型, 则可以像下面这样, 直接用 self 代表值自身
- NSArray *array = @[@(productA.price), @(productB.price), @(productC.price), @(productD.price)];
- NSNumber *avg = [array valueForKeyPath:@"@avg.self"];
非对象值处理
KVC 是支持基础数据类型和结构体的, 可以在 setter 和 getter 的时候, 通过 NSValue 和 NSNumber 来转换为 OC 对象 Swift 中不存在这样的需求, 因为 Swift 中所有变量都是对象
以下是结构体转换的示例代码, 可以调用 initWithBool: 方法对基础数据类型进行包装, 除了调用方法外还可以通过字面量实现, 例如 @(YES)的调用通过 NSNumber 的 boolValue 属性转换为基础数据类型
- @property (nonatomic, assign, readonly) BOOL boolValue;
- - (NSNumber *)initWithBool:(BOOL)value NS_DESIGNATED_INITIALIZER;
结构体转换的代码定义在 UIGeometry.h 中, 以 NSValue 的 Category 形式存在 NSValue 对 CGPointCGRect 等结构体都提供了转换方法, 例如下面是对 CGPoint 进行转换的示例代码
- @property(nonatomic, assign, readonly) CGPoint CGPointValue;
- + (NSValue *)valueWithCGPoint:(CGPoint)point;
需要注意的是, 无论什么时候都不应该给 setter 中传入 nil, 会导致 Crash 并引起
NSInvalidArgumentException
异常
属性验证
在调用 KVC 时可以先进行验证, 验证通过下面两个方法进行, 支持 key 和 keyPath 两种方式验证方法默认实现返回 YES, 可以通过重写对应的方法修改验证逻辑
验证方法需要我们手动调用, 并不会在进行 KVC 的过程中自动调用
- - (BOOL)validateValue:(inout id _Nullable * _Nonnull)ioValue forKey:(NSString *)inKey error:(out NSError **)outError;
- - (BOOL)validateValue:(inout id _Nullable * _Nonnull)ioValue forKeyPath:(NSString *)inKeyPath error:(out NSError **)outError;
下面是使用验证方法的例子在 validateValue 方法的内部实现中, 如果传入的 value 或 key 有问题, 可以通过返回 NO 来表示错误, 并设置 NSError 对象
- Person *person = [[Person alloc] init];
- NSError *error;
- NSString *name = @"John";
- if (![person validateValue:&name forKey:@"name" error:&error]) {
- NSLog(@"%@", error);
- }
单独验证
KVC 还支持对单独属性做验证, 可以通过定义
validate<Key>:error:
格式的方法, 并在方法内部实现验证代码在编写 KVC 验证代码的时候, 应该先查找属性有没有自定义 validate 方法, 然后再查找 validateValue: 方法, 如果有则调用自己实现的方法, 如果两个方法都没有实现则默认返回 YES
- - (BOOL)validateName:(id *)ioValue error:(NSError * __autoreleasing *)outError{
- if ((*ioValue == nil) || ([(NSString *)*ioValue length] < 2)) {
- if (outError != NULL) {
- *outError = [NSError errorWithDomain:PersonErrorDomain
- code:PersonInvalidNameCode
- userInfo:@{ NSLocalizedDescriptionKey
- : @"Name too short" }];
- }
- return NO;
- }
- return YES;
- }
我觉得 KVC 应该支持 validateValue 自动验证, 在调用 setValue 或 getValue 时自动进行验证, 如果不符合验证规则, 就调用失败如果外界使用的地方都先调用一次 validateValue 的话, 这是很麻烦的当然也有解决方法, 可以通过 Method Swizzling 方法 hook 住 setValue 和 getValue 方法
搜索规则
KVC 在通过 key 或者 keyPath 进行操作的时候, 可以查找属性方法成员变量等, 查找的时候可以兼容多种命名具体的查找规则要以官方文档为主, 所以我把官方文档翻译了一下写在下面
在 KVC 的实现中, 依赖 setter 和 getter 的方法实现, 所以方法命名应该符合苹果要求的规范, 否则会导致 KVC 失败
在学习 KVC 的搜索规则前, 要先弄明白一个属性的作用, 这个属性在搜索过程中起到很重要的作用这个属性表示是否允许读取实例变量的值, 如果为 YES 则在 KVC 查找的过程中, 从内存中读取属性实例变量的值
@property (class, readonly) BOOL accessInstanceVariablesDirectly;
基础 Getter 搜索模式
这是 valueForKey: 的默认实现, 给定一个 key 当做输入参数, 开始下面的步骤, 在这个接收 valueForKey: 方法调用的类内部进行操作
通过 getter 方法搜索实例, 例如 get<Key>, <key>, is<Key>, _<key > 的拼接方案按照这个顺序, 如果发现符合的方法, 就调用对应的方法并拿着结果跳转到第五步否则, 就继续到下一步
如果没有找到简单的 getter 方法, 则搜索其匹配模式的方法 countOf<Key>
- objectIn<Key>AtIndex:
- <key>AtIndexes:
如果找到其中的第一个和其他两个中的一个, 则创建一个集合代理对象, 该对象响应所有 NSArray 的方法并返回该对象否则, 继续到第三步
代理对象随后将 NSArray 接收到的 countOf<Key>
objectIn<Key>AtIndex:
<key>AtIndexes: 的消息给符合 KVC 规则的调用方
当代理对象和 KVC 调用方通过上面方法一起工作时, 就会允许其行为类似于 NSArray 一样
如果没有找到 NSArray 简单存取方法, 或者 NSArray 存取方法组则查找有没有 countOf<Key>enumeratorOf<Key>memberOf<Key>: 命名的方法
如果找到三个方法, 则创建一个集合代理对象, 该对象响应所有 NSSet 方法并返回否则, 继续执行第四步
此代理对象随后转换 countOf<Key>enumeratorOf<Key>memberOf<Key>: 方法调用到创建它的对象上实际上, 这个代理对象和 NSSet 一起工作, 使得其表象上看起来是 NSSet
如果没有发现简单 getter 方法, 或集合存取方法组, 以及接收类方法
accessInstanceVariablesDirectly
是返回 YES 的搜索一个名为_<key>_is<Key><key>is<Key > 的实例, 根据他们的顺序
如果发现对应的实例, 则立刻获得实例可用的值并跳转到第五步, 否则, 跳转到第六步
如果取回的是一个对象指针, 则直接返回这个结果
如果取回的是一个基础数据类型, 但是这个基础数据类型是被 NSNumber 支持的, 则存储为 NSNumber 并返回
如果取回的是一个不支持 NSNumber 的基础数据类型, 则通过 NSValue 进行存储并返回
如果所有情况都失败, 则调用
valueForUndefinedKey:
方法并抛出异常, 这是默认行为但是子类可以重写此方法
基础 Setter 搜索模式
这是 setValue:forKey: 的默认实现, 给定输入参数 value 和 key 试图在接收调用对象的内部, 设置属性名为 key 的 value, 通过下面的步骤:
查找 set<Key>: 或_set<Key > 命名的 setter, 按照这个顺序, 如果找到的话, 调用这个方法并将值传进去(根据需要进行对象转换)
如果没有发现一个简单的 setter, 但是
accessInstanceVariablesDirectly
类属性返回 YES, 则查找一个命名规则为_<key>_is<Key><key>is<Key > 的实例变量根据这个顺序, 如果发现则将 value 赋值给实例变量
如果没有发现 setter 或实例变量, 则调用
setValue:forUndefinedKey:
方法, 并默认提出一个异常, 但是一个 NSObject 的子类可以提出合适的行为
NSMutableArray 搜索模式
这是
mutableArrayValueForKey:
的默认实现, 给一个 key 当做输入参数在接收访问器调用的对象中, 返回一个名为 key 的可变代理数组, 这个代理数组就是用来响应外界 KVO 的对象, 通过下面的步骤进行查找:
查找一对方法
insertObject:in<Key>AtIndex:
和
removeObjectFrom<Key>AtIndex:
(相当于 NSMutableArray 的原始方法
insertObject:atIndex:
和
removeObjectAtIndex:
)或者方法名是
insert<Key>:atIndexes:
和
remove<Key>AtIndexes:
(相当于 NSMutableArray 的原始方法
insertObjects:atIndexes:
和
- removeObjectsAtIndexes:
- )
如果找到最少一个 insert 方法和最少一个 remove 方法, 则返回一个代理对象, 来响应发送给 NSMutableArray 的组合消息
- insertObject:in<Key>AtIndex:
- removeObjectFrom<Key>AtIndex:
- insert<Key>:atIndexes:
, 和
remove<Key>AtIndexes:
消息
当对象接收一个
mutableArrayValueForKey:
消息并实现可选替换方法, 例如
replaceObjectIn<Key>AtIndex:withObject:
或
replace<Key>AtIndexes:with<Key>:
方法, 代理对象会在适当的情况下使用它们, 以获得最佳性能
如果对象没有可变数组方法, 查找一个替代方法, 命名格式为 set<Key>: 在这种情况下, 向
mutableArrayValueForKey:
的原始响应者发送一个 set<Key>: 消息, 来返回一个代理对象来响应 NSMutableArray 事件
提示:
这一步描述的机制远不如上一步有效, 因为它可能重复创建新的集合对象, 而不是修改现有的对象因此, 在自己设计的 KVC 时应该尽量避免它
如果没有可变数组的方法, 也没有找到访问器, 但接受响应的类
accessInstanceVariablesDirectly
属性返回 YES, 则查找一个名为_<key > 或 < key > 的实例变量
按照这个顺序, 如果找到实例变量, 则返回一个代理对象改对象将接收所有 NSMutableArray 发送过来的消息, 通常是 NSMutableArray 或其子类
如果所有情况都失败, 则返回一个可变的集合代理对象当它接收 NSMutableArray 消息时, 发送一个
setValue:forUndefinedKey:
消息给接收
mutableArrayValueForKey:
消息的原始对象
这个
setValue:forUndefinedKey:
的默认实现是提出一个
NSUndefinedKeyException
异常, 但是子类可以重写这个实现
其他搜索模式
还有 NSMutableSet 和 NSMutableOrderedSet 两种搜索模式, 这两种搜索模式和 NSMutableArray 步骤相同, 只是搜索和调用的方法不同详细的搜索方法都可以在 KVC 官方文档中找到, 再套用上面的流程即可理解
代码示例
根据上面 KVC 查找规则的描述, 我们定义一个 TestObject 类, 并指定其他 setter 和 getter, 以及合成为其他的成员变量, 看 KVC 是否能够找到属性的对象并赋值
- @interface TestObject : NSObject {
- NSObject *_newObject;
- }
- @property (nonatomic, strong, setter=newSetObject:, getter=newObject) NSObject *object;
- @property (nonatomic, strong) NSObject *twoObject;
- @end
- @implementation TestObject
- @synthesize object = _newObject;
- @end
这里对两个属性进行赋值, twoObject 属性赋值没有任何问题, 而第二个属性赋值则会导致 Crash 崩溃信息如上面所述抛出一个
NSUnknownKeyException
异常, 并提示没有找到 object 获取方法和实例对象
- TestObject *object = [[TestObject alloc] init];
- [object setValue:[NSObject new] forKey:NSStringFromSelector(@selector(twoObject))];
- [object setValue:[NSObject new] forKey:NSStringFromSelector(@selector(object))];
如果将 object 改为 newObject 则可以解决这个问题, 以此验证上面的 KVC 查找规则
KVC 性能
根据上面 KVC 的实现原理, 我们可以看出 KVC 的性能并不如直接访问属性快, 虽然这个性能消耗是微乎其微的所以在使用 KVC 的时候, 建议最好不要手动设置属性的 settergetter, 这样会导致搜索步骤变长
而且尽量不要用 KVC 进行集合操作, 例如 NSArrayNSSet 之类的, 集合操作的性能消耗更大, 而且还会创建不必要的对象
私有访问
根据上面的实现原理我们知道, KVC 本质上是操作方法列表以及在内存中查找实例变量我们可以利用这个特性访问类的私有变量, 例如下面在. m 中定义的私有成员变量和属性, 都可以通过 KVC 的方式访问
这个操作对 readonly 的属性,@protected 的成员变量, 都可以正常访问如果不想让外界访问类的成员变量, 则可以将
accessInstanceVariablesDirectly
属性赋值为 NO
TestObject.m 文件
- @interface TestObject () {
- NSObject *_objectOne;
- }
- @property (nonatomic, strong) NSObject *objectTwo;
- @end
KVC 在实践中也有很多用处, 例如 UITabbar 或 UIPageControl 这样的控件, 系统已经为我们封装好了, 但是对于一些样式的改变并没有提供足够的 API, 这种情况就需要我们用 KVC 进行操作了
可以自定义一个 UITabbar 对象, 然后在内部创建自己想要的视图, 并通过 layoutSubviews 方法在内部进行重新布局然后通过 KVC 的方式, 将 UITabbarController 的 tabbar 属性替换为自定义的类即可
安全性检查
KVC 存在一个问题在于, 因为传入的 key 或 keyPath 是一个字符串, 这样很容易写错或者属性自身修改后字符串忘记修改, 这样会导致 Crash
可以利用 iOS 的反射机制来规避这个问题, 通过 @selector()获取到方法的 SEL, 然后通过
NSStringFromSelector()
将 SEL 反射为字符串这样在 @selector()中传入方法名的过程中, 编译器会有合法性检查, 如果方法不存在或未实现会报黄色警告
[self valueForKey:NSStringFromSelector(@selector(object))];
来源: https://segmentfault.com/a/1190000013476163