NSFetchedResultsController问题

编者注:本文最早在iOS 10发行之前发布。Apple自那时以来对CoreData进行了重大更改: NSPersistentContainer现在可以用作标准的Core Data堆栈。

您可能已经注意到的另一个更改是对本文所述的NSFetchedResultsController的长期问题的修复。

尽管iOS的采用率很高,但某些设备仍在运行存在NSFetchedResultsController问题的iOS 9版本。 因此,本文仍然与今天的iOS开发人员相关。

NSFetchedResultsController

NSFetchedResultsController是iOS核心数据开发的主要内容。 在iOS 3中引入的此类负责有效管理Core Data实体的集合。

在过去的六年中,我在所有类型的Core Data堆栈配置中都使用了该控制器。 在针对Black Pixel顶级客户之一的最新项目中,我们决定使用标准的“兄弟”核心数据堆栈配置:

  • 一个 NSFetchedResultsController用于从主UI上下文中的商店中获取对象。 此主要上下文仅用于从商店读取。
  • 用于从服务器检索实体的后台上下文已连接到持久性存储协调器,作为主UI上下文的同级对象。
  • 设置主上下文时,只要后台上下文将其更改保存到存储中,就会自动合并来自后台上下文的更改。

令我惊讶的是,我最终遇到了一些奇怪的问题,即NSFetchedResultsController有时与商店的内容不同步:某些与NSFetchedResultsController的谓词匹配的现有实体将永远不会被获取。

如此基本和预期的事情将如何发生?

一些解释和修正

快速的Google搜索产生了很多答案。 特别是其中一个详细说明了NSFetchedResultsController如何进行。 这是给出的说明(注意:FRC = NSFetchedResultsController ):

1.使用与所有对象都不匹配的谓词设置FRC(从而防止将与谓词不匹配的对象注册到FRC上下文中)。

2.第二个上下文更改了一个对象,这意味着它现在与FRC的谓词匹配。 保存第二个上下文。

3. FRC的上下文处理 NSManagedObjectContextDidSaveNotification 但仅更新其注册的对象。 因此,它不会更新现在与FRC谓词匹配的对象。

4.保存时FRC不会执行另一次提取,因此它不知道应该包含更新的对象。

我对第三点的发言感到不安。

这是建议的修复程序:

解决方案是在合并通知时获取所有更新的对象。

这个想法是在NSManagedContextDidSaveNotification userInfo有效内容的一部分的每个更新对象上调用refreshObject(_:mergeChanges:)

另一组说明(例如,文章“ Core Data Gotcha”和“带谓词的NSFetchedResultsController忽略了从不同NSManagedObjectContext合并的更改”)提到当NSManagedContextDidSaveNotification 被触发时,某些对象可能是主上下文中的故障,因此需要在调用mergeChangesFromContextDidSaveNotification()之前触发这些故障。

这里的想法是在NSManagedContextDidSaveNotification userInfo有效内容一部分中的每个更新对象上调用willAccessValueForKey(nil) 。 然后调用mergeChangesFromContextDidSaveNotification()

深入调查的时间

在项目进行过程中,我选择了第一个解决方案,该解决方案引入了一系列新问题,这些问题已解决但并非总是令我满意。 我想了解发生了什么,并核实我一直在阅读的发现令人不安的声明。

这些调查的目的是:

  • 找出将更改从一个上下文合并到另一个上下文时真正发生的事情:目标上下文中的内容以及通知有效内容中的内容?
  • 弄清楚NSFetchedResultsController在哪种情况下无法按预期运行。
  • 评估各种建议的解决方案。

调查设置

使用独立的iOS应用程序执行调查,该应用程序的设置如下:

核心数据栈

核心数据堆栈是一个非常基本的“同级”堆栈,具有:

  • 主上下文( MainQueueConcurrencyType )用作只读上下文。
  • 读写的背景上下文( PrivateQueueConcurrencyType )。

主要上下文和背景上下文是同级,并且都直接连接到NSPersistentStoreCoordinator 。 当更改保存在后台上下文中时,它们会使用mergeChangesFromContextDidSaveNotification()方法在主上下文中自动合并。

核心数据模型

我们使用一个非常简单的模型,其中包含具有三个属性的单个实体TestDummyid: Intname: StringisEven: Bool

UI和主视图控制器

我们有一个单一的视图控制器,可以访问Core Data堆栈中的两个上下文,并允许一个:

  • 在后台上下文中插入,更新和删除对象。
  • 保存主要和背景上下文。
  • 显示有关由两个NSFetchedResultsController实例获取的在两个上下文中存在的对象的信息。

控制器还允许控制如何处理在主上下文上接收到的NSManagedObjectContextDidSaveNotification通知。 默认情况下, mergeChangesFromContextDidSaveNotification()

NSFetchedResultsController

FRC的两个实例由主视图控制器管理:

  • 一个实例获取主UI上下文中的所有TestDummy对象。 该实例称为“主FRC”。
  • 一个实例在后台上下文中获取所有TestDummy对象。 该实例称为“后台FRC”。

主上下文FRC可以使用两个谓词来获取所有TestDummy实体,或者仅获取那些标记为isEven == true实体。

让我们全部拿走!

我们首先将主要的FRC设置为获取所有TestDummy实体,然后研究在后台上下文中发生的三种不同情况:插入,更新和删除。

插入物件

我们执行了以下简单测试:

  1. 在后台上下文中插入四个实体。
  2. 保存背景上下文。

插入导致以下内容:

  1. 保存之前,背景上下文中的insertedObjects属性的内容与registeredObjects属性的内容匹配。
  2. 后台FRC提取的对象与registeredObjects集匹配。

不出所料,在后台上下文中进行的保存更改会将所有这些更改推送到主上下文中:

  1. 主上下文和后台上下文中registeredObjects的内容应相同。
  2. 由主FRC提取的对象与在主上下文中设置的registeredObjects匹配。
  3. 主FRC通知其delegate插入。

附带说明,保存后台上下文还会将在该上下文上设置的insertedObjects重置为nil

更新对象

了解更新对象时会发生什么,需要更深入地研究并观察两种替代方案:

场景1

  1. 在后台上下文中插入四个实体。
  2. 通过更改实体的isEven (从falsetrue )和title属性来更新实体0和2。
  3. 保存背景上下文。

场景2

  1. 在后台上下文中插入四个实体。
  2. 保存背景上下文。
  3. 像以前一样更新实体0和2。

结果

就Core Data而言,在保存背景上下文之前已插入然后更新的对象被视为insert 。 这意味着,如果您检查背景上下文的updatedObjects集,那么在收到更改通知时该updatedObjects将为空(更新后不会立即为空)。 这可能是预料之中的,但这仍然令我们感到惊讶。

第二种情况更为简单。 由于对象更新之前已保存,因此它们将出现在背景上下文的updatedObjects集中。 这符合人们的期望。

主FRC再次按预期方式工作:提取所有实体并适当地通知其delegate

删除物件

对于删除,我们还需要研究两种不同的情况。 但是,就主FRC而言,删除的情况并不像插入和更新那样有趣。 确实,如果在主上下文中注册了一个对象,则FRC将始终对要删除的该对象做出反应。

最有趣的发现是:

  • 在保存该上下文之前,先从后台上下文中删除对象会从deletedObjects集合中删除这些对象,而insertedObjects集合将包含插入内容和删除内容之间的净差。
  • 保存更改后删除对象将把这些对象带入deletedObjects集中,就像人们期望的那样。
  • 在后台上下文中设置的registeredObjectsdeletedObjects的内容可能反映了保存期间的过渡状态,因此应谨慎使用。
  • 主上下文将包含对后台上下文所做的所有更改(即, registeredObjects集将包含删除前的所有对象,而deletedObjects集将包含已删除的对象)。
  • 主FRC再次对所有更改做出正确反应。

结论与见解

如果未设置FRC的delegate

  • fetchedObjects 数组将仅包含初始提取产生的对象。
  • 当对象更改或保存其初始化的上下文时,FRC不会接收通知。

如果为主FRC设置了delegate ,则其行为与匹配FRC的提取请求的实体完全一样:

  • 当后台上下文将其更改合并到主上下文中时,将正确获取(删除)在后台上下文中插入,更新或删除的对象。
  • 后台上下文中永久对象的删除(即,保存到存储中并带有永久objectID)将被转移到主上下文中,以供在主上下文中注册的对象使用。

但是,这些测试非常具体:主FRC 设置为获取所有TestDummy实体,这在实际应用程序中很少见。

让我们只获取一个子集!

为了反映出更现实的内容,我们进行了与以前相同的测试,但稍有更改。 现在,将主FRC设置为仅获取标记为isEven == true TestDummy实体。 让我们看看发生了什么。

将实体插入后台上下文时, isEven属性设置为false 。 因此,在后台上下文中插入对象并保存它们之后,主FRC不会获取任何实体。 但是如果发生以下情况,会发生什么情况:

  • 我们插入与主FRC predicate匹配的实体吗?
  • 我们更新一些实体以匹配FRC的主要predicate 吗?

插入匹配实体

当插入到后台上下文中的实体与主FRC匹配时,当保存后台上下文时,此FRC将正确地提取它们。

更新实体以匹配主要FRC的谓词

这种情况比较麻烦。 如前所述,更新实体的行为将取决于该实体是否已保存:

  • 如果将实体插入到后台上下文中,进行更新以匹配主FRC的predicate ,然后保存,则主FRC将获取这些实体。 所有对象的行为就好像是插入这些实体以首先匹配谓词一样。
  • 另一方面,如果插入,保存然后更新实体以匹配主FRC的predicate ,则主FRC 将永远不会提取它们

寻找潜在的解决方案

我们可以考虑解决此问题的四种不同方法,并将在本节中分别进行讨论。

更改堆栈配置

请记住,此问题适用于将在后台上下文中进行的更改推送到持久性存储协调器,然后合并到主上下文中的配置。

切换到后台上下文将其更改写入主上下文的配置,这将通过FRC消除此问题。 这将是解决此问题的根本方法。 的确,将堆栈重新配置为“父子”配置是与变更管理完全不同的体系结构方法,但有一些警告:

  • “父子”配置会导致通过主要上下文的流量增加。 所有获取保存操作都会在发生时阻止主上下文。
  • 您需要处理临时对象ID,直到将对象保存到持久性存储中为止。 或者,您可以在后台上下文中插入对象时请求永久对象ID。 但同样,这是以牺牲一些性能为代价的。
  • 如果后台上下文中的内容与主上下文中的内容发生冲突,则您对合并的控制较少。

在主上下文中刷新对象

典型实施
这个想法是在处理主上下文上的NSManagedObjectContextDidSaveNotification通知有效负载时为更新的对象调用refreshObjects(mergeChanges:)

实现通常刷新通知有效负载中的所有更新对象。

好处

  • 易于实现。
  • 可以在NSManagedContext扩展中集中实施。
  • 我们可以选择错误(mergeChanges = false)或合并(mergeChanges = true)

缺点

  • 我们需要在每个对象上调用此方法,每次调用都会导致FRC的更新。 这很容易造成性能瓶颈。 以前在FRC中注册的所有更新对象将被更新两次。
  • 使用mergeChanges = false ,我们mergeChanges = false主上下文中的所有刷新的对象mergeChanges = false故障。 如果FRC引用了这些对象,则故障将立即引发。 这将导致已更新的,由FRC提取的完整对象集的至少三个更新:一次是默认FRC更新机制的一部分,一次是由于强制刷新,一次是在立即引发故障时。
  • 但是,通过mergeChanges = false ,在上下文中对现有对象进行故障处理可能会产生令人讨厌的副作用。 所有关系都是有缺陷的,这意味着对那些有缺陷的对象或它们所涉及的任何对象的任何引用都将变为无效。 这很容易变得很难有效管理。 最后,您想要的是对失效的托管对象的引用,当您尝试访问它时,该对象将使您的应用程序崩溃。
  • 使用mergeChanges = true ,您将现有对象保留在内存中,但会使用持久性存储中的值(即,在这种情况下为后台上下文)覆盖所有更改。 如果您采取强方法将主上下文设为只读,则可以强制将所有更改专门应用于后台上下文,这可能会起作用。
  • 我们需要选择是设置mergeChanges = false还是mergeChanges = true

NSManagedObjectContext扩展和用法的典型示例

在refreshObject(_,mergeChanges :)上进行改进

全局实现不加选择地刷新主要上下文中的所有更新对象的主要缺点是,它将刷新由主FRC很好地管理的对象。

以更精细的方式使用相同机制的一种显而易见的方法是为NSManagedObjectDidSaveNotification设置FRC寄存器 直。 这样,我们可以将刷新调用限制为以下对象:

  • 尚未在主上下文中注册。
  • 匹配FRC的fetchRequest属性(即, entitypredicate ,如果已定义)。

好处

  • NSFetchedResultsController简单实现 延期。
  • 能够针对未注册且仅针对FRC的刷新对象。
  • 刷新效果更好。
  • 与FRC无关并且已经在主上下文中注册的对象没有故障。

退税

在主FRC注册保存背景上下文时,需要了解背景上下文。 如果要在应用程序中隐藏此上下文,则可以选择直接从核心数据堆栈或任何有权访问这两个上下文的类中出售FRC。

NSFetchedResultsController样本扩展

此扩展程序背后的想法是:

  1. 告诉每个FRC监视对特定上下文(通常是背景上下文)的保存。
  2. 收到观察到的上下文的NSManagedObjectContextDidSaveNotification ,请检查此FRC的谓词是否过滤掉实体。 如果没有,则什么也不要做。 FRC将按预期工作。
  3. 从通知有效负载中检索所有insertedupdated实体,并且仅保存与FRC的entitypredicate匹配的对象的objectID
  4. 从这组对象中,删除所有已在FRC上下文中注册的对象。 在注册后,默认情况下将正确管理这些对象。
  5. 每个剩余的对象都新插入到受监视的上下文中,并且尚未在FRC中注册:调用refreshObject(_, mergeChanges:false) 。 设置mergeChanges:false可以完美地工作:在FRC的上下文中该对象不存在,并且故障不会产生任何副作用。

调用willAccessValueForKey(nil)

在Stack Overflow上看到的另一种典型解决方案是refreshObjects(_, mergeChanges:)的调用willAccessValueForKey(nil)willAccessValueForKey(nil)的调用,以使上下文中的所有新对象mergeChangesFromContextDidSaveNotification() ,然后根据需要调用mergeChangesFromContextDidSaveNotification()

再次,在处理NSManagedObjectContextDidSaveNotification通知时,将对作为主上下文接收的通知有效负载的一部分的所有updatedObjects调用此方法。 根据Apple文档:

您可以使用键值nil调用此方法,以确保已引发故障。

在全球范围内实施,此方法将具有与先前方法相同的缺点。 使用NSFetchedResultsController仅针对目标对象实现 扩展正常。 这是这种扩展实现的示例:

您可以调用此方法,而不是先前定义的processChangesWithRefreshObject(_, mergeChanges:) 。 再一次,调用此方法应该没有副作用。 我们只对与主上下文无关且与主FRC 谓词相匹配的对象进行故障处理。

发生更改时重新引用对象

您可能很想实现这个想法。 它包括使用相同的NSManagedObjectContextDidSaveNotification监视对后台上下文的更改。 通知并在需要时调用performFetch()

我们实现了此方法以查看它是否有效,因为我们发现一次刷新所有对象比一次刷新更可取。

但是,我们发现使用此方法存在一个巨大缺点: 永远不会将更改通知给FRC的delegate 。 新对象正在主要上下文中获取和注册,但没人知道它们。

解决此问题的一种方法是在对performFetch进行调用之后,自己调用委托方法。 只要更改不影响FRC的sections这就很容易。 那时,所需的工作开始看起来很像是对FRC变更跟踪主要功能的重新实现,这不是明智的选择。

结论

对于iOS 10之前的同级堆栈配置(主上下文和专用队列上下文已连接至persistentStoreCoordinator ):

  • 如果在保存背景上下文时,插入的对象与NSFetchedResultsController的谓词相匹配,则在主上下文中获取对象的NSFetchedResultsController将仅能够获取在背景上下文中插入的对象
  • 如果不存在谓词,则NSFetchedResultsController将按预期方式运行,以获取插入到后台上下文中的所有相关实体。
  • 如果存在谓词,则NSFetchedResultsController仅在且仅当对象与首次在后台上下文中保存时匹配该谓词时,才提取在后台上下文中插入的对象。
  • 如果在第一次保存之后进行了更改,则如果更新对象尚未在主上下文中注册,则将永远不会获取它们。
  • 在后台上下文中保存对象并在主上下文中合并更改的行为与当前运行循环末尾记录的相同。
  • 在处理来自后台上下文的NSManagedObjectDidSaveNotification通知有效负载时,在所有更新的对象上调用refreshObject(_:mergeChanges:) ,这是堆栈溢出最常提出的解决方案,效率低下,并且充斥着与故障相关的问题。
  • 相比之下,在NSFetchedResultsController扩展上调用相同的方法,该扩展允许NSFetchedResultsController的实例监视对后台上下文所做的更改,效果非常好,并且不会导致我们可以识别的不良副作用。

前进的道路

苹果修复了影响NSFetchedResultsController的长期问题 在iOS 10发行版中。对于最新的应用程序,此问题已成为过去。 但是,某些项目仍然可以从本文介绍的见解中受益。 WWDC即将来临,我们很高兴看到Apple为Core Data带来了哪些新变化。