WWDC Quick Look 💓 By SwiftGGTeam
Core Data: Sundries and maxims

Core Data: Sundries and maxims

观看原视频

Highlight

Apple 演示了 Core Data 的 batch insert block 变体、batch update/delete、fetch 裁剪和 ObjectID 通知,帮助开发者降低大批量数据导入、查询和持久化变更响应的内存与时间成本。

核心内容

很多 Core Data App 都有同一个开场:网络拿到一大批 JSON,后台 context 把它们变成 managed object,保存到本地 store,再让 view context 合并变化来更新 UI。Earthquakes 示例就是这样工作,它从 USGS feed 读取地震数据。数据量一大,后台 context 会创建或 fetch 出很多对象,这些对象保存后很快就被丢掉。(00:32

这场 session 的主线是把这类重活搬到更轻的 Core Data 路径上。批量操作直接处理 insert、update、delete,可以减少 managed object 的创建成本。它们不会发出普通 save notification,也不会触发 accessor 或 callback,所以 Apple 把 persistent history 放在同一条链路里:先记录批量操作,再用历史记录驱动通知和局部刷新。(01:16

后半段讨论 fetch 和通知。列表页只显示十几行数据时,没有必要把全部对象都 hydrate 到内存;跨线程处理时,也不适合直接传 managed object。Core Data 提供 batch size、propertiesToFetch、relationship prefetch、ObjectID result、dictionary result 和 count result。store 被扩展、另一个 App 或 share extension 修改时,ObjectID 通知、remote change notification 和 persistent history 可以让 App 知道具体变化来自哪里。(08:2513:06

详细内容

开启 persistent history 承接批量操作(01:43

批量操作的代价很明确:它们没有普通保存流程,所以不会产生 save notification;它们也没有实例化 managed object,所以 accessor 和 callback 不会被调用。Session 给出的补救方式是开启 persistent history,让 batch operation 被记录下来,后续再从历史记录里找到当前视图关心的变化。

storeDesc.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)

关键点:

  • storeDesc 是 persistent store description。
  • NSPersistentHistoryTrackingKey 开启后,batch insert/update/delete 会进入 persistent history。
  • 这段配置解决的是“怎么知道 store 发生了什么”,不负责自动执行 managed object 的 accessor 逻辑。
  • 如果 UI 只关心某些对象,需要再按 ObjectID 或时间范围筛选 persistent history。

用 NSBatchInsertRequest 降低导入峰值(02:15

NSBatchInsertRequest 最初支持传入字典数组。iOS 14 增加了 handler 形式,可以让 Core Data 反复向你的 block 要下一条字典或 managed object。Transcript 明确说,这能降低导入过程的峰值内存,也能减少对象分配。

//NSBatchInsertRequest.h

@available(iOS 13.0, *)
open class NSBatchInsertRequest : NSPersistentStoreRequest {
    open var resultType: NSBatchInsertRequestResultType

    public convenience init(entityName: String, objects dictionaries: [[String : Any]])
    public convenience init(entity: NSEntityDescription, objects dictionaries: [[String : Any]])

    @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)
}

关键点:

  • 字典数组 initializer 适合已经把输入整理成 [[String: Any]] 的场景。
  • dictionaryHandlermanagedObjectHandler 是 iOS 14 增加的入口。
  • handler 让 Core Data 按需取得下一条数据,避免先构造完整数组。
  • resultType 决定执行后返回什么结果,具体值需要按写入后的合并需求选择。

Earthquakes 示例先展示传统保存:为每条 quake 创建 managed object,填值,然后 save()

//Earthquakes Sample - Regular Save

   for quakeData in quakesBatch {
        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 { ... }

关键点:

  • 循环里会创建大量 Quake managed object。
  • 每个对象都要执行属性更新和错误处理。
  • 最后一次 save() 会带来大量变化合并。
  • Session 的测量结果显示,这条路径导入大量地震数据超过一分钟,idle memory 约 30 MB。

同样的数据改成 block 形式的 batch insert:

//Earthquakes Sample - Batch Insert with a block

var batchInsert = NSBatchInsertRequest(entityName: "Quake", dictionaryHandler: {
    (dictionary) in
        if (blockCount == batchSize) {
            return true
        } else {
            dictionary = quakesBatch[blockCount]
            blockCount += 1
        }
    })
    var insertResult : NSBatchInsertResult
    do {
        insertResult = try taskContext.execute(batchInsert) as! NSBatchInsertResult
        ...
    }

关键点:

  • dictionaryHandler 每次填充一条 quake 字典。
  • 返回 true 表示数据已经填完,Core Data 可以停止请求下一条。
  • taskContext.execute(batchInsert) 直接执行 persistent store request。
  • Session 的测量结果显示,字典数组 batch insert 用 13 秒完成,block ingestion 用 11 秒完成。

用 unique constraint 和 merge policy 做 UPSERT(04:33

Earthquakes feed 每次都会返回最近 30 天的数据。大部分 quake 已经存在,只有少数是新增或更新。示例把 quake 的 code 设为 unique constraint,然后在执行 batch insert 的 context 上设置 merge policy。遇到相同 code 时,Core Data 会更新已有行,而不是删除旧对象再插入新对象。

let moc = NSManagedObjectContext(concurrencyType:
                           NSManagedObjectContextConcurrencyType.privateQueueConcurrencyType)

moc.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy

insertResult = try moc.execute(insertRequest)

关键点:

  • unique constraint 放在 Core Data model 里,示例用的是 quake 的 code
  • NSMergeByPropertyObjectTrumpMergePolicy 让当前对象的属性覆盖 store 中冲突对象的属性。
  • merge policy 必须设置在执行 batch insert request 的 context 上。
  • 这段逻辑对应 transcript 中的 SQL UPSERT:插入时遇到冲突就更新指定属性。

用 batch update/delete 避免拉全量对象(06:10

如果只是把符合条件的 quake 标记为已验证,先 fetch 出所有对象、逐个改属性、再保存,会把很多不需要展示的对象拉进内存。NSBatchUpdateRequest 可以直接把谓词命中的对象属性改掉。

//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
    ...
}

关键点:

  • NSBatchUpdateRequest(entityName:) 指向要更新的 entity。
  • propertiesToUpdate 写出要改的字段和值。
  • predicate 限制更新范围,示例只更新 magnitude > 2.5 的 quake。
  • execute 后返回 NSBatchUpdateResult,不会逐个实例化 managed object。

删除也适合走 batch path。示例删除 30 天前的 quake,并且给 batch delete 设置 fetchLimit,避免一次写锁占用 store 太久。

// 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)
           }
       }
   }

关键点:

  • 清理任务放在 background queue,实际 Core Data 操作仍进入 moc.performAndWait
  • creationDate < expirationDate 是删除条件。
  • NSBatchDeleteRequest(fetchRequest:) 复用 fetch request 定义删除范围。
  • fetchLimit = 1000 把一次删除控制在有界范围内,减少长时间写锁。

按视图需求裁剪 fetch(08:25

Fetch 的默认结果通常是 managed object。它适合完整遍历对象图,也适合让 fetched results controller 跟随对象更新。但列表一次只显示少量行时,可以设置 batch size,让 Core Data 先 hydrate 第一批对象,滚动到后面时再继续加载。Session 的测量里,同样的 Earthquakes 列表从约 17 MB idle memory 降到约 12 MB。

另一个裁剪点是属性和关系。已知 UI 只显示部分字段时,可以设置 propertiesToFetch。已知某条 relationship 很可能被访问时,可以预取对应 key path,避免第一次遍历关系时逐个触发 fault。跨线程传递结果时,ObjectID result 比 managed object 更合适;只读聚合数据可以用 dictionary result。

//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

关键点:

  • NSExpression(forKeyPath: "magnitude") 选中要聚合的属性。
  • NSExpression(forFunction: "avg:", arguments:) 定义平均值计算。
  • NSExpressionDescription 给聚合结果命名并声明结果类型。
  • propertiesToFetch 同时包含聚合字段和分组字段。
  • propertiesToGroupBy = ["place"] 按地点分组。
  • dictionaryResultType 返回轻量只读结果,适合跨线程传递或直接驱动统计视图。

用 ObjectID 通知和 remote change 响应 store 变化(13:16

iOS 14 给 NSManagedObjectContext 增加了更适合 Swift 的 notification name,也加入了 ObjectID 版本的通知。ObjectID notification 是 managed object save notification 的轻量对应物,由 persistent history transaction 提供。

//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

    public static let didSaveObjectIDsNotification: Notification.Name
    public static let didMergeChangesObjectIDsNotification: Notification.Name
}

关键点:

  • willSaveObjectsNotificationdidSaveObjectsNotificationdidChangeObjectsNotification 是现代化后的 Swift 名称。
  • didSaveObjectIDsNotificationdidMergeChangesObjectIDsNotification 传递 ObjectID,避免把完整 managed object 放进通知。
  • 这类通知适合只需要识别“哪些对象变了”的 UI 或后台处理。
  • Session 也列出了新的 NotificationKey,包括 inserted、updated、deleted 对象和 ObjectID 版本。

如果同一个 store 被 share extension、第二个 App 或 Photos extension 修改,App 过去往往要在回到前台时 poll persistent history。开启 remote change notification 后,store 被 Core Data client 修改时可以直接通知当前 App。

storeDesc.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
storeDesc.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)

关键点:

  • NSPersistentStoreRemoteChangeNotificationPostOptionKey 开启 remote change notification。
  • NSPersistentHistoryTrackingKey 让通知的 userInfo 能带上 persistent history token。
  • token 可以继续用于读取 persistent history,并转成 ObjectID 层面的变化。
  • 这条链路覆盖进程内外的 Core Data client 变化,比前台轮询更直接。

persistent history 本身也要裁剪。Session 最后展示了按 ObjectID 查历史变化的请求。

let changeDesc = NSPersistentHistoryChange.entityDescription(with: moc)
let request = NSFetchRequest<NSFetchRequestResult>()

//Set fetch request entity and predicate
request.entity = changeDesc
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)

关键点:

  • NSPersistentHistoryChange.entityDescription(with:) 取得 history change 的 entity description。
  • fetch request 的 predicate 指向 changedObjectID
  • fetchHistory(after:) 定义历史查询起点。
  • historyReq.fetchRequest = request 把普通 fetch 的裁剪条件应用到 history request。
  • 执行后只返回指定 ObjectID 在给定时间之后的变化。

核心启发

  • 做什么:给新闻、天气、行情类 App 的后台导入改用 batch insert。为什么值得做:Session 的 Earthquakes 示例里,传统保存大量对象超过一分钟,batch insert 把同样导入降到十几秒。怎么开始:先把输入数据整理成 entity 属性字典;如果数据源很大,再改成 NSBatchInsertRequest(entityName:dictionaryHandler:),并为 store 开启 persistent history。

  • 做什么:把重复导入的 feed 设计成 UPSERT。为什么值得做:最近 30 天的地震 feed 大多是旧数据,唯一 code 加 merge policy 可以更新已有行,避免删除再插入。怎么开始:在 Core Data model 里给业务唯一键设置 unique constraint,在执行 batch insert 的 context 上设置 NSMergeByPropertyObjectTrumpMergePolicy

  • 做什么:给定期清理任务加 batch delete 和 fetch limit。为什么值得做:清理过期对象会拿 store 写锁;没有上限时,大量匹配对象可能让用户等待很久。怎么开始:用 NSFetchRequest 写出过期条件,创建 NSBatchDeleteRequest,按数据规模设置 fetchLimit,让任务分批运行。

  • 做什么:为大列表做 fetch 裁剪。为什么值得做:Session 展示了 batch size 能把 Earthquakes 列表的 idle memory 从约 17 MB 降到约 12 MB。怎么开始:先确认首屏需要的字段,设置 batch size 和 propertiesToFetch;如果 cell 一定会访问关系,再配置 relationship prefetch。

  • 做什么:用 remote change notification 替代前台轮询。为什么值得做:share extension、第二个 App 或扩展修改同一个 store 时,App 可以通过 notification 和 persistent history token 找到具体变化。怎么开始:同时开启 NSPersistentStoreRemoteChangeNotificationPostOptionKeyNSPersistentHistoryTrackingKey,收到通知后按 ObjectID 或时间范围查询 history。

关联 Session

评论

GitHub Issues · utterances