跳转至

5.NSFetchedResults控制器

如果您仔细阅读了前面的章节,您可能会注意到大多数示例项目都使用表视图。 这是因为Core Data非常适合表视图。 设置获取请求,获取托管对象数组,并将结果插入表视图的数据源。 这是一个常见的日常场景。

如果您看到Core DataUITableView之间的关系很紧密,那么您就处于良好的公司中。 苹果公司核心数据框架的作者们也是这么想的! 事实上,他们看到了UITableViewCore Data之间紧密联系的巨大潜力,于是写了一个类来正式化这种联系:NSFetchedResultsController

顾名思义,NSFetchedResultsController是一个控制器,但它不是一个 view 控制器。 它没有用户界面。 它的目的是通过抽象出将表视图与CoreData支持的数据源同步所需的大量代码,使开发人员的工作更轻松。

正确设置一个NSFetchedResultsController,您的表将“神奇地”模仿其数据源,而无需编写超过几行代码。 在本章中,您将了解这门课的来龙去脉。 你还将学习什么时候使用它,什么时候不使用它。你准备好了吗?

世界杯APP介绍

本章的示例项目是一个适用于iOS的世界杯记分牌应用程序。 在启动时,一页的应用程序将列出所有参加世界杯的球队。 点击一个国家的细胞将增加一个国家的胜利。 在这个简化版的世界杯中,敲击次数最多的国家赢得比赛。 这个排名大大简化了真实的的淘汰规则,但它足以用于演示目的。

转到本章的文件,找到 starter 文件夹。 打开WorldCup.xcodeproj. 生成并运行启动项目: img

示例应用程序在表视图中包含20个静态单元格。 那些亮蓝色的盒子是球队的旗帜应该放在的地方。 而不是真实的的名字,你看到Team Name。虽然示例项目并不太令人兴奋,但它实际上为您做了很多设置。

打开项目导航器,查看启动项目中的完整文件列表: img

在进入代码之前,让我们简要地回顾一下每个类在开箱即用时为您做了什么。 你会发现你在前面章节中手动做的很多设置已经为你实现了。 万岁!

  • CoreDataStack:和前面章节一样,这个对象包装了一个NSPersistentContainer的实例,它又包含了核心数据对象的核心,即“堆栈”;上下文、模型、持久存储和持久存储协调器。 没必要设置这个。 它是现成的。
  • ViewController: 示例项目是一个单页应用程序,该文件代表了这一页。 在第一次启动时,视图控制器读取 seed.json:创建相应的Core Data对象并将其保存到持久化存储中。 如果你对它的UI元素感兴趣,请前往 Main.storyboard 。:有一个表、一个导航栏和一个原型单元格。
  • Team+CoreDataClass & Team+CoreDataProperties:这些档案代表一个国家的球队。 它是一个NSManagedObject子类,其四个属性中的每一个都有属性:teamNamequalifyingZoneimageNamewins。 如果你对它的实体定义感到好奇,请前往 WorldCup。xcdatamodel
  • Assets.xcassets :示例项目的资产目录包含seed.json中每个国家的国旗图像。

本书的前三章涵盖了上面提到的核心数据概念。 如果“托管对象子类”没有让你想起什么,或者如果你不确定核心数据栈应该做什么,你可能想回去重读相关的章节。 当您返回时,NSFetchedResultsController将在这里。

否则,如果您准备继续,您将开始实现世界杯应用程序。 你可能已经知道谁赢得了上一次世界杯,但这是你为你选择的国家改写历史的机会,只需轻轻几下!

这一切都始于一个fetch请求。..

在其核心,NSFetchedResultsControllerNSFetchRequest结果的包装器。 现在,示例项目包含静态信息。 您将创建一个fetched results控制器,以在表视图中显示来自Core Data的团队列表。

打开 ViewController。swift 并添加一个lazy属性,以在coreDataStack下面保存您获取的结果控制器:

lazy var fetchedResultsController:
  NSFetchedResultsController<Team> = {
  // 1
  let fetchRequest: NSFetchRequest<Team> = Team.fetchRequest()

  // 2
  let fetchedResultsController = NSFetchedResultsController(
    fetchRequest: fetchRequest,
    managedObjectContext: coreDataStack.managedContext,
    sectionNameKeyPath: nil,
    cacheName: nil)

  return fetchedResultsController
}()

NSFetchRequest一样,NSFetchedResultsController需要一个泛型类型参数,在本例中为Team,以指定您 * 期望 * 使用的实体类型。

让我们一步一步地完成这个过程:

  1. fetch results控制器处理Core Data和表视图之间的协调,但它仍然需要您提供NSFetchRequest。 记住NSFetchRequest类是高度可定制的。 它可以接受排序描述符、谓词等。 在本例中,您直接从Team类获取NSFetchRequest,因为您希望获取所有Team对象。
  2. 获取的结果控制器的初始化器方法接受四个参数:首先是你刚刚创建的fetch请求。 第二个参数是NSManagedObjectContext的实例。 与“NSFetchRequest”类似,获取的结果控制器类需要托管对象上下文来执行获取。 实际上它自己什么都取不出来。 其他两个参数是可选的:sectionNameKeyPathcacheName。 暂时留空;你会在本章后面读到更多关于它们的内容。

接下来,在viewDidLoad()的末尾添加以下代码,以实际执行抓取:

do {
  try fetchedResultsController.performFetch()
} catch let error as NSError {
  print("Fetching error: \(error), \(error.userInfo)")
}

在这里执行fetch请求。 如果出现错误,您将错误记录到控制台。

但是等一下。...你得到的结果在哪里? 当使用NSFetchRequest进行抓取时,会返回一个结果数组,而使用NSFetchedResultsController进行抓取时,则不会返回任何结果。

NSFetchedResultsController既是获取请求的包装器,又是获取结果的容器。 你可以使用fetchedObjects属性或object(at:)方法来获取它们。

接下来,您将把获取的结果控制器连接到常用的表视图数据源方法。 所提取的结果确定区段的数目和每个区段的行数。

考虑到这一点,重新实现numberOfSections(in:)tableView(_:numberOfRowsInSection:),如下所示:

func numberOfSections(in tableView: UITableView) -> Int {
  fetchedResultsController.sections?.count ?? 0
}

func tableView(_ tableView: UITableView,
               numberOfRowsInSection section: Int)
               -> Int {
  guard let sectionInfo = 
    fetchedResultsController.sections?[section] else {
      return 0
  }

  return sectionInfo.numberOfObjects
}

表视图中的节数对应于提取的结果控制器中的节数。 您可能想知道这个表视图怎么可以有多个节。 你不是简单地获取和显示所有球队吗?

没错 这次你只有一个部分,但是请记住,NSFetchedResultsController可以将你的数据分割成多个部分。 你会在本章后面看到一个例子。

此外,每个表视图部分中的行数对应于每个提取结果控制器部分中的对象的数目。 可以通过sections属性查询获取的结果控制器节的信息。

Note

sections数组包含实现NSFetchedResultsSectionInfo协议的不透明对象。 这个轻量级协议提供了有关节的信息,例如它的标题和对象的数量。

实现tableView(_:cellForRowAt:)通常是下一步。

然而,快速浏览一下该方法,就会发现它已经在必要时出售TeamCell细胞。 需要更改的是填充单元格的helper方法。

找到configure(cell:for:)并替换为以下内容:

func configure(cell: UITableViewCell,
               for indexPath: IndexPath) {
  guard let cell = cell as? TeamCell else {
      return
  }

  let team = fetchedResultsController.object(at: indexPath)
  cell.teamLabel.text = team.teamName
  cell.scoreLabel.text = "Wins: \(team.wins)"

  if let imageName = team.imageName {
    cell.flagImageView.image = UIImage(named: imageName)
  } else {
    cell.flagImageView.image = nil
  }
}

此方法接受一个表视图单元格和一个索引路径。使用此索引路径从获取的结果控制器中获取相应的Team对象。

接下来,使用Team对象填充单元格的旗帜图像、球队名称和得分标签。

再次注意,没有数组变量来保存团队。 它们都存储在获取的结果控制器中,您可以通过object(at:)访问它们。

是时候测试你的创作了。 构建并运行应用程序。准备,预备。闪退?

*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'An instance of NSFetchedResultsController requires a fetch request with sort descriptors'
*** First throw call stack:
(
    0   CoreFoundation                      0x00007fff2043a126 __exceptionPreprocess + 242
    1   libobjc.A.dylib                     0x00007fff20177f78 objc_exception_throw + 48
    2   CoreData                            0x00007fff25293b57 -[NSFetchedResultsController _keyPathContainsNonPersistedProperties:] + 0    
    --- snip! ---
    30  WorldCup                            0x0000000108e086db main + 75
)
libc++abi.dylib: terminating with uncaught exception of type NSException

发生了什么事? NSFetchedResultsController正在帮助您解决这个问题,尽管它可能感觉不像!

如果您想使用它来填充表视图并让它知道哪个托管对象应该出现在哪个索引路径上,那么您不能只是向它抛出一个基本的获取请求。

崩溃日志的关键部分是这样的:

'An instance of NSFetchedResultsController requires a fetch request with sort descriptors'

一个常规的fetch请求不需要排序描述符。

它的最低要求是设置一个实体描述,它将获取该实体类型的所有对象。 但是,NSFetchedResultsController至少需要一个排序描述符。 否则,它如何知道表视图的正确顺序?

返回fetchedResultsControllerlazy属性,在let fetchRequest后面添加以下行:NSFetchRequest<Team>= Team.fetchRequest()

let sort = NSSortDescriptor(
  key: #keyPath(Team.teamName),
  ascending: true)
fetchRequest.sortDescriptors = [sort]

添加这个排序描述符将按字母顺序从AZ显示球队,并修复了早期的崩溃。 生成并运行应用程序。

img

成功了! 世界杯参赛者的完整列表在您的设备或iOS模拟器上。 然而,请注意,每个国家都有零胜,并且没有办法增加分数。 有人说足球是一项得分低的运动,但这是荒谬的!

修改数据

让我们修正每个人的零分,并添加一些代码来增加获胜次数。 仍在 ViewController中。swift,将当前空的表视图委托方法tableView(_:didSelectRowAt:)的实现替换为:

func tableView(_ tableView: UITableView,
               didSelectRowAt indexPath: IndexPath) {

  let team = fetchedResultsController.object(at: indexPath)
  team.wins += 1
  coreDataStack.saveContext()
}

当用户点击一行时,您将获取与所选索引路径相对应的Team,增加其获胜次数并将更改提交到Core Data的持久存储中。

您可能认为 * fetched results controller * 只适用于从Core Data获取结果,但是您返回的Team对象是相同的旧的托管对象子类。 您可以更新它们的值并像往常一样保存。

再次构建并运行,并点击列表中的第一个国家(阿尔及利亚)三次:

img

这是怎么回事? 你在不停地打,但胜率却没有上升。 您正在Core Data的底层持久性存储中更新阿尔及利亚的获胜次数,但您没有触发UI刷新。

返回Xcode,停止应用程序,然后重新构建并运行。

img

正如你所怀疑的那样,从头开始重新启动应用程序会强制刷新UI,显示阿尔及利亚的真实的得分为3分。 NSFetchedResultsController对这个问题有一个很好的解决方案,但是现在,让我们使用暴力解决方案。

tableView(_:didSelectRowAt:)的末尾添加以下行:

tableView.reloadData()

除了增加球队的获胜次数外,点击单元格现在会重新加载整个表视图。 这种方法是严厉的,但它目前的工作。 再次构建并运行应用程序。

点击任意多个国家/地区,次数任意。 验证UI始终是最新的。

img

给你 您已经启动并运行了一个获取结果控制器。 兴奋吗

如果这就是NSFetchedResultsController所能做的,您可能会感到有点失望。 毕竟,您可以使用NSFetchRequest和一个简单的数组完成相同的事情。

真实的的魔法在本章的其余部分。 NSFetchedResultsController在可可Touch框架中使用了一些特性,例如节处理和更改监视,您将在下面介绍这些特性。

将结果分组到节中

世界杯有六个预选赛区:非洲、亚洲、大洋洲、欧洲、南美洲和北美洲/中美洲。 Team实体具有一个名为qualifyingZone的字符串属性,用于存储此信息。

在本节中,您将把国家/地区列表划分为各自的资格区。 NSFetchedResultsController使这变得非常简单。

让我们看看它的实际效果。 返回到实例化NSFetchedResultsController的lazy属性,并对获取的结果控制器的初始化器进行以下更改:

let fetchedResultsController = NSFetchedResultsController(
  fetchRequest: fetchRequest,
  managedObjectContext: coreDataStack.managedContext,
  sectionNameKeyPath: #keyPath(Team.qualifyingZone),
  cacheName: nil)

这里的区别在于,您为可选的sectionNameKeyPath参数传入了一个值。 您可以使用此参数指定获取的结果控制器应用于对结果进行分组并生成节的属性。

这些部分究竟是如何生成的? 每个唯一的属性值将成为一个节。 NSFetchedResultsController然后将其获取的结果分组到这些节中。 在这种情况下,它将为qualifingZone的每个唯一值生成节,例如AfricaAsiaOceania等。这正是你想要的!

Note

sectionNameKeyPath采用keyPath字符串。 它可以采用属性名的形式,如qualifyingZoneteamName,也可以深入到Core Data关系中,如employee。地址。街道。 使用#keyPath语法来防御拼写错误和字符串类型的代码。

fetched results控制器现在将向表视图报告节和行,但当前UI看起来没有任何不同。

要解决此问题,请在UITableViewDataSource扩展中添加以下方法:

func tableView(_ tableView: UITableView,
               titleForHeaderInSection section: Int)
               -> String? {
  let sectionInfo = fetchedResultsController.sections?[section]
  return sectionInfo?.name
}

实现此数据源方法会向表视图中添加节标题,从而可以轻松查看一个节的结束位置和另一个节的开始位置。 在这种情况下,该部分从资格区获得其标题。 与前面一样,此信息直接来自NSFetchedResultsSectionInfo协议。

生成并运行应用程序。 您的应用程序将看起来如下所示:

img

向下滚动页面。 有好消息也有坏消息。 好消息是,该应用程序占了所有六个部分。 万岁! 坏消息是世界颠倒了。

仔细看看这些部分。 你会看到非洲的阿根廷,亚洲的喀麦隆和南美的俄罗斯。 这是怎么发生的? 这不是数据的问题你可以打开seed.json 并验证每个团队列出了正确的资格区。

你想明白了吗? 国家列表仍然按字母顺序显示,并且所获取的结果控制器只是将表分成多个部分,就好像同一资格区的所有球队都分组在一起一样。

返回到您的惰性实例化的NSFetchedResultsController属性,并进行以下更改以修复该问题。

用以下代码替换在获取请求上创建和设置排序描述符的现有代码:

let zoneSort = NSSortDescriptor(
  key: #keyPath(Team.qualifyingZone), 
  ascending: true)
let scoreSort = NSSortDescriptor(
  key: #keyPath(Team.wins), 
  ascending: false)
let nameSort = NSSortDescriptor(
  key: #keyPath(Team.teamName), 
  ascending: true)

fetchRequest.sortDescriptors = [zoneSort, scoreSort, nameSort]

问题出在排序描述符上。 这是另一个要记住的NSFetchedResultsControllergotcha。 如果要使用节keyPath分隔提取的结果,则第一个排序描述符的属性必须与键路径的属性匹配。

NSFetchedResultsController的文档强调了这一点,而且有充分的理由! 您已经看到了当排序描述符与键路径不匹配时会发生什么--对您的数据没有意义。

再次构建并运行以验证此更改是否修复了问题:

img

的确如此。 更改排序描述符恢复了示例应用程序中的地缘政治平衡。 非洲球队在非洲,欧洲球队在欧洲,等等。

Note

唯一一支可能仍然令人惊讶的球队是澳大利亚,它出现在亚洲的资格区。 这就是FIFA对澳大利亚的分类。 如果你不喜欢它,你可以向他们提交bug报告!

请注意,在每个排位赛区域内,球队按获胜次数从高到低排序,然后按名称排序。 这是因为在前面的代码片段中,您添加了三个排序描述符:首先按排位区排序,然后按获胜次数排序,最后按名称排序。

在继续之前,花点时间想想你需要做些什么才能在没有提取结果控制器的情况下将球队按排位区分开。 首先,您必须创建一个字典,并遍历团队以找到唯一的资格区。

当您遍历一系列球队时,您必须将每个球队与正确的资格区相关联。 一旦你有了按区域划分的球队列表,你就必须对数据进行排序。

当然,自己做这件事也不是不可能,但这很乏味。 这就是NSFetchedResultsController使您免于执行的操作。 你可以休息一天,去海滩或看一些旧的世界杯比赛。 谢谢,NSFetchedResultsController

“缓存”球

正如您可能想象的那样,将团队分组到各个部分并不是一个便宜的操作。 没有办法避免对每个团队进行迭代。

在这种情况下,这不是性能问题,因为只有32个团队需要考虑。 但是想象一下,如果你的数据集大得多,会发生什么。 如果您的任务是迭代超过300万个人口普查记录,并按州或省将它们分开,情况会怎样?

“我会把它放在后台线程上!“ 可能是你的第一个想法。 但是,在所有节都可用之前,表视图不能自我填充。 您可能会保存阻塞主线程,但您仍然会看到一个微调器。 无可否认,这个手术很昂贵。 至少,您应该只支付一次费用:计算出一次分组的部分,然后每次重复使用你的结果。

NSFetchedResultsController的作者思考了这个问题,并提出了一个解决方案:缓存。 你不需要做太多就能打开它。

返回到延迟实例化的NSFetchedResultsController,并对获取的结果控制器初始化进行以下修改,向cacheName参数添加一个值:

let fetchedResultsController = NSFetchedResultsController(
  fetchRequest: fetchRequest,
  managedObjectContext: coreDataStack.managedContext,
  sectionNameKeyPath: #keyPath(Team.qualifyingZone),
  cacheName: "worldCup")

指定缓存名称以打开NSFetchedResultsController的磁盘上节缓存。 这就是你需要做的! 请记住,这个部分缓存与Core Data的持久存储完全分离,在那里您可以持久化团队。

Note

NSFetchedResultsController的节缓存对它的获取请求的变化非常敏感。 可以想象,任何更改(例如不同的实体描述或不同的排序描述符)都会给予您一组完全不同的获取对象,从而该高速缓存完全无效。 如果进行这样的更改,则必须使用deleteCache(withName:)删除现有缓存或使用其他缓存名称。

构建并运行应用程序几次。 第二次发射应该比第一次快一点。 这不是作者的暗示能力(psst,连续说五次fast);是NSFetchedResultsController的缓存系统在工作!

在第二次启动时,NSFetchedResultsController将直接从缓存中读取。 这节省了到Core Data持久存储的往返行程,以及计算这些部分所需的时间。 万岁!

在第8章“测量和提升性能”中,您将了解如何测量性能,并查看代码更改是否真的使事情变得更快。

在您自己的应用中,如果您要将结果分组到部分中,并且具有非常大的数据集或针对较旧的设备,请考虑使用NSFetchedResultsController的缓存。

监控变化

本章已经介绍了使用NSFetchedResultsController的三个主要优点中的两个:节和缓存。 第三个也是最后一个好处是一把双刃剑:它功能强大,但也容易被滥用。

在本章的前面部分,当您实现点击以增加获胜次数时,您添加了一行代码来重新加载表视图以显示更新后的分数。 这是一个暴力解决方案,但它奏效了。

当然,您可以聪明地使用UITableViewAPI,只重新加载选定的单元格,但这并不能解决根本问题。

不要太哲学化,但根本问题是“改变”。 底层数据发生了变化,您必须明确地重新加载用户界面。

想象一下世界杯应用程序的第二个版本会是什么样子。 也许每支球队都有一个细节屏幕,你可以在那里改变比分。

也许应用程序调用API端点并从Web服务获取新的分数信息。 您的工作是为更新底层数据的 every code path 刷新表视图。

显式地做这件事很容易出错,更不用说有点无聊了。 有没有更好的办法? 是的,有。 再一次,fetched results控制器来拯救。

NSFetchedResultsController可以监听其结果集中的更改,并通知其委托NSFetchedResultsControllerDelegate。 您可以使用此委托在基础数据发生更改时根据需要刷新表视图。

获取结果控制器可以监视其result set中的变化是什么意思? 这意味着它可以监视所有对象的变化,旧的和新的,它 would 已经获取的对象,除了它已经获取的对象。 这一区别将在本节后面变得更加清楚。

让我们在实践中看看。 仍在 ViewController中。swift,在文件底部添加以下扩展名:

// MARK: - NSFetchedResultsControllerDelegate
extension ViewController: NSFetchedResultsControllerDelegate {

}

这简单地告诉编译器ViewController类将实现一些获取的结果控制器的委托方法。

接下来,返回到惰性的NSFetchedResultsController属性,并在返回之前将视图控制器设置为获取的结果控制器的委托。 在初始化获取的结果控制器后添加以下代码行:

fetchedResultsController.delegate = self

这就是您开始监控更改所需的全部内容! 当然,下一步是在这些变更报告到来时做些什么。 接下来你会这么做的。

Note

获取的结果控制器只能监视通过其初始化器中指定的托管对象上下文所做的更改。 如果您在应用的其他地方创建了一个单独的NSManagedObjectContext,并开始在那里进行更改,则在保存这些更改并将其与获取的结果控制器的上下文合并之前,委托方法不会运行。

响应变化

首先,从tableView(_:didSelectRowAt:)中删除reloadData()调用。 如前所述,这是您现在要替换的蛮力方法。

NSFetchedResultsControllerDelegate有四个不同粒度的方法。 首先,实现最广泛的delegate方法,即:“咦,有变化!“

NSFetchedResultsControllerDelegate扩展中添加以下方法:

func controllerDidChangeContent(_ controller: 
  NSFetchedResultsController<NSFetchRequestResult>) {
    tableView.reloadData()
}

更改可能看起来很小,但实现此方法意味着任何更改(无论其来源如何)都将刷新表视图。 生成并运行应用程序。 通过点击几个单元格,验证表格视图的单元格是否仍然正确更新:

img

分数标签像以前一样更新,但发生了一些其他的事情。 当一个国家在同一个资格区比另一个国家得分多时,那个国家就会“jump”上一个级别。 这是获取结果控制器注意到其获取结果的排序顺序的变化,并相应地重新调整表视图的数据源。

当细胞移动的时候,它是非常跳跃的。..几乎就像每次有什么变化时你都要完全重新加载表。

接下来,您将从重新加载整个表转到只刷新需要更改的内容。 fetched results控制器委托可以告诉您是否需要移动、插入或删除特定的索引路径,因为fetched results控制器的结果集发生了更改。

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 .insert:
    tableView.insertRows(at: [newIndexPath!], with: .automatic)
  case .delete:
    tableView.deleteRows(at: [indexPath!], with: .automatic)
  case .update:
    let cell = tableView.cellForRow(at: indexPath!) as! TeamCell
    configure(cell: cell, for: indexPath!)
  case .move:
    tableView.deleteRows(at: [indexPath!], with: .automatic)
    tableView.insertRows(at: [newIndexPath!], with: .automatic)
  @unknown default:
    print("Unexpected NSFetchedResultsChangeType")
  }
}

func controllerDidChangeContent(_ controller:
  NSFetchedResultsController<NSFetchRequestResult>) {
    tableView.endUpdates()
}

哇! 这是一堵代码墙。 幸运的是,它主要是样板文件,易于理解。 让我们简单地回顾一下您刚刚添加或修改的所有三个方法。

  • controllerWillChangeContent(_:): 此委托方法通知您即将发生更改。 使用beginUpdates()准备好表视图。
  • controller(_:didChange:at:for:newIndexPath:):这个方法很拗口。 而且有很好的理由-它告诉你确切地哪些对象发生了变化,发生了什么类型的变化(插入,删除,更新或重新排序)以及受影响的索引路径是什么。

这个中间方法是众所周知的粘合剂,它可以使表视图与核心数据同步。 无论底层数据发生了多大变化,您的表视图都将与持久存储中发生的事情保持一致。

  • controllerDidChangeContent(_:) 您最初实现的用于刷新UI的委托方法原来是通知您更改的三个委托方法中的第三个。 您只需调用endUpdates()来应用更改,而不是刷新整个表视图。

Note

您最终如何处理更改通知取决于您的个人应用。你上面看到的实现是Apple在NSFetchedResultsControllerDelegate文档中提供的一个示例。

请注意,方法的顺序和性质与用于更新表视图的“开始更新,进行更改,结束更新”模式紧密相连。 这不是巧合!

构建并运行,以查看您的工作实际情况。 马上,每个排位赛区列出的胜利数量的球队。 在不同的国家点击几次。 您将看到细胞平滑地设置动画以保持此顺序。

第一:

img

然后:

img

例如,在第一张截图中,瑞士队以6胜领先欧洲。 敲击波斯尼亚和黑塞哥维那,直到他们的分数也是六移动的细胞在瑞士顶部与一个漂亮的动画。 这是正在运行的获取结果控制器委托!

在本节中还有一个要研究的NSFetchedResultsControllerDelegate方法。 将其添加到扩展:

func controller(_ controller: 
  NSFetchedResultsController<NSFetchRequestResult>,
  didChange sectionInfo: NSFetchedResultsSectionInfo,
  atSectionIndex sectionIndex: Int,
  for type: NSFetchedResultsChangeType) {

  let indexSet = IndexSet(integer: sectionIndex)

  switch type {
  case .insert:
    tableView.insertSections(indexSet, with: .automatic)
  case .delete:
    tableView.deleteSections(indexSet, with: .automatic)
  default: break
  }
}

此委托方法类似于controllerDidChangeContent(_:),但通知您对节而不是单个对象的更改。 在这里,您将处理基础数据中的更改触发创建或删除整个节的情况。

花点时间想想什么样的变化会触发这些通知。 也许,如果一支新的球队从一个全新的排位赛区进入世界杯,则获取的结果控制器会发现这个值的唯一性,并通知其代表关于新的部分。

这种情况永远不会发生在一个标准问题的世界杯上。 一旦32支资格赛球队进入系统,就没有办法再增加一支新的球队。 还是真的有

插入失败者

为了演示当结果集中插入时表视图会发生什么,我们假设有一种方法可以添加一个新的团队。

如果您仔细观察,您可能会注意到右上角的 + 栏按钮项。 它一直被禁用。

让我们现在就实现它。 在 ViewController中。swiftviewDidLoad()下面添加如下方法:

override func motionEnded(
  _ motion: UIEvent.EventSubtype,
  with event: UIEvent?) {
  if motion == .motionShake {
    addButton.isEnabled = true
  }
}

您覆盖motionEnded(_:with:),因此摇动设备启用 +bar按钮项。 这将是你的秘密入口。 addButton属性沿着保存着对这个条形按钮项的引用!

接下来,在标有// MARK: - Internal的扩展名上方添加以下扩展名:

// MARK: - IBActions
extension ViewController {
  @IBAction func addTeam(_ sender: Any) {
    let alertController = UIAlertController(
      title: "Secret Team",
      message: "Add a new team",
      preferredStyle: .alert)

    alertController.addTextField { textField in
      textField.placeholder = "Team Name"
    }

    alertController.addTextField { textField in
      textField.placeholder = "Qualifying Zone"
    }

    let saveAction = UIAlertAction(
      title: "Save",
      style: .default
    ) { [unowned self] _ in

      guard 
        let nameTextField = alertController.textFields?.first,
        let zoneTextField = alertController.textFields?.last
        else {
          return
      }

      let team = Team(
        context: self.coreDataStack.managedContext)

      team.teamName = nameTextField.text
      team.qualifyingZone = zoneTextField.text
      team.imageName = "wenderland-flag"
      self.coreDataStack.saveContext()
    }

    alertController.addAction(saveAction)
    alertController.addAction(UIAlertAction(title: "Cancel",
                                            style: .cancel))

    present(alertController, animated: true)
  }
}

这是一个相当长但易于理解的方法。 当用户点击 Add 按钮时,它会显示一个提醒控制器,提示用户进入新的团队。

警报视图有两个文本字段:一个用于输入球队名称,另一个用于进入资格区。 点击 Save 提交更改,并将新团队插入Core Data的持久存储中。

情节提要中的动作已经连接好了,所以您无需再做什么了。 再次构建并运行应用程序。

如果你在设备上运行,摇动它。如果您在模拟器上运行,请按 Command + Control + Z 模拟震动事件。

img

芝麻开门! 经过多方协商,双方决定“摇一摇”,现在 Add 按钮已经激活!

世界杯正式接纳一支新球队。 向下滚动到欧洲排位赛区的末尾和北美、中美洲和加勒比地区排位赛区的开头。 你马上就会明白为什么。

在继续之前,花几秒钟来接受这个。 你将改变历史,为世界杯增加一支球队。 你准备好了吗?

点击右上角的 + 按钮。 您将看到一个提示视图,询问新团队的详细信息。

img

进入虚构的(但蓬勃发展的)国家 Wenderland 作为新的团队。 输入 Internets 为合格区域,然后点击 Save 。 快速动画之后,用户界面应如下所示:

img

由于Internets是获取的结果控制器的sectionNameKeyPath的新值,因此此操作创建了一个新节并向获取的结果控制器结果集添加了一个新组。

处理数据方面的事情。 此外,由于您正确地实现了获取的结果控制器委托方法,因此表视图通过插入一个新的部分和一个新的行来响应。

这就是NSFetchedResultsControllerDelegate的美妙之处。 你可以设置一次,然后忘记它。基础数据源和表视图将始终保持同步。

至于温德兰旗是如何进入app的:嘿,我们是开发商! 我们需要为各种可能性做好计划。

可区分的数据源

iOS 13中,Apple引入了一种新的方式来实现表格视图和集合视图:不同的数据源。 使用diffable数据源,您可以使用 snapshots 提前设置表的节和单元格,而不是实现通常的数据源方法,如numberOfSections(in:)tableView(_:cellForRowAt:)来出售节信息和单元格。

沿着diffable数据源之外,还有一种新的方法可以使用NSFetchedResultsController来监视fetch请求结果集中的更改。

让我们从示例项目中删除现有的数据源实现开始。 继续删除符合UITableViewDataSource的整个ViewController扩展。 注释// MARK:- UITableViewDataSource标记开始。

然后,滚动到 ViewController 顶部,添加以下属性:

var dataSource: UITableViewDiffableDataSource<String, NSManagedObjectID>?

UITableViewDiffableDataSource对于两种类型是通用的 - String表示节标识符,NSManagedObjectID表示不同团队的托管对象标识符。

接下来,在configure(cell:for)下面添加如下新方法:

func setupDataSource()
  -> UITableViewDiffableDataSource<String, NSManagedObjectID> {
    UITableViewDiffableDataSource(
    tableView: tableView
    ) { [unowned self] (tableView, indexPath, managedObjectID) 
      -> UITableViewCell? in

      let cell = tableView.dequeueReusableCell(
        withIdentifier: self.teamCellIdentifier,
        for: indexPath)

      if let team =
          try? coreDataStack.managedContext.existingObject(
            with: managedObjectID) as? Team {
        self.configure(cell: cell, for: team)
      }
      return cell
    }
}

此方法创建可区分的数据源。 当创建这样的数据源时,它会自动将自身添加为表视图的数据源。 请注意,您传入了一个闭包来配置单元格,而不是使用单独的方法。

由于数据源对于NSManagedObjectID是通用的,因此您可以使用existingObject(with:)将标识符转换为相应的Team对象来配置每个单元格。

因为你解析了datasource闭包中的Team对象,所以你需要重新实现configure(cell:for)。 将其执行改为:

  func configure(cell: UITableViewCell,
                 for team: Team) {

    guard let cell = cell as? TeamCell else {
        return
    }

    cell.teamLabel.text = team.teamName
    cell.scoreLabel.text = "Wins: \(team.wins)"

    if let imageName = team.imageName {
      cell.flagImageView.image = UIImage(named: imageName)
    } else {
      cell.flagImageView.image = nil
    }
  }

接下来,在viewDidLoad()importJSONSeedDataIfNeeded()后面添加以下内容:

dataSource = setupDataSource()

在前面的设置中,表视图的数据源是视图控制器。 表视图数据源现在是前面设置的可区分数据源对象。

现在找到NSFetchedResultsControllerDelegate实现,并删除您在上一节中设置的所有四个委托方法:

  • controllerWillChangeContent(_:)
  • controller(_:didChangeContentWith:)
  • controllerDidChangeContent(_:)
  • controller(didChange:atSectionIndex:for:)

在它们的位置,实现以下delegate方法:

func controller(
  _ controller: NSFetchedResultsController<NSFetchRequestResult>,
  didChangeContentWith
  snapshot: NSDiffableDataSourceSnapshotReference) {

  let snapshot = snapshot
    as NSDiffableDataSourceSnapshot<String, NSManagedObjectID>
  dataSource?.apply(snapshot)
}

您删除的旧委托方法告诉您更改将在何时发生、更改是什么以及更改何时完成。

这些委托调用与UITableView中的方法(如beginUpdates()endUpdates())很好地结合在一起,您不再需要调用这些方法,因为您已经切换到了可区分的数据源。

相反,新的委托方法为您提供对所获取的结果集的任何更改的摘要,并传递给您一个预先计算的快照,您可以将其直接应用于表视图。 简单多了!

构建并运行以查看新的diffable快照所处的位置:

img

太好了! 似乎大多数事情都奏效了,但有两个问题。 首先,控制台警告您,表格视图在显示在屏幕上之前正在布局其单元格,第二个是团队似乎按资格区分组,但部分标题不见了。

控制台警告正在发生,因为事情现在以不同的顺序发生。 当视图控制器是表的数据源,并且您正在实现旧的fetched results控制器委托方法时,表在加载并添加到屏幕之前不会请求任何信息。 现在您使用的是一个diffable数据源,第一个更改发生在您在结果控制器上调用performFetch()时,结果控制器又调用controller(_:didChangeContentWith:),它在第一次获取的所有行中“添加”。 在viewDidLoad()中调用performFetch(),这在视图添加到窗口之前发生。 呼!

要解决这个问题,您需要稍后执行第一次提取。从viewDidLoad()中删除do / catch语句,因为这在生命周期中发生得太早。 实现viewDidAppear(_:),在 * 视图添加到窗口后调用:

override func viewDidAppear(_ animated: Bool) {
  super.viewDidAppear(animated)
  UIView.performWithoutAnimation {
    do {
      try fetchedResultsController.performFetch()
    } catch let error as NSError {
      print("Fetching error: \(error), \(error.userInfo)")
    }
  }
}

构建并运行,控制台警告消失了。 现在来修复章节标题。

为什么他们消失了? 之前,当您删除UITableViewDataSource的实现时,也删除了tableView(_:titleForHeaderInSection:)。 这个方法提供了字符串来填充节头,如果没有这些字符串,节头就会消失。

没有办法使用UITableViewDiffableDataSource重新打开这些标题,因此您将采取另一条路线。 找到实现UITableViewDelegate方法的部分,实现这两个:

func tableView(_ tableView: UITableView,
               viewForHeaderInSection section: Int) -> UIView? {

  let sectionInfo = fetchedResultsController.sections?[section]

  let titleLabel = UILabel()
  titleLabel.backgroundColor = .white
  titleLabel.text = sectionInfo?.name

  return titleLabel
}

func tableView(_ tableView: UITableView,
               heightForHeaderInSection section: Int)
  -> CGFloat {
  20
}

这两个delegate方法创建并返回UILabel,而不是只返回填充节标题的标题,以便与节标题的高度沿着显示。

构建并运行,看看是否恢复了丢失的头文件:

img

部分标题又回来了,但是如果你点击任何团队单元格,你会注意到获胜的数量不再上升。 可区分的数据源只考虑哪些对象ID在哪个部分中以什么顺序排列。 即使您的团队因为获胜次数增加而在部分中上移,数据源也只会移动现有的单元格,而不会重新配置它。你只会看到新的分数时,细胞移动屏幕上,并再次回来。

要解决这个问题,请在UITableViewDelegate部分中找到tableView(_:didSelectRowAt:),并在调用saveContext()之前添加以下代码:

if var snapshot = dataSource?.snapshot() {
  snapshot.reloadItems([team.objectID])
  dataSource?.apply(snapshot, animatingDifferences: false)
}

在这里,您将获得现有的快照,告诉它您的团队需要重新加载,然后将更新后的快照应用回数据源。 然后数据源将为您的小组重新加载单元格。 保存上下文时,将触发获取结果控制器的delegate方法,该方法将应用需要发生的任何重新排序。 构建并再次运行,并确认一切都如广告所示。

如果你能走到这一步,就拍拍自己的背吧。 您不仅使用diffable数据源重新实现了示例项目,而且还使用新的fetched results控制器委托方法使监视更改的方式现代化。 在此过程中,您还删除了许多以前需要的样板文件。

Note

如果您正在监视更改以管理不支持diffable数据源的视图的状态,则应记住还有另一个NSFetchedResultsControllerDelegate方法,该方法一次性为您提供对获取结果的所有更改的摘要,但使用CollectionDifference<NSManagedObjectID>返回结果。

关键点

  • NSFetchedResultsController 将表视图与Core Data存储同步所需的大部分代码抽象出来。
  • 在其核心,NSFetchedResultsController是一个 NSFetchRequest 的包装器,也是 fetched results 的容器。
  • 获取结果控制器要求在其获取请求上至少设置 one sort descriptor 。 如果您忘记了排序描述符,您的应用程序将崩溃。
  • 您可以通过设置获取结果的控制器 sectionNameKeyPath,指定一个属性,将结果分组到表视图 sections 中。 每个唯一值对应于不同的表视图节。
  • 将一组提取的结果分组到部分中是一个昂贵的操作。 通过在获取的结果控制器上指定 cache name ,避免必须多次计算节。
  • 获取的结果控制器可以侦听其结果集中的更改,并通知其 delegate NSFetchedResultsControllerDelegate响应这些更改。
  • NSFetchedResultsControllerDelegate监视单个Core Data记录的更改(无论是插入、删除还是修改)以及整个部分的更改。
  • 可区分的数据源使处理获取的结果控制器和表视图更容易。

接下去哪?

您已经看到了NSFetchedResultsController是多么强大和有用,并且您已经了解了它与表视图一起工作的情况。 表视图在iOS应用中非常常见,您已经亲眼目睹了获取结果控制器如何为您节省大量时间和代码!

通过对delegate方法进行一些调整,您还可以使用fetched results控制器来驱动集合视图-主要区别在于集合视图不会将其更新与开始和end调用一起进行,因此有必要存储更改并在最后将其全部应用于一批。

在其他上下文中使用fetched results控制器之前,您应该记住几件事。 注意如何实现获取的结果控制器委托方法。 即使底层数据中最轻微的更改也会触发这些更改通知,因此请避免执行任何您不愿意反复执行的昂贵操作。

不是每天都有一个单独的类得到一本书中的一整章;这个荣誉是留给少数人的 NSFetchedResultsController就是其中之一。 正如您在本章中所看到的,这个类存在的原因是为了保存您的时间。

NSFetchedResultsController之所以重要,还有另一个原因:它填补了iOS开发人员与macOS开发人员相比所面临的空白。 与iOS不同,macOS具有可可绑定,它提供了一种将视图与其底层数据模型紧密耦合的方法。 听起来耳熟吗

如果你曾经发现自己在编写复杂的逻辑来计算部分,或者为了让你的表视图与核心数据很好地配合而流汗,请回想一下这一章!