《驾驭Core Data》 第三章 数据建模
本文由海水的味道编译整理,请勿转载,请勿用于商业用途。
当前版本号:0.1.2
第三章数据建模
Core Data栈配置好之后,接下来的工作就是设计对象图,在Core
Data框架中,对象图被表示为NSManagedObjectModel。对象图由若干个实体组成,实体被表示为NSEntityDescription。本章将围绕实体设计的相关细节展开讨论。本章也涉及到了常规的数据建模原则,因为这些原则也适用于创建实体。
托管对象NSManagedObject和实体NSEntityDescription
托管对象和实体之间的关系就好比对象和类之间的关系。从概念上讲,实体的构造与标准的面向对象编程类似。在Objective-C语言中,一个对象是一个NSObject类的实例,类描述(如isa指针)定义了对象的属性。对象的类型不同表明它们是不同类的实例。每一个类都有不同的类描述。
在Core Data中,一个NSManagedObject对象关联一个实体描述(entity description),一个实体描述由一个NSEntityDescription对象表示。它提供了描述NSManagedObject对象的元数据,包括实体名,属性名和关系名。NSManagedObject继承自NSObject,通过Core Data提取到的对象,默认情况下都是NSManagedObject对象。这里读者可能会疑问:为什么提取到的对象都是NSManagedObject类型呢?拥有不同属性的实体对应的对象类型不是也应该不同吗?原因是NSManagedObject的工作模式有点类似于字典:NSManagedObject对象可以存取任意的键-值对,即NSManagedObject对象会为所有的实体属性保存相应的键-值对。
常规的Objective-C包含属性和方法,而一个实体只能包含属性(关系也是属性),如果你需要为实体添加自定义方法,你可以创建一个NSManagedObject子类。在子类中实现自定义方法。
将数据抽象为实体
设计数据模型的第一步是决定如何将你的数据划分为实体。通常这和在Web程序中使用数据库表的原则类似。如果需要在数据库中使用一张表,那就表示此处需要定义一个实体。
对于该规则的特例是连接表。在关系数据库中,连接表被用来表示表与表之间的多对多关系。然而在Core Data中,这种表与表之间的连接关系Core Data会自动为你创建,你所需要做的就是在实体之间建立一个连接关系。Core Data会明白如何在持久化存储文件中存储关系:Core Data会在SQLite数据库层创建一个相关的连接表,你永远不用担心这是如何实现的。
在提取数据过程中,一个传统数据库表列被转化为一个实体的属性。表行被转化为实体的NSManagedObject实例。
属性和关系
为了将一个模型数据映射为一个对象,我们一般通过继承NSObject类创建一个模型类,然后将数据填充到模型类实例中。与此类似,为了让Core Data能够将从数据库中提取的数据映射为对象,我们需要创建对应的实体,实体告诉NSPersistentStoreCoordinator映射对象的结构信息。实体的创建不需要编写任何代码,只需借助Xcode的数据模型设计器将模型类中的属性相应的设置为实体的属性。
当实体的属性的类型是另一个实体,就需要使用关系。关系有两种类型:一对一To One和一对多To Many。关系可以处理两类问题。一类是当实体的属性值类型是一个实体。比如Person实体的car属性的值类型是Car实体,这个用实体属性就无法表示,你需要通过一对一关系将Person和Car进行关联。第二类问题是当实体的属性需要引用多个实体实例。比如有一个客户订单类CustomerOrder,订单类中有一个订单项属性orderItems,orderItems用来包含多个OrderItem对象,言下之意就是一个客户订单包含多个订单项。因为Core Data没有类似NSArray或NSSet这样的集合属性类型,所以你需要使用一对多关系将CustomerOrder与多个OrderItem关联。在一对多关系中,无论“多”的一方指向何种类型,即使是NSString,你也需要创建一个表示NSString对象的实体(比如一个CustomerComment实体包含了一个commentString属性)。
如果你觉得为了一个NSString类型的属性而创建一个新的实体没这个必要。Core Data为此也提供了一个Binary Data属性类型。你可以设置一个Binary Data类型的属性,然后编写一个将字符串数组归档为NSData对象并存储为实体属性的方法。当一个CustomerOrder实例从持久化存储文件中被提取, NSData对象会被解档为字符串数组。这种做法虽然可能会让建模额外实体变得简单,但这显然会引入不必要的性能开销。
数据标准化
数据标准化指的是为了最小化信息冗余以及让查询信息更简单而将数据分离为相关联的实体的过程。如果你要对之前例子中的CustomerOrder进行建模,那么将CustomerOrder从Customer中独立出来会更有意义,而不是在每个订单中都重复客户的信息(比如Custom实体上有一个CustomerOrder属性)。
如果一个客户修改了他的名字,相对于需要修改该客户所有的CustomerOrder对象上的客户名信息,你只需要更新Customer对象的customerName属性。这会更加快速简单并且更少出错。这也意味着从一个特殊的客户对象中获取他的所有订单,仅需要向Customer对象查询所有的CustomerOrder对象,而不是根据customerName搜索所有的CustomerOrder对象。
在传统的数据库设计中,范式是对标准化级别的衡量。对范式的一个完整讨论已然超过了本书的范围,但设计Web程序数据库的原则也适用于Core Data使用。
如果你需要开发一个简单的客户订单app,你可能决定使用三个单独的实体:CustomerOrder、OrderItem、Product。如图3.1所示。
图3.1 客户订单、订单项、商品之间的关系
当一个客户订购了多个商品,一个CustomerOrder对象将被创建,并包含多个OrderItem对象表示已购买的商品。OrderItem对象存储了商品购买时的价格,所以如果商品价格随后发生了变化,CustomerOrder也会列出与订单相关的正确价格。OrderItem没有存储商品名,因为这可以从OrderItem和Product对象之间的关系推导出来,使用key-path形式,查询OrderItem对象的product.name。
在图3.1中,CustomerOrder没有存储总价,因为可能只需将选中的OrderItems的价格(乘以数量)相加起来[2]。
如果这就是iOS app的数据模型设计,那么你可能考虑用一个页面显示最近的订单,如图3.2所示,每行显示了一个OrderItem对象,订单日期以及总价。
图3.2 一个简单的订单页面,列出了最近的客户订单
在计算表格视图所有行的订单总价时,你需要提取所有的OrderItem对象,然后对每个OrderItem执行purchasePrice * quantity的计算,最后将计算结果相加求和。该做法虽然可行,但在iOS设备上可能会遇到内存和性能问题。
如果在每个CustomerOrder对象中新增一个orderTotal用来保存订单总价会更有意义。当某一个CustomerOrder已有1000条OrderItem,新增一个订单项,你只需要将此订单项的价格加到orderTotal上。为了显示图3.2所示的信息,你不再需要加载所有的OrderItem对象或执行任何计算。所以此方案可以获得很好的性能。虽然这违反了传统的正规化实践,但这是一种以空间换时间的做法。
此外,通过缓存每个CustomerOrder对象的customerName,也会得到一点性能上的提升。因为这避免了只是为了显示客户姓名而提取相关Customer对象的开销(注:假设CustomOrder实体有一个指向Customer实体的关系)。但是这需要为每种次序(按firstName或者lastName升序或降序)存储重复的字符串。
当为Core Data项目设计数据模型时,规范化是必须的,这可以进一步优化app性能,减少数据的重复。然而适当的非规范化的重复数据,有时候反而会获得更好的性能。个中利弊都需要你去权衡。
存储二进制数据
如果你需要为一个NSManagedObject对象存储二进制数据,比如一张图片或一段音频。你可以使用Binary Data属性类型。同样无论何时你使用这类数据,你都要警惕性能和内存问题。
考虑一个记录姓名和地址的通讯录iOS app。如果你想为每个联系人存储一张照片,你可能提出如图3.3所示的实体设计。
表3.3 一个通讯录管理app的基本数据模型,数据模型包含照片属性
如果你在页面上每次只显示一个联系人,那么你不会遇到内存使用的问题。当你想显示所有的联系人,那么很明显你需要从持久化存储文件中提取所有联系人对象。如果每个联系人有512Kb的图片附件,当你一次性加载所有联系人,iOS设备的内存将很快被耗尽。
可以让Core Data只提取指定的的属性,将图片属性排除在外,除非在需要图片的时候才进行提取。
另一种替代方案是给大的数据对象创建一个专门的实体,在主实体与数据实体之间使用一对一关系,如图3.4所示。
图3.4一个通讯录管理app的基本数据模型,为图片数据创建了一个单独的实体
使用这种模型设计,你可以安全地一次从磁盘上加载所有的联系人;当你提取这些对象,所有的图片关系都处于惰性状态,所以没有图片会被加载到内存。当你需要为联系人显示图片时,一旦尝试访问图片关系,图片关系的惰性状态就会被消除,接着图片就从存储区中被提取了。
本节主要介绍的术语和优化建议会贯穿本书其余的部分。
使用XCode的数据模型设计器
现在你已经了解了一些针对iOS Core Data实体设计的最佳实践,下面介绍如何使用XCode内置的数据模型设计器创建NSManagedObject对象模型(也就是对象图)。
为了遵循本章剩余部分的描述,现在使用Xcode创建一个新的数据模型文件。首先创建一个新的Empty Application模板项目,接着选择File>New>New File(Command+N快捷键),并从Core Data分组中选择Data Model文件模板,如图3.5所示。
图3.5 新建Data Model文件的对话框
点击Next,输入模型标识名,选择保存的磁盘位置。
当你创建了该文件后,空白的数据模型将会在数据模型设计器中打开。如图3.6所示。
图3.6 XCode数据模型设计器中的一个空白的数据模型文档
在XCode中,数据模型设计器有两中编辑风格,Table和Graph风格。
创建实体
为在XCode中创建实体,在窗口底栏点击Add Entity按钮。一个新的实体将被创建,在Table或Graph编辑风格模式下,都会显示这个新创建的实体。
作为一个例子,让我们创建之前例子中的CustomerOrder实体。选择View>Utilites>Data Model Inspector(按住Option+Command+3快捷键),打开数据模型检视器。你将看到更详细的配置选项,如图3.7所示。
图3.7 XCode中的数据模型检视器
在数据模型检视器中,将实体名修改为CustomerOrder。
当命名实体时有许多要求和约定需要注意。比如Objective-C类名必须是以大写字母开头,不能包含空格,使用驼峰式大小写。使用单数形式命名。
在右侧的数据模型检视器面板中,你也可以为实体指定Class、 Parent Entity,以及是否为Abstract Entity;
Ø 实体的Class默认被设置为NSManagedObject,这意味着每一个从存储区提取的对象将被实例化为NSManagedObject类实例。如果你需要创建了自定义的NSManagedObject类(比如你有一个Person实体,你想将该实体的NSManagedObject子类名设置为AWPerson),你就可以设置该值为你创建的自定义类。
Ø 虽然你不需要为你的实体名使用前缀,但是为你的自定义NSManagedObject类加上前缀是不错的做法。比如实例化一个AWCustomerOrder类。
Ø Parent Entity下拉菜单允许你指定一个继承的父实体。与Objective-C类继承中所有父类的属性(对比:实体中的属性和关系)会被子类继承一样。注意你无可以使用多继承。
Ø Abstract复选框被用于指定一个实体纯粹是被用来用做另一个实体的父实体。对于这样的实体,Core Data不会为其创建实际的NSManagedObject对象,它的存在仅是为了那些需要继承抽象属性的实体。
现在不用去管Class文本域中的内容,因为我们不打算在本章创建任何NSManagedObject的子类。Parent Entity也让它保持默认值。继承性将在第6章“使用NSManagedObject对象”中详细讨论。
实体检视器也允许你配置一些其他的选项:
Ø User Info区域用于为一个实体(不是实例)指定信息,信息将存储在数据模型自身,而非属性中。
Ø Versioning区域用于配置需要版本控制的数据模型选项,这将在第8章“迁移与版本控制”中做更详细的讨论。
Ø Entity Sync区域被用于使用桌面平台的同步服务,iOS平台不作使用。
创建属性
现在在你的模型列表中,你已经有一个全新的实体。是时候对实体添加一些属性了。
当在数据模型设计器中使用Table编辑器风格时,你将看到选中的实体属性包含Attributes,Relationships以及Fetched Properties。Attributes和Relationship两项已经在之前的章节做过介绍。Fetched Properties是一个定义在实体上的属性,它的值实际上是从另一个实体提取的。确保你的CustomerOrder实体被选中,点击在窗口底栏的Add Attribute按钮创建一个新的属性,新的属性会显示在Attributes列表中。如果切换到Graph编辑风格,新的属性会出现在实体的Attributes区域。如图3.8所示。
图3.8 在XCode数据模型设计器中创建属性
当你选中一个属性,在窗口右侧的数据模型检视面板中你会看到有一些设置,而且这些设置项会根据你所设置的属性类型不同而不同,大致分为以下几类:
Ø Transient(瞬时)选项表示属性不会存储到持久化存储文件中。Transient通常用在属性的值是由其他属性计算或合成而来(比如fullName属性是根据Persion类实例的firstName和lastName拼接而来)。
Ø Optional(可选)选项表示当前属性的值允许为空。如果将属性指定为Optional,那么即使属性的值为空,实体的NSManagedObject实例也可被保存。如果该值是必选的(不选中Optional则为必选),如果属性值为空,NSManagedObject对象上下文将拒绝保存这个属性值不完整的NSManagedObject对象(当你尝试并保存上下文,你将接收到一个NSError对象)。所有的属性最初的状态都是Optinal。
Ø Indexed选项表示底层的持久化存储文件应该为该属性生成一个索引;如果你使用基于多个属性的查询条件来提取对象,指定此类属性为Indexed,可以大幅提高提取速度。
Ø Reg.Ex是Regular Expression的缩写,主要是用来验证属性值是否匹配特定的模式。此选项只对String类型有效。
Ø Validation可以保证非法数据不被保存进持久化存储文件中。数值属性类型(Integer 16/32/64、Float、Double、Decimal)都有maximum和minimum最大值最小值设定。你也可以对String类型设置最大长度和最小长度。或对Date类型设置日期范围。不过最好的做法是当用户向UITextField中输入数据时就开始验证数据,而非等到向上下文发送save消息才验证数据。
Ø 除了Transformable和Binary Data类型以外,Default适用于所有属性类型。它被用来配置属性的默认值。
Ø Allows External Storage允许大尺寸的二进制数据可以保存在持久化存储文件的外部。当你保存如照片,音频或视频时,建议是选中该选项, 这样Core Data就会对大于1MB的数据保存在持久化存储文件的外部。
将新的属性名修改为orderDate。与实体名一样,属性的命名也有很多的要求和约定。属性名不能以大写字母开头,不能包含空格,一般都是单数。对于Boolean类型属性,你应该遵循Objective-C实例变量的约定,比如指定一个CustomerOrder对象是否已经发货,那么应该命名属性为shipped,而不是isShipped、hasShipped等。
注意:
一些关键字不能被用作属性名。在写本书时,苹果公司还没有公布这类关键字列表。但是Xcode将生成一个编译期警告。如果程序的行为古怪或者无法提取数据,那么可能属性命名发生了冲突。一个常犯的错误是使用description作为属性名,因为这和NSObject的同名方法相冲突。所以不要使用已经被NSObject或NSManagedObject方法使用的名称。
接下来改变orderDate的类型为Date。当你改变了类型,另外的输入域让你指定约束和初始值,如图3.9所示。
图3.9 改变orderDate属性的类型
Default文本域被用作指定属性的初始值,无论何时,当一个新的NSManagedObject类实例被创建,该默认值将被自动设置。对于一个NSDate属性,在自然语言字符基础上这个域被用作设置一个日期,所以你可以指定一个明确的日期,比如“January 9, 2007 10:30 PST”或者一个模糊的日期参考,比如“yesterday atlunchtime”(没错,这样可以!)。
不幸的是,在写本书时,任何你设置的默认值都会在编译时作解释。这对明确的日期是没问题的,但自然语言字符串的表现将不会如你期望那样。你可能认为设置初始值为“now”会让orderData自动被设置为客户订单被创建的时间,但是事实是将会被设置为项目编译时的时间。
注意:
如果你需要为新加入的NSManagedObject对象设置日期和时间,你可以在NSManagedObject子类的awakeFromInsert方法中设置,或者在插入一个对象后,手动设置日期。就像设置上一章标准模板项目中的timeStamp属性。
Core Data属性类型
Core Data属性的可用类型如图3.10所示。
图3.10 Core Data可用的属性类型
Undefined选项值是新创建的属性的默认类型;如果属性类型为undefined,项目将无法通过编译。
Integer 16/32/64只表示整数,没有小数点。所以如果10除以3,你将会得到3,而余数1会丢失。Integer 16/32/64之间唯一的区别是所表示的数值范围不同。因为Core Data使用符号数,所以起始范围是负数,而不是0。
Integer 16 数值范围:-32768~32767;
Integer 32 数值范围:-2147483648~2147483647;
Integer 64 数值范围:–9223372036854775808~9223372036854775807。
标准整型数的最大值和最小值可以在stdint.h中找到。在任何类文件中输入INT32_MAX,选中右击,然后选择Jump To Definition,你将看到许多最大值最小值定义。实体的属性的类型是Integer 16/32/64,当创建此实体对应的NSManagedObject子类时,属性最终的类型将会是NSNumber。
Double和Float可以认为是有小数部分的整数。它们都是基于二进制数值系统,在CPU运算时很可能会发生舍入误差。比如1/5,如果使用十进制数值系统,可以精确表示为0.2.但在二进制数值系统中,只能表示一个大概,在小数部分你会得到大量数字。所以不要使用Integer、Double、Float表示货币值。计算精度越高则越加趋于准确值,但内存占用也会越大。一个Float数使用32bit进行存储,一个Double数使用64bit。它们都使用科学计数法进行存储,所以一个数包含尾数和指数部分。
在iOS中,最大的Float值是340282346638528859811704183484516925440.000000,最小的Float值是340282346638528859811704183484516925440.000000Double和Float都有一个符号位。而Double比Float的数值范围更大。
当你决定该选择Float还是Double时,想一下你的属性是否真的需要超过Float提供的7位精度,如果不是,你应该选择Float,因为它更加匹配64bit的iPhone 5S底层处理器。除此之外,如果你想增加浮点数的计算速度而精度并没有严格要求,Float也是最佳选择。实体的属性的类型是Float或Double,当创建此实体对应的NSManagedObject子类时,属性最终的类型将会是NSNumber。
Decimal(十进制)是处理货币值和其他需要十进制场合下最佳选择,Decimal提供了优秀的计算精度,也消除了计算过程中的舍入误差。因为CPU的本地数制是二进制,所以CPU在处理十进制数时,开销会多一点。实体的属性的类型是Decimal,当创建此实体对应的NSManagedObject子类时,属性最终的类型将会是NSDecimalNumber。 当你使用NSDecimalNumber执行计算时(如加减乘除计算),为了保证计算精度,你只能使用它提供的内建方法。更多关于NSDecimalNumber可参见这里。
String类型和Objective-C中的NSString类似,用于保存字符数组。当生成实体对应的NSManagedObject子类时,String属性被表示为NSString。
Boolean数据类型被用于表示YES/NO值。当生成实体对应的NSManagedObject子类时,Boolean数据类型会被表示为NSNumber。所以为了获取布尔值,你需要想NSNumber对象发送boolValue消息。
Date类型是自解释类型。用来存储日期和时间。当生成实体对应的NSManagedObject子类时,Date类型会被表示为NSDate。
Binary Data用来表示照片,音频,或一些BLOB类型数据。当生成实体对应的NSManagedObject子类时,Binary Data数据类型会被表示为NSData。
Transformable属性类型用于存储一个Objective-C对象。该属性类型允许你存储任何类的实例,比如你使用Transformable属性表示UIColor。当生成NSManagedObject子类时,Transformable类型会被表示为id。对于id对象的保存和解档需要使用一个NSValueTransformer的实例或子类的实例。由该类负责属性值与NSData之间的转换。但这也相当的简单,尤其是当属性值的类型已经实现了NSCoding协议,此时系统会自动提供一个默认的NSValueTransformer实例来完成归档和解档。
创建另外的实体
为了在下一节中创建关系,还需创建一个OrderItem实体,实体包含以下属性:
Ø 属性名purchasePrice,类型Decimal,默认值为1.0;
Ø 属性名quantity,类型Integer16,默认值为1;
接下来再次新增一个Product的新实体,包含以下属性:
Ø 属性名name,类型String;
Ø 属性名itemCost,类型Decimal,默认值为1.0;
在Graph编辑器模式下调整实体的位置,让它们看上去如图3.11那样。
图3.11 定义了多个实体的数据模型
创建关系
现在你已经定义了多个实体,是时候在它们之间加入关系了。
在Xcode中,使用Table编辑器风格创建关系是最简单的。选中某一个实体,点击Relationships列表底栏的+号。通过在CustomerOrder实体上创建一个新的关系开始。针对关系设置的检视器面板如图3.12所示。
图3.12 数据模型设计器中的关系检视器面板
在Name文本域中将关系命名为orderItems。对关系的命名约定和属性的命名约定相似,但是对于一个一对多或多对多的关系,关系的命名应该为复数。一般来说,好的命名可以表明相关联的实体名。在本例中,相关联的实体是OrderItem。
使用Destination下拉框设置关系指向的实体名,这里是OrderItem作为Destination值。Inverse下拉框用于设置一个定义在Destination上且回指当前CustomerOrder的关系名。以CustomerOrder的角度来看,Destination是从当前实体指出去,Inverse表示从其他实体指回来。因为你现在还没有在OrderItem实体上定义任何关系,你现在可以跳过这一步。
一个CustomerOrder有许多的orderItems。所以选中To-Many Relationship。这将会启用Minimum和Maximum Count文本域,它被用来设置关系上的约束,比如一个客户订单必须有一个项目,但不超过50个。因为没有对orderItems关系的约束,所以保留文本域的空白状态。
检视器中最后的下拉框被用来设置Delete Rule;当对象被删除,该项设置发生在所有相关的被管理对象上。在第4章“基本的数据存储与提取”会对这些选项值做更多的讨论。
接下来选择OrderItem实体,并创建一个叫做customerOrder关系。设置它的目标为CustomerOrder实体。因为任何一个订单项目仅属于一个客户订单,所以它不是to-many关系;所以关系名应是单数,并且To-Many Relationship复选框依然保留未选中状态。
如果使用Graph编辑器风格显示模型,你将发现两个单独的箭头连接着CustomerOrder和OrderItem实体,如图3.13所示。
图3.13 数据模型设计器中实体间的关系
注意到自CustomerOrder到OrderItem实体的关系是一个双向箭头,这表明这是to-many关系。
现在你已经定义了两个关系,你可以指定一个关系是另一个的反转。点击customerOrder关系,在Inverse下拉框中选择相对关系orderItems。此时两个带箭头的线段就变成了一个带箭头的线段,线段的两头都有箭头。
指定反转关系意味着当你在关系的一端设置了一个值,Core Data将处理相对的另一端。当你添加了一个订单项目到客户订单中(比如,新增了一个orderItem到orderItems关系),Core Data也会自动设置OrderItem的customerOrder关系。
创建余下的关系
现在你已经知道如何在实体之间设置关系,为了完成我们的数据模型,在实体之间创建如图3.14的关系。
图3.14 数据模型设计器中正确设置在实体之间包含关系的数据模型
注意
Graph编辑器风格是为了在查看实体和关系时更直观。工整的摆放实体还是将实体凌乱的堆在一起,实际上并没有什么区别。
小结
你现在应该已经对Xcode中的数据模型设计器有了一个完整的认识。知道了如何创建实体和属性,以及如何在实体间定义双向关系。
你也已经了解了传统的规范化原则并不总是适用于Core Data的模型;你经常因为性能和内存的原因需要非规范化你的数据模型。
在下一章,你将通过为iOS app创建新的被管理对象模型,把对Core Data模型的知识转变为实践。你将了解到如何使用Core Data框架创建新的NSManagedObject对象,并保存它们到持久化存储文件。接着你将学习如何把它们提取出来,并显示在表格视图中。