访问我的博客 www.fatbobman.com[1] 可以获得更好的阅读体验。今天 WWDC 2022 开幕了,欢迎大家在 Discord 频道[2] 中畅聊各自的收获。
Core Data 是 Apple 为其生态提供的拥有持久化功能的对象图管理框架。具备稳定( 广泛应用于苹果的各类系统软件 )、成熟( Core Data 发布于 2009 年,其历史可以追溯到上世纪 90 年代 )、开箱即用( 内置于整个苹果生态系统 )等特点。
Core Data 的优势主要体现在对象图管理、数据描述、缓存、延迟加载、内存管理等方面,但在对持久化数据的操作性能方面表现一般。事实上,在相当长的时间中,Core Data 的竞品总是喜欢通过各种图表来展现它们在数据操作性能上对 Core Data 的碾压之势。
Apple 于数年前起陆续提供了批量更新、批量删除以及批量添加等 API ,在相当程度上改善 Core Data 在处理大量数据时性能劣势。
本文将对 Core Data 的批量操作做以介绍,包括:原理、使用方法、高级技巧、 注意事项等内容。
在官方文档中并没有对批量操作的使用方法进行过多的讲解,苹果为开发者提供了一个持续更新的 演示项目[3] 来展示它的工作流程。本节将按照由易到难的顺序,逐个介绍批量删除、批量更新和批量添加。
批量删除可能是 Core Data 所有批量操作中使用最方便、应用最广泛的一项功能了。
func delItemBatch() async throws -> Int {
// 创建私有上下文
let context = container.newBackgroundContext()
// 在私有上下文线程中执行(避免对视图线程造成影响)
return try await context.perform {
// 创建 NSFetchRequest ,其指明了批量删除对应的实体
let request = NSFetchRequest<NSFetchRequestResult>(entityName: "Item")
// 设置谓词,timestamp 早于三天前的所有 Item 数据 。 不设置谓词则意味着全部 Item 数据均 m
request.predicate = NSPredicate(format: "%K < %@", #keyPath(Item.timestamp),Date.now.addingTimeInterval(-259200) as CVarArg)
// 创建批量删除请求( NSBatchDeleteRequest )
let batchDeleteRequest = NSBatchDeleteRequest(fetchRequest: request)
// 设置返回结果类型
batchDeleteRequest.resultType = .resultTypeCount
// 执行批量删除操作
let result = try context.execute(batchDeleteRequest) as! NSBatchDeleteResult
// 返回批量删除的记录数量
return result.result as! Int
}
}
上面的代码将从持久化数据中( 数据库 )删除所有属性 timestamp
早于当前日期三天前的 Item
实体数据。代码中的注释应该能够清楚地解释全部的批量删除操作过程。
其他需要注意的还有:
context.execute(batchDeleteRequest)
),经由持久化存储协调器直接转发给持久化存储resultType
可以设置批量操作的返回结果类型。共三种:结果状态( statusOnly )、记录数量( count )、所有记录的 NSManagedObjectID ( objectIDs ) 。如果想在批量操作后在同一段代码中将数据变化合并到视图上下文,需要将结果类型设置为 resultTypeObjectIDsaffectedStores
指定仅在某个( 或某几个 )持久化存储中进行批量操作。默认值为在所有持久化存储上操作。该属性在所有批量操作(删除、更新、添加)中作用均相同。关于如何让不同的持久化存储拥有同样的实体模型,请参阅 同步本地数据库到 iCloud 私有数据库中[5] 的对应章节除了通过 NSFetchRequest 来指定需要删除的数据外,还可以使用 NSBatchDeleteRequest 的另一个构造方法,直接指定需要删除数据的 NSManagedObjectID :
func batchDeleteItem(items:[Item]) async throws -> Bool {
let context = container.newBackgroundContext()
return try await context.perform {
// 通过 [NSManagedObjectID] 创建 NSBatchDeleteRequest
let batchDeleteRequest = NSBatchDeleteRequest(objectIDs: items.map(\.objectID))
batchDeleteRequest.resultType = .resultTypeStatusOnly
let result = try context.execute(batchDeleteRequest) as! NSBatchDeleteResult
return result.result as! Bool
}
}
此种方式适合于数据或数据 ID 已被载入内存场景。需要注意的是,所有的 NSManagedObjectID 对应的实体( Entity )必须一致,比如本例中均为 Item 。
批量删除对 Core Data 中的关系提供了有限度的支持,详细内容见下文。
相较于批量删除,批量更新除了需要指定实体以及谓词外( 可省略 ),还要提供需要更新的属性和值。
下面的代码将更新所有 timestamp
晚于三天前的 Item 数据,将其的 timestamp
更新为当前日期:
func batchUpdateItem() async throws -> [NSManagedObjectID] {
let context = container.newBackgroundContext()
return try await context.perform {
// 创建 NSBatchUpdateRequest ,设置对应的实体
let batchUpdateRequest = NSBatchUpdateRequest(entity: Item.entity())
// 设置结果返回类型,本例中返回所有更改记录的 NSManagedObjectID
batchUpdateRequest.resultType = .updatedObjectIDsResultType
let date = Date.now // 当前日期
// 设置谓词,所有 timestamp 晚于三天前的记录
batchUpdateRequest.predicate = NSPredicate(format: "%K > %@", #keyPath(Item.timestamp), date.addingTimeInterval(-259200) as CVarArg)
// 设置更新字典 [属性:更新值] ,可以设置多个属性
batchUpdateRequest.propertiesToUpdate = [#keyPath(Item.timestamp): date]
// 执行批量操作
let result = try context.execute(batchUpdateRequest) as! NSBatchUpdateResult
// 返回结果
return result.result as! [NSManagedObjectID]
}
}
需要注意如下事项:
item.count += 1
仍只能通过传统的手段includesSubentities
设置更新是否包含子实体NSPredicate(format: "attachment.count > 10")
。下面的代码将创建给定数量( amount
)的 Item 数据:
func batchInsertItem(amount: Int) async throws -> Bool {
// 创建私有上下文
let context = container.newBackgroundContext()
return try await context.perform {
// 已添加的记录数量
var index = 0
// 创建 NSBatchInsertRequest ,并声明数据处理闭包。如果 dictionaryHandler 返回 false , Core Data 将继续调用闭包创建数据,直至闭包返回 true 。
let batchRequest = NSBatchInsertRequest(entityName: "Item", dictionaryHandler: { dict in
if index < amount {
// 创建数据。当前的 Item 只有一个属性 timestamp ,类型为 Date
let item = ["timestamp": Date().addingTimeInterval(TimeInterval(index))]
dict.setDictionary( item )
index += 1
return false // 尚未全部完成,仍需继续添加
} else {
return true // index == amout , 已添加了指定数量( amount )的数据,结束批量添加操作
}
})
batchRequest.resultType = .statusOnly
let result = try context.execute(batchRequest) as! NSBatchInsertResult
return result.result as! Bool
}
}
NSBatchInsertRequest 提供了三种添加新数据的构造方法:
下面的代码采用了方法三:
let batchRequest = NSBatchInsertRequest(entityName: "Item", managedObjectHandler: { obj in
let item = obj as! Item
if index < amount {
// 通过属性赋值避免了通过字典添加可能导致的属性名称或值类型错误
item.timestamp = Date().addingTimeInterval(TimeInterval(index))
index += 1
return false
} else {
return true
}
})
其他需要注意的事项:
由于批量操作是直接在持久化存储上完成的,因此必须通过某种方式将变化后的数据合并到视图上下文中,才能将变化在 UI 上体现出来。
可以采用如下两种方式:
func batchInsertItemAndMerge(amount: Int) async throws {
let context = container.newBackgroundContext()
try await context.perform {
var index = 0
let batchRequest = NSBatchInsertRequest(entityName: "Item", dictionaryHandler: { dict in
if index < amount {
let item = ["timestamp": Date().addingTimeInterval(TimeInterval(index))]
dict.setDictionary(item)
index += 1
return false
} else {
return true
}
})
// 设置返回类型必须设置为 [NSManagedObjectID]
batchRequest.resultType = .objectIDs
let result = try context.execute(batchRequest) as! NSBatchInsertResult
let objs = result.result as? [NSManagedObjectID] ?? []
// 创建变动字典。根据数据变化类型,创建不同的键值对。插入:NSInsertedObjectIDsKey、更新:NSUpdatedObjectIDsKey、删除:NSDeletedObjectIDsKey。
let changes: [AnyHashable: Any] = [NSInsertedObjectIDsKey: objs]
// 合并变动
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [self.container.viewContext])
}
}
无论是官方给出的数据,还是开发者的实际测试,Core Data 的批量操作相较于实现相同结果的传统方式( 在托管对象上下文中使用托管对象 )来说都具有相当明显的优势 —— 执行速度快、内存占用小。那么其中的原因是什么呢?为了获得这些优势,“批量操作” 又是牺牲了哪些 Core Data 的重要特性呢?本节将上述问题做一点探讨。
想搞清楚批量操作又快又省的原因,需要对 Core Data 的几大组件之间的协作规则以及数据在各个组件间传递的机制有一定了解。
以从 Core Data 中对获取的结果修改属性值为例,我们简单了解一下各组件之间的协作以及数据的流动( 存储格式为 SQLite ):
let request = NSFetchRequest<Item>(entityName: "Item")
request.predicate = NSPredicate(format: "%K > %@", #keyPath(Item.timestamp), date.addingTimeInterval(-259200) as CVarArg)
let items = try! context.fetch(request)
for item in items {
item.timestamp = Date()
}
try! context.save()
executeRequest()
方法将 “获取请求” 传递给持久化存储协调器( NSPersistentStoreCoordinator )executeRequest(_:with:)
方法,将 “获取请求” 和发起请求的 “上下文” 一并发送给所有的持久化存储( NSPersistentStore )willSave
方法executeRequest(_:with:)
方法didSave()
方法或许上面的步骤已经让你有点头痛,但事实上我们还是省略了相当多的细节。
这些烦琐的操作或许会造成 Core Data 在某些情况下的性能问题,但 Core Data 的强大也同样在这些细节中得以展现。不仅让开发者可以从多个维度、时机来处理数据,同时 Core Data 也将根据数据的状态在性能、内存占用等方面寻找合适的平衡。对于一个成熟的 Core Data 开发者,从整体的收益上来看,Core Data 相较于直接操作数据库或使用其他的 ORM 框架仍是有优势的。
上面使用传统的方式实现的功能与本文之前介绍的批量更新代码完全一样。那么 Core Data 在使用批量更新代码时的内部操作过程是如何的呢?
execute
将持久化存储查询请求( NSBatchUpdateRequest )发送给持久化存储协调器看到这里,我想无须再继续解释批量操作为什么相较于传统操作效率要更高了吧。
所谓有得必有失,Core Data 的批量操作是在放弃了大量的细节处理的基础上换取的效率提升。整个过程中,我们将失去检验、通知、回调机制、关系处理等功能。
因此,如果你的操作要求并不需要上述略过的能力,那么批量操作确实是非常好的选择。
对于更新和删除操作来说,由于批量操作无须将数据提取到内存中( 上下文、行缓存 ),因此整个操作过程中几乎不会造成什么内存的占用。
至于添加新数据的批量操作,dictionaryHandler 闭包( 或 managedObjectHandler 闭包)会在每次构建一个数据后立即将其转换成对应的 SQL 语句并发送给持久化存储,在整个的创建过程中,内存中只会保留一份数据。相较于传统的方法需要在上下文中实例化所有的新添加数据的方式,内存占用也几乎可以忽略不计。
由于批量操作对内存的占用极小,导致开发者在使用批量操作上几乎没有什么心理负担,从而容易在一次操作过程中执行过量的指令。默认情况下 Core Data 为 SQLite 启用了 WAL 模式,当 SQL 事务的量过大时,WAL 文件的尺寸会急速增加并达到 WAL 的预设检查点,容易造成文件溢出,从而导致操作失败。
因此开发者仍需控制每次批量操作的数据规模,如果确实有需要,可以通过设置持久化存储元数据( NSSQLitePragmasOption[8] )的方式,修改 Core Data 的 SQLite 数据库的默认设置。
除了上文中介绍的能力外,批量操作中还有一些其他有用的技巧。
在 Core Data 中,通过在数据模型编辑器中将实体中某个属性( 或某几个属性 )设置为约束,以使此属性的值具有唯一性。
image-20220605145151785
因为 Core Data 的唯一约束是依赖 SQLite 的特性实现的,因此批量操作也自然地拥有了这项能力。
假设,应用程序需要定期从服务器上下载一个巨大的 JSON 文件,并将其中的数据保存到数据库中。如果可以确定源数据中的某个属性是唯一的( 例如 ID、城市名、产品号等等 ),那么可以在数据模型编辑器中将该属性设置为约束属性。当使用批量添加将 JSON 数据保存到数据库时,Core Data 将根据开发者设定的合并策略来进行操作( 有关合并策略的详细内容,请参阅 关于 Core Data 并发编程的几点提示[9]。比如说以新数据为准,或者以数据库中的数据为准。
Core Data 会根据是否在数据模型中开启了约束已经定义了何种合并策略来创建批量添加操作对应的 SQL 语句。例如下面的情况:
INSERT INTO ZQUAKE(Z_PK, Z_ENT, Z_OPT, ZCODE, ZMAGNITUDE, ZPLACE, ZTIME) VALUES(?, ?, ?, ?, ?, ?, ?)
INSERT OR IGNORE INTO ZQUAKEZ_PK, Z_ENT, Z_OPT, ZCODE, ZMAGNITUDE, ZPLACE, ZTIME) VALUES(?, ?, ?, ?, ?, ?, ?)
INSERT INTO ZQUAKE(Z_PK, Z_ENT, Z_OPT, ZCODE, ZMAGNITUDE, ZPLACE, ZTIME) VALUES(?, ?, ?, ?, ?, ?, ?) ON CONFLICT(ZCODE) DO UPDATE SET Z_OPT = Z_OPT+1 , ZPLACE = excluded.ZPLACE , ZMAGNITUDE = excluded.ZMAGNITUDE , ZTIME = excluded.ZTIME
注意:创建约束 与 Core Data with CloudKit 功能冲突,了解哪些属性或功能无法在 Core Data with CloudKit 下开启,请参阅 Core Data with CloudKit(二) —— 同步本地数据库到 iCloud 私有数据库[10]
在以下两种情况下,批量删除可以自动完成关系数据的清理工作:
image-20220605153333679
image-20220605154156584
或许正因为批量删除提供了对部分 Core Data 关系的支持,因此让它成为最常使用的批量操作。
批量操作改善了某些场合下 Core Data 数据操作效率低、内存占用大的问题,使用得当,必将成为开发者的得力工具。
希望本文能够对你有所帮助。
[1] www.fatbobman.com: https://www.fatbobman.com
[2] Discord 频道: https://discord.gg/ApqXmy5pQJ
[3] 演示项目: https://developer.apple.com/documentation/swiftui/loading_and_displaying_a_large_data_feed
[4] NSPersistentStoreRequest: https://developer.apple.com/documentation/coredata/nspersistentstorerequest
[5] 同步本地数据库到 iCloud 私有数据库中: https://www.fatbobman.com/posts/coreDataWithCloudKit-2/#在不同的_Configuration_中放置同一个_Entity
[6] 在 CoreData 中使用持久化历史跟踪: https://www.fatbobman.com/posts/persistentHistoryTracking/
[7] Core Data 是如何在 SQLite 中保存数据的: https://www.fatbobman.com/posts/tables_and_fields_of_CoreData/
[8] NSSQLitePragmasOption: https://developer.apple.com/documentation/coredata/nssqlitepragmasoption
[9] 关于 Core Data 并发编程的几点提示: https://www.fatbobman.com/posts/concurrencyOfCoreData/#设置正确的合并策略)的对应章节
[10] Core Data with CloudKit(二) —— 同步本地数据库到 iCloud 私有数据库: https://www.fatbobman.com/posts/coreDataWithCloudKit-2/#创建可同步_Model_的注意事项