简单易懂KVC基础篇

博客日志:2019-3-14 起笔。
博客日志:2019-3-22 根据个人阅读感受对文章大幅重构。
博客日志:2019-3-25 封笔。

引言

这篇文章其实就是被他的兄弟KVO给逼出来的,没办法。官方文档中介绍过KVC是KVO技术实现的基础,闲话免提,咱们请入座。学识有限,有不对的地方,还请大家多多指正。

概述

KVC(Key-value coding)键值编码是一种由NSKeyValueCoding非正式协议(其实就是我们所说的分类或类别)启用的机制,对象采用该机制提供对其属性间接访问。当对象符合键值编码时,其属性可使用字符串参数通过简洁,统一的消息传递接口(方法)寻址。这种间接访问机制补充了实例变量及其相关访问器方法提供的直接访问。

键值编码是一个基本概念,是许多其他Cocoa技术的基础,例如KVO,(macOS)Cocoa绑定,Core Data和AppleScript。在某些情况下,键值编码还有助于简化代码。

这里我们搞了段很官方的描述,其实简单来说的话,就是通过字符串名称访问对象属性,就这么简单。

API接口

普通用法

访问对象属性

- (nullable id)valueForKey:(NSString *)key;
- (void)setValue:(nullable id)value forKey:(NSString *)key;
复制代码

KVC提供了简洁,统一的方法,用来访问对象属性。分别是对应于getter访问器的valueForKey:和对应于setter访问器的setValue:forKey:。幸运的是,NSObject采用了NSKeyValueCoding协议并为它们和其他基本方法提供默认实现。因此,如果你从NSObject(或其许多子类中的任何一个)派生对象,那么大部分都工作已经完成了。

@interface BankAccount : NSObject
@property (nonatomic) NSNumber* currentBalance;              // An attribute
@property (nonatomic) Person* owner;                         // A to-one relation
@property (nonatomic) NSArray< Transaction* >* transactions; // A to-many relation
@end

@interface Person : NSObject
@property (nonatomic, copy) NSString *name;
@property (nonatomic) NSUInteger age;
@end
复制代码

现在我们声明了两个类来说明KVC的基础用法,我们假设BankAccount的实例对象是myAccount,通常我们会直接使用访问器方法操作属性。

myAccount.currentBalance = @(100.0);
// 或者
[myAccount setCurrentBalance:@(100.0)];
复制代码

当然我们知道上面两个方法是等价的。现在我们看一下KVC的使用方式:

// setter
[myAccount setValue:@(100.0) forKey:@"currentBalance"];
// getter
NSNumber *currentBalance = [myAccount valueForKey:@"currentBalance"];
复制代码

按键路径访问属性

如果我们想要获取银行账户户主的姓名,我们可以在引入Person.h之后,使用点语法很轻松的获取到:

NSString *myName = myAccount.owner.name;
复制代码

当然KVC也提供了我们访问属性的属性的操作方法,通过键路径来访问属性。键路径是以点分隔多个键的字符串用来指定要遍历的对象属性的序列。序列中第一个键是相对于接收者的属性,并且每个后续键是相对于前一个键的属性。

- (nullable id)valueForKeyPath:(NSString *)keyPath;
- (void)setValue:(nullable id)value forKeyPath:(NSString *)keyPath;
复制代码

现在我们可以使用键路径访问属性了:

NSString *myName = [myAccount valueForKeyPath:@"owner.name"];
[myAccount setValue:@"SepCode" forKeyPath:@"owner.name"];
复制代码

键未定义异常

根据KVC规定的方式(搜索模式)找不到由key命名的属性时,就会调用获取值的valueForUndefinedKey:或设置值的setValue:forUndefinedKey:方法,系统默认的该方法会引发一个 NSUndefinedKeyException的异常导致崩溃,我们可以重写该方法避免崩溃。并且我们也可以在重写该方法时,加入逻辑处理以使其更加的优雅。

// 重写UndefinedKey:方法
// getter
- (id)valueForUndefinedKey:(NSString *)key {
    return nil;
}
// setter
- (void)setValue:(id)value forUndefinedKey:(NSString *)key {
    
}
复制代码

非对象值和nil

当我们通过setValue:forKey:对属性赋值,如果该属性不是对象而是标量或结构体时,KVC会自动展开对象获取值并赋值给属性。同样当执行valueForKey:时,则会自动包装属性值,返回一个与其对应的NSNumber或NSValue对象。

// setter
[owner setValue:@(26) forKey:@"age"];
// getter
NSNumber *myAge = [owner valueForKey:@"age"];
复制代码

当我们给对象赋值nil时,这很容易理解,表示把对象设置为空。但是当我们通过setValue:forKey:设置非对象属性值为nil时,没有对象可展开了,难道我们都把这些非对象值设置为0吗?官方并没有给我们实现默认的赋值操作,而是调用setNilValueForKey:方法,而系统默认的该方法会引发一个NSInvalidArgumentException的异常,当然我们也可以重写该方法实现特定的行为。

// nil
[owner setValue:nil forKey:@"age"];
...
- (void)setNilValueForKey:(NSString *)key {
    if ([key isEqualToString:@"age"]) {
        [self setValue:@(0) forKey:@”age”];
    } else {
        [super setNilValueForKey:key];
    }
}
复制代码

多值访问

我们看到官方还提供了dictionary相关的方法,但他并不是针对字典的方法。而是同时访问多个属性的方法,其实就是调用每个key的setValue:forKey:valueForKey:方法,这很容易理解我们不再赘述。

NSDictionary *dict = [owner dictionaryWithValuesForKeys:@[@"name",@"age"]];
dict = @{@"name":@"sepCode",@"age":@(62)};
[owner setValuesForKeysWithDictionary:dict];
复制代码

特殊用法

访问集合属性

我们前面讲述了KVC访问对象的方式,当然它也同样适用于集合对象。你可以像使用任何其他对象一样,通过valueForKey:setValue:forKey:(或它们的键路径方式)获取或设置集合对象。

@interface Transaction : NSObject
 
@property (nonatomic) NSString* payee;   // To whom
@property (nonatomic) NSNumber* amount;  // How much
@property (nonatomic) NSDate* date;      // When
 
@end
复制代码

现在我们又定义了一个交易类,假如我们想获取个人银行账户中的所有收款人。

NSArray *payees = [myAccount valueForKeyPath:@"transactions.payee"];
复制代码

请求transactions.payee键路径的值将返回一个数组,包含transactions中所有的payee对象。这也适用于键路径中的多个数组。假如我们想获取多个银行账户中的所有收款人,请求键路径accounts.transactions.payee的值返回一个数组,其中包含所有帐户中所有交易的所有收款人对象。

对于获取值我们看到了KVC的方便之处,但是对于设置值我们却很少用到KVC。它会把集合内包含的所有键对象的值设置为相同的值,这不是我们想要的结果。

虽然我们可以使用通用的方式访问集合对象,但是,当你想要操纵这些集合的内容时,官方推荐我们最有效的方法是使用协议定义的可变代理方法。 协议为访问集合对象定义了三种不同的代理方法,每种方法都有key和keyPath变种: mutableArrayValueForKey:mutableArrayValueForKeyPath: 它们返回一个行为类似于NSMutableArray对象的代理对象。 mutableSetValueForKey:mutableSetValueForKeyPath: 它们返回一个行为类似于NSMutableSet对象的代理对象。 mutableOrderedSetValueForKey:mutableOrderedSetValueForKeyPath: 它们返回一个行为类似于NSMutableOrderedSet对象的代理对象。

当你对代理对象进行操作,添加对象,从中删除对象或替换对象时,协议的默认实现会相应地修改原对象。现在假如我们想使用KVC通用方法,在个人银行账户增加一次交易,通过valueForKey:获取非可变集合对象,创建可变集合对象增加内容,然后使用setValue:forKey:消息将其存储回对象。相比之下通过代理对象操作,就显得方便很多。在许多情况下,它比直接使用可变属性更有效。例如,当我们不使用常量字符串作为key,而是使用变量时。这允许我们不必知道调用方法的确切名称,只要对象和正在使用的key符合KVC,一切都会正常工作。 当维护集合中对象时,这些方法还使其可以支持键值观察机制。这也是为什么KVO的文章写到一半时,我又突然先来写KVC了。

这里我们需要注意的是,这些方法的作用是返回一个集合对象的代理对象。当然你也可以像我们之前讲到的一样,请求集合内对象的属性,从而达到返回一个属性集合对象,但这仅仅局限于获取值。如果这种情况下操作属性集合对象原集合内的对象的属性的值就会被设置为操作后的属性集合对象,这也不是我们想要的结果。

使用集合运算符

当你向符合键值编码的对象发送valueForKeyPath:消息时,或者表述为当对象调用valueForKeyPath:方法时,可以在键路径中嵌入集合运算符。集合运算符是一个前面是at符号(@)的关键字,它指定了getter应该执行的操作,以便在返回之前以某种方式操作数据。NSObject为此行为提供了默认实现。

当键路径包含集合运算符时,运算符之前的键路径(称为左键路径)指示相对于消息接收者操作的集合。如果将消息直接发送到集合对象(例如NSArray实例),则可以省略左键路径。操作符之后的键路径部分(称为右键路径)指定操作员应处理的集合中的属性。除了@count之外,所有集合运算符都需要右键路径。

集合运算符键路径格式

集合运算符的表现行为可分为三种基本类型:

  • 聚合运算符以某种方式合并集合的对象,并返回通常与右键路径中指定的属性的数据类型匹配的单个对象。@count是一个例外,它没有右键路径即便是有也会被忽略并始终将返回一个NSNumber实例。

    NSNumber *transactionAverage = [self.transactions valueForKeyPath:@"@avg.amount"];
    NSNumber *numberOfTransactions = [self.transactions valueForKeyPath:@"@count"];
    NSDate *latestDate = [self.transactions valueForKeyPath:@"@max.date"];
    复制代码
  • 数组运算符返回与右键路径指示的特定对象集相对应的对象数组。

  • 嵌套操作符处理包含其他集合的集合,并根据操作符返回一个NSArray或NSSet实例,它以某种方式组合嵌套集合的对象。

具体运算符用法,请点击上述各类型超链接在官方文档中查看。

属性验证

键值编码协议定义了支持属性验证的方法。就像使用KVC通用方法一样,你也可以按键(或键路径)验证属性。当你调用validateValue:forKey:error:(或validateValue:forKeyPath:error:)方法时,协议的默认实现会使对象实例搜索是否实现了validate<Key>:error:方法。如果对象没有实现此类方法,则默认验证成功,并返回YES。

通常可采用以下验证方式:

  • 当值对象有效时,返回YES,不更改值对象或错误。

  • 当值对象无效时,并且你不能或不想提供有效的替代方法,设置错误原因NSError并且返回NO。

  • 当值对象无效但你知道有效的替代方法时,创建有效对象,将值引用分配给新对象,然后返回YES,不设置NSError错误。如果提供其他值,则始终返回新对象,而不是修改正在验证的对象,即使原始对象是可变的。

Person* person = [[Person alloc] init];
NSError* error;
NSString* name = @"John";
if (![person validateValue:&name forKey:@"name" error:&error]) {
    NSLog(@"%@",error);
}
...

- (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;
}
复制代码

上述用例演示了一个name字符串属性的验证方法,该方法确保值对象的最小长度和不为nil。如果验证失败,此方法不会替换其他值。

原理解析

访问者搜索模式

KVC协议中最关键的部分就是访问者搜索模式,NSObject提供的NSKeyValueCoding协议的默认实现,使用明确定义的规则集将基于键的访问器(KVC存取方法)调用映射到对象的属性。这些协议方法使用键参数在其自己的对象实例中搜索访问器,实例变量以及遵循某些命名约定的相关方法。

可变数组的搜索模式

这里我们仅介绍一种模式可变数组的搜索模式,其他搜索模式可通过访问者搜索模式了解详细内容。

mutableArrayValueForKey:的默认实现,输入一个键参数,返回一个可变代理数组。对象内部的名为key的属性,通过以下过程接受访问器的调用:

  1. 查找一对方法名如insertObject:in<Key>AtIndex:removeObjectFrom<Key>AtIndex:(分别对应于NSMutableArray的基本方法insertObject:atIndex:removeObjectAtIndex:)或名称类似于insert<Key>:atIndexes:remove<Key>AtIndexes:的方法(对应于NSMutableArrayinsertObjects:atIndexes:removeObjectsAtIndexes:方法)。

    如果对象具有至少一个插入方法和至少一个删除方法,返回一个代理对象来响应这些NSMutableArray的消息。通过发送一些组合的消息insertObject:in<Key>AtIndex:, removeObjectFrom<Key>AtIndex:, insert<Key>:atIndexes:,和remove<Key>AtIndexes:mutableArrayValueForKey:消息的接受者来实现。 或者可以表述为通过使调用mutableArrayValueForKey:方法的对象,调用上述方法,来响应这些插入或删除方法。

    当接收mutableArrayValueForKey:消息的对象也实现名称为replaceObjectIn<Key>AtIndex:withObject:replace<Key>AtIndexes:with<Key>:的(可选)替换方法时,代理对象也会在适当时使用这些方法以获得最佳性能。

  2. 如果对象没有可变数组的方法,查找名称与模式集匹配的set<Key>:的访问器方法。在这种情况下,返回一个代理对象。通过向mutableArrayValueForKey:的原始接收者发出set<Key>:消息,来响应上述那些NSMutableArray的消息。

    注意:前两步简单来说就是代理对象操作集合内容时,先去查找是否实现了插入,删除,(可选)替换的方法,没实现就去查找setter方法。步骤2中描述的机制比前一步骤的效率低得多,因为它可能涉及重复创建新的集合对象而不是修改现有的集合对象。因此,在设计自己的符合键值编码的对象时,通常应该避免使用它。

  3. 如果既未找到可变数组方法,也未找到访问器,并且接收者的类对accessInstanceVariablesDirectly的响应为YES,表示允许搜索实例变量,则按顺序搜索名称为_<key><key>的实例变量。 如果找到这样的实例变量,则返回一个代理对象,该对象将它接收的每个NSMutableArray消息转发给实例变量,通常是NSMutableArray或其子类之一的实例。

  4. 如果所有其他方法都失败了,则返回一个可变集合代理对象,该对象在收到NSMutableArray消息时向mutableArrayValueForKey:消息的原始接收者发出setValue:forUndefinedKey:消息。 setValue:forUndefinedKey:的默认实现会引发NSUndefinedKeyException异常。

    注意:后两步简单来说就是,如果允许搜索实例变量,就去查找变量,如果以上搜索都失败,就报错。

原理实践

现在我们根据可变数组的搜索模式,做一些实践和测试:

@interface ViewController ()
/// array
@property (nonatomic, strong) NSMutableArray *array;
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view, typically from a nib.
    
    self.array = [@[@(1),@(2),@(3)] mutableCopy];
    NSMutableArray *kvcArray = [self mutableArrayValueForKey:@"array"];
    // 发送NSMutableArray消息
    [kvcArray addObject:@(4)];
    [kvcArray removeLastObject];
    [kvcArray replaceObjectAtIndex:0 withObject:@(4)];
    
}
// 可变数组多对多优化
- (void)insertObject:(NSNumber *)object inArrayAtIndex:(NSUInteger)index {
    [self.array insertObject:object atIndex:index];
}

- (void)removeObjectFromArrayAtIndex:(NSUInteger)index {
    [self.array removeObjectAtIndex:index];
}

- (void)replaceObjectInArrayAtIndex:(NSUInteger)index withObject:(id)object {
    [self.array replaceObjectAtIndex:index withObject:object];
}

@end
复制代码

上图的测试结果,向我们展现了如果我们使用代理对象时,最好实现完整协议,优化多对多关系,否则随着数据量级增加,性能会呈指数级下降,这真的很糟糕。

疑点解惑

在这里我要说一下我对于kvc是kvo实现的基础的理解。因为在网上看到一位文章写的还不错的作者,他讲到二者实现机制不同,并无必然联系,只是KVC对KVO的支持比较好。我非常不同意这个观点。在官方键值观察编程指南中明确指出,该类的属性必须遵守KVC合规性。KVC是一个通过字符串访问对象属性的协议,包括搜索模式也属于该协议的一部分。KVO观察的属性,必须遵守KVC合规性,并且支持观察KVC兼容的所有访问器修改属性。通常我们所理解的KVO都是基于setter访问器实现的,然而并非如此。下图也充分验证KVO支持KVC的搜索模式:

这里让我想到了饿了么技术沙龙中兰建刚的忠告:中文博客-在你没有能力分辨对错之前,少看。

结语

这篇文章呢,写着写着我就又有感慨了。我深深的感受到,我是一个学习者,这些知识都是别人创造的,用的都是别人提供给我们的方法。就连学习也可能是靠他人总结的,我还不是一个创造者。

不过认清自己是多么菜,也没什么不好的。即便同样处于学习阶段的他人,也可以成为自己的老师,希望大家可以多多指点迷津。

最近看了不少他人的文章,我从自己的感受发现几点。

  • 喜欢作者把技术通过图或者文字表述的很清楚,不喜欢看作者大段的代码来表述,但是简单的用例还是必须的。
  • 不要一下子把接口全列出来,最多扫一眼,除非作者的目的也是你就瞄一眼就可以了。所以讲解时的顺序可以是表述,接口,用例。一个点一个点的展开。
  • 文章结构清晰,不要天上一脚,地上一脚,所以前提是作者思路清晰。

另外有大牛建议不需要看太多书,经典的书多读几遍,独立思考。本篇文章基本是在多看官方文档的基础上诞生的,本人对于细节知识还是比较在意的,如果有理解不对的地方,还请大家多多指正。

猜你喜欢

转载自juejin.im/post/5c948d6a6fb9a070eb267a08
KVC