iOS 项目实用其他

Core Data: 多线程大量数据同步

2015-09-13  本文已影响10379人  seedante

前言:本文是我几个月前的这篇《iOS 面试基础题目》 其中的一个问题的回答,这几天整理博客,更新内容,自觉有能力回答这个问题了。这篇单独拿出来首先是因为这个问题很不错,值得单独写一篇;其次为了便于检索,因为简书目前不支持标签,只能通过文集来分类,有点不方便,折腾个优美的基于 Github 的博客又嫌麻烦,暂时还是在这里写吧,等有精力了迁移;最后是因为这个回答写得太长了,原本打算写个大纲型的,但由于回答是定位于基础,所以加了很多基础知识的介绍和补充,这样一来,原文就更长了。我这里的很多文章都写得太长,罗里吧嗦的,我已经难以忍受,但目前我还没太多能力进行精简。
老实说,当时写的东西大部分只是搬运而已,是我的博客里很水的一篇,但却是我这么多文章里最受欢迎的一篇。能说明什么,大家都很水呗,水不要紧,日拱一卒,共勉。

第一步:搭建 Core Data 多线程环境
这个问题首先要解决的是搭建 Core Data 多线程环境。Core Data 对并发模式的支持非常完备,NSManagedObjectContext 的指定初始化方法中就指定了并发模式:

init(concurrencyType ct: NSManagedObjectContextConcurrencyType)

有三种模式:
1.NSConfinementConcurrencyType
这种模式是用于向后兼容的,使用这种模式时你应该保证只能在创建的线程里使用 context,然而这不容易得到保证。关于此模式的最新消息是 iOS 9 中它将被废弃,不推荐使用。
2.NSPrivateQueueConcurrencyType
在一个私有队列中创建并管理 context。
3.NSMainQueueConcurrencyType
其实这种模式与第2种模式比较相似,只不过 context 与主队列绑定,同时也因此与应用的 event loop 紧密相连。当 context 与 UI 更新相关的话就使用这种模式。

从 iOS 9 开始就剩下后面两种模式了,那么搭建多线程 Core Data 环境的方案一般如下,创建一个 NSMainQueueConcurrencyType 的 context 用于响应 UI 事件,其他涉及大量数据操作可能会阻塞 UI 的,就使用 NSPrivateQueueConcurrencyType 的 context。

let mainContext = NSManagedObjectContext(concurrencyType: .MainQueueConcurrencyType)
let backgroundContext = NSManagedObjectContext(concurrencyType: .PrivateQueueConcurrencyType)

但需要注意的是「Core Data 使用线程或者序列化的队列来保护 managed objects 和 managed object context,因此 context 假设它的默认拥有者是它初始化时分配的线程或队列,你不能在某个线程中初始化一个 context 后传递给另外一个线程来使用它。」这段蹩脚的话是我从NSManagedObjectContext的文档翻译来的,意思就是说 managed object context 并非线程安全的,你不能随便地开启一个后台线程访问 managed object context 进行数据操作就管这叫支持多线程了,那么应该怎么做呢?官方文档《Using a Private Queue to Support Concurrency》为我们做了示范,在 private queue 的 context 中进行操作时,应该使用以下方法:

 func performBlock(_ block: () -> Void)//在私有队列中异步地执行 Blcok
 func performBlockAndWait(_ block: () -> Void)//在私有队列中执行 Block 直至操作结束才返回

要在不同线程中使用 managed object context 时,不需要我们创建后台线程然后访问 managed object context 进行操作,而是交给 context 自身绑定的私有队列去处理,我们只需要在上述两个方法的 Block 中执行操作即可。事实上,你也可以在其他线程中来使用 context,但是要保证以上两个方法。而且,在 NSMainQueueConcurrencyType 的 context 中也应该使用这种方法执行操作,这样可以确保 context 本身在主线程中进行操作。

题外话,在构建多线程 context 时,经常会出现这样的局面:Multi-contexts vs Concurrency。前者可能有更加复杂的情况,在 iOS 5之后,context 可以指定父 context,persistent store coordinator 不再是其与 persistent store 联系的唯一选择。需要注意的是,子 context 的 fetch 和 save 操作都会交给父 context 来完成,对于子 context 的 save 操作,只会到达上一层的父 context 里,只有父 context 执行了 save 操作,子 context 中的变化才会提交到 persistent store 保存。这种子 context 适合在后台执行长时间操作,比如在后台里在子 context 里导入大量数据,在主线程的父 context 里更新进度。另外一种是平行的多 context。Concurrency 的特性也是在 iOS 5 后开始支持,这个特性减少了平行的多 context 的需求。关于这个话题,可以看这篇文章:《Concurrent Core Data Stacks – Performance Shootout》

第二步:数据的同步操作
总的来说,在多 context 环境的下,context 的生命周期里有两个阶段需要处理数据同步的问题。当某个 context 里的状态发生了变化并执行保存来更新 persistent store后,对于其他 context 来说有两个选择:1. persistent store 更新后,此时其他 context 与 persistent store 进行同步;2. persistent store 更新后,其他 context 并不立即同步,而在自身进行保存时与 persistent store 进行同步,两者有差异时需要解决冲突问题。前者采取的是「一处修改处处同步」的策略,所有的 context 中同步为一个版本,后者采取的是「多版本共存协商处理」的策略。以下讨论都基于多个 context,假设应用配置了两个 managed object context,mainContext,在主线程运行 ,另外一个 backgroundContext 用于后台处理。

多 context 单数据版本

在 context 中执行保存时,应用并不会主动告知其他 context。那么如何在多个 context 间进行通信呢?Core Data 提供了通知机制,context 执行保存时,会发出以下通知:
1.NSManagedObjectContextWillSaveNotification
Managed object context 即将执行保存操作时发出此通知,没有附带任何信息。
2.NSManagedObjectContextDidSaveNotification
Managed object context 的保存操作完成之后由该 context 自动发出此通知,包含了所有的新增、更新和删除的对象的信息。注意在通知里的 managed objects 只能在 context 所在的线程里使用。由于 context 也只能在自身的线程里执行操作,所以没法直接使用通知里的 managed objects,这时候应该通过 managed object 的 objectID 以及 context 的objectWithID(_ objectID: ) 来获取。

合并其他 context 的数据可以通过mergeChangesFromContextDidSaveNotification(_ notification:)来完成。在这个方法里,context 会更新发出通知的 context 里变化的任何同样的对象,引入新增的对象并处于 faults 状态,并删除在发出通知的 context 里已经删除的对象。

代码示例,在 backgroundContext 中编辑后使 mainContext 与之同步:

NSNotificationCenter.defaultCenter().addObserver(self, 
                                             selector: "backgroundContextDidSave:", 
                                                 name: NSManagedObjectContextDidSaveNotification, 
                                               object: backgroundContext)

func backgroundContextDidSave(notification: NSNotification){
    mainContext.performBlock(){
        mainContext.mergeChangesFromContextDidSaveNotification(notification)
    }
}
多 context 多数据版本

在这种方案下,backgroundContext 并不针对 mainContext 的保存做出反应。在 mainContext 和 backgroundContext 中 fetch 了同类的 managed objects,两个 context 都发生了变化并且变化不一样,此时让 backgroundContext 与 mainContext 前后脚分别执行保存的话,就会发生冲突导致后者保存失败。

在 managed object context 中执行 fetch 操作时,会对 persistent store 里的状态进行快照,当 context 执行保存时,会使用快照与 persistent store 进行对比,如果状态不一致,说明 persistent store 在其他地方被更改了,而这个变化并不是当前 context 造成的,这样就造成了当前 context 状态的不连续,此时保存就会产生冲突。这里需要介绍 managed object context 的属性mergePolicy,这个属性指定了 context 的合并策略,决定了保存时合并数据发生冲突时如何应对,该属性有以下几种值:
1.NSErrorMergePolicy
默认策略,有冲突时保存失败,persistent store 和 context 都维持原样,并返回错误信息,是唯一反馈错误信息的合并策略。
2.NSMergeByPropertyStoreTrumpMergePolicy
当 persistent store 和 context 里的版本有冲突,persistent store 里的版本有优先权, context 里使用 persistent store 里的版本替换。
3.NSMergeByPropertyObjectTrumpMergePolicy
与上面相反,context 里的版本有优先权,persistent store 里使用 context 里的版本替换。
4.NSOverwriteMergePolicy
用 context 里的版本强制覆盖 persistent store 里的版本。
5.NSRollbackMergePolicy
放弃 context 中的所有变化并使用 persistent store 中的版本进行替换。

除了默认的 NSErrorMergePolicy在发生冲突时返回错误等待下一步处理外,其他的合并策略直接根据自身的规则来处理合并冲突,因此在选择时要谨慎处理。从上面的解释来看,似乎NSMergeByPropertyStoreTrumpMergePolicyNSRollbackMergePolicy没什么区别,NSMergeByPropertyObjectTrumpMergePolicyNSOverwriteMergePolicy也没有什么区别。区别在于怎么对待被覆盖的一方中没有冲突的变化(解释见此处)NSMergeByPropertyStoreTrumpMergePolicyNSMergeByPropertyObjectTrumpMergePolicy采取的是局部替换,前者 context 中没有冲突的变化不会受到影响,后者 persistent store 中没有冲突的变化不受影响;NSOverwriteMergePolicyNSRollbackMergePolicy 采取的是全局替换,persistent store 和 context 中只有一方的状态得以保留。

回到本节开始的场景,mainContext 和 backgroundContext 中的版本不一致,会产生合并冲突,解决方案有以下两种选择:
1.不管 mainContext 中是否发生改变,与 backgroundContext 中状态同步;

//此时 mainContext 和 backgroundContext 都采用默认合并策略即可。
mainContext.performBlock(){
    do{
       try mainContext.save()
    } catch {
        /*
        清空 mainContext,其中所有的 managed objects 消失。
        如果引用了其中的 managed objects 的话,注意在 reset 前取消对这些对象的引用。
        */
        mainContext.reset()
        //重新 fetch
        let fetchRequest = ...
        let updatedFetchedResults = try mainContext.executeFetchRequest(fetchRequest)
    }
}

又或者,mainContext 的合并策略采用NSMergeByPropertyStoreTrumpMergePolicyNSRollbackMergePolicy,这样就省去了 reset 操作。实际上,采用这种方案不如上一个策略来得方便。
2.不管其他 context 中发生什么变化,当前 context 进行保存时直接覆盖 persistent store 里的版本,这种方案下 context 的合并策略需要采用NSOverwriteMergePolicyTypeNSMergeByPropertyObjectTrumpMergePolicy,而且执行保存时不会返回错误,不需要后续的处理。

小结

同步多个 context 是个比较复杂的事情,需要根据具体的需要来设定 context 的合并策略以及选择同步的时机,不仅仅限于以上的两种策略,融合两种策略也可以,当然那样可能会大大增加复杂度,更容易导致 Bug。另外,还有一种使用 child context 的方法,就是将其他 context 作为 context 的 parentContext,这种方法没有研究,自己有兴趣可以试试。
1.同步问题第一原则:不要跨线程使用 managed object,而应该通过其对应的 objectID,在其他线程里的 context 里来获取对象。
2.NSManagedObjectContext的合并方法mergeChangesFromContextDidSaveNotification(_ notification:)可以替完全复制另一个 context 的状态;如果你不想完全复制,可以使用更精确的方法refreshAllObjects(),这是 iOS 9 中推出的新方法;或者手动处理,当然,不推荐这么做。
3.利用NSMergePolicy来处理同步相对而言危险一点,你得明确知道你在做什么。

最后一站:大量数据操作

从上面的内容可以得知,在多线程环境下同步数据基本上不需要我们手动去处理 managed objects 的同步,因此处理大量数据的同步,关注的重点更多在于内存占用和性能。写代码要记住以下几点:
1.涉及大量数据的操作尽量要放在后台线程里处理,防止阻塞主线程;对于多 context 的结构,可以参考这篇文章《Concurrent Core Data Stacks – Performance Shootout》,作者通过验证,证明了「设置一个 persistent store coordinator 和两个独立的 contexts 被证明了是在后台处理 Core Data 的好方法」。
2.能够保持 faults 状态的 managed objects 尽量不要触发 fire,降低内存占用,同时也能提升响应速度。
3.fetch 大量数据时注意技巧,可以通过利用 predicate 来筛选实际需要的数据,限制 fetch 的总数量,设定合适的批量获取数量来降低 IO 的频次,这些需要在实际环境中寻找平衡点。
4.尽量让 context 中的 entity 类别少一些,降低对同步的需求。
(从 iOS 8 开始,Core Data 在性能方面有了较大的提升,尽量合理利用。)
5.使用异步请求 Asynchronous Fetch,尽管可以将 fetch 大量数据的操作放在后台线程里,但是这样依然会阻塞那个线程,使用异步请求,则依然可以在后台线程里进行其他操作,并且还有方便的进度提示和取消功能。
6.使用批量更新 Batch Update,有效降低内存占用并大幅提升保存的速度。以往在NSManagedObjectContext中进行保存时,只能将其中包含的变化进行保存,而 Batch Update 则是直接对 persistent store 进行更新而不需要将 managed objects 读入内存,可以大幅降低内存占用而且更新速度提升不少。但需要注意的是,使用批量更新并不会提醒 context,需要我们对 context 手动进行更新,而且没有进行有效验证,也需要开发者来保证有效性。
7.使用批量删除 Batch Delete,与批量更新类似,直接对 persistent store 进行操作,效率非常高,也有着和批量更新类似的问题。
8.使用 iOS 9 新增的NSManagedObjectContext的新 API:refreshAllObjects(),该方法会对 context 中注册的所有对象进行刷新,还没有保存的变化也会得到保留,这样就可以解放 6 和 7 中的手动更新工作。

上一篇下一篇

猜你喜欢

热点阅读