4.2 加载并显示大型数据源

原文链接: https://developer.apple.com/documentation/swiftui/loading-and-displaying-a-large-data-feed

4.2 加载并显示大型数据源

在后台消费数据,并通过批量导入和防止重复记录来降低内存占用。

概述

这个示例创建了一个应用,它通过消费美国地质调查局(USGS)的实时数据源,显示过去 30 天内记录到的美国地震列表。

要加载 USGS 的 JSON 数据源,请执行下列任一操作:

  • 在 iOS 上,下拉刷新 List。
  • 在 iOS 和 macOS 上,按下刷新按钮(⌘R)。

应用会在 URLSession 的默认委托队列上加载所请求的数据,那是一个在后台运行的操作队列。数据源下载完成、会话数据任务结束后,应用会继续在这个队列上把大量数据源元素导入存储,而不会阻塞主队列。

注意:这个示例代码项目与 WWDC21 的 10017:把 Core Data 并发带到 Swift 和 SwiftUI 场次相关。

在后台导入数据

要在后台导入数据,应用可以使用一到两个托管对象上下文。这个示例使用了两个(NSManagedObjectContext)实例:

  • 一个主队列上下文,用来向用户界面提供数据。
  • 一个私有队列上下文,用来在后台队列上执行导入。

两个上下文都连接到同一个 persistentStoreCoordinator。这种配置比使用嵌套上下文更高效。

示例通过 NSPersistentContainer 搭建 Core Data 栈来创建主队列上下文,后者会在其 viewContext 属性中初始化一个主队列上下文。

1
let container = NSPersistentContainer(name: "Earthquakes")

调用持久化容器的 newBackgroundContext() 方法,创建一个私有队列上下文。

1
let taskContext = container.newBackgroundContext()

数据源下载完成后,示例用任务上下文在后台消费该数据源。在 Core Data 中,每个基于队列的上下文都有自己的串行队列,应用必须把操作该上下文的任务与队列串行化,做法是用 perform(_:)(带或不带 await 关键字)或 performAndWait(_:) 闭包把代码包起来。

1
try await taskContext.perform {

关于并发的更多信息,参见 NSManagedObjectContext。

为高效处理大型数据集,示例使用 NSBatchInsertRequest,它直接访问存储——不与上下文交互、不触发任何键值观察,也不分配托管对象。NSBatchInsertRequest 的闭包式构造器让应用可以在 Core Data 调用 dictionaryHandler 闭包时一次提供一条记录,这有助于应用保持较低的内存占用,因为无需为所有记录准备缓冲区。

1
2
3
4
5
6
let batchInsertRequest = self.newBatchInsertRequest(with: propertiesList)
if let fetchResult = try? taskContext.execute(batchInsertRequest),
   let batchInsertResult = fetchResult as? NSBatchInsertResult,
   let success = batchInsertResult.result as? Bool, success {
    return
}

合并改动并更新用户界面

由于 NSBatchInsertRequest 绕过上下文、不会触发 NSManagedObjectContextDidSaveNotification 通知,需要用改动更新界面的应用有两种选择:

  • 通过解析存储的持久化历史提取相关改动,再把它们合并到视图上下文中。关于持久化历史跟踪的更多信息,参见消费相关的存储改动。
  • 从存储重新获取数据。不过,如果视图上下文被固定到某个查询代(query generation),就需要在获取数据之前重置该上下文。关于查询代的更多信息,参见在存储变化时访问数据。

这个示例用持久化存储远程变更通知和持久化历史跟踪来更新界面,原因是:

  • 数据模型只包含一个实体,因此所有改动都与 List 相关,不需要解析历史中的具体改动。
  • FetchRequest 直接从存储获取并取出结果,List 会自动刷新其内容。
  • SwiftUI 只关心视图上下文,因此 QuakesProvider 观察 NSPersistentStoreRemoteChange 通知,把后台上下文(执行批量操作的那个)的改动合并到视图上下文中。

把存储描述的 NSPersistentStoreRemoteChangeNotificationPostOptionKey 选项设为 true,即可为持久化存储启用远程变更通知。

1
2
description.setOption(true as NSNumber,
                      forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)

把 NSPersistentHistoryTrackingKey 选项也设为 true,即可为持久化存储启用持久化历史跟踪。

1
2
description.setOption(true as NSNumber,
                      forKey: NSPersistentHistoryTrackingKey)

每当持久化存储内发生改动——包括其他进程的写入——存储都会发出远程变更通知。示例收到该通知后,会获取在给定令牌之后发生的持久化历史事务和改动。持久化历史变更请求取出历史之后,示例把每个事务的 objectIDNotification() 通过 mergeChanges(fromContextDidSave:) 合并到视图上下文中。

1
2
3
4
5
let changeRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: lastToken)
let historyResult = try taskContext.execute(changeRequest) as? NSPersistentHistoryResult
if let history = historyResult?.result as? [NSPersistentHistoryTransaction] {
    return history
}

每执行完一个 NSBatchInsertRequest 或 NSBatchDeleteRequest,示例都会把任何界面更新派发回主队列,以便在 SwiftUI 中渲染。

1
2
3
4
5
6
7
let viewContext = container.viewContext
let tokens = await viewContext.perform {
    history.map { (transaction: NSPersistentHistoryTransaction) -> NSPersistentHistoryToken in
        viewContext.mergeChanges(fromContextDidSave: transaction.objectIDNotification())
        return transaction.token
    }
}

合并完最后一个事务的改动之后,示例需要把该令牌存到内存或磁盘上,以便在后续的持久化历史变更请求中使用。

分批处理以降低内存占用

当应用在上下文中获取或创建对象时,Core Data 会缓存该对象,以免应用再次使用这些对象时还要往返存储文件。然而,随着处理的对象越来越多,这种做法会增大应用的内存占用,最终可能导致 iOS 上的低内存警告或应用被终止。NSBatchInsertRequest 不会明显增加应用的内存占用,因为它不会把数据加载到内存中。

注意:需要在早于 iOS 13 或 macOS 10.15 的系统上运行的应用,必须通过分批处理对象、并在每批之后调用 reset() 重置上下文,来避免内存占用增长。

示例把 viewContext 的 automaticallyMergesChangesFromParent 属性设为 false,以免 Core Data 在每次后台上下文存储时都自动合并改动。

1
container.viewContext.automaticallyMergesChangesFromParent = false

防止存储中出现重复数据

示例应用每次重新加载 JSON 数据源时,解析出的数据都包含过去一个月的全部地震记录,因此可能与已导入的数据大量重复。为避免创建重复记录,应用约束某个属性、或属性组合,使其在所有实例中唯一。

code 属性唯一标识一条地震记录,因此在 Quake 实体上约束 code,可以确保没有两条已存储记录的 code 值相同。

在数据模型编辑器中选中 Quake 实体。在数据模型检查器中,点击 Constraints 列表下方的 + 按钮添加一个新约束。此时会出现一个约束占位符。

1
comma, separated, properties

双击该占位符进行编辑。输入作为该实体唯一约束的属性名,或者以逗号分隔的属性列表。

1
code

存储新记录时,存储会检查是否已存在被约束属性取值相同的记录。发生冲突时,会启用 NSMergeByPropertyObjectTrumpMergePolicy 策略,由新记录覆盖已有记录的所有字段。

1
container.viewContext.automaticallyMergesChangesFromParent = false