最近因为新项目想用到数据持久化,本来这是很简单的事情,复杂数据一般直接SQLite
就可以解决了。
但是一直以来使用SQLite
确实存在要自己设计数据库,处理逻辑编码,还有调试方面的种种繁琐问题。所以考虑使用iOS的Core Data方案。
上网查了一堆资料后,发现很多代码都已经是陈旧的了。甚至苹果官方文档提供的代码样例都未必是最新的Swift
版本。于是萌生了自己写一篇文章来整理一遍思路的想法。尽可能让新人快速的上手,不但要知道其然,还要知道其设计的所以然,这样用起来才更得心应手。
我们写app肯定要用到数据持久化,说白了,就是把数据保存起来,app不删除的话可以继续读写。
iOS提供数据持久化的方案有很多,各自有其特定用途。
比如很多人熟知的UserDefaults
,大部分时候是用来保存简单的应用配置信息;而NSKeyedArchiver
可以把代码中的对象保存为文件,方便后来重新读取。
另外还有个常用的保存方式就是自己创建文件,直接在磁盘文件中进行读写。
而对于稍微复杂的业务数据,比如收藏夹,用户填写的多项表格等,SQLite
就是更合适的方案了。关于数据库的知识,我这里就不赘述了,稍微有点技术基础的童鞋都懂。
Core Data
比SQLite
做了更进一步的封装,SQLite
提供了数据的存储模型,并提供了一系列API,你可以通过API读写数据库,去处理想要处理的数据。但是SQLite
存储的数据和你编写代码中的数据(比如一个类的对象)并没有内置的联系,必须你自己编写代码去一一对应。
而Core Data
却可以解决一个数据在持久化层和代码层的一一对应关系。也就是说,你处理一个对象的数据后,通过保存接口,它可以自动同步到持久化层里,而不需要你去实现额外的代码。
这种 对象→持久化 方案叫 对象→关系映射(英文简称ORM
)。
除了这个最重要的特性,Core Data
还提供了很多有用的特性,比如回滚机制,数据校验等。
图1: Core Data与应用,磁盘存储的关系
当我们用Core Data
时,我们需要一个用来存放数据模型的地方,数据模型文件就是我们要创建的文件类型。它的后缀是.xcdatamodeld
。只要在项目中选 新建文件→Data Model 即可创建。
默认系统提供的命名为 Model.xcdatamodeld
。下面我依然以 Model.xcdatamodeld
作为举例的文件名。
这个文件就相当于数据库中的“库”。通过编辑这个文件,就可以去添加定义自己想要处理的数据类型。
当在xcode中点击Model.xcdatamodeld
时,会看到苹果提供的编辑视图,其中有个醒目的按钮Add Entity
。
什么是Entity
呢?中文翻译叫“实体”,但是我这里就不打算用各种翻译名词来提高理解难度了。
如果把数据模型文件比作数据库中的“库”,那么Entity
就相当于库里的“表格”。这么理解就简单了。Entity
就是让你定义数据表格类型的名词。
假设我这个数据模型是用来存放图书馆信息的,那么很自然的,我会想建立一个叫Book
的Entity
。
当建立一个名为Book
的Entity
时,会看到视图中有栏写着Attributes
,我们知道,当我们定义一本书时,自然要定义书名,书的编码等信息。这部分信息叫Attributes
,即书的属性。
Book的Entity
:
属性名 | 类型 |
---|---|
name | String |
isbm | String |
page | Integer32 |
其中,类型部分大部分是大家熟知的元数据类型,可以自行查阅。
同理,也可以再添加一个读者:Reader的Entity
描述。
Reader的Entity
:
属性名 | 类型 |
---|---|
name | String |
idCard | String |
图2: 在项目中创建数据模型文件
在我们使用Entity
编辑时,除了看到了Attributes
一栏,还看到下面有Relationships
一栏,这栏是做什么的?
回到例子中来,当定义图书馆信息时,刚书籍和读者的信息,但这两个信息彼此是孤立的,而事实上他们存在着联系。
比如一本书,它被某个读者借走了,这样的数据该怎么存储?
直观的做法是再定义一张表格来处理这类关系。但是Core Data
提供了更有效的办法 - Relationship
。
从Relationship
的思路来思考,当一本书A被某个读者B借走,我们可以理解为这本书A当前的“借阅者”是该读者B,而读者B的“持有书”是A。
从以上描述可以看出,Relationship
所描述的关系是双向的,即A和B互相以某种方式形成了联系,而这个方式是我们来定义的。
在Reader
的Relationship
下点击+
号键。然后在Relationship
栏的名字上填borrow
,表示读者和书的关系是“借阅”,在Destination
栏选择Book
,这样,读者和书籍的关系就确立了。
对于第三栏,Inverse
,却没有东西可以填,这是为什么?
因为我们现在定义了读者和书的关系,却没有定义书和读者的关系。记住,关系是双向的。
就好比你定义了A是B的父亲,那也要同时去定义B是A的儿子一个道理。计算机不会帮我们打理另一边的联系。
理解了这点,我们开始选择Book
的一栏,在Relationship
下添加新的borrowBy
,Destination
是Reader
,这时候点击Inverse
一栏,会发现弹出了borrow
,直接点上。
这是因为我们在定义Book
的Relationship
之前,我们已经定义了Reader
的Relationship
了,所以电脑已经知道了读者和书籍的关系,可以直接选上。而一旦选好了,那么在Reader
的Relationship
中,我们会发现Inverse
一栏会自动补齐为borrowBy
。因为电脑这时候已经完全理解了双方的关系,自动做了补齐。
我们建立Reader
和Book
之间的联系的时候,发现他们的联系逻辑之间还漏了一个环节。
假设一本书被一个读者借走了,它就不能被另一个读者借走,而当一个读者借书时,却可以借很多本书。
也就是说,一本书只能对应一个读者,而一个读者却可以对应多本书。
这就是 一对一→to one
和 一对多→to many
。
Core Data
允许我们配置这种联系,具体做法就是在RelationShip
栏点击对应的关系栏,它将会出现在右侧的栏目中。(栏目如果没出现可以在xcode
右上角的按钮调出,如果点击后栏目没出现Relationship
配置项,可以多点击几下,这是xcode
的小bug)。
在Relationship
的配置项里,有一项项名为Type
,点击后有两个选项,一个是To One
(默认值),另一个就是To Many
了。
图3: 数据模型的关系配置
当我们配置完Core Data
的数据类型信息后,我们并没有产生任何数据,就好比图书馆已经制定了图书的规范 - 一本书应该有名字、isbm、页数等信息,规范虽然制定了,却没有真的引进书进来。
那么怎么才能产生和处理数据呢,这就需要通过代码真刀真枪的和Core Data
打交道了。
由于Core Data
的功能较为强大,必须分成多个类来处理各种逻辑,一次性学习多个类是不容易的,还容易混淆,所以后续我会分别一一列出。要和这些各司其职的类打交道,我们不得不提第一个要介绍的类,叫NSPersistentContainer
,因为它就是存放这多个类成员的“仓库类”。
这个NSPersistentContainer
,就是我们通过代码和Core Data
打交道的第一个目标。它存放着几种让我们和Core Data
进行业务处理的工具,当我们拿到这些工具之后,就可以自由的访问数据了。所以它的名字 - Container
蕴含着的意思,就是 仓库、容器、集装箱。
进入正式的代码编写的第一步,我们先要在使用Core Data
框架的swift
文件开头引入这个框架:
import CoreData
❝早期,在iOS 10之前,还没有
NSPersistentContainer
这个类,所以Core Data
提供的几种各司其职的工具,我们都要写代码一一获得,写出来的代码较为繁琐,所以NSPersistentContainer
并不是一开始就有的,而是苹果框架设计者逐步优化出来的较优设计。
图4: NSPersistentContainer和其他成员的关系
在新建的UIKIT
项目中,找到我们的AppDelegate
类,写一个成员函数(即方法,后面我直接用函数这个术语替代):
private func createPersistentContainer() {
let container = NSPersistentContainer(name: "Model")
}
这样,NSPersistentContainer
类的建立就完成了,其中"Model"字符串就是我们建立的Model.xcdatamodeld
文件。但是输入参数的时候,我们不需要(也不应该)输入.xcdatamodeld
后缀。
当我们创建了NSPersistentContainer
对象时,仅仅完成了基础的初始化,而对于一些性能开销较大的初始化,比如本地持久化资源的加载等,都还没有完成,我们必须调用NSPersistentContainer
的成员函数loadPersistentStores
来完成它。
private func createPersistentContainer() {
let container = NSPersistentContainer(name: "Model")
container.loadPersistentStores { (description, error) in
if let error = error {
fatalError("Error: \(error)")
}
print("Load stores success")
}
}
❝从代码设计的角度看,为什么
NSPersistentContainer
不直接在构造函数里完成数据库的加载?这就涉及到一个面向对象的开发原则,即构造函数的初始化应该是(原则上)倾向于原子级别,即简单的、低开销内存操作,而对于性能开销大的,内存之外的存储空间处理(比如磁盘,网络),应尽量单独提供成员函数来完成。这样做是为了避免在构造函数中出错时错误难以捕捉的问题。
=
现在我们已经持有并成功初始化了Core Data
的仓库管理者NSPersistentContainer
了,接下去我们可以使用向这个管理者索取信息了,我们已经在模型文件里存放了读者和书籍这两个Entity
了,如何获取这两个Entity的信息?
这就需要用到NSPersistentContainer
的成员,即managedObjectModel
,该成员就是标题所说的NSManagedObjectModel
类型。
为了讲解NSManagedObjectModel
能提供什么,我通过以下函数来提供说明:
private func parseEntities(container: NSPersistentContainer) {
let entities = container.managedObjectModel.entities
print("Entity count = \(entities.count)\n")
for entity in entities {
print("Entity: \(entity.name!)")
for property in entity.properties {
print("Property: \(property.name)")
}
print("")
}
}
为了执行上面这个函数,需要修改createPersistentContainer
,在里面调用parseEntities
:
private func createPersistentContainer() {
let container = NSPersistentContainer(name: "Model")
container.loadPersistentStores { (description, error) in
if let error = error {
fatalError("Error: \(error)")
}
self.parseEntities(container: container)
}
}
在这个函数里,我们通过NSPersistentContainer
获得了NSManagedObjectModel
类型的成员managedObjectModel
,并通过它获得了文件Model.xcdatamodeld
中我们配置好的Entity
信息,即图书和读者。
由于我们配置了两个Entity
信息,所以运行正确的话,打印出来的第一行应该是Entity count = 2
。
container
的成员managedObjectModel
有一个成员叫entities
,它是一个数组,这个数组成员的类型叫NSEntityDescription
,这个类名一看就知道是专门用来处理Entity
相关操作的,这里就没必要多赘述了。
示例代码里,获得了entity
数组后,打印entity
的数量,然后遍历数组,逐个获得entity
实例,接着遍历entity
实例的properties
数组,该数组成员是由类型NSPropertyDescription
的对象组成。
关于名词Property
,不得不单独说明下,学习一门技术最烦人的事情之一就是理解各种名词,毕竟不同技术之间名词往往不一定统一,所以要单独理解一下。
在Core Data
的术语环境下,一个Entity
由若干信息部分组成,之前已经提过的Entity
和Relationship
就是了。而这些信息用术语统称为property
。NSPropertyDescription
看名字就能知道,就是处理property
用的。
只要将这一些知识点梳理清楚了,接下去打印的内容就不难懂了:
Entity count = 2
Entity: Book
Property: isbm
Property: name
Property: page
Property: borrowedBy
Entity: Reader
Property: idCard
Property: name
Property: borrow
我们看到,打印出来我们配置的图书有4个property,最后一个是borrowedBy,明显这是个Relationship
,而前面三个都是Attribute
,这和我刚刚对property的说明是一致的。
开篇我们就讲过,Core Data
是一个 对象-关系映射 持久化方案,现在我们在Model.xcdatamodeld
已经建立了两个Entity
,那么如果在代码里要操作他们,是不是会有对应的类?
答案是确实如此,而且你还不需要自己去定义这个类。
如果你点击Model.xcdatamodeld
编辑窗口中的Book这个Entity
,打开右侧的属性面板,属性面板会给出允许你编辑的关于这个Entity
的信息,其中Entity
部分的Name
就是我们起的名字Book
,而下方还有一个Class
栏,这一栏就是跟Entity
绑定的类信息,栏目中的Name
就是我们要定义的类名,默认它和Entity
的名字相同,也就是说,类名也是Book
。所以改与不改,看个人思路以及团队的规范。
所有Entity
对应的类,都继承自NSManagedObject
。
为了检验这一点,我们可以在代码中编写这一行作为测试:
var book: Book! // 纯测验代码,无业务价值
如果写下这一行编译通过了,那说明开发环境已经给我们生成了Book
这个类,不然它就不可能编译通过。
测试结果,完美编译通过。说明不需要我们自己编写,就可以直接使用这个类了。
❝关于类名,官方教程里一般会把类名更改为
Entity名 + MO
,比如我们这个Entity
名为Book
,那么如果是按照官方教程的做法,可以在面板中编辑Class
的名字为BookMO
,这里MO
大概就是Model Object
的简称吧。但是我这里为简洁起见,就不做任何更改了,Entity
名为Book
,那么类名也一样为Book
。
❝另外,你也可以自己去定义
Entity
对应的类,这样有个好处是可以给类添加一些额外的功能支持,这部分Core Data
提供了编写的规范,但是大部分时候这个做法反而会增加代码量,不属于常规操作。
接下来我们要隆重介绍NSPersistentContainer
麾下的一名工作任务最繁重的大将,成员viewContext
,接下去我们和实际数据打交道,处理增删查改这四大操作,都要通过这个成员才能进行。
viewContext
成员的类型是NSManagedObjectContext
。
NSManagedObjectContext
,顾名思义,它的任务就是管理对象的上下文。从创建数据,对修改后数据的保存,删除数据,修改,五一不是以它为入口。
从介绍这个成员开始,我们就正式从 定义数据 的阶段,正式进入到 产生和操作数据 的阶段。
梳理完前面的知识,就可以正式踏入数据创建的学习了。这里,我们先尝试创建一本图书,用一个createBook
函数来进行。示例代码如下:
private func createBook(container: NSPersistentContainer,
name: String, isbm: String, pageCount: Int) {
let context = container.viewContext
let book = NSEntityDescription.insertNewObject(forEntityName: "Book",
into: context) as! Book
book.name = name
book.isbm = isbm
book.page = Int32(pageCount)
if context.hasChanges {
do {
try context.save()
print("Insert new book(\(name)) successful.")
} catch {
print("\(error)")
}
}
}
在这个代码里,最值得关注的部分就是NSEntityDescription
的静态成员函数insertNewObject
了,我们就是通过这个函数来进行所要插入数据的创建工作。
insertNewObject
对应的参数forEntityName
就是我们要输入的Entity
名,这个名字当然必须是我们之前创建好的Entity
有的名字才行,否则就出错了。因为我们要创建的是书,所以输入的名字就是Book
。
而into
参数就是我们的处理增删查改的大将NSManagedObjectContext
类型。
insertNewObject
返回的类型是NSManagedObject
,如前所述,这是所有Entity
对应类的父类。因为我们要创建的Entity
是Book
,我们已经知道对应的类名是Book
了,所以我们可以放心大胆的把它转换为Book
类型。
接下来我们就可以对Book
实例进行成员赋值,我们可以惊喜的发现Book
类的成员都是我们在Entity
表格中编辑好的,真是方便极了。那么问题来了,当我们把Book
编辑完成后,是不是这个数据就完成了持久化了,其实不是的。
这里要提一下Core Data
的设计理念:懒原则。Core Data
框架之下,任何原则操作都是内存级的操作,不会自动同步到磁盘或者其他媒介里,只有开发者主动发出存储命令,才会做出存储操作。这么做自然不是因为真的很懒,而是出于性能考虑。
为了真的把数据保存起来,首先我们通过context
(即NSManagedObjectContext
成员)的hasChanges
成员询问是否数据有改动,如果有改动,就执行context
的save
函数。(该函数是个会抛异常的函数,所以用do→catch
包裹起来)。
至此,添加书本的操作代码就写完了。接下来我们把它放到合适的地方运行。
我们对createPersistentContainer
稍作修改:
private func createPersistentContainer() {
let container = NSPersistentContainer(name: "Model")
container.loadPersistentStores { (description, error) in
if let error = error {
fatalError("Error: \(error)")
}
//self.parseEntities(container: container)
self.createBook(container: container,
name: "算法(第4版)",
isbm: "9787115293800",
pageCount: 636)
}
}
运行项目,会看到如下打印输出:
Insert new book(算法(第4版)) successful.
至此,书本的插入工作顺利完成!
❝因为这个示例没有去重判定,如果程序运行两次,那么将会插入两条书名都为"算法(第4版)"的
book
记录。
有了前面基础知识的铺垫,接下去的例子只要 记函数 就成了,读取的示例代码:
private func readBooks(container: NSPersistentContainer) {
let context = container.viewContext
let fetchBooks = NSFetchRequest<Book>(entityName: "Book")
do {
let books = try context.fetch(fetchBooks)
print("Books count = \(books.count)")
for book in books {
print("Book name = \(book.name!)")
}
} catch {
}
}
处理数据处理依然是我们的数据操作主力context
,而处理读取请求配置细节则是交给一个专门的类,NSFetchRequest
来完成,因为我们处理读取数据有各种各样的类型,所以Core Data
设计了一个泛型模式,你只要对NSFetchRequest
传入对应的类型,比如Book
,它就知道应该传回什么类型的对应数组,其结果是,我们可以通过Entity
名为Book
的请求直接拿到Book
类型的数组,真是很方便。
打印结果:
Books count = 1
Book name = 算法(第4版)
通过NSFetchRequest
我们可以获取所有的数据,但是我们很多时候需要的是获得我们想要的特定的数据,通过条件筛选功能,可以实现获取出我们想要的数据,这时候需要用到NSFetchRequest
的成员predicate
来完成筛选,如下所示,我们要找书名叫 算法(第4版) 的书。
在新的代码示例里,我们在之前实现的readBooks
函数代码里略作修改:
private func readBooks(container: NSPersistentContainer) {
let context = container.viewContext
let fetchBooks = NSFetchRequest<Book>(entityName: "Book")
fetchBooks.predicate = NSPredicate(format: "name = \"算法(第4版)\"")
do {
let books = try context.fetch(fetchBooks)
print("Books count = \(books.count)")
for book in books {
print("Book name = \(book.name!)")
}
} catch {
print("\(error)")
}
}
通过代码:
fetchBooks.predicate = NSPredicate(format: "name = \"算法(第4版)\"")
我们从书籍中筛选出书名为 算法(第4版) 的书,因为我们之前已经保存过这本书,所以可以正确筛选出来。
筛选方案还支持大小对比,如
fetchBooks.predicate = NSPredicate(format: "page > 100")
这样将筛选出page数量大于100的书籍。
当我们要修改数据时,比如说我们要把 isbm = "9787115293800"
这本书书名修改为 算法(第5版) ,可以按照如下代码示例:
let context = container.viewContext
let fetchBooks = NSFetchRequest<Book>(entityName: "Book")
fetchBooks.predicate = NSPredicate(format: "isbm = \"9787115293800\"")
do {
let books = try context.fetch(fetchBooks)
if !books.isEmpty {
books[0].name = "算法(第5版)"
if context.hasChanges {
try context.save()
print("Update success.")
}
}
} catch {
print("\(error)")
}
在这个例子里,我们遵循了 读取→修改→保存 的思路,先拿到筛选的书本,然后修改书本的名字,当名字被修改后,context
将会知道数据被修改了,这时候判断数据是否被修改(实际上不需要判断我们也知道被修改了,只是出于编码规范加入了这个判断),如果被修改,就保存数据,通过这个方式,成功更改了书名。
数据的删除依然遵循 读取→修改→保存 的思路,找到我们想要的思路,并且删除它。删除的方法是通过context
的delete
函数。
以下例子中,我们删除了所有 isbm="9787115293800"
的书籍:
let context = container.viewContext
let fetchBooks = NSFetchRequest<Book>(entityName: "Book")
fetchBooks.predicate = NSPredicate(format: "isbm = \"9787115293800\"")
do {
let books = try context.fetch(fetchBooks)
for book in books {
context.delete(books[0])
}
if context.hasChanges {
try context.save()
}
} catch {
print("\(error)")
}
如果跟我一步步走到这里,那么关于Core Data
的基础知识可以说已经掌握的差不多了。当然了,这部分基础对于日常开发已经基本够用了。
关于Core Data
开发的进阶部分,我在这里简单列举一下:
Relationship
部分的开发,事实上通过之前的知识可以独立完成。UndoManager
。Entity
的Fetched Property
属性。context
一起操作数据的冲突问题。以上诸个主题都可以自己进一步探索,不在这篇文章的讲解范围。不过后续不排除会单独出文探索。
Core Data
在圈内是比较出了名的“不好用”的框架,主要是因为其抽象的功能和机制较为不容易理解。本文已经以最大限度的努力试图从设计的角度去阐述该框架,希望对你有所帮助。
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8