在文章开始之前,我们先明确两个基本点:
1.CoreData不是线程安全的,NSManagedObject和NSManagedObjectContext只能在自身的线程中使用。
2.NSPersistentStoreCoordinator(PSC)是可以多线程共享,因此一般CoreData Stack中只使用一个。
本篇旨在讨论一种合理的CoreData Stack,下面列出4种常用CoreData Stack,我们将分别讨论它们的优劣以及展示一些基本的测试数据,测试过程或者由此推断的结论若有不妥,欢迎讨论。
Stack A
如图所示,这是最基本的CoreData Stack的形式,也是默认勾选“Use CoreData”后系统默认生成的结构。
关于CoreData入门的文章一般采取这种结构,然后会告诉你“一般的”应用只要这样就够用了。但我们轻易发现,增删改查都在主线程,数据量一大,主线程肯定是要被阻塞的。带来的唯一结果就是用户觉得你的应用很卡,不流畅。
那么问题来了,什么才是“一般的”应用,数据量要多大才算大?
我们不妨从“流畅”的角度来反推,以每秒60帧来算,每帧保持17毫秒左右。
当UI的刷新率低于60Hz,那么就会出现不流畅。
所以我们要保证耗时操作要么限制在17毫秒以内,要么到其他线程去处理。
我做了一些简单的测试,模型的属性保持10个左右,在没有任何优化的情况下,增查两种操作数据量和耗时的关系如下表所示。(iPhone 6s真机测试)
数据量 | 查询耗时(毫秒) | 插入耗时(毫秒) |
---|---|---|
100 | 2.6~3.6 | 14~18 |
1000 | 5~21.6 | 26~65 |
10000 | 56~112 | 302~573 |
100000 | 498~539 | 3400~3500 |
以上测试大致可以知道什么是“一般的”应用:
1.单次查询限制在1000条以内
2.单次插入限制在100条以内
3.结合业务场景、老机型处理能力不同等因素,实际应用中以上两个数字必须更小。
优点:易上手,适合学习和demo演示
缺点:一言不合就阻塞主线程
注意NSFetchedResultsController(FRC)performFetch:
调用时机。调用performFetch:
之前insert数据不会触发代理方法。
Stack B
Stack B是CoreData多线程的经典结构,采用独立的主线程MOC和后台MOC。
这种结构可以让你灵活的运用其它线程来做增删改查,而且Background MOC可以有多个。
Background MOC可以是临时的,创建MOC非常快,不需要考虑性能问题。
我们需要考虑的是,由于NSManagedObject只能在该线程使用,因此后台MOC查询到的数据模型不能在主线程使用。需要根据objectID在主线程MOC中用objectWithID:
方法重新获取数据模型。
下面是StackA和StackB的比较结果,同样是查询了70万条数据并转化成主线程可用的模型:
1.StackA用了1.35秒(直接在主线程查询,不需要转换)
2.StackB用了1.92秒(在后台线程查询,需要根据查询到的objectID,在主线程MOC用objectWithID:
方法获取数据)
其实查询的耗时是一样的,但是StackB需要根据objectID重新获取数据,因此多用了0.55秒的时间。
(这次是用模拟器测试的,切勿和上面真机的测试数据对比)
2016-07-01 16:33:24.172 CoreDataTest[4317:171189] StackA:本次查询740300条数据耗时:1.353634秒
2016-07-01 16:33:30.400 CoreDataTest[4317:171189] StackB:本次查询740300条数据耗时:1.919627秒 在主线程耗时:0.555359秒
这里有个问题:“转换耗时”?
主线程MOC调用objectWithID:
获取NSManagedObject的时间(就叫做“转换耗时”吧)能不能省掉?
虽然70万条数据的转换耗时只有0.55秒,但是能不能把这个做到极致,在主线程一点不卡?
希望看到这里的朋友思考一下,在评论区提出您的想法,万分感谢😊
另外有个问题:NSFetchedResultsController?
我们在Stack A中使用FRC来管理数据,那么在Stack B中如何使用FRC?这是官方文档中的典型应用,但没有多线程的说明。
按照上面的结构图,FRC只放在主线程MOC,因为FRC与UI关系密切,这点似乎理所当然。
细心的朋友应该发现,图中各种关系仅包含Insert、Update和Delete三种操作,这三种操作可以通过NSManagedObjectContextDidSaveNotification
来跨线程合并,那么查询操作呢?
由于NSFetchRequest
没有提供objectID相关方法,我们无法将后台MOC获取的objectID转化为FRC中的fetchedObjects。
一次尝试
这里有个哥们提出在NSFetchRequest
的predicate
中加入@"self IN(%@)", objectIDs
来查询。
我按照他的方法做了测试,步骤:
1.在后台MOC中查询
2.遍历查询结果,得到objectID数组
3.切换到主线程,FRC修改predicate
,调用performFetch:
获取数据
以下是测试结果,看到结果就觉得这方法可行但并不靠谱。
StackB单单在主线程的耗时就比StackA的耗时长,还不如直接在主线程查询。
2016-07-02 10:08:14.423 CoreDataTest[924:41114] StackA:本次查询19766条数据耗时:0.051086秒
2016-07-02 10:08:18.750 CoreDataTest[924:41114] StackB:本次查询19766条数据耗时:0.101769秒 在主线程耗时:0.058902秒
2016-07-02 10:16:20.797 CoreDataTest[967:45523] StackA:本次查询119766条数据耗时:0.231401秒
2016-07-02 10:16:23.882 CoreDataTest[967:45523] StackB:本次查询119766条数据耗时:0.613336秒 在主线程耗时:0.377715秒
小插曲:
测试过程中遇到一个问题,在添加@"self IN(%@)", objectIDs
predicate之后,insert操作无法正常触发FRC的代理方法,我的猜想是predicate约束了FRC的监听范围,它只管理满足这个predicate的对象。
于是我又做了一个小实验,将predicate改成@"title CONTAINS %@",@"1"
,也就是获取名字包含“1”的数据。接着不断insert数据,title是随机5位数字,当随机到名字包含“1”,可以成功insert,而名字不包含“1”则失败。验证猜想。
另一次尝试
这里又有个哥们提出在后台MOC中使用FRC,这样FRC的代理方法都在后台线程调用,如果需要在代理方法中更新UI,切换到主线程更新就可以了。
这样的做法看上去其实挺美好的,但稍有不慎可能就出错了。例如在主线程中修改了属性,甚至读取了属性,都是跨线程使用NSManagedObject。参考文章
目前看来,如果要使用FRC,还是老老实实在主线程查询比较妥当,如果您有更好的方案,求大神指点一二。
优点:独立的MOC可以让你灵活的使用后台线程完成数据库的耗时操作。
缺点:转换耗时问题、FRC的使用问题。
Stack C
Stack C是iOS 5之后的方案,context之间存在父子关系。主线程MOC有了几个后台MOC儿子。
从结构图中可以发现,子context调用save以后,数据不会保存到数据库,而是上交给父context处理(合并到父context)。
子contex解决了查询耗时问题,但是需要增删改大量数据时,最后还是由主线程来操作数据库,会阻塞。
这篇文章给出一个典型的应用场景:一个存在save和cancel按钮的页面,当你修改了某些数据,点击save即提交到父MOC处理,点击cancel则不需要做任何回滚的操作,因为这个临时工MOC跟你的修改数据可以一起丢弃。
另外,父MOC的修改不会自动通知子MOC,而且大多数情况下子MOC只是临时的,也没有必要通知。
优点:不需要写通知合并的逻辑。
缺点:通过主线程增删改会阻塞。
Stack D
Stack D是Stack C的一种改进。它解决了增删改时阻塞主线程的问题。
Temporary Background MOC秉承Stack C的优点,解决查询耗时问题,取消修改也不需要任何回滚操作。
Main MOC的增删改会合并到Writer MOC,不需要我们通过objectID跨线程传递,也不需要写任何合并的逻辑。
对开发者而言,Stack D相当优雅。
但这里有篇文章关于Stack BCD三种结构的性能讨论。有兴趣的朋友可以了解一下,大致的结论是Stack B的性能最优异,Stack D性能最差。
简单总结
- 数据量小,或者可以分批处理的情况,可以使用Stack A
- Stack C基本上没有存在意义,当然如果你的应用查询任务较重、增删改任务非常轻,也可以尝试Stack C
- Stack B和Stack D之间如何选择,前者性能更优,后者代码复杂度低