iOS 10 by Tutorials 笔记(十一)

Chapter 11: What’s New with Core Data

以前使用 Core Data 时,总要写一堆繁琐的样板代码,导致大家学习 Core Data 也提不起什么兴趣,iOS 10 苹果对 Core Data 的使用体验做出了一些改进,节省了开发者很多时间,主要体现在以下两点:

  • 减少样板文件的输入,提供了新的便利方法自动生成
  • 更少地去明确类型

managed object contextpersistent store coordinator 也提供了一些有用的新特性,可能会影响你构建代码的方式。

本章我们构建一个马铃薯的评分 App --- TaterRater 运行下先来大概看一下效果:

大体结构如下,一个 split view controller 展示列表和详情页面,Model 目录下存在一个土豆的模型类 Potato.swift

An eye to new data models

既然本章的内容是 Core Data,那就先来创建一个 Data Model,命名为 TaterRater.xcdatamodeld

然后创建一个 Potato 实体,并添加如下属性

  • crowdRating(Float)
  • notes(String)
  • userRating(Integer 16)
  • variety(String)

现在就不需要 Potato.swift 文件了,删除它。选中 Potato 实体,在右侧的面板中可以看到在 Class 这一栏多了 Codegen 这一栏,可以用来决定代码的组织形式:

  • Manual / None:没有文件会被创建
  • Class Definition:一个完整定义的文件会被创建
  • Category / Extension:一个带 core data 属性声明的 extension 会被创建

此时我们选择 Class Definition

试着编译一下,编译没有问题(但是运行会崩溃)。虽然我们删除了 Potato.swift 文件,也没有创建新的 model 文件,为什么编译不会报错?这是因为 Xcdoe 自动替我们生成了 model 的子类,它放置于系统的 Derived Data 文件夹下(和工程相关的目录中),以后每当在模板 *.xcdatamodeld 上的修改都会自动映射到相关的模型类上,不再需要我们手动去重新生成模型类了。

你可以打开 AppDelegate.swift 找到 Potato 类型,手动 Command + 鼠标左键跳转到它定义的位置,查看系统提供的实现细节:

import Foundation  
import CoreData  
extension Potato {  
  @nonobjc public class func fetchRequest() -> NSFetchRequest<Potato> {
    return NSFetchRequest<Potato>(entityName: "Potato");
  }
  @NSManaged public var crowdRating: Float
  @NSManaged public var notes: String?
  @NSManaged public var userRating: Int16
  @NSManaged public var variety: String?
}

虽然会自动替你创建 model,但并不意味着你就万事大吉了,迁移数据模型的时候还是需要手动来管理

虽然编译能通过,但运行还是会崩溃,这是因为系统在尝试调用 Potato() 指定初始化时调用失败,managed objects 显然没有提供相关方法。

错误信息:Failed to call designated initializer on NSManagedObject class 'Potato'

A stack with a peel

用过 Core Data 的同学都知道,搭建 Core Data stack 是一件相当琐碎的事情,iOS 10 推出了新的 NSPersistentContainer 类封装好了那些单调而乏味的工作,并提供了一些便利方法和特性。

NSPersistentContainer 简化了创建和管理 Core Data stack 的过程,它会替我们创建 NSManagedObjectModel, NSPersistentStoreCoordinator, 以及 NSManagedObjectContext.

打开 AppDelegate.swift 导入 import CoreData,然后添加一个新属性:

var coreDataStack: NSPersistentContainer!  

并且在 application(_:didFinishLaunchingWithOptions:) 的开始,添加

coreDataStack = NSPersistentContainer(name: "TaterRater")  

这一步传入了之前我们创建的模型文件(TaterRater.xcdatamodeld)的名称,它会检索对应的模型,然后自动生成相应的 Core Data stack(这里是 NSPersistentContainer 类型),底层其实是创建了一个 persistent store coordinator。

其中一个有意思的新特性是:你可以让 persistent container 异步构建它的存储(store),如果有一个大数据或要做复杂迁移,使用异步不至于阻塞主线程。设置起来也非常简单:

coreDataStack.persistentStoreDescriptions.first?  
    .shouldAddStoreAsynchronously = true

但在本工程中还是保持默认的同步设置(不做额外设置),接下来我们来指示 persistent container 加载持久存储区,完成 Core Data stack 的最终创建

coreDataStack.loadPersistentStores {  
  description, error in
  if let error = error {
    print("Error creating persistent stores: \
(error.localizedDescription)")
    fatalError()
  }
}

上面的代码因为是同步加载,所以可能会需要时间,你需要在 UI 上做点等待动画。

最后将 let potato = Potato() 方法修改为使用 coreDataStack 来初始化并创建

let potato = Potato(context: coreDataStack.viewContext)  

这行代码体现出 Core Data 的两个新特性:

  • 你可以通过 context 来创建 NSManagedObjectContext 子类
  • persistent container 所保持的一个针对 NSManagedObjectContext 对象的引用(viewContext),该对象运行在主线程上,直接与 persistent store coordinator 相连

再次运行,不会崩溃了。不过现在我们的数据源还是数组,接下来改造为使用 fetched results controller

Frenched russet controllers

打开 PotatoTableViewController.swift,除了导入 CoreData 之外,添加两个新属性来持有 fetched results controllercontext

var resultsController: NSFetchedResultsController<Potato>!  
var context: NSManagedObjectContext!  

我们发现 fetched results controllers 现在可以带类型了(泛型),继续添加以下代码到 viewDidLoad() 中:

// 1 这里的 fetchRequest() 实现系统已经替你实现好了: 
//   NSFetchRequest<Potato>(entityName: "Potato");
let request: NSFetchRequest<Potato> = Potato.fetchRequest()  
// 2 这里使用了 `#keyPath` 语法糖,防止你输入错误
let descriptor = NSSortDescriptor(key: #keyPath(Potato.variety),  
ascending: true)  
// 3 将排序描述添加到 request 中
request.sortDescriptors = [descriptor]  
// 4 执行
resultsController = NSFetchedResultsController(fetchRequest: request,  
managedObjectContext: context, sectionNameKeyPath: nil, cacheName: nil)  
do {  
  try resultsController.performFetch()
} catch {
  print("Error performing fetch \(error.localizedDescription)")
}

最后将之前所有用数组做数据源的地方,替换为 resultsController 来实现

numberOfSections(in:) 下的

return resultsController.sections?.count ?? 0  

tableView(_: numberOfRowsInSection:)

return resultsController.sections?[section].numberOfObjects ?? 0  

configureCell(_: atIndexPath:)

let potato = resultsController.object(at: indexPath)  

别忘了 prepare(for: sender:) 方法

detail.potato = resultsController.object(at: path)  

最后你可以安全地删除 potatoes 属性数组了,此时还会报个错,切换到 AppDelegate.swift,替换报错行为如下代码:

potatoList.context = coreDataStack.viewContext  

现在 table 在主线程上使用和导入对象相同的 managed object context 了。运行一下,确保现在是由 results-controller 驱动的数据源了,potatoes 常数因为不再使用,会报一个小警告。

results controller 更多功能还是要依赖它的代理才能实现,代理的 API 因为 Swift 3.0 语法有些调整外,和以前相比没太大变化

extension PotatoTableViewController: NSFetchedResultsControllerDelegate {  
  func controllerWillChangeContent(_ controller:
NSFetchedResultsController<NSFetchRequestResult>) {  
    tableView.beginUpdates()
  }
  func controller(_ controller:
NSFetchedResultsController<NSFetchRequestResult>,  
    didChange anObject: Any, at indexPath: IndexPath?,
    for type: NSFetchedResultsChangeType,
    newIndexPath: IndexPath?) {
    switch type {
    case .delete:
      guard let indexPath = indexPath else { return }
      tableView.deleteRows(at: [indexPath], with: .automatic)
    case .insert:
      guard let newIndexPath = newIndexPath else { return }
      tableView.insertRows(at: [newIndexPath], with: .automatic)
    case .update:
      guard let indexPath = indexPath else { return }
      if let cell = tableView.cellForRow(at: indexPath) {
        configureCell(cell, atIndexPath: indexPath)
      }
    case .move:
      guard let indexPath = indexPath,
      let newIndexPath = newIndexPath else { return }
      tableView.deleteRows(at: [indexPath], with: .automatic)
      tableView.insertRows(at: [newIndexPath], with: .automatic)
    } 
  }
  func controllerDidChangeContent(_ controller:
NSFetchedResultsController<NSFetchRequestResult>) {  
    tableView.endUpdates()
  }
}

返回 viewDidLoad(),设置代理

resultsController.delegate = self  

再次运行,调整评分星级也可以正常工作了

不过当前每次应用启动都会从头创建土豆列表,Core Data 一个特性就是可以持久化,所以我们要将设置好的结果保存起来。为了演示另一个新特性,我们要把创建工作挪到到后台执行。

Digging in to the background

在 Model 组下面添加一个新文件 PotatoTasks.swift,代码如下:

import CoreData  
extension NSPersistentContainer {  
  func importPotatoes() {
    // 1
    performBackgroundTask { context in
      // 2
      let request: NSFetchRequest<Potato> = Potato.fetchRequest()
      do {
      // 3
        if try context.count(for: request) == 0 {
          // TODO: Import some spuds
        }
      } catch {
        print("Error importing potatoes: \(error.localizedDescription)")
      }
    } 
  }
}
  1. 新方法 performBackgroundTask(_:) 会在后台私有线程执行 block,该block带一个私有 NSManagedObjectContext 类型的 context,它仅限于该私有线程上。
  2. 生成 fetch request 的代码
  3. 关于 context 的另一个新方法 .count(for:)countForFetchRequest(_: error:) 抛出异常的版本

替换 TODO 的注释部分,模拟一个从服务器上加载数据的过程:

sleep(3)  
guard let spudsURL = Bundle.main.url(forResource: "Potatoes",  
withExtension: "txt") else { return }  
let spuds = try String(contentsOf: spudsURL)  
let spudList = spuds.components(separatedBy: .newlines)  
for spud in spudList {  
  let potato = Potato(context: context)
  potato.variety = spud
  potato.crowdRating = Float(arc4random_uniform(50)) / Float(10)
}
try context.save()  

回到 AppDelegate.swift,在 application(_: didFinishLaunchingWithOptions) 中找到 loadPersistentStores(_:) 方法,在其后面修改为如下代码:

coreDataStack.importPotatoes()  
if let split = window?.rootViewController as? UISplitViewController {  
  if
    let primaryNav = split.viewControllers.first as?
UINavigationController,  
    let potatoList = primaryNav.topViewController as?
PotatoTableViewController {  
      potatoList.context = coreDataStack.viewContext
  }
  split.delegate = self
  split.preferredDisplayMode = .allVisible

上面的代码把创建土豆的过程放到了后台执行,再次运行

咦?土豆在哪里?你可能会希望后台线程 context 保存后会过渡到主线程 context 来管理,通常我们会将后台 context 作为主线程 context 的孩子,但是 NSPersistentContainer 并没有替我们实现这一点。

如果你再次运行,会发现土豆又回来了,这会给我们一些启示。以前处理多个 managed object contexts 对象类似于下面这张图

这里只有唯一的 context 与 persistent store coordinator 对话,通常情况下它是一个后台 context,主要任务是负责执行保存。

而在主线程 context(View Context)之下的两个 Context:Editing ContextBG Context 都属于主线程 context 的孩子

这是非常必要的,因为 persistent store coordinator 和 SQL store 数据库在没有锁的情况下无法处理多个读写操作。

但在 iOS 10 中,SQL store 可以允许同时有多个读操作和单个写操作,persistent store coordinator 也不再需要锁了。这就意味着整个结构变成了下面这样:

persistent container 提供的后台 contexts 可以直接与 persistent store coordinator 进行对话,而不再需要作为主线程 context 的孩子,它可以直接通过 persistent store coordinator 写入 SQL 数据库,主线程 context 也无从知道保存究竟什么时候发生,除非重新运行 fetch requests 操作。

因此在旧版本的 iOS 上这会产生一个问题,我们可以通过监听的方式来告知主线程保存操作的发生。不过幸运的是,iOS 10 已经拿出了解决方案,在 AppDelegate.swift 中找到 importPotatoes(),在该方法前添加:

coreDataStack.viewContext.automaticallyMergesChangesFromParent = true  

这是 NSManagedObjectContext 的新属性,它替你做了所有的合并操作。

  • 如果 context 直接位于 persistent store coordinator 的下方(如图所示),当相邻的 context(直接链接到 psc)保存时,该 context 也会收到更新。
  • 如果 context 是另一个 context 的孩子,那么当父 context 保存时,孩子 context 也会收到更新。

删除 App 后(清除数据)再次运行,这次会先看到一个空白的 tableview,但等一会后台 context 操作执行完成后,新的数据会自动在前台(主线程)显示。

iCloud Core Data gets mashed

iCloud Core Data 同步相关的方法都被移除掉了。因为 iCloud 和 Core Data 配合起来总有些问题,苹果最终决定放弃了。根据文档描述,现存的方法依然能工作,但用到 iCloud 的新项目还是不推荐 Core Data 了。

经过这些年的演变,苹果貌似打算将 Core Data 打造为一个更易使用的模型层,iCloud 的同步工作就交给 CloudKit 或其他的一些方法去处理吧。


-EOF-

chengway

认清生活真相之后依然热爱它!

Subscribe to Talk is cheap, Show me the world!

Get the latest posts delivered right to your inbox.

or subscribe via RSS with Feedly!