WWDC20 10017 - Core Data 杂项与准则
本文是对 WWDC 2020 Core Data: Sundries and maxims Session 的翻译。
本 Session 将从三个方面介绍如何优化 Core Data 性能:
- 使用批量操作
- 定制查询操作
- 响应变化通知
下面会以这个地震信息列表 Demo 为例,说明上面的内容:
示例中,从 USGS(美国地质调查局) 获取到 JSON Feed 通过 JSON Parser 解析后,通过 Background Context 存入到持久化存储中,View Context 合并过数据后交给显示层。
在上面的过程中,大量的 Managed Object 在被创建保存之后立即就被废弃了。这正是批量操作的使用场景。
批量操作
批量操作在保持轻量的同时支持插入、更新和删除,但它没有提供结果的通知或回调。一个解决方案是开启持久化历史(Persistent History),这样我们可以得到批量操作的通知。至于回调,我们可以通过解析持久化历史来找出对应的变化。
storeDesc.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
接下来会详细解说各个批量操作。
批量插入
iOS 14 在 NSBatchInsertRequest
上新增了一套基于 Block 的批量插入 API:
// NSBatchInsertRequest.h
@available(iOS 13.0, *)
open class NSBatchInsertRequest : NSPersistentStoreRequest {
open var resultType: NSBatchInsertRequestResultType
// iOS 13 旧接口,通过数组批量插入
public convenience init(entityName: String, objects dictionaries: [[String : Any]])
public convenience init(entity: NSEntityDescription, objects dictionaries: [[String : Any]])
// iOS 14 新增,通过 Block 批量插入
@available(iOS 14.0, *)
open var dictionaryHandler: ((inout Dictionary<String, Any>) -> Void)?
open var managedObjectHandler: ((inout NSManagedObject) -> Void)?
public convenience init(entity: NSEntityDescription, dictionaryHandler handler: @escaping (inout Dictionary<String, Any>) -> Void)
public convenience init(entity: NSEntityDescription, managedObjectHandler handler: @escaping (inout NSManagedObject) -> Void)
}
举个🌰,不使用 Batch 逐个插入新对象:
// Earthquakes Sample - Regular Save
for quakeData in quakesBatch {
// 逐个创建 Entity
guard let quake = NSEntityDescription.insertNewObject(forEntityName: "Quake", into: taskContext) as? Quake else { ... }
do {
// 逐个填充数据
try quake.update(with: quakeData)
} catch QuakeError.missingData {
...
taskContext.delete(quake)
}
...
}
do {
try taskContext.save()
} catch { ... }
使用老的数组式 Batch Request 插入对象:
// Earthquakes Sample - Batch Insert
// 构建数组
var quakePropertiesArray = [[String:Any]]()
for quake in quakesBatch {
quakePropertiesArray.append(quake.dictionary)
}
let batchInsert = NSBatchInsertRequest(entityName: "Quake", objects: quakePropertiesArray)
var insertResult : NSBatchInsertResult
do {
insertResult = try taskContext.execute(batchInsert) as! NSBatchInsertResult
...
}
使用 iOS 14 新加的 Block 式 Batch:
//Earthquakes Sample - Batch Insert with a block
var batchInsert = NSBatchInsertRequest(entityName: "Quake", dictionaryHandler: {
(dictionary) in
if (blockCount == batchSize) {
// 返回 true 表示结束
return true
} else {
dictionary = quakesBatch[blockCount]
blockCount += 1
}
})
var insertResult : NSBatchInsertResult
do {
insertResult = try taskContext.execute(batchInsert) as! NSBatchInsertResult
...
}
让我们来看看这三种方式在插入大量数据时性能上的差别:
方式 | 操作耗时 | 内存 |
---|---|---|
无 Batch | 62s | 31M |
iOS 13 数组式 Batch | 30s | 25.2 M |
iOS 14 Block 式 Batch | 11s | 24.3 M |
Block 式 Batch 无论在耗时还是在内存峰值上都有最好的表现。非 Batch 的耗时主要花费在合并数据更变 Notification 上了。
自动合并对象
在 Core Data 文件的 Entities 的 右侧 Core Data Inspector 编辑栏 ,将属性加入到 Constraints 后,就在这个属性上建立了一个 Unique 的约束。设置 mergePolicy
可以让 Core Data 自动支持 Model 更新。
managedObjectContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
批量更新
NSBatchUpdateRequest
支持快速批量的更新数据,我们无需再经历查询、更新、保存的老流程。下面的代码将所有 magitute
大于 2.5
的数据的 validate
属性更新为 true
:
// Earthquakes Sample - Batch Update
let updateRequest = NSBatchUpdateRequest(entityName: "Quake")
updateRequest.propertiesToUpdate = ["validated" : true]
updateRequest.predicate = NSPredicate("%K > 2.5", "magnitude")
var updateResult : NSBatchUpdateResult
do {
updateResult = try taskContext.execute(updateRequest) as! NSBatchUpdateResult
...
}
批量删除
NSBatchDeleteRequest
支持:
- 批量删除对象图中的大部分内容
- 遵守对象关系。删除是级联的,关系也会被置空
- 适用于清理过期对象或控制对象的 TTL(存活时间)
下面的代码在后台线程将删除所有 creationDate
在 30 天之前的数据:
// Batch Delete without and with a Fetch Limit
DispatchQueue.global(qos: .background).async {
moc.performAndWait { () -> Void in
do {
let expirationDate = Date.init().addingTimeInterval(-30*24*3600)
let request = NSFetchRequest<Quake>(entityName: "Quake")
request.predicate = NSPredicate(format:"creationDate < %@", expirationDate)
let batchDelete = NSBatchDeleteRequest(fetchRequest: request)
// batchDelete.fetchLimit = 1000
moc.execute(batchDelete)
}
}
}
代码十分简单但也存在一个问题,如果需要处理的数据足够多,那么会耗费相当长的不受控的时间去执行。为了解决这一问题,可以设置 fetchLimit
,让耗时回到可控的范围内。
定制查询请求
在编写查询请求时,我们可以问问自己“真的需要这么多数据吗”。通常通过裁剪查询请求,我们可以获得更好的性能。NSFetchRequest
的 resultType
属性允许我们控制查询结果的类型。
查询对象
managedObjectResultType
支持以对象作为查询结果,支持对象图的完整遍历,特别适用与 FetchResultController
配合使用。
控制 Batch 数量
在界面上显示出的 Cell 个数只有 15 个,而我们实际拉取的数量远远高于这个数字,这里就存在优化空间。通过控制 fetchBatchSize
,我们可以仅让前排需要展示的对象填充数据(Hydrate)。
批量获取的 Array 与普通的 Array 行为不同。
普通 Array 中所有的数据都是已经填充过的:
Batch Array 中只有当前 Batch 对应的数据会被填充成 Managed Object,其他都是以 ObjectID 存在:
通过开启 fetchBacthSize
,在我们的 Demo 中,内存使用从 17M 降低到了 12M!🎉
按需读取属性
Core Data 允许我们精细的控制需要获取的属性:
@available(iOS 3.0, *)
open var propertiesToFetch: [Any]?
设置了 propertiesToFetch
后,Demo 的应用内存下降了 1.2 M!
CoreData 为了节省内存占用,默认会用一个占位对象表示尚未加载到内存中的关联对象,当第一次访问到这个对象时才触发加载,这个过程被称为 faluting。当我们明确知道某些对象一定会被遍历到,那将这些对象预拉取 可以获得更好的性能。
@available(iOS 3.0, *)
open var relationshipKeyPathsForPrefetching: [String]?
Object IDs
不同于 Object, ObjectID 是线程安全的,因此可以在不同的线程之间传递,适用于只需要进行识别筛选对象的场景。
字典数据
通过将 resultType
设置为 dictionaryResultType
,查询结果会以字典的形式展示。这些轻量的可以安全的传递到其他线程,同时也支持一些复杂的数据统计操作。下面的代码以地区 (place
) 为单位计算了震幅的平均值:
// Fetch average magnitude of each place
let magnitudeExp = NSExpression(forKeyPath: "magnitude")
let avgExp = NSExpression(forFunction: "avg:", arguments: [magnitudeExp])
let avgDesc = NSExpressionDescription()
avgDesc.expression = avgExp
avgDesc.name = "average magnitude"
avgDesc.expressionResultType = .floatAttributeType
let fetch = NSFetchRequest<NSFetchRequestResult>(entityName: "Quake")
fetch.propertiesToFetch = [avgDesc, "place"]
fetch.propertiesToGroupBy = ["place"]
fetch.resultType = .dictionaryResultType
数量统计
通过将 resultType
设置为 countResultType
,可以获得查询结果的数量统计。简单实用,不多说了。
响应通知
Core Data 提供了丰富的通知,让应用可以实时得知数据的变化。本文将着重介绍 ObjectID
通知和远端改变通知。
objectID
通知
iOS 14 中新增了 ObjectID 相关的通知,与 Managed Object 的通知相对应,是由持久性历史事务生成。这些通知也终于 Swift 化。
//NSManagedObjectContext.h
@available(iOS 14.0, *)
extension NSManagedObjectContext {
public static let willSaveObjectsNotification: Notification.Name
public static let didSaveObjectsNotification: Notification.Name
public static let didChangeObjectsNotification: Notification.Name
// ObjectID
public static let didSaveObjectIDsNotification: Notification.Name
public static let didMergeChangesObjectIDsNotification: Notification.Name
}
//NSManagedObjectContext.h
@available(iOS 14.0, *)
extension NSManagedObjectContext {
public enum NotificationKey : String {
case sourceContext
case queryGeneration
case invalidatedAllObjects
case insertedObjects
case updatedObjects
case deletedObjects
case refreshedObjects
case invalidatedObjects
// Object ID
case insertedObjectIDs
case updatedObjectIDs
case deletedObjectIDs
case refreshedObjectIDs
case invalidatedObjectIDs
}
}
远端更变通知
远程更变通知提供的信息量非常大,在我们进程内外的所有操作都会使 CoreData 客户端发送一个通知 。这允许我们避免轮询查询变化,而是由通知驱动。
开启远端更变通知也非常简单:
storeDesc.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
storeDesc.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
开启了远程改变通知后,应用可以接收到进程外的数据改变(如 Share Extension),并且可以知道是哪个进程,在什么时刻,在哪里改变了什么数据。
值得一提的是,对于持久化历史的查询也同样支持通过条件裁剪:
let changeDesc = NSPersistentHistoryChange.entityDescription(with: moc)
let request = NSFetchRequest<NSFetchRequestResult>()
// Set fetch request entity and predicate
request.entity = changeDesc
// 仅查询指定 ID 的数据
request.predicate =
NSPredicate(format: "%K = %@",changeDesc?.attributesByName["changedObjectID"], objectID)
// Set up history request with distantPast and set fetch request
let historyReq = NSPersistentHistoryChangeRequest.fetchHistory(after: Date.distantPast)
historyReq.fetchRequest = request
let results = try moc.execute(historyReq)
总结
- 尽可能使用批量操作
- 按需查询
- 充分利用通知
Tags: WWDC, Core Data