iOS中的Key-Value-Coding

之前系统性的把Objective-C的Runtime机制过了一遍,深感iOS编码中强大的动态特性。接下来学一下也时常可以用到的另外一个动态能力KVC,打开官方文档Key-Value-Coding第一句描述给它的定义就是KVC是一种利用NSKeyValueCoding协议实现的允许对象间接访问它属性的机制。

我们打开Foundation中NSKeyValueCoding.h这个文件,果不其然它的定义就是NSObject / NSArray / NSMutableDictionary / NSOrderedSet / NSSet的拓展分类NSKeyValueCoding,KVC机制补充了Ivar实例变量和它的选择器方法的访问方式。

同时Key-value Coding还是许多其他Cocoa技术的基础,例如Key-value observing,Cocoa bindings,CoreData和AppleScript-ability。在某些情况下,KVC还有助于简化代码。

ps:Swift中原本属于NSObject对象还是存在KVC能力的。

Key-value Coding的能力及其应用场景

根据文档上的描述,KVC有以下几项功能:

  • 访问对象属性,KVC允许我们valueForKey:setValue:forKey:用于通过存取器的方法名(属性名)访问一个对象的属性值。
  • 操纵集合属性,允许我们通过Key获得容器的一组可变集合。
  • 在集合对象上调用集合运算符。
  • 访问非对象属性,基本数据结构/结构体可以通过NSValue来转换到OC对象中。
  • 按key path访问属性,KVC让我们能够通过关系来访问属性,key path是一个以点(“.”)分割Key的字符串,通过指定一连串的对象属性去获取这个最终的属性。

在KVC中可以通过Key或者KeyPath访问属性。Key是一个用于识别对象中特定的属性的字符串。Key与存取器的方法名(属性名)或者实例变量名保持一致,使用两种方法:

  • valueForKey: 获取对象中指定的属性值
  • setValue:forKey: 给对象中指定的属性赋值

KeyPath则是一种以点(”.”)分割Key的字符串,可以通过这种方式访问多级属性。具体通过以下两种方法:

  • valueForKeyPath: 获取键路径指定的属性值
  • setValue:forKeyPath: 给键路径指定的属性赋值

访问对象属性

一个类通常声明的属性是以下三种类型:

  • 值属性,如number、strings、NSColor等这种表示数值的属性。
  • 一对一关系属性,如自定义一个类作为属性。
  • 多对多关系属性,如使用NSArray容器作为属性。

比如下方这个Demo中:

1
2
3
4
5
6
7
8
9
@interface Transaction : NSObject
@property (nonatomic) NSNumber *payee;
@end

@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

我们可以通过Key或者KeyPath访问属性:

1
2
[account setValue:@(100.0) forKey:@"currentBalance"];
id transactions = [account mutableArrayValueForKeyPath:@"transactions"];

这种是最简单的使用一个Key/KeyPath来访问属性,对于没有Key的时候可以这个类定义setValue:forUndefinedKey:valueForUndefinedKey:来处理这种情况。假如最后还没有处理而系统则会报NSUndefinedKeyException异常。

而同时KVC中还提供dictionaryWithValuesForKeys:setValuesForKeysWithDictionary用于使用多个Key批量访问属性的方式。

应用场景——访问系统框架中不开放的属性

在维护公司基于PhotoKit编写的相册库的时候,发现它的API在极端情况下会存在性能问题。比如假如iPhone中存在数万个资源对象的时候使用获取原始数据的接口requestImageDataForAsset:以及requestPlayerItemForVideo:处理时间就特别久。同时由于业务上需要通过原始数据获得完整的元数据(访问相册资源的metadata,filename,url等信息真的很不方便),这里只能尝试使用其他方式去处理。

比如PHAsset的filename通过PHAssetResource中的originalFilename获取,单个对象需要5~8ms的耗时,而直接使用KVC获取则几乎没有损耗。这里存在一个隐患,当有新系统的时候,框架内部该实例变量可能被取消掉,这里我们可以简单拓展一个分类方法来捕获这个异常判断是否有这个Key以用于判断是否能够使用这种方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@implementation NSObject (Key)
- (BOOL)objectHaveKey:(NSString *)key {
if (!key.length) {
return NO;
}

BOOL haveKey = YES;
@try {
[self valueForKey:key];
} @catch (NSException *exception) {
haveKey = NO;
}
return haveKey;
}
@end

操纵集合属性

KVC默认为NSArray/NSSet/NSOrderSet提供了修改容器的方法,它会直接在原有容器的基础上进行修改,比使用valueForKey:获取非可变集合对象再创建具有更改内容的集合对象,最后使用setValue:forKey:将其设置回去更加高效。

  • mutableArrayValueForKey:mutableArrayValueForKeyPath:
  • mutableSetValueForKey:mutableSetValueForKeyPath:
  • mutableOrderedSetValueForKey:mutableOrderedSetValueForKeyPath:

文档中提到这种实现方式在许多情况下比直接使用可变属性更有效,为KVO提供便利的方式。

应用场景——修改不可变容器

这里直接使用前面提到的类做一个简单的示例:

1
2
3
4
5
6
7
8
9
10
11
12
BankAccount *account = [[BankAccount alloc] init];
[account setTransactions:@[
[Transition transitionWith:@(0)],
[Transition transitionWith:@(1)],
[Transition transitionWith:@(2)],
[Transition transitionWith:@(3)],
[Transition transitionWith:@(4)]
]];

NSMutableArray<Transition *> *transactions = [account mutableArrayValueForKey:@"transactions"];
[transactions firstObject].payee = @(111);
[transactions removeLastObject];

这将会直接修改BankAccount的NSArray< Transaction* >*transactions容器内容。

值得注意的是假如你要修改的属性实际上并非一个容器对象,但同时你又使用这种方式修改了这个对象,这将会把你的原有属性变为一个容器而不会进行类型校验并报错。比如:

1
2
NSMutableArray<NSNumber *> *payees = [account mutableArrayValueForKeyPath:@"transactions.payee"];
[payees removeLastObject];

在集合对象上调用集合运算符

当使用key path方式访问一个集合属性的时候,可以以如下格式内置相关的集合运算符到这个key path中实现直接对集合中的对象的某个属性进行运算。

集合运算可以分为三类:

  • 聚合运算(Aggregation Operators),以某种方式合并集合并返回出一个与右侧KeyPath属性类型一致的对象。(多->一)
  • 数组运算(Array Operators),返回一组包含该属性的子集NSArray。(多->多)
  • 嵌套运算(Nesting Operators),从嵌套的数组结构中返回一组包含该属性的子集NSArray。(多->多)

聚合运算包括:

  1. @avg将读取集合中指定的属性,将其转换为double(将0替换为nil值),并计算这些值的算术平均值。
  2. @count返回集合中对象的数量NSNumber,右键路径(如果存在)将被忽略。
  3. @max返回集合中最大值的对象,实际上通过Foundation中的compare:函数进行比较(NSDate/NSString/NSNumber等都有这个方法)。
  4. @min返回集合中最小值的对象,也是通过compare:实现。
  5. @sum将读取集合中指定的属性,将其转换为double(将0替换为nil值),并计算这些值的算术和。

数组运算包括:

  • @distinctUnionOfObjects/ @unionOfObjects返回一个由操作符右边的key path所指定的对象属性组成的数组,distinct会进行对象去重。

嵌套运算包括:

  • @distinctUnionOfArrays/@unionOfArrays/@distinctUnionOfSets和数组运算符相似,只是它允许从一个嵌套数组中赛选相关属性对象到一个数组中。

这些运算使用valueForKeyPath:读取获取需要运算的属性如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 聚合运算
NSNumber *avgPayee = [account valueForKeyPath:@"transactions.@avg.payee"];
NSNumber *count = [account valueForKeyPath:@"transactions.@count"];
NSNumber *max = [account valueForKeyPath:@"transactions.@max.payee"];
NSNumber *min = [account valueForKeyPath:@"transactions.@min.payee"];
NSNumber *sum = [account valueForKeyPath:@"transactions.@sum.payee"];

// 数组运算
NSArray <NSNumber *> *payees = [account valueForKeyPath:@"transactions.@distinctUnionOfObjects.payee"];
// 默认处理数组中的对象必须包含这个属性可以为nil否则会导致异常

// 嵌套运算
NSArray <NSNumber *> *payees = [@[@[[Transition transitionWith:@(0)], [Transition transitionWith:@(1)]],@[[TransitionChild transitionWithChild:@(100)],[TransitionChild transitionWithChild:@(101)]]] valueForKeyPath:@"@distinctUnionOfArrays.payee"];
// 假如某个数组中的指定属性全为nil则返回NSNull对象

访问非对象的属性

在Objective-C中KVC的这项能力还是非常好用的,它能够将C语言中的基础数据类型以及结构体转换成NSValue对象。

对于标量数据类型:

对于部分系统中的结构体:

当然对于我们自定义的结构体还可以通过以下方式转换成NSValue对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef struct {
float x, y, z;
} ThreeFloats;

@interface MyClass
@property (nonatomic) ThreeFloats threeFloats;
@end

// 可以如此访问这个结构体
NSValue* result = [myClass valueForKey:@"threeFloats"];

// 通过如下方式设置结构体的值
ThreeFloats floats = {1., 2., 3.};
NSValue* value = [NSValue valueWithBytes:&floats objCType:@encode(ThreeFloats)];
[myClass setValue:value forKey:@"threeFloats"];

验证属性

在KVC中支持通过validateValue:forKey:error: 或者validateValue:forKeyPath:error:来验证一个属性是否有效,就像使用KVC通用方法一样,你也可以按key/key path验证一个属性。当调用了这两个方法,协议的默认实现会判断对象实例是否实现了validate<Key>:error:方法。如果对象没有实现此类方法,则默认验证成功,并返回YES。

当一个对象实现了准守协议实现了上述的两种验证方法,一般是有以下三种用途:

  1. 当通过验证方法判断一个值对象是否满足某些条件而为有效,不满足则返回。
  2. 当通过验证方法认为值对象无效时,获取无效的原因NSError。
  3. 当通过验证方法认为值对象无效时,会创建一个新的有效对象作为替换。从而实现属性有效纠错。

这里提供一个简单的示例,用于进行属性有效性校验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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;
}

访问器的实现细节

在前面我们利用NSKeyValueCoding默认实现的访问器valueForKey:或者valueForKeyPath:来存取对象属性,一般而言我们不会去修改这些访问器的实现。但是通过了解它们的实现有助于跟踪KVC对象的行为。

基础Getter访问器(valueForKey:)

我们知道在使用@property声明一个属性的时候,编译器会自动为我们默认生成相应的Getter 、Setter方法。而valueForKey:访问器将会以下面几个步骤实现:

  1. 通过get<Key>, <key>, is<Key>, _<key>这样的顺序搜索对应实例的方法函数,如果检索成功则直接进入第5步,否则进入第2步看这个属性是否被当成一个有序集合来处理。
  2. 如果实现了countOf<Key>objectIn<Key>AtIndex:或者<key>AtIndexes:中两个方法则会被当做一个NSArray对象来处理,还可通过实现get<Key>:range:提高效率,否则进入第3步看这个属性是否被当做一个无序集合来处理。
  3. 如果实现了countOf<Key>enumeratorOf<Key>以及memberOf<Key>这三个方法则会被当成一个NSSet对象来处理,否则进入第4步。
  4. 当一个属性既不会被当成NSArray处理,也不想被当成NSSet处理,则accessInstanceVariablesDirectly方法返回YES表明它只是一个默认实现的搜索器,如果通过第1步提到的方法找到则进入第5步,否则进入第6步。
  5. 如果搜索到结果是一个指针则直接返回结果,如果值是NSNumber支持的标量则返回一个NSNumber对象,如果值NSNumber不支持则返回NSValue。
  6. 如果所有其他方法都失败了则调用valueForUndefinedKey:,默认情况下会引发异常。

基础Setter访问器(setValue:forKey:)

对于基础的Setter访问器实现流程上就相对简单了一些:

  1. 如果set<Key>:或者_set<Key>方法被定义了则直接使用它进行设值。
  2. 如果找不到第一步的访问器,则accessInstanceVariablesDirectly返回YES,并通过搜索_<key>, _is<Key><key>,<Key>找到对应的实例变量并设值。
  3. 如果找不到访问器也找不到类似的实例变量则会触发setValue:forUndefinedKey:,默认情况会引发异常。

同时需要注意的是,如果对于非对象的属性(标量),需要对设置为nil的情况通setNilValueForKey:进行处理,例如我们有BOOL类型的hidden:

1
2
3
4
5
6
7
8
- (void)setNilValueForKey:(NSString *)theKey {
if ([theKey isEqualToString:@"hidden"]) {
[self setValue:@YES forKey:@"hidden"];
}
else {
[super setNilValueForKey:theKey];
}
}

可变集合类型访问器

上面学到,KVC还提供三个操纵集合属性的方法对于它们的实现细节大同小异,都是通过实现某些方法使得属性的行为表现得类似MutableArray/MutableOrderSet/MutableSet。这里就详细表述了,可参考官方文档

参考

Key-value Coding
iOS KVC
简单易懂KVC基础篇