我不知道有多少人像本人一样,n 年来查看过不知道多少次这个 KVC 文档,但是每次通篇读下来总是会遇到读不懂的地方,总是莫名其妙的出现一个章节完全不知道在讲什么东西,不论是用传统的 Google 翻译或者目前主流的 AI 翻译翻译了,也还是会遇到读起来狗屁不通的情况。其实根本原因是原始文档的英文描述就有问题,有歧义,导致不论你是直译还是意译都是让人摸不着头脑的翻译,只能根据上下文的理解才能正确翻译。这次作者决定结合 AI 和个人的理解将 KVC 文档彻底弄清楚,即使不能完全弄明白也要留下一个深刻的印象,方便后续遇到文章中的场景时能够回想起来。


入门指南

关于键值编码

键值编码是由 NSKeyValueCoding 非正式协议启用的一种机制,对象采用该机制来提供对其属性的间接访问。当对象实现了 NSKeyValueCoding 协议时(文章后续提到的实现了键值编码协议,实现了键值编码都是一个意思),其属性可以通过简洁、统一的接口通过字符串参数进行寻址。这种间接访问机制补充了实例变量及其相关访问方法所提供的直接访问。

通常使用访问器方法来访问对象的属性。get 访问器(或 getter)返回属性的值。set 访问器(或 setter)设置属性的值。在 Objective-C 中,您还可以直接访问属性的底层实例变量。通过上面任何一种方式访问对象属性都很简单,但需要调用特定于属性的方法或变量名(如属性名是 name 那么 getter 方法就是 name,setter 方法就是 setName: 或者直接访问实例变量 _name)。随着属性列表的增长或变化,访问这些属性的代码也必须随之变化。相比之下,实现了键值编码协议的对象提供了一个简单的编程接口,该接口在其所有属性之间保持一致。

键值编码是许多其他 Cocoa 技术(例如键值观察、Cocoa 绑定、Core Data 和 AppleScript 能力)的基础概念。在某些情况下,键值编码还可以帮助简化代码。

解释一下非正式协议

在 Objective-C 中,协议的语法是以下形式:

1
2
3
@protocol <#protocol name#> <NSObject>
<#methods#>
@end

使用这种形式声明的协议就是正式协议,比如 AppKit 中的 NSTableViewDelegate, NSTableViewDataSource 或者 UIKit 中的 UITableViewDelegate,NSTableViewDataSource。而 NSKeyValueCoding 并没有这样显示的声明为一个协议,所以称之为非正式协议。

它的实现方式是在 Foundation 框架内创建名为 NSKeyValueCoding.h 和 NSKeyValueCoding.m 的文件。其中 .h 头文件是我们大家都可以阅读的,而 .m 实现文件在编译 Foundation 框架时被处理成二进制形式的库文件了。除了 Apple 公司 Foundation 框架组内的成员可以阅读以外,其他人几乎都看不到 NSKeyValueCoding.m 文件。除了 Foundation 框架开发人员以外,其他人就像大部分开发者也完全没有必要阅读或者查看 NSKeyValueCoding.m 文件。

通过对 NSKeyValueCoding.h 的阅读可以知道,键值编码协议的实现就是为 NSObject、NSArray、NSDictionary、NSSet 等相关类提供一个 NSKeyValueCoding 分类,这个分类实现了一些键值编码有关的方法。

使用符合键值编码的对象

对象通常从 NSObject(直接或间接)继承时就符合了键值编码协议,NSObject 它实现了 NSKeyValueCoding 协议,又为必要方法提供默认实现。此类对象(基本可以认为是所有 Objective-C 对象)允许其他对象通过紧凑的消息传递接口执行以下操作:

  • 访问对象属性。 该协议指定了方法,例如通用 getter valueForKey: 和通用 setter setValue:forKey: 用于通过名称或键(以字符串为参数)访问对象属性。这些方法和相关方法的默认实现使用键来定位和与底层数据交互,如访问对象属性中所述。

  • 操作集合属性。 访问方法的默认实现与对象的集合属性(例如 NSArray 对象)一起工作,就像任何其他属性一样。此外,如果对象为属性定义了集合访问器方法,它就可以实现对集合内容的键值访问。这通常比直接访问更有效,并且允许您通过标准化接口使用自定义集合对象,如访问集合属性中所述。

  • 在集合对象上调用集合操作符。 当访问符合键值编码的对象中的集合属性时,可以将集合操作符插入到键字符串中,如使用集合操作符中所述。集合操作符指示默认的 NSKeyValueCoding 获取器实现对集合执行操作,然后返回集合的新过滤版本,或代表集合某些特征的单个值。

  • 访问非对象属性。 协议的默认实现检测非对象属性,包括标量和结构,并自动将它们包装为对象和解包装为原始数据类型以便在协议接口上使用,如非对象属性的处理中所述。此外,该协议声明了一种方法,允许兼容对象为通过键值编码接口在非对象属性上设置 nil 值的情况提供适当的操作。

  • 通过键路径访问属性。 当您拥有一个符合键值编码的对象层次结构时,您可以使用基于键路径的方法调用来深入研究,使用单个调用获取或设置层次结构深处的值。

个人小结

呃,看上去提供了 1,2,3,4,5 很多功能啰里八嗦的其实就一句话可以利用 KVC 协议提供的方法访问对象的所有属性。。。感觉像是按字数拿稿费的作者想方设法多凑点儿字数出来

使对象符合键值编码

为了使你自己的对象符合键值编码,您应该确保它们采用 NSKeyValueCoding 非正式协议并实现相应的方法,例如 valueForKey: 作为通用 getter 和 setValue:forKey: 作为通用 setter。幸运的是,如前面所述,NSObject 采用了该协议并为其必要方法提供了默认实现。因此,如果您从 NSObject(或其众多子类中的任何一个)派生对象,那么大部分工作已经为您完成。

为了使默认方法能够发挥作用,您需要确保对象的访问器方法和实例变量遵循某些明确定义的规则。这允许默认实现根据键值编码消息来查找对象的属性。然后,您可以选择通过提供验证方法和处理某些特殊情况的方法来扩展和定制键值编码。

个人小结

这一段其实挺让人迷惑的,明明 NSObject 就实现了键值编码协议方法使得所有继承自 NSObject 的对象就是符合键值编码的,还要怎么使对象符合键值编码。作为 iOS、MacOS 应用开发者来说就是保证自己创建的对象是继承自 NSObject 就足够了。

Swift 中的键值编码

从 NSObject 或其子类继承的 Swift 对象默认对其属性遵循键值编码。而在 Objective-C 中,属性的访问器和实例变量必须遵循某些模式,而 Swift 中的标准属性声明会自动保证这一点。另一方面,该协议的许多功能要么不相关,要么可以使用 Objective-C 中不存在的原生 Swift 构造或技术更好地处理。例如,由于所有 Swift 属性都是对象,因此您永远不会使用默认实现对非对象属性的特殊处理。

因此,虽然键值编码协议方法可以直接转换为 Swift,但本指南主要关注 Objective-C,您需要做更多工作来确保合规性,并且键值编码通常最有用。整个指南都指出了需要在 Swift 中采用明显不同方法的情况。

如需了解更多关于如何将 Swift 与 Cocoa 技术结合使用的信息,请阅读《将 Swift 与 Cocoa 和 Objective-C 结合使用 (Swift 3)》。如需 Swift 的完整描述,请阅读《Swift 编程语言 (Swift 3)》。

个人小结

虽然 Swift 中的对象不必继承自 NSObject,但如果你想让你的 Swift 对象具备 KVC 能力那就记得继承 NSObject。

依赖键值编码的其他 Cocoa 技术

符合键值编码的对象可以参与依赖于这种访问方式的各种 Cocoa 技术,包括:

  • 键值观察。 该机制使对象能够注册由另一个对象属性的变化驱动的异步通知,如《键值观察编程指南》中所述。
  • Cocoa 绑定。 这套技术完全实现了模型-视图-控制器范例,其中模型封装应用程序数据,视图显示和编辑该数据,控制器在两者之间进行调解。阅读 Cocoa Bindings 编程主题以了解有关 Cocoa Bindings 的更多信息。
  • Core Data。 该框架为与对象生命周期和对象图管理(包括持久化)相关的常见任务提供了通用且自动化的解决方案。您可以在《Core Data 编程指南》中阅读关于 Core Data 的内容。
  • AppleScript。 这种脚本语言可以直接控制可编写脚本的应用以及 macOS 的许多部分。Cocoa 的脚本支持利用键值编码来获取和设置可编写脚本的对象中的信息。NSScriptKeyValueCoding 非正式协议中的方法扩展了键值编码功能,包括通过索引在多值键中获取/设置键值,以及将键值强制转换(或转换)为适当数据类型。《AppleScript概述》提供了对 AppleScript 及其相关技术的高层介绍。

个人小结

核心观点是,键值编码是基础中的基础,它是 KVO、Cocoa 绑定、Core Data、AppleScript 这些技术的基础,这些技术依赖键值编码。


键值编码基础知识

访问对象的属性

对象通常在其接口声明中定义属性,这些属性属于以下几个类别:

  • 属性(Attributes) 这些是简单值,例如标量、字符串或布尔值。数值对象(例如 NSNumber)和其他不可变类型(例如 NSColor)也被视为属性。
  • 一对一关系(To-one relationships) 这些是具有自身属性的可变对象。对象的属性可以在不改变对象本身的情况下发生变化。例如,银行账户对象可能具有 owner 属性,该属性是 Person 对象的一个实例,而 Person 对象本身具有 address 属性。账户所有人的地址可能会更改,但银行账户持有的所有者引用不会改变。银行账户的所有者没有改变,只是她的地址改变了。
  • 一对多关系(To-many relationships) 这些是集合对象。通常使用 NSArray 或 NSSet 的实例来保存此类集合,尽管也可以使用自定义集合类。

清单 2-1 中声明的 BankAccount 对象演示了每种类型的属性。

清单 2-1 BankAccount 对象的属性

1
2
3
4
5
6
7
@interface BankAccount : NSObject

@property (nonatomic) NSNumber* currentBalance; // 属性
@property (nonatomic) Person* owner; // 一对一关系
@property (nonatomic) NSArray< Transaction* >* transactions; // 一对多关系

@end

为了保持封装性,对象通常会在其接口上提供属性的访问器方法。对象作者可以显式编写这些方法,也可以依赖编译器自动合成。无论哪种方式,使用这些访问器的代码作者都必须在编译前将属性名写入代码中。访问器方法的名称由此成为调用代码的静态组成部分。例如,给定清单 2-1 中声明的银行账户对象,编译器会合成一个可以为 myAccount 实例调用的 setter:

1
[myAccount setCurrentBalance:@(100.0)];

这种方式虽然直接,但缺乏灵活性。相比之下,实现了键值编码协议的对象提供了一种更通用的机制 —— 通过字符串标识符来访问对象属性。

个人小结

这里关于属性的分类中,最抽象的就是 Attributes 和 To-one relationships。不过这其实不是 KVC 文档的重点,就不要太在意了。总之知道可以使用 KVC 方法获取对象的各种类型的属性就足够了。

使用键和键路径识别对象的属性

键是标识特定属性的字符串。通常,按照惯例,表示属性的键是代码中的属性本身的名称。键必须使用 ASCII 编码,不得包含空格,并且通常以小写字母开头(尽管也有例外,例如许多类中的 URL 属性)。

因为清单 2-1 中的 BankAccount 类是实现了键值编码协议的,所以它识别键 owner ,currentBalance 和 transactions,它们是它的属性的名称。您可以通过其键设置其值,而不是调用 setCurrentBalance: 方法。

1
[myAccount setValue:@(100.0) forKey:@"currentBalance"];

实际上,你可以使用同一个方法,通过传入不同的键参数来设置 myAccount 对象的所有属性。由于参数是字符串类型,它可以作为运行时动态操作的变量。

键路径是由点分隔的键序列组成的字符串,用于指定需要连续访问的对象属性链。序列中首个键的属性相对于消息接收者而确定,后续每个键的值都基于前一个属性的值来获取。因此通过单次调用方法就能深入访问对象层级结构,这便是键路径的核心价值所在。

例如,将键路径 owner.address.street 应用于银行账户实例时,其含义可解析为:

  1. 首先获取银行账户持有者(owner,Person类实例)
  2. 继而访问该持有者的地址(address,Address类实例)
  3. 最终取得地址对象中的街道字符串值(street)

在 Swift 中,您可以使用 #keyPath 表达式替代字符串来指定键或键路径。这种方法提供编译时检查的优势,正如《Swift与Cocoa、Objective-C互操作性指南(Swift 3版)》中”键与键路径”章节所述。

使用键获取属性的值

当对象遵循 NSKeyValueCoding 协议时,即视为支持键值编码。继承自 NSObject 的对象会自动遵循此协议(NSObject 已提供该协议核心方法的默认实现),并具备特定的默认行为。此类对象至少实现以下基于键的基本读取方法:

  1. valueForKey:

    • 返回由键参数指定的属性值。若根据访问器搜索逻辑规则无法找到对应键的属性,对象会向自身发送 valueForUndefinedKey: 消息。
    • valueForUndefinedKey: 默认实现会抛出 NSUndefinedKeyException 异常,但子类可重写此方法以更优雅地处理该场景。
  2. valueForKeyPath:

    • 返回相对于消息接收者的指定键路径值。若键路径序列中任意对象对特定键不兼容键值编码(即 valueForKey: 的默认实现无法找到访问器方法),该对象会收到 valueForUndefinedKey: 消息。
  3. dictionaryWithValuesForKeys:

    • 返回与接收者相关的键数组对应值。该方法会遍历数组中的每个键并调用 valueForKey:
    • 返回的 NSDictionary 包含数组中所有键的对应值。

重要提示

集合对象(如 NSArray/NSSet/NSDictionary)不可包含 nil 值,需用 NSNull 实例表示空值。dictionaryWithValuesForKeys: 及其相关方法 setValuesForKeysWithDictionary: 的默认实现会自动完成 NSNull(字典参数中)与 nil(存储属性中)的转换。

使用键路径寻址属性时,如果键路径中除最后一个键外的任何键是对多关系(即,它引用集合),则返回值是一个集合,其中包含对多键右侧键的所有值。例如,请求键路径 transactions.payee 的值将返回一个数组,其中包含所有交易的 payee 对象。这也适用于键路径中的多个数组。键路径 accounts.transactions.payee 返回一个数组,其中包含所有账户中所有交易的所有收款人对象。

使用键设置属性的值

与 getter 一样,符合键值编码的对象还提供了一小组通用 setter,其默认行为基于 NSObject 中的 NSKeyValueCoding 协议的实现。

  • setValue:forKey:

    • 为消息接收对象的指定键设置给定值。该方法默认实现会自动拆解表示标量和结构体的 NSNumber 与 NSValue 对象,并将其赋值给对应属性。非对象值的包装与解包语义详见非对象属性的处理
    • 若指定键在接收对象中无对应属性,对象将向自身发送 setValue:forUndefinedKey: 消息。该消息默认实现会触发 NSUndefinedKeyException 异常,但子类可通过覆写此方法实现自定义处理逻辑。
  • setValue:forKeyPath:

    • 沿指定键路径为接收对象设置给定值。若键路径中任一节点对象不遵循键值编码规范,该节点将收到 setValue:forUndefinedKey: 消息。
  • setValuesForKeysWithDictionary:

    • 通过字典键名批量设置接收对象的属性值。默认实现将遍历字典键值对,为每个键调用 setValue:forKey: 方法,并根据需要将 NSNull 对象替换为 nil。

根据默认实现,当尝试为非对象属性设置 nil 值时,遵循键值编码规范的对象会向自身发送 setNilValueForKey: 消息。该消息默认实现将触发 NSInvalidArgumentException 异常,但开发者可通过覆写方法替换为默认值或标记值(详见《非对象值处理机制》)。

使用键简化对象访问

若要了解基于键的 getter 和 setter 如何简化代码,请考虑以下示例。在 macOS 中,NSTableView 和 NSOutlineView 对象将标识符字符串与其每列相关联。如果支持表的模型对象不符合键值编码,则表的数据源方法将被迫依次检查每个列标识符以找到要返回的正确属性,如示例 2-2 所示。此外,将来,当您向模型添加另一个属性(在本例中为 Person 对象)时,还必须重新访问数据源方法,添加另一个条件来测试新属性并返回相关值。

示例 2-2 未采用键值编码的数据源方法的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- (id)tableView:(NSTableView *)tableview objectValueForTableColumn:(id)column row:(NSInteger)row
{
id result = nil;
Person *person = [self.people objectAtIndex:row];

if ([[column identifier] isEqualToString:@"name"]) {
result = [person name];
} else if ([[column identifier] isEqualToString:@"age"]) {
result = @([person age]); // 将标量年龄值包装为NSNumber对象
} else if ([[column identifier] isEqualToString:@"favoriteColor"]) {
result = [person favoriteColor];
} // 其他属性判断...

return result;
}

相比之下,示例 2-3 显示了同一数据源方法的更简洁的实现,该方法利用了符合键值编码的 Person 对象。仅使用 valueForKey: 访问器,数据源方法使用列标识符作为键返回适当的值。除了更短之外,它还更通用,因为稍后添加新列时,只要列标识符始终与模型对象的属性名称匹配,它就会继续保持不变地工作。

示例 2-3 使用键值编码实现数据源方法

1
2
3
4
5
- (id)tableView:(NSTableView *)tableview objectValueForTableColumn:(id)column row:(NSInteger)row
{
// 直接通过列标识符作为键值获取对应属性
return [[self.people objectAtIndex:row] valueForKey:[column identifier]];
}

访问集合属性

遵循键值编码规范的对象以统一方式暴露其集合类型属性(To-Many)。与普通属性操作类似,开发者可使用 valueForKey:setValue:forKey:(或其键路径变体)直接访问集合对象。但若需修改集合内容,采用协议定义的可变代理方法通常更高效。

协议定义三类代理访问方法(均含键与键路径版本):

  • mutableArrayValueForKey: 和 mutableArrayValueForKeyPath:

    • 它们返回一个代理对象,其行为类似于 NSMutableArray 对象。
  • mutableSetValueForKey: and mutableSetValueForKeyPath:

    • 它们返回一个代理对象,其行为类似于 NSMutableSet 对象。
  • mutableOrderedSetValueForKey: and mutableOrderedSetValueForKeyPath:

    • 它们返回一个代理对象,其行为类似于 NSMutableOrderedSet 对象。

当您操作代理对象(添加/移除/替换元素)时,协议的默认实现会相应修改基础属性。这种方式比传统流程更高效 —— 即先用 valueForKey: 获取不可变集合副本,修改内容后通过 setValue:forKey: 回写。多数情况下,此方式甚至比直接操作可变属性更高效。这些方法还具有额外优势:能自动维护集合内对象的键值观察合规性(详见《键值观察编程指南》)。

使用集合操作符

当向实现了键值编码的对象发送 valueForKeyPath: 消息时,可在键路径中嵌入集合操作符。集合操作符是以 “at” 符号(@)为前缀的关键字,指示 getter 在返回数据前执行特定操作。NSObject 提供的 valueForKeyPath: 默认实现支持此功能。

若键路径包含集合操作符,操作符前的部分(称为左路径)指明操作所针对的集合对象,该集合相对于消息接收者。当直接向集合对象(如 NSArray 实例)发送消息时,左路径可省略。

操作符后的键路径部分(称为右路径)指定操作符处理的集合内属性。除 @count 外,所有集合操作符都需要右路径。图 4-1 展示了操作符键路径的格式。

图 4-1 操作符键路径格式

集合操作符分为三种基本行为类型:

  • 聚合操作符

    • 对集合对象进行归约计算,返回与右路径属性数据类型匹配的单个对象。@count 是例外 —— 它不需要右路径,且始终返回 NSNumber 实例。
  • 数组操作符

    • 返回 NSArray 实例,包含目标集合中的特定对象子集。
  • 嵌套操作符

    • 处理包含其他集合的集合对象,根据操作符类型返回 NSArray 或 NSSet 实例,该结果集以特定方式合并嵌套集合内的对象。

示例数据

以下描述包含展示各操作符调用方式及结果的代码片段。示例基于代码清单 2-1 的 BankAccount 类,该类持有 Transaction 对象数组(如代码清单 4-1 所声明)。每个 Transaction 对象代表一条简易支票簿记录。

清单 4-1 Transaction 对象的接口声明。

1
2
3
4
5
6
7
@interface Transaction : NSObject

@property (nonatomic) NSString* payee; // To whom
@property (nonatomic) NSNumber* amount; // How much
@property (nonatomic) NSDate* date; // When

@end

为便于说明,假设您的 BankAccount 实例包含由表 4-1 数据构成的 transactions 数组,且所有示例调用均在 BankAccount 对象内部执行。

表 4-1 Transactions 对象的示例数据

payee amount date
Green Power $120.00 Dec 1, 2015
Green Power $150.00 Jan 1, 2016
Green Power $170.00 Feb 1, 2016
Car Loan $250.00 Jan 15, 2016
Car Loan $250.00 Feb 15, 2016
Car Loan $250.00 Mar 15, 2016
General Cable $120.00 Dec 1, 2015
General Cable $155.00 Jan 1, 2016
General Cable $120.00 Feb 1, 2016
Mortgage $1,250.00 Jan 15, 2016
Mortgage $1,250.00 Feb 15, 2016
Mortgage $1,250.00 Mar 15, 2016
Animal Hospital $600.00 Jul 15, 2016

聚合操作符

聚合操作符作用于 array 或 set 属性,生成反映该集合特定维度特征的单一结果值。

@avg

指定 @avg 操作符时,valueForKeyPath: 将读取集合中每个元素的正确键路径指定的属性,将其转换为 double(将 0 替换为 nil 值),并计算这些值的算术平均值。然后返回存储在 NSNumber 实例中的结果。

要获取表 4-1 中示例数据的平均交易金额:

1
NSNumber *transactionAverage = [self.transactions valueForKeyPath:@"@avg.amount"];

返回的的结果是 456.54

@count

指定 @count 操作符时,valueForKeyPath: 将返回集合的数量在一个 NSNumber 对象中。如果出现了右路径将会被忽略。

获取 Transaction 中 transactions 个对象的数量:

1
NSNumber *numberOfTransactions = [self.transactions valueForKeyPath:@"@count"];

返回的结果是 13。

@max

指定 @max 操作符时,valueForKeyPath: 方法将在右路径指向的集合元素中执行搜索,并返回最大值。该搜索通过调用 compare: 方法进行比较(此方法由许多 Foundation 类(如NSNumber)定义)。因此,右路径对应的属性必须包含能响应此方法的有效对象,搜索过程会自动忽略值为 nil 的集合元素。

要获取表 4-1 中列出的交易中最大日期值,即最新交易的日期:

1
NSDate *latestDate = [self.transactions valueForKeyPath:@"@max.date"];

格式化的 latestDate 值为 2016 年 7 月 15 日。

@min

指定 @min 操作符时,valueForKeyPath: 将在右路径命名的集合元素中搜索并返回最小的元素。搜索使用 compare: 方法进行比较,该方法由许多基础类(例如 NSNumber 类)定义。因此,右路径对应的属性必须包含能响应此方法的有效对象,搜索过程会自动忽略值为 nil 的集合元素。

要获得表 4-1 中列出的交易中最早交易的最小日期值,即最早交易的日期:

1
NSDate *earliestDate = [self.transactions valueForKeyPath:@"@min.date"]; 

格式化的 earliestDate 值为 2015 年 12 月 1 日。

@sum

指定 @sum 操作符时,valueForKeyPath: 会读取集合中每个元素的正确键路径指定的属性,将其转换为 double(用 0 替换 nil 值),并计算这些值的总和。然后返回存储在 NSNumber 实例中的结果。

要获取表 4-1 中示例数据中交易金额的总和:

1
NSNumber *amountSum = [self.transactions valueForKeyPath:@"@sum.amount"]; 

返回的结果是 5,935.00

数组操作符

数组操作符导致 valueForKeyPath: 返回一个对象数组,该数组对应于右键路径指示的特定对象集。

重要

如果使用数组操作符时,如果任何叶对象为 nil,则 valueForKeyPath: 方法会引发异常。

@distinctUnionOfObjects

指定 @distinctUnionOfObjects 操作符时,valueForKeyPath: 将创建并返回一个数组,其中包含与正确键路径指定的属性相对应的集合的不同对象。

要获取 transactions 中交易的 payee 属性值集合,但省略重复值:

1
NSArray *distinctPayees = [self.transactions valueForKeyPath:@"@distinctUnionOfObjects.payee"]; 

生成的 distinctPayees 数组包含以下字符串中的每个实例:Car Loan、General Cable、Animal Hospital、Green Power、Mortgage。

@unionOfObjects

指定 @unionOfObjects 操作符时,valueForKeyPath: 将创建并返回一个数组,其中包含与正确键路径指定的属性相对应的集合的所有对象。与 @distinctUnionOfObjects 不同,重复的对象不会被删除。

要获取 transactions 中交易的 payee 属性值的集合:。

1
NSArray *payees = [self.transactions valueForKeyPath:@"@unionOfObjects.payee"]; 

生成的 payees 数组包含以下字符串:Green Power、Green Power、Green Power、Car Loan、Car Loan、Car Loan、General Cable、General Cable、General Cable、Mortgage、Mortgage、Mortgage、Animal Hospital。注意重复项

嵌套操作符

嵌套操作符作用于嵌套集合 —— 即集合中的每个条目本身又包含一个集合。

重要提示

当使用嵌套操作符时,若任何叶节点对象为 nil,valueForKeyPath: 方法将触发异常。

下文描述基于名为 moreTransactions 的新数据数组(数据内容见表4-2),该数组与原始 transactions 数组(来自”样本数据”部分)共同构成嵌套数组:

1
2
NSArray* moreTransactions = @[<# transaction data #>];
NSArray* arrayOfArrays = @[self.transactions, moreTransactions];

表4-2 moreTransactions 阵列中假设的 Transaction 数据。

payee amount date
General Cable - Cottage $120.00 Dec 18, 2015
General Cable - Cottage $155.00 Jan 9, 2016
General Cable - Cottage $120.00 Dec 1, 2016
Second Mortgage $1,250.00 Nov 15, 2016
Second Mortgage $1,250.00 Sep 20, 2016
Second Mortgage $1,250.00 Feb 12, 2016
Hobby Shop $600.00 Jun 14, 2016

@distinctUnionOfArrays

当指定 @distinctUnionOfArrays 操作符时,valueForKeyPath: 方法将创建并返回一个新数组,其中包含右路径指定属性对应的所有集合去重合并后的唯一对象。

要获取数组集合 arrayOfArrays 中所有 payee 属性的去重值:

1
NSArray *collectedDistinctPayees = [arrayOfArrays valueForKeyPath:@"@distinctUnionOfArrays.payee"]; 

生成的 collectedDistinctPayees 数组将包含以下值:Hobby Shop, Mortgage, Animal Hospital, Second Mortgage, Car Loan, General Cable - Cottage, General Cable, Green Power。

@unionOfArrays

指定 @unionOfArrays 操作符时,valueForKeyPath: 将创建并返回一个数组,其中包含与正确键路径指定的属性相对应的所有集合组合的所有对象,而不删除重复项。

获取 arrayOfArrays 内所有数组中的 payee 属性的值:。

1
NSArray *collectedPayees = [arrayOfArrays valueForKeyPath:@"@unionOfArrays.payee"];

生成的 collectedPayees 数组包含以下值:Green Power、Green Power、Green Power、Car Loan、Car Loan、Car Loan、General Cable、General Cable、General Cable、Mortgage、Mortgage、Mortgage、Mortgage、Animal Hospital、General Cable - Cottage、General Cable - Cottage、General Cable - Cottage、Second Mortgage、Second Mortgage、Second Mortgage、Hobby Shop。

@distinctUnionOfSets

指定 @distinctUnionOfSets 操作符时,valueForKeyPath: 将创建并返回一个 NSSet 对象,其中包含与右键路径指定的属性相对应的所有集合组合的不同对象。

此操作符的行为与 @distinctUnionOfArrays 类似,不同之处在于它需要一个包含 NSSet 对象实例的 NSSet 实例,而不是 NSArray 实例的 NSArray 实例。此外,它还返回一个 NSSet 实例。假设示例数据存储在集合而不是数组中,则示例调用和结果与 @distinctUnionOfArrays 中显示的结果相同。

个人小结

关于集合操作符,这点估计是蛮多人的盲点,反正作者没看过这篇文档之前是不知道还能这么写的,所以实际用到的也不多。个人感觉意义其实不是很大,就是简化了一些操作,每个集合操作符都可以拆开为其他几句代码,使用它就相当于能少些几句代码。

非对象属性的处理

NSObject 默认提供的键值编码协议方法实现,既能处理对象类型的属性,也能处理非对象类型(比如标量或结构体)的属性。它的默认实现会在对象类型的参数或返回值,与底层存储的非对象类型属性之间,自动进行封装和拆箱(boxing/unboxing)。这样一来,无论属性实际是一个对象,还是一个整型、浮点数或结构体,键值访问方法(getter/setter)的参数和返回值签名始终保持为对象类型,接口一致性得以保证。

注意:

由于 Swift 中的所有属性都是对象,因此本部分仅适用于 Objective-C 属性。

当您调用协议中的某个获取器,例如 valueForKey: 时,默认实现会根据《访问器搜索逻辑》中描述的规则确定为指定键提供值的具体访问器方法或实例变量。如果返回值不是对象,获取器会使用该值初始化一个 NSNumber 对象(对于标量)或 NSValue 对象(对于结构),然后返回该对象。

同样,默认情况下,像 setValue:forKey: 这样的 setter 会根据特定的键确定属性访问器或实例变量所需的数据类型。如果数据类型不是对象,setter 会先向传入的值对象发送一个适当的 Value 消息来提取底层数据,并将该数据存储起来。

注意:

当您使用非对象属性的 nil 值调用键值编码协议的 setter 方法时,setter 方法没有明显的一般处理方式。因此,它会向接收 setter 调用的对象发送一个 setNilValueForKey: 消息。默认实现会抛出一个 NSInvalidArgumentException 异常,但子类可以覆盖此行为,例如设置一个标记值或提供一个有意义的默认值。

包装和解包标量类型

表 5-1 列出了默认键值编码实现使用 NSNumber 实例包装的标量类型。对于每种数据类型,表格显示了用于从底层属性值初始化 NSNumber 以提供 getter 返回值的创建方法。然后显示了在设置操作中从 setter 输入参数提取值的访问器方法。

表 5-1 中的标量类型作为 NSNumber 对象的封装

Data type Creation method Accessor method
BOOL numberWithBool: boolValue(在 iOS 中)
charValue(在 macOS 中)
char numberWithChar: charValue
double numberWithDouble: doubleValue
float numberWithFloat: floatValue
int numberWithInt: intValue
long numberWithLong: longValue
long long numberWithLongLong: longLongValue
short numberWithShort: shortValue
unsigned char numberWithUnsignedChar: unsignedChar
unsigned int numberWithUnsignedInt: unsignedInt
unsigned long numberWithUnsignedLong: unsignedLong
unsigned long long numberWithUnsignedLongLong: unsignedLongLong
unsigned short numberWithUnsignedShort: unsignedShort

注意

在 macOS 中,由于历史原因, BOOL 被定义为 signed char 类型,而 KVC 不区分这两种类型。因此,当你使用一个 BOOL 类型的键时,不应该传递字符串值如 @“true” 或 @“YES” 给 setValue:forKey: 。KVC 会尝试调用 charValue (因为 BOOL 本质上是 char 类型),但 NSString 并没有实现这个方法,这会导致运行时错误。相反,当你使用一个 BOOL 类型的键时,应该只传递一个 NSNumber 对象,如 @(1) 或 @(YES) 作为 setValue:forKey: 方法的值参数。这种限制在 iOS 中并不适用,因为在 iOS 中 BOOL 被定义为原生的布尔类型 bool ,KVC 会调用 boolValue ,它可以处理 NSNumber 对象或格式正确的 NSString 对象。

包装和解包结构类型

表 5-2 列出了默认访问器用于包装和解包常见的 NSPoint 、 NSRange 、 NSRect 和 NSSize 结构的创建和访问方法。

表 5-2 中常见的 struct 类型是如何使用 NSValue 进行包装的。

Data type Creation method Accessor method
NSPoint valueWithPoint: pointValue
NSRange valueWithRange:: rangeValue
NSRect valueWithRect: (仅限 macOS) rectValue
NSSize valueWithSize: sizeValue

自动包装和解包不仅限于 NSPoint 、 NSRange 、 NSRect 和 NSSize 。结构类型(即其 Objective-C 类型编码字符串以 { 开头的类型)可以被包装成一个 NSValue 对象。例如,考虑在列表 5-1 中声明的结构和类接口。

列表 5-1 一个使用自定义结构的示例类

1
2
3
4
5
6
7
typedef struct {
float x, y, z;
} ThreeFloats;

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

通过名为 myClass 的此类实例,您可以使用键值编码获取 threeFloats 的值

1
NSValue* result = [myClass valueForKey:@"threeFloats"]; 

默认实现的 valueForKey: 调用 threeFloats 的获取器方法,然后将结果包装在一个 NSValue 对象中返回。

同样,你可以使用键值编码设置 threeFloats 的值

1
2
3
ThreeFloats floats = {1., 2., 3.};
NSValue* value = [NSValue valueWithBytes:&floats objCType:@encode(ThreeFloats)];
[myClass setValue:value forKey:@"threeFloats"];

默认实现会通过 getValue: 消息解包值,然后使用结果结构调用 setThreeFloats: 。

验证属性

键值编码协议定义了方法来支持属性验证。就像你使用基于键的访问器来读取和写入键值编码兼容对象的属性一样,你也可以通过键(或键路径)来验证属性。当你调用 validateValue:forKey:error: (或 validateValue:forKeyPath:error: )方法时,协议的默认实现会在接收验证消息的对象(或键路径的终点对象)中搜索一个方法,该方法的名称符合 validate:error: 的模式。如果对象没有这样的方法,验证默认成功,并返回 YES 。当存在特定于属性的验证方法时,协议的默认实现会调用该方法并返回其结果。

注意

通常你只在 Objective-C 中使用这里描述的验证。在 Swift 中,属性验证通常通过依赖编译器对可选类型和强类型检查的支持来处理,同时使用内置的 willSet 和 didSet 属性观察者来测试任何运行时 API 合约,如《Swift 编程语言(Swift 3)》一书的属性观察者部分所述。

因为属性特定的验证方法通过引用接收值和错误参数,验证可能有三种结果:

  1. 验证方法认为值对象有效,并返回 YES ,而不修改值或错误。

  2. 验证方法认为值对象无效,但不对其进行修改。在这种情况下,该方法返回 NO ,并设置错误引用(如果由调用者提供)为一个表示失败原因的 NSError 对象。

  3. 验证方法认为值对象无效,但会创建一个新的有效对象作为替代。在这种情况下,方法返回 YES ,同时保留错误对象不变。在返回之前,方法会修改值引用,使其指向新的值对象。每当进行修改时,方法总是会创建一个新的对象,而不是修改旧的对象,即使该对象是可变的。

列表 6-1 展示了如何为名称字符串调用验证的示例。

列表 6-1 名称属性的验证

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

自动验证

通常,键值编码协议及其默认实现并未定义任何自动执行验证的机制。相反,您应在适当的情况下使用验证方法。

某些其他 Cocoa 技术在某些情况下会自动进行验证。例如,Core Data 在保存管理对象上下文时会自动进行验证(参见 Core Data 编程指南)。此外,在 macOS 中,Cocoa 绑定允许您指定验证应自动进行(参见 Cocoa 绑定编程主题以获取更多信息)。

个人总结

这个验证属性功能,作者几乎没有使用过,也没怎么遇到过。。。

访问器搜索逻辑

键值编码协议的默认实现由 NSObject 提供,通过一组明确定义的规则将基于键的访问器调用映射到对象的底层属性。这些协议方法使用一个键参数,在其自身对象实例中搜索访问器、实例变量以及遵循特定命名约定的相关方法。尽管你很少会修改这种默认搜索,但了解其工作原理对于追踪键值编码对象的行为以及使自己的对象符合要求都是很有帮助的。

注意

本节中的描述使用了 <key><Key> 作为占位符,代表出现在某个键值编码协议方法参数中的键字符串,该方法随后使用该字符串作为次要方法调用或变量名查找的一部分。映射的属性名称遵循占位符的大小写。例如,对于获取器 <key>is<Key>,名为 hidden 的属性映射到 hiddenisHidden

基本 getter 的搜索逻辑

默认实现的 valueForKey: ,在接收到 valueForKey: 参数时,会在接收该调用的类实例内部执行以下过程。

  1. 首先,在实例中查找第一个名为 get<Key> 、 <key> 、 is<Key> 或 _<key> 的访问器方法。如果找到该方法,则调用它,并将结果作为输入进行第 5 步。否则,继续下一步。

  2. 如果没有找到简单的访问器方法,则在实例中查找名称匹配 countOf<Key> 和 objectIn<Key>AtIndex: (对应于 NSArray 类定义的原始方法)以及 <key>AtIndexes: (对应于 NSArray 方法 objectsAtIndexes: )的方法。

    如果找到了第一个这些方法,并且至少找到了另外两个方法中的一个,则创建一个集合代理对象,该对象能够响应所有 NSArray 方法,并返回该对象。否则,继续下一步。

    代理对象随后会将接收到的任何 NSArray 消息转换为对符合键值编码的对象的一些 countOf<Key> 、 objectIn<Key>AtIndex: 和 <key>AtIndexes: 消息。如果原始对象还实现了一个可选的方法,其名称类似于 get<Key>:range: ,代理对象在适当的情况下也会使用该方法。实际上,代理对象与符合键值编码的对象一起工作,使得底层属性的行为类似于 NSArray ,即使它不是。

  3. 如果未找到简单的访问器方法或一组数组访问方法,则查找名为 countOf<Key> 、 enumeratorOf<Key> 和 memberOf<Key>: 的三组方法(对应于由 NSSet 类定义的基本方法)。

    如果找到了这三个方法,创建一个集合代理对象,该对象能够响应所有 NSSet 消息,并返回该对象。否则,继续执行步骤 4。

    代理对象随后会将接收到的任何 NSSet 消息转换为对创建它的对象的一些 countOf<Key> 、 enumeratorOf<Key> 和 memberOf<Key>: 消息。实际上,代理对象与符合键值编码的对象一起工作,使得底层属性的行为类似于 NSSet ,即使它不是。

  4. 如果未找到简单的访问器方法或集合访问器方法组,并且接收者的类方法 accessInstanceVariablesDirectly 返回 YES ,则按顺序查找名为 _<key> 、 _is<Key> 、 <key> 或 is<Key> 的实例变量。如果找到,直接获取实例变量的值并进行第 5 步。否则,进行第 6 步。

  5. 如果检索到的属性值是对象指针,则直接返回结果。

    如果值是 NSNumber 支持的标量类型,则将其存储在 NSNumber 实例中并返回该实例。

    如果结果是 NSValue 不支持的标量类型,则将其转换为 NSValue 对象并返回该对象。

  6. 如果其他方法都无效,可以调用 valueForUndefinedKey: 。默认情况下,这会抛出异常,但 NSObject 的子类可以提供特定于键的行为。

基本 setter 的搜索逻辑

给定 key 和 value 参数, setValue:forKey: 的默认实现尝试在接收调用的对象中将名为 key 的属性设置为 value (或者,对于非对象属性,设置为其未包装的版本,如在非对象属性的处理中所述),使用以下步骤:

  1. 首先查找名为 set<Key>: 或 _set<Key>: 的第一个访问器。如果找到,使用输入值(或根据需要的未包装值)调用它并结束。

  2. 如果未找到简单的访问器,并且类方法 accessInstanceVariablesDirectly 返回 YES ,则查找名为 _ 、 _is<Key> 、 <key> 或 is<Key> 的实例变量(按此顺序)。如果找到,则直接使用输入值(或未包装值)设置该变量并结束。

  3. 若未找到访问器或实例变量,则调用 setValue:forUndefinedKey: 。默认情况下这将引发异常,但 NSObject 的子类可以提供特定键的行为。

可变数组的搜索逻辑

给定一个 key 参数, mutableArrayValueForKey: 的默认实现返回一个代理数组,该数组用于接收访问器调用的对象中的名为 key 的属性,具体步骤如下:

  1. 寻找名为 insertObject:in<Key>AtIndex: 和 removeObjectFrom<Key>AtIndex: 的方法对(分别对应 NSMutableArray 原始方法 insertObject:atIndex: 和 removeObjectAtIndex: ),或者名为 insert<Key>:atIndexes: 和 remove<Key>AtIndexes: 的方法对(分别对应 NSMutableArray 、 insertObjects:atIndexes: 和 removeObjectsAtIndexes: 方法)。

    如果对象至少有一个插入方法和至少一个移除方法,返回一个代理对象,该对象通过发送 insertObject:in<Key>AtIndex: 、 removeObjectFrom<Key>AtIndex: 、 insert<Key>:atIndexes: 和 remove<Key>AtIndexes: 方法的消息来响应 NSMutableArray 消息,这些消息是发送给 mutableArrayValueForKey: 的原始接收者的。

    当对象接收 mutableArrayValueForKey: 消息时,如果该对象还实现了可选的替换对象方法,方法名为 replaceObjectIn<Key>AtIndex:withObject: 或 replace<Key>AtIndexes:with<Key>: ,则代理对象在适当的情况下也会利用这些方法以获得最佳性能。

  2. 如果对象没有可变数组方法,而是寻找一个方法名符合 set<Key>: 模式的访问器方法。在这种情况下,返回一个代理对象,该对象通过向 mutableArrayValueForKey: 的原始接收者发送 set<Key>: 消息来响应 NSMutableArray 消息。

    注意

    本步骤描述的机制比上一步骤的效率要低得多,因为它可能会反复创建新的集合对象,而不是修改现有的对象。因此,在设计自己的符合键值编码的对象时,你应该尽量避免使用这种方法。

  3. 如果既没有找到可变数组方法,也没有找到访问器,且接收者的类响应 YES 方法 accessInstanceVariablesDirectly ,则按顺序查找名为 _<key> 或 <key> 的实例变量。

    如果找到了这样的实例变量,则返回一个代理对象,该对象将接收到的每个 NSMutableArray 消息转发给实例变量的值,通常该值是 NSMutableArray 的实例或其子类的实例。

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

    默认的 setValue:forUndefinedKey: 实现会抛出一个 NSUndefinedKeyException 错误,但子类可以覆盖这种行为。

可变有序集合的搜索逻辑

mutableOrderedSetValueForKey: 的默认实现与 valueForKey: 认识到相同的基本访问器方法和有序集合访问器方法(参见基本获取器的默认搜索逻辑),并且遵循相同的直接实例变量访问策略,但总是返回一个可变集合代理对象,而不是 valueForKey: 返回的不可变集合。此外,它还会执行以下操作:

  1. 搜索名称类似于 insertObject:in<Key>AtIndex: 和 removeObjectFrom<Key>AtIndex: (对应于 NSMutableOrderedSet 类定义的两个最基本的方法)以及 insert<Key>:atIndexes: 和 remove<Key>AtIndexes: (对应于 insertObjects:atIndexes: 和 removeObjectsAtIndexes: )的方法。

    如果找到至少一个插入方法和至少一个移除方法,当代理对象接收到 NSMutableOrderedSet 消息时,它会向原始接收者发送 insertObject:in<Key>AtIndex: 、 removeObjectFrom<Key>AtIndex: 、 insert<Key>:atIndexes: 和 remove<Key>AtIndexes: 消息中的某些组合消息。

    代理对象在原对象中存在类似 replaceObjectIn<Key>AtIndex:withObject: 或 replace<Key>AtIndexes:with<Key>: 的方法时也会使用这些方法。

  2. 如果未找到可变集合方法,则会搜索类似 set<Key>: 的访问器方法。在这种情况下,每次代理对象接收到 NSMutableOrderedSet 消息时,都会向原 mutableOrderedSetValueForKey: 接收者发送一个 set<Key>: 消息。

    注意:

    本步骤中描述的机制比上一步骤中的机制效率低得多,因为它可能会反复创建新的集合对象而不是修改现有的一个。因此,在设计您自己的符合键值编码的对象时,您应该尽量避免使用它。

  3. 如果既没有找到可变集合消息也没有找到访问器,并且接收者的 accessInstanceVariablesDirectly 类方法返回 YES ,则按顺序查找一个名为 _<key> 或 <key> 的实例变量。如果找到了这样的实例变量,返回的代理对象会将接收到的 NSMutableOrderedSet 消息转发给该实例变量的值,该值通常是 NSMutableOrderedSet 或其子类的一个实例。

  4. 如果所有方法都失败了,返回的代理对象会在接收到可变集合消息时,向原始接收者 mutableOrderedSetValueForKey: 发送一个 setValue:forUndefinedKey: 消息。

    setValue:forUndefinedKey: 的默认实现会抛出一个 NSUndefinedKeyException 异常,但对象可以覆盖此行为。

可变集合的搜索逻辑

mutableSetValueForKey: 的默认实现,给定一个 key 参数作为输入,返回一个指向接收访问器调用的对象内部名为 key 的数组属性的可变代理集合,采用以下步骤:

  1. 搜索名称类似于 add<Key>Object: 和 remove<Key>Object: (分别对应于 NSMutableSet 原始方法 addObject: 和 removeObject: )以及 add<Key>: 和 remove<Key>: (分别对应于 NSMutableSet 方法 unionSet: 和 minusSet: )的方法。如果至少找到一个添加方法和一个移除方法,则返回一个代理对象,该对象在接收到 add<Key>Object: 消息时,将发送 remove<Key>Object: 、 add<Key>: 、 remove<Key>: 中的某些组合消息到原始接收者 mutableSetValueForKey: 。

  2. 如果 mutableSetValueForKey: 调用的接收者是一个受管理的对象,则搜索逻辑不会继续,就像对于非受管理的对象那样。有关更多信息,请参阅《Core Data 编程指南》中的受管理对象访问器方法。

  3. 如果找不到可变集合方法,并且对象不是管理对象,则搜索一个名为 set<Key>: 的访问器方法。如果找到这样的方法,返回的代理对象会将它接收到的每个 set<Key>: 消息转发给原始接收者 mutableSetValueForKey: 。

    注意

    本步骤中描述的机制比第一步的机制效率低得多,因为它可能会反复创建新的集合对象而不是修改现有的对象。因此,在设计自己的符合键值编码的对象时,您应该尽量避免使用它。

  4. 如果找不到可变集合方法和访问器方法,并且 accessInstanceVariablesDirectly 类方法返回 YES ,则按顺序搜索一个名为 _<key> 或 <key> 的实例变量。如果找到这样的实例变量,代理对象会将它接收到的每个 NSMutableSet 消息转发给实例变量的值,该值通常是 NSMutableSet 或其子类的一个实例。

  5. 如果所有其他方法都失败了,返回的代理对象会将接收到的 NSMutableSet 消息转发给原始接收者 mutableSetValueForKey: ,并发送一个 setValue:forUndefinedKey: 消息。

小结

访问器搜索逻辑这一小节应该算是 KVC 里面的重点了,面试的时候也有可能会问到。其实如果是直译过来的话应该叫访问器搜索模式,但是这样听起来就容易让人摸不着头脑。


重要提示:

作为 iOS,macOS,或 Apple 生态应用层开发者,只要你的类继承自 NSObject,就完全不必阅读和理解文档里的以下这一章 —— NSObject 已经帮你把所有默认的 KVC 行为(包括 to-many 集合代理)都做好了。之所以有以下

采用键值编码协议

实现基本键值编码合规性

基础的获取器方法

基础的设置器方法

实例变量

定义集合方法

访问有序集合

有序集合的获取器方法

有序集合的修改方法

访问无序集合

无序集合的获取器方法

无序集合的修改方法

处理非对象值

添加验证

实现验证方法

验证基本数据类型的属性

描述属性关系

类描述

以性能为导向进行设计

覆写键值编码方法

优化对多关系

这个意思应该是尽可能集合类型的属性,要实现那些特定的集合存取器方法?不确定

合规性检查清单

属性和一对一关系的合规性

有序的对多关系合规性

无序的对多关系合规性

属性验证