Core Data性能优化及多上下文操作

1 前言

尽管CoreData内部已经做了大量优化,但是由于不合理的模型和效率低下的自定义查找和抓取逻辑仍然可能会降低CoreData的效率。CoreData的性能是在内存占用率和速度之间平衡的结果,一个好的APP需要使用尽可能的少的内存占有率达到相对快的效率。

Measure, Change, Verify
对于每一次性能调优,应当遵循Measure:测试当前性能, change:做出适当修改, verify:验证是否达到优化目的。直至最终达到要求的性能。

2 性能优化

CoreData优化主要是从设计合理的模型NSManagedObjectModle,设计合理的查询逻辑两个方向出发:

2.1 合理的模型NSManagedObjectModle

在优化模型时,通常可以使用XCode自带的内存管理工具来查看程序内存占用,优化程序性能。

当CoreData访问内存中一调记录时,会将这条记录的所有属性和关系的地址加载到内存中。这意味着尽管只访问了一条记录Record的name属性时,CoreData会将Record的所有属性加载到内存中。这个操作需要从两个角度考量,一方面系统需要时间取磁盘上读取这些数据,另一方面系统需要内存空间用于存储这些数据。因此如果Record对象中存在大容量数据如NSData类型的Image时,读取大量的Record对象时会耗费大量时间,也会占用大量内存,显然这不是合理的模型。

此时这些属性应单独抽像成一个实体Attachment,通常在Record中只保存一个Attachment的关系,这样Image只有在访问Attachment时才会被加载到内存中,从而降低内存的使用率和提高程序执行速度。

2.2 合理的查询逻辑

在优化查询逻辑时,可以通过XCode自带的Instrument工具中的CoreData模板来分析优化查询效率。注意改方法只能使用模拟器,因为真机中不包含该方法所必须的DTrace工具。(目前在macOS 10.12,Xcode 8.3无法抓取任何数据,App Developer论坛上也未发现解决方案,可能是一个系统Bug)。另外通过XCTest也可以测试CoreData的性能,通过这种方式能判断出某个查询逻辑执行所需要的时间。其使用方法如下。

func testTotalEmployeesPerDepartment() {
  measureMetrics([XCTPerformanceMetric_WallClockTime], automaticallyStartMeasuring: false) { 
    let departmentList = DepartmentListViewController()
    departmentList.coreDataStack = CoreDataStack(modelName: "EmployeeDirectory")
    self.startMeasuring()
    _ = departmentList.totalEmployeesPerDepartment()
    self.stopMeasuring()
  }
}

为了优化程序性能,必须很好的平衡每次查询记录的数量和内存的消耗率。高效率的查询逻辑不会查询冗余的数据。

2.2.1 分批抓取

在以Tableview展示所有Person对象的案例中,初次进入Tableview视图并不需要将数据库中所有的Person对象都查询出来。这类情况可以通过CoreData的fetchBatchSize进行优化,通过设置batchSize,CoreData每次只查询指定数量的记录,当需要更多数据的时候,CoreData会自动执行新的批次查询操作。fetchBatchSize通常指定为当前页面需要展示的记录量的两倍。

2.2.2 谓词NSPredicate

当使用复合谓词时,尽量将更容易给数据分类的条件放在前面。例如使用如下格式的谓词“(active == YES) AND (name CONTAINS[cd] %@)”会比使用后面这个谓词"(name CONTAINS[cd] %@) AND (active == YES)"更高效。更多的谓词使用方法见官网文档。

2.2.3 查询类型FetchType和表达式NSExpression
dictionaryResultType

将fetchRequest的resultType设置为dictionaryResultType,并配置合适的NSExpression对数据进行统计可以极大的提示查询效率。NSExpression可以提供多种函数的统计工作,如count、sum、min等函数,具体用法见官方文档,这里只演示count方法。

func totalEmployeesPerDepartmentFast() -> [[String: String]] {
  //1 创建NSExpressionDescription命名为“headCount”
  let expressionDescreption = NSExpressionDescription()
  expressionDescreption.name = "headCount"
  
  //2 创建函数统计每个"department"的成员数量,更多的函数关键字如average,sum,count,min等见NSExpression文档
  expressionDescreption.expression =
    NSExpression(forFunction: "count:",
                 arguments: [NSExpression(forKeyPath: "department")])
  
  //3 通过设置propertiesToFetch初始化fetch的内容,这样CoreData就不会查寻每条记录的所有数据,这里只查询"department"属性,并通过expressionDescreption函数记录不同"department"的数量。
  let fetchRequest: NSFetchRequest<NSDictionary> = NSFetchRequest(entityName: "Employee")
  // 这两个参数都是必须的,第一个"department"只会关注对应的属性并不会关注统计,其对应结果是【"department":name】的字典,第二个参数expressionDescreption只关注统计结果并不关注具体是哪一个department,其结果是【"headCount":value】的字典
  fetchRequest.propertiesToFetch = ["department", expressionDescreption]
  //查询结果以"department"分组,这样将返回一个数组
  fetchRequest.propertiesToGroupBy = ["department"]
  fetchRequest.resultType = .dictionaryResultType
  
  //4 执行查询操作
  var fetchResults: [NSDictionary] = []
  do {
    fetchResults = try coreDataStack.mainContext.fetch(fetchRequest)
  } catch let error as NSError {
    print("ERROR: \(error.localizedDescription)")
    return [[String: String]]()
  }
  //5 查询的结果是一个[NSDictionary],其中元素个数取决于fetchRequest.propertiesToGroupBy的分组个数,每个字典的元素个数取决于fetchRequest.propertiesToFetch中的个数。在上述两个属性都未设置时,其结果为[NSManagedObject]。
  return fetchResults as! [[String: String]]
}
执行Context的count方法

在查找一个实体的数量,并且并不关心其具体属性时可以使用NSManagedContext的count方法。

func salesCountForEmployeeFast(_ employee: Employee) -> String {
  let fetchRequest: NSFetchRequest<Sale> = NSFetchRequest(entityName: "Sale")
  let predicate = NSPredicate(format: "employee == %@", employee)
  fetchRequest.predicate = predicate
  
  let context = employee.managedObjectContext!
  
  do {
    let results = try context.count(for: fetchRequest)
    return "\(results)"
  } catch let error as NSError {
    print("Error: \(error.localizedDescription)")
    return "0"
  }
}
小结

在优化查询逻辑的时候,当实体Employee有一对多的关系Sales时,当只需要知道某个Employee有多少个Sale时除了上述两种方法查询数量,还可以直接通过Employee的关系Salse集合数量直接获取。

func salesCountForEmployeeSimple(_ employee: Employee) -> String {
  return "\(employee.sales.count)"
}

该方法代码结构较前两个方法更简单,易于理解。在效率方面,经过XCTest,这种方式耗时介于上述两个方法之间,因为当访问关系时尽管不会像第一个方法中那样查询所有的Sales对象,但是仍会访问该Employee的所有Sales对象,并将它们加载到内存中。

3 多上下文操作(Multiple Managed Object Contexts)

在CoreData中,通常使用和主线程关联的Context(使用CoreDataStack初始化时即是Container中的viewContext)执行存储和修改操作。如果直接使用GCD的方式进行上述的多线程操作是线程不安全的,需要利用CoreData提供的多上下文操作(Multiple Managed Object Contexts),CoreData内部负责处理线程安全问题。

对于一个APP,尽管大多数任务在主线程使用一个ManagedContext即可,当导入的文件过大需要大量时间时,或者当希望对一些实例做一些临时的编辑并且并不希望将其存到数据库中的时候,仍需要使用多个ManagedContext。在CoreData中,每个ManagedContext的.concurrencyType属性有三中类型:

  • ConfinementConcurrencyType:这种类开发者需要手动管理线程的转换,这类通常不用。
  • PrivateConcurrencyType:这类Context将会和一个私有的分发队列相关联,其中的任务不会阻塞主线程,Container中调用performBackgroundTask或者newBackgroundContext得到的都是这类Context。
  • MainQueueConcurrencyType:这类Context和主队列关联,其中的任务会阻塞主线程,和PersistenceStore直接关联的mainContext就属于这类,Container中调用viewContext得到的也是这类context。通常CoreData中绝大部分操作也是由这类Context完成。

当NSManagedObjectContext创建时指定了其关联的队列时,它提供了两个对象方法perform和performAndWait分别用于同步和异步执行任务。调用上述两个方法时CoreData会讲block中的代码切换到context关联的线程中执行。这里需要注意的是,performAndWait可以嵌套使用,不会出现线程死锁。

3.1 耗时任务处理

当需要执行某个耗时任务时可以使用后台上下文执行任务,可以通过新建一个类型为PrivateConcurrencyType的上下文Context并将其和当前的Coordinator关联。但是通常直接调用Container的performBackgroundTask方法。

coreDataStack.storeContainer.performBackgroundTask { (context) in
  var results: [JournalEntry] = []
  do {
    results = try context.fetch(self.surfJournalFetchRequest())
  } catch let error as NSError {
    print("Error: \(error.localizedDescription)")
  }
}

3.2 临时编辑任务

首先,需要了解CoreData中NSManagedContext和NSPersistentStore之间的关系。NSPersistentStore可以和多个Context关联,但是通常NSPersistentStore和一个主mainContext关联,当mainContext中执行编辑操作后,执行提交存储后会直接改变数据库,但是当mainContext的子上下文childContext执行提交存储后,其改动只会被提交到mainContext中,只有当mainContext下次提交时,数据库才会做改动。

当执行了某些编辑操作,并希望将这些操作单独保存在一个临时的区域,在后面某个时刻决定提交还是丢弃。此时就可以通过为mainContext创建一个子上下文childContext来完成相关操作。这里需要注意在CoreData中对一个实例对象的创建、编辑和删除操作必须位于同一个上下文中。在多上下文操作实例对象时,正确的方法是新建一个上下文childContext,将其parent属性设置为mainContext,再通过mainContext创建需要编辑的实体objectMain,此时CoreData会自动在childContext中关联一个新的实体objectChild,可以通过objectMain的objectid得到。最后使用childContext和objectChild进行相关编辑即可。

guard let navigationController = segue.destination as? UINavigationController,
  let detailViewController = navigationController.topViewController as? JournalEntryViewController,
  let indexPath = tableView.indexPathForSelectedRow else {
    fatalError("Application storyboard mis-configuration")
}

let surfJournalEntry = fetchedResultsController.object(at: indexPath)

let childContext = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
childContext.parent =  coreDataStack.mainContext

let childEntry = childContext.object(with: surfJournalEntry.objectID) as? JournalEntry

//这里需要注意的是实体object对于context是weak弱引用,因此都需要传递给下一个接收者
detailViewController.journalEntry = childEntry
detailViewController.context = childContext
detailViewController.delegate = self

上面代码展示了修改已有实体需先从mainContext中取出,再由objectid获得,但是当新建实体时可以直接使用childContext创建,CoreData同样也会在mainContext中创建对应的实体,并且当childContext执行save方法后,这些改变会被提交到mainContext中。

guard let navigationController = segue.destination as? UINavigationController,
  let detailViewController = navigationController.topViewController as? JournalEntryViewController else {
    fatalError("Application storyboard mis-configuration")
}

let childContext = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
childContext.parent = coreDataStack.mainContext

let newJournalEntry = JournalEntry(context: childContext)

detailViewController.journalEntry = newJournalEntry
detailViewController.context = newJournalEntry.managedObjectContext
detailViewController.delegate = self
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,098评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,213评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,960评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,519评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,512评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,533评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,914评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,574评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,804评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,563评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,644评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,350评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,933评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,908评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,146评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,847评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,361评论 2 342

推荐阅读更多精彩内容

  • 1 前言 CoreData不仅仅是数据库,而是苹果封装的一个更高级的数据持久化框架,SQLite只是其提供的一种数...
    RichardJieChen阅读 2,972评论 2 2
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,580评论 18 139
  • 从三月份找实习到现在,面了一些公司,挂了不少,但最终还是拿到小米、百度、阿里、京东、新浪、CVTE、乐视家的研发岗...
    时芥蓝阅读 42,169评论 11 349
  • 不知不觉入手kindle已经一年了,周围很多人问用起来怎么样,在我的影响下已经又有好几个人买了,有自己用的有买给...
    采采二小乙阅读 3,838评论 3 5
  • 今天在网上借阅了《成为乔布斯》,感受颇多,之前看过《乔布斯传》,由于当时是帮主刚过世,所以是被舆论推着读的,而且感...
    哪儿黑阅读 586评论 1 5