版本记录
版本号 | 时间 |
---|---|
V1.0 | 2018.09.26 星期三 |
前言
数据是移动端的重点关注对象,其中有一条就是数据存储。CoreData是苹果出的数据存储和持久化技术,面向对象进行数据相关存储。感兴趣的可以看下面几篇文章。
1. iOS CoreData(一)
2. iOS CoreData实现数据存储(二)
3. Core Data详细解析(三) —— 一个简单的入门示例(一)
4. Core Data详细解析(四) —— 一个简单的入门示例(二)
开始
首先看一下写作环境
Swift 4.2, iOS 12, Xcode 10
managed object context
是用于处理managed objects
的内存中暂存器。大多数应用只需要一个托managed object context
。大多数Core Data应用程序中的默认配置是与主队列关联的单个managed object context
。多个managed object context
使您的应用程序更难调试;在任何情况下,它都不是你在每个应用程序中使用的东西。
话虽如此,某些情况确实需要使用多个managed object context
。例如,长时间运行的任务(例如导出数据(exporting data)
)将阻塞仅使用单个主队列managed object context
的App的主线程并导致UI迟钝。
在其他情况下,例如在对用户数据进行编辑时,将managed object context
视为一组更改是有好处的,如果应用程序不再需要它们,则可以将其丢弃。使用子上下文使这成为可能。
在本教程中,您将通过为冲浪者提供日记应用程序并通过添加多个上下文以多种方式改进它来了解多个managed object context
。
本教程的入门项目是一个简单的日记应用程序,适合冲浪者。 在每次冲浪活动之后,冲浪者可以使用该应用程序创建一个记录海洋参数的新日记帐分录,例如膨胀高度或周期,并将活动评分为1到5。
Introducing SurfJournal - 引入SurfJournal
新建立SurfJournal
入门项目。 打开项目,然后构建并运行应用程序。
启动时,应用程序会列出所有以前的冲浪会话日记帐分录。 点击一行可以显示具有编辑功能的冲浪会话的详细视图。
如您所见,示例应用程序可以运行并具有数据。 点击左上角的Export
按钮可将数据导出为逗号分隔值(CSV)
文件。 点击右上角的加号(+)按钮可添加新的日记帐分录。 点击列表中的一行将以编辑模式打开条目,您可以在其中更改或查看冲浪会话的详细信息。
尽管示例项目看起来很简单,但它实际上做了很多工作,并且可以作为添加多上下文支持的良好基础。 首先,让我们确保您对项目中的各个类有很好的理解。
打开项目导航器,查看项目中的完整文件列表:
在进入代码之前,请花一点时间来了解每个类的内容。
-
AppDelegate:首次启动时,app委托创建
Core Data
堆栈并在主视图控制器JournalListViewController
上设置coreDataStack
属性。 -
CoreDataStack:此对象包含称为
stack
的Core Data
对象的的主要部分。 这次堆栈会在首次启动时安装已包含数据的数据库。 暂时不用担心,你会很快看到它是如何运作的。 -
JournalListViewController:示例项目是一个基于表的单页应用程序。 该文件代表该表。 如果您对其UI元素感到好奇,请转到
Main.storyboard
。 有一个嵌入在导航控制器中的table view
控制器和一个SurfEntryTableViewCell
类型的单个原型单元。 -
JournalEntryViewController:此类处理创建和编辑冲浪日记条目。 您可以在
Main.storyboard
中查看其UI。 -
JournalEntry:此类表示冲浪日记条目。 它是一个
NSManagedObject
子类,具有六个属性属性:date, height, location, period, rating and wind
。 如果您对此类的实体定义感到好奇,请查看SurfJournalModel.xcdatamodel
。
-
JournalEntry + Helper:这是
JournalEntry
对象的扩展。 它包括CSV导出方法csv()
和stringForDate()
辅助方法。 这些方法在扩展中实现,以避免在更改Core Data模型时被销毁。
首次启动应用时,已经有大量数据。 此示例项目附带了seeded Core Data
数据库。
The Core Data Stack - Core Data堆栈
打开CoreDataStack.swift
并在seedCoreDataContainerIfFirstLaunch():
中找到以下代码:
// 1
let previouslyLaunched =
UserDefaults.standard.bool(forKey: "previouslyLaunched")
if !previouslyLaunched {
UserDefaults.standard.set(true, forKey: "previouslyLaunched")
// Default directory where the CoreDataStack will store its files
let directory = NSPersistentContainer.defaultDirectoryURL()
let url = directory.appendingPathComponent(
modelName + ".sqlite")
// 2: Copying the SQLite file
let seededDatabaseURL = Bundle.main.url(
forResource: modelName,
withExtension: "sqlite")!
_ = try? FileManager.default.removeItem(at: url)
do {
try FileManager.default.copyItem(at: seededDatabaseURL,
to: url)
} catch let nserror as NSError {
fatalError("Error: \(nserror.localizedDescription)")
}
如您所见,本教程的CoreDataStack.swift
版本略有不同:
1) 首先检查
UserDefaults
以获取先前已启动的布尔值。 如果当前执行确实是应用程序的首次启动,则Bool将为false,使if语句为true。 在首次启动时,您要做的第一件事就是将之前的启动设置为true,以便seeding
操作再也不会发生。2) 然后,将应用程序包中包含的
SQLite
种子seed
文件SurfJournalModel.sqlite
复制到Core Data提供的方法NSPersistentContainer.defaultDirectoryURL()
返回的目录中。
现在查看seedCoreDataContainerIfFirstLaunch()
的其余部分:
// 3: Copying the SHM file
let seededSHMURL = Bundle.main.url(forResource: modelName,
withExtension: "sqlite-shm")!
let shmURL = directory.appendingPathComponent(
modelName + ".sqlite-shm")
_ = try? FileManager.default.removeItem(at: shmURL)
do {
try FileManager.default.copyItem(at: seededSHMURL,
to: shmURL)
} catch let nserror as NSError {
fatalError("Error: \(nserror.localizedDescription)")
}
// 4: Copying the WAL file
let seededWALURL = Bundle.main.url(forResource: modelName,
withExtension: "sqlite-wal")!
let walURL = directory.appendingPathComponent(
modelName + ".sqlite-wal")
_ = try? FileManager.default.removeItem(at: walURL)
do {
try FileManager.default.copyItem(at: seededWALURL,
to: walURL)
} catch let nserror as NSError {
fatalError("Error: \(nserror.localizedDescription)")
}
print("Seeded Core Data")
}
3) 一旦
SurfJournalModel.sqlite
的副本成功,您就可以复制支持文件SurfJournalModel.sqlite-shm
。4) 最后,复制剩余的支持文件
SurfJournalModel.sqlite-wal
。
SurfJournalModel.sqlite
,SurfJournalModel.sqlite-shm
或SurfJournalModel.sqlite-wal
在首次启动时无法复制的唯一原因是,如果发生了一些非常糟糕的事情,例如宇宙辐射造成的磁盘损坏。 在这种情况下,设备(包括任何应用程序)可能也会失败。 如果文件无法复制,则继续没有意义,因此catch
块会调用fatalError
。
开发人员经常对使用
abort
和fatalError
感到不满,因为它会导致应用程序突然退出并且没有解释而使用户感到困惑。 这是fatalError
可接受的一种情况,因为应用程序需要Core Data才能工作。 如果一个应用程序需要Core Data,但是Core Data不起作用,那么让应用程序继续运行是没有意义的,只会在以后的某个时间以非确定的方式失败。调用fatalError
至少会生成堆栈跟踪,这在尝试解决问题时很有用。 如果您的应用程序支持远程日志记录或崩溃报告,则应在调用fatalError
之前记录可能对调试有帮助的任何相关信息。
为了支持并发读取和写入,此示例应用程序中的持久性SQLite存储使用SHM(共享内存文件)和WAL(预写日志记录)文件。 您不需要知道这些额外文件的工作方式,但您确实需要知道它们的存在,并且需要在播种数据库时复制它们。 如果您无法复制这些文件,该应用程序将起作用,但可能会丢失数据。
现在您已经了解了从seeded
数据库开始的事情,您将通过处理临时私有上下文来了解多个managed object contexts
。
Doing Work in the Background - 在后台工作
如果尚未执行此操作,请点击左上角的Export
按钮,然后立即尝试滚动会话日记条目列表。 注意什么? 导出操作需要几秒钟,并且它会阻止UI响应滚动等触摸事件。
在导出操作期间阻塞了UI,因为导出操作和UI都使用主队列来执行其工作。 这是默认行为。
解决此问题的传统方法是使用Grand Central Dispatch
在后台队列上运行导出操作。 但是,Core Data的managed object contexts
不是线程安全的。 这意味着您不能只调度到后台队列并使用相同的Core Data堆栈。
解决方案很简单:使用私有后台队列而不是主队列进行导出操作。 这将使主队列免费供UI使用。
但在您进入并解决问题之前,您需要了解导出操作的工作原理。
1. Exporting Data - 导出数据
首先查看应用程序如何为JournalEntry
实体创建CSV
字符串。 打开JournalEntry + Helper.swift
并找到csv()
:
func csv() -> String {
let coalescedHeight = height ?? ""
let coalescedPeriod = period ?? ""
let coalescedWind = wind ?? ""
let coalescedLocation = location ?? ""
let coalescedRating: String
if let rating = rating?.int32Value {
coalescedRating = String(rating)
} else {
coalescedRating = ""
}
return "\(stringForDate()),\(coalescedHeight),\(coalescedPeriod),\(coalescedWind),\(coalescedLocation),\(coalescedRating)\n"
}
如您所见,JournalEntry
返回实体属性的逗号分隔字符串。 因为允许JournalEntry属性为nil,所以该函数使用nil coalescing
运算符(??)来导出空字符串,而不是属性为nil
的无用调试消息。
注意:nil合并运算符
(??)
如果包含值,则进行解包该值,否则返回默认值。 例如,以下内容:let coalescedHeight = height!= nil? height! :“”
可以使用nil合并运算符缩短:let coalescedHeight = height ??“”
。
这就是应用程序为单个日记帐分录创建CSV
字符串的方式,但应用程序如何将CSV文件保存到磁盘? 打开JournalListViewController.swift
并在exportCSVFile()
中找到以下代码:
// 1
let context = coreDataStack.mainContext
var results: [JournalEntry] = []
do {
results = try context.fetch(self.surfJournalFetchRequest())
} catch let error as NSError {
print("ERROR: \(error.localizedDescription)")
}
// 2
let exportFilePath = NSTemporaryDirectory() + "export.csv"
let exportFileURL = URL(fileURLWithPath: exportFilePath)
FileManager.default.createFile(atPath: exportFilePath,
contents: Data(), attributes: nil)
下面就逐步看一下CSV
导出代码:
- 1) 首先,通过执行
fetch
请求来检索所有JournalEntry
实体。
获取请求与获取的结果控制器使用的请求相同。 因此,您重复使用surfJournalFetchRequest
方法来创建请求以避免重复。
- 2) 接下来,通过将文件名
(“export.csv”)
附加到NSTemporaryDirectory
方法的输出,为导出的CSV文件创建URL。
NSTemporaryDirectory
返回的路径是临时文件存储的唯一目录。 这是一个很容易再次生成并且不需要由iTunes
或iCloud
备份的文件的好地方。
创建导出URL后,调用createFile(atPath:contents:attributes :)
创建一个空文件,您将在其中存储导出的数据。 如果文件已存在于指定的文件路径中,则此方法将首先将其删除。
一旦应用程序具有空文件,它就可以将CSV
数据写入磁盘:
// 3
let fileHandle: FileHandle?
do {
fileHandle = try FileHandle(forWritingTo: exportFileURL)
} catch let error as NSError {
print("ERROR: \(error.localizedDescription)")
fileHandle = nil
}
if let fileHandle = fileHandle {
// 4
for journalEntry in results {
fileHandle.seekToEndOfFile()
guard let csvData = journalEntry
.csv()
.data(using: .utf8, allowLossyConversion: false) else {
continue
}
fileHandle.write(csvData)
}
// 5
fileHandle.closeFile()
print("Export Path: \(exportFilePath)")
self.navigationItem.leftBarButtonItem =
self.exportBarButtonItem()
self.showExportFinishedAlertView(exportFilePath)
} else {
self.navigationItem.leftBarButtonItem =
self.exportBarButtonItem()
}
以下是文件处理的工作原理:
3) 首先,应用程序需要创建一个用于写入的文件处理程序,它只是一个处理写入数据所需的低级磁盘操作的对象。 要创建用于写入的文件处理程序,请使用
FileHandle(forWritingTo :)
初始化程序。4) 接下来,迭代所有
JournalEntry
实体。
在每次迭代期间,您尝试使用JournalEntry
上的csv()
和String
上的data(using:allowLossyConversion:)
创建UTF8编码的字符串。
如果成功,则使用文件处理程序write()
方法将UTF8字符串写入磁盘。
- 5) 最后,关闭导出文件写入文件处理程序,因为不再需要它。
应用程序将所有数据写入磁盘后,它会显示一个包含导出文件路径的警告对话框。
注意:具有导出路径的此alert控制器可用于学习目的,但对于真实应用程序,您需要为用户提供检索导出的CSV文件的方法,例如使用
UIActivityViewController
。
要打开导出的CSV
文件,请使用Excel
,Numbers
或您喜欢的文本编辑器导航到并打开alert
对话框中指定的文件。 如果您在Numbers
中打开文件,您将看到以下内容:
现在您已经了解了应用程序当前如何导出数据,现在是时候进行一些改进了。
2. Exporting in the Background - 在后台导出
您希望UI在导出过程中继续工作。 要修复UI问题,您将在私有后台上下文而不是主上下文上执行导出操作。
打开JournalListViewController.swift
并在exportCSVFile()
中找到以下代码:
// 1
let context = coreDataStack.mainContext
var results: [JournalEntry] = []
do {
results = try context.fetch(self.surfJournalFetchRequest())
} catch let error as NSError {
print("ERROR: \(error.localizedDescription)")
}
如前所述,此代码通过在managed object context
中调用fetch()
来检索所有日记条目。
接下来,使用以下代码替换上面的代码:
// 1
coreDataStack.storeContainer.performBackgroundTask { context in
var results: [JournalEntry] = []
do {
results = try context.fetch(self.surfJournalFetchRequest())
} catch let error as NSError {
print("ERROR: \(error.localizedDescription)")
}
您现在在堆栈的持久存储容器上调用performBackgroundTask(_ :)
,而不是使用UI也使用的主managed object context
。 这将创建一个新的managed object context
并将其传递给闭包。
performBackgroundTask(_ :)
创建的上下文位于私有队列上,该队列不会阻塞主UI队列。 闭包中的代码在该专用队列上运行。 您还可以手动创建一个新的临时私有上下文,其并发类型为.privateQueueConcurrencyType
,而不是使用performBackgroundTask(_ :)
。
注意:
managed object context
可以使用两种并发类型:
Private Queue指定将与专用调度队列而不是主队列关联的上下文。 这是您刚刚用于将导出操作移出主队列的队列类型,因此它不再干扰UI。
Main Queue是默认类型,指定上下文将与主队列关联。 此类型是主上下文(coreDataStack.mainContext)
使用的类型。 任何UI操作(例如为表视图创建fetched
的结果控制器)都必须使用此类型的上下文。
只能从正确的队列访问上下文及其managed objects
。NSManagedObjectContext
执行perform(_:)
和performAndWait(_ :)
以将工作定向到正确的队列。 您可以将启动参数-com.apple.CoreData.ConcurrencyDebug 1
添加到应用程序的scheme
中,以捕获调试器中的错误。
接下来,在同一方法中找到以下代码:
print("Export Path: \(exportFilePath)")
self.navigationItem.leftBarButtonItem =
self.exportBarButtonItem()
self.showExportFinishedAlertView(exportFilePath)
} else {
self.navigationItem.leftBarButtonItem =
self.exportBarButtonItem()
}
用以下代码进行替换:
print("Export Path: \(exportFilePath)")
// 6
DispatchQueue.main.async {
self.navigationItem.leftBarButtonItem =
self.exportBarButtonItem()
self.showExportFinishedAlertView(exportFilePath)
}
} else {
DispatchQueue.main.async {
self.navigationItem.leftBarButtonItem =
self.exportBarButtonItem()
}
}
} // 7 Closing brace for performBackgroundTask
要完成任务:
- 6) 您应始终执行与主队列上的UI相关的所有操作,例如在导出操作完成时显示警报视图; 否则,可能发生不可预测的事情。 使用DispatchQueue.main.async在主队列上显示最终的警报视图消息。
- 7) 最后,添加一个结束大括号,通过
performBackgroundTask(_ :)
调用关闭您在步骤1中先前打开的块。
现在您已将导出操作移动到具有专用队列的新上下文,构建并运行以查看它是否有效!
您应该看到之前看到的确切内容:
点击左上角的Export
按钮,立即尝试滚动浏览会话日记条目列表。 注意这次有什么不同吗? 导出操作仍需要几秒钟才能完成,但table view
在此期间继续滚动。 导出操作不再阻塞UI。
您刚刚目睹了如何在私有后台队列上工作可以改善用户的应用体验。 现在,您将通过检查子上下文来扩展多个上下文的使用。
后记
本篇主要讲述了基于多上下文的Core Data简单解析示例,感兴趣的给个赞或者关注~~~