Core Data详细解析(五) —— 基于多上下文的Core Data简单解析示例(一)

版本记录

版本号 时间
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:此对象包含称为stackCore 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.sqliteSurfJournalModel.sqlite-shmSurfJournalModel.sqlite-wal在首次启动时无法复制的唯一原因是,如果发生了一些非常糟糕的事情,例如宇宙辐射造成的磁盘损坏。 在这种情况下,设备(包括任何应用程序)可能也会失败。 如果文件无法复制,则继续没有意义,因此catch块会调用fatalError

开发人员经常对使用abortfatalError感到不满,因为它会导致应用程序突然退出并且没有解释而使用户感到困惑。 这是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返回的路径是临时文件存储的唯一目录。 这是一个很容易再次生成并且不需要由iTunesiCloud备份的文件的好地方。

创建导出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文件,请使用ExcelNumbers或您喜欢的文本编辑器导航到并打开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 objectsNSManagedObjectContext执行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简单解析示例,感兴趣的给个赞或者关注~~~

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,013评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,205评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,370评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,168评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,153评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,954评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,271评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,916评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,382评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,877评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,989评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,624评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,209评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,199评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,418评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,401评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,700评论 2 345

推荐阅读更多精彩内容