WWDC Quick Look 💓 By SwiftGGTeam
Optimize your use of Core Data and CloudKit

Optimize your use of Core Data and CloudKit

观看原视频

Highlight

Apple 用 NSPersistentCloudKitContainer 示例 App 演示了如何用数据生成器、XCTest、Instruments、统一日志和 sysdiagnose 分析 Core Data 与 CloudKit 同步应用的性能和诊断问题。


核心内容

Core Data 和 CloudKit 同步的问题,常常出现在数据规模变大之后。小数据集运行正常,不代表 10GB 图片数据、首次全量导入、后台导出、推送触发导入时也正常。

这场 Session 的重点是一个开发循环:先构造数据来探索假设,再用工具分析结果,最后收集能让团队或 Apple 复现问题的诊断资料。(00:42

示例 App 的模型很具体:Post 有标题和正文,Post 关联多个 Attachment,图片数据 ImageData 放在 to-one relationship 后面,按需加载。(02:51)这个模型让大图片、关系遍历、同步导入导出都能被稳定测试。

详细内容

1. 用数据生成器制造可重复的大数据集

内置的 “Generate 1000 Posts” 只能验证一种数据形状。Apple 建议把探索动作写成数据生成器(data generator):用固定规则生成特定大小、结构和变化范围的数据,再把它接到测试或调试 UI 里。(03:30

官方示例生成 60 个 Post,每个 Post 有 11 张图片附件,总共 660 张图片。每张图片平均 10 到 20MB,整套数据接近 10GB。(04:34

class LargeDataGenerator {
    func generateData(context: NSManagedObjectContext) throws {
        try context.performAndWait {
            for postCount in 1...60 {
                //add a post               
                for attachmentCount in 1...11 {
                    //add an attachment with an image
                    let imageFileData = NSData(contentsOf: url!)!
               }
            }
        }
    }
}

关键点:

  • LargeDataGenerator 把数据构造逻辑封装成单独类型,测试和 UI 都能调用同一个入口。
  • generateData(context:) 接收 NSManagedObjectContext,生成逻辑不绑定具体容器。
  • context.performAndWait 把 Core Data 写入放到 context 的队列里执行。
  • 外层 1...60 控制 Post 数量,内层 1...11 控制每个 Post 的附件数量。
  • NSData(contentsOf:) 读取真实图片数据,让测试面对真实的大对象,而不是只测空壳对象。

有了这个入口,单元测试可以先验证生成器本身。(05:07

class TestLargeDataGenerator: CoreDataCloudKitDemoUnitTestCase {
    func testGenerateData() throws {
        let context = self.coreDataStack.persistentContainer.newBackgroundContext()
        try self.generator.generateData(context: context)
        try context.performAndWait {                     
            let posts = try context.fetch(Post.fetchRequest())
            for post in posts {
                self.verify(post: post, has: 11, matching: imageDatas)
            }
        }
    }
}

关键点:

  • newBackgroundContext() 给生成器一个后台 context,避免把大量写入放到主 context。
  • try self.generator.generateData(context:) 在测试中生成 10GB 级别的代表性数据。
  • Post.fetchRequest() 取回生成后的 Post。
  • verify(post:has:matching:) 断言每个 Post 都有 11 个图片附件,防止测试数据本身出错。

2. 把同步事件变成 XCTest 的控制点

这场 Session 讨论的是 NSPersistentCloudKitContainer,所以生成数据之后要测试导出和导入。(05:33)官方示例创建一个 export 容器,生成数据,然后等待 .export 事件完成。

func testExportThenImport() throws {
    let exportContainer = newContainer(role: "export", postLoadEventType: .setup)
    try self.generator.generateData(context: exportContainer.newBackgroundContext())
    self.expectation(for: .export, from: exportContainer)
    self.waitForExpectations(timeout: 1200)
}

关键点:

  • newContainer(role:postLoadEventType:) 创建用于导出的 NSPersistentCloudKitContainer
  • generateData(context:) 直接把大数据集写入这个容器的后台 context。
  • expectation(for: .export, from:) 把 CloudKit 导出事件转成 XCTest expectation。
  • waitForExpectations(timeout: 1200) 给大数据上传留出最多 20 分钟。

等待逻辑依赖 NSPersistentCloudKitContainer.eventChangedNotification。(06:35

func expectation(for eventType: NSPersistentCloudKitContainer.EventType,
                 from container: NSPersistentCloudKitContainer) -> [XCTestExpectation] {
    var expectations = [XCTestExpectation]()
    for store in container.persistentStoreCoordinator.persistentStores {
        let expectation = self.expectation(
            forNotification: NSPersistentCloudKitContainer.eventChangedNotification,
            object: container
        ) { notification in
            let userInfoKey = NSPersistentCloudKitContainer.eventNotificationUserInfoKey
            let event = notification.userInfo![userInfoKey]               
            return (event.type == eventType) &&
                (event.storeIdentifier == store.identifier) &&
                (event.endDate != nil)
        }
        expectations.append(expectation)
    }
    return expectations
}

关键点:

  • 方法参数里的 eventType 指定要等待 .setup.export.import
  • 循环遍历 persistentStores,为每个 store 建一个 expectation。
  • eventChangedNotificationNSPersistentCloudKitContainer 发出的事件通知。
  • eventNotificationUserInfoKey 取出通知里的事件对象。
  • event.type == eventType 确认事件类型匹配。
  • event.storeIdentifier == store.identifier 确认事件属于当前 store。
  • event.endDate != nil 确认事件已经结束。

接着,测试创建一个新的 import 容器,使用空 store 文件触发首次导入,观察另一台设备首次下载所有数据时会发生什么。(07:18

func testExportThenImport() throws {
    let exportContainer = newContainer(role: "export", postLoadEventType: .setup)
    try self.generator.generateData(context: exportContainer.newBackgroundContext())
    self.expectation(for: .export, from: exportContainer)
    self.waitForExpectations(timeout: 1200)
    
    let importContainer = newContainer(role: "import", postLoadEventType: .import)
    self.waitForExpectations(timeout: 1200)
}

关键点:

  • 前半段完成大数据集导出。
  • newContainer(role: "import", postLoadEventType: .import) 用新容器模拟空设备首次导入。
  • 第二次 waitForExpectations 等待导入完成。
  • 这个测试把“上传大数据”和“首次下载大数据”放进同一条可重复链路。

同一个生成器也可以接到调试 UI。(08:23

UIAlertAction(title: "Generator: Large Data", 
              style: .default) {_ in
    let generator = LargeDataGenerator()
    try generator.generateData(context: context)
    self.dismiss(animated: true)
}

关键点:

  • UIAlertAction 把生成器暴露为 App 内的调试入口。
  • LargeDataGenerator() 复用测试中的数据构造逻辑。
  • generateData(context:) 在当前 App 数据库里生成同样的大数据集。
  • dismiss(animated:) 收起调试弹窗,让开发者直接观察列表和附件的同步表现。

3. 用 Instruments 找到真正耗时的代码

数据生成器让问题可重复,Instruments 负责告诉你时间花在哪里。Apple 在 Xcode 测试 gutter 里选择 Profile,启动 Time Profiler,直接分析某个 XCTest。(09:42

第一次分析发现,LargeDataGenerator 的重栈在生成缩略图。可是数据模型已经把缩略图设计为从 ImageData 按需计算,所以生成器提前生成缩略图是多余工作。(10:38

func generateData(context: NSManagedObjectContext) throws {
    try context.performAndWait {
        for postCount in 1...60 {
            for attachmentCount in 1...11 {
                let attachment = Attachment(context: context)
                let imageData = ImageData(context: context)
                imageData.attachment = attachment
                imageData.data = autoreleasepool {
                    let imageFileData = NSData(contentsOf: url!)!
                    attachment.thumbnail = Attachment.thumbnail(from: imageFileData,        
                                                                thumbnailPixelSize: 80)
                    return imageFileData
                }
            }
        }
    }
}

关键点:

  • Attachment(context:)ImageData(context:) 创建 Core Data 对象。
  • imageData.attachment = attachment 建立图片数据和附件的关系。
  • autoreleasepool 控制临时图片数据的释放范围。
  • NSData(contentsOf:) 读取大图片。
  • attachment.thumbnail = Attachment.thumbnail(...) 提前生成缩略图;这正是 Time Profiler 找到的额外成本。

修正后的生成器去掉缩略图生成,只保存原始图片数据。(11:13

func generateData(context: NSManagedObjectContext) throws {
    try context.performAndWait {
        for postCount in 1...60 {
            for attachmentCount in 1...11 {
                let attachment = Attachment(context: context)
                let imageData = ImageData(context: context)
                imageData.attachment = attachment
                imageData.data = autoreleasepool {
                    return NSData(contentsOf: url!)!
                }
            }
        }
    }
}

关键点:

  • 对象创建和关系设置保持不变,测试数据形状没有改变。
  • imageData.data 仍保存真实图片,CloudKit 同步压力还在。
  • 删除 attachment.thumbnail 赋值,让缩略图回到模型设计的按需计算路径。
  • Apple 展示的结果是 generateData 测试运行时间降到原来的十分之一。(11:55

4. 用 Allocations 控制 Core Data 测试的内存上限

Time Profiler 解决了耗时问题,Allocations 揭示了另一个问题:测试运行时内存超过 10GB,几乎整套数据都被保留在内存里。(12:26

问题出在验证代码。它先 fetch 所有 Post,再通过 relationship 访问 Attachment 和 ImageData。这样会让 attachment 和图片数据注册在 managed object context 里,直到 context 完成工作才释放。(14:15

func verifyPosts(in context: NSManagedObjectContext) throws {
    try context.performAndWait {
        let fetchRequest = Post.fetchRequest()
        let posts = try context.fetch(fetchRequest)

        for post in posts {
            // verify post

            let attachments = post.attachments as! Set<Attachment>
            for attachment in attachments {

                XCTAssertNotNil(attachment.imageData)
                //verify image
            }
        }
    }
}

关键点:

  • Post.fetchRequest() 把验证入口放在 Post 上。
  • context.fetch(fetchRequest) 一次取回所有 Post。
  • post.attachments 会沿 relationship 访问附件集合。
  • attachment.imageData 会加载图片数据。
  • 在快速执行的测试里,这些对象来不及自然释放,内存峰值会升高。

Apple 的改法是反过来 fetch Attachment,并且先只取 objectID。随后按需 materialize 对象,每验证 10 个 attachment 就 reset context。(14:49

func verifyPosts(in context: NSManagedObjectContext) throws {
    try context.performAndWait {
        let fetchRequest = Attachment.fetchRequest()
        fetchRequest.resultType = .managedObjectIDResultType
        let attachments = try context.fetch(fetchRequest) as! [NSManagedObjectID]

        for index in 0...attachments.count - 1 {
            let attachment = context.object(with: attachments[index]) as! Attachment

            //verify attachment
            let post = attachment.post!
            //verify post

            if 0 == (index % 10) {
                context.reset()
            }
        }
    }
}

关键点:

  • Attachment.fetchRequest() 让验证从附件维度开始。
  • .managedObjectIDResultType 让 fetch 返回 NSManagedObjectID,先不把完整对象载入 context。
  • context.object(with:) 在循环中按需取出当前 attachment。
  • attachment.post! 再反向验证所属 Post。
  • index % 10 控制清理频率。
  • context.reset() 释放 context 缓存的对象和相关内存。

这类改动的价值不只是“省内存”。它给测试建立了可调的高水位,让同一个测试可以在内存更小的机器上运行。(16:27

5. 用统一日志观察同步链路

NSPersistentCloudKitContainer 的同步不只发生在 App 进程里。Apple 展示了一条导出和导入链路:App 写入 store 后,容器会询问 dasd 是否适合执行导出;随后通过 cloudd 把对象导出到 CloudKit;接收设备由 apsd 收到推送,再触发导入。(17:27

开发时可以用 log stream 开多个 Terminal 标签,分别看 App、CloudKit、Push、调度日志。(20:41

# Application
log stream --predicate 'process = "CoreDataCloudKitDemo" AND 
                        (sender = "CoreData" OR sender = "CloudKit")'

# CloudKit 
log stream --predicate 'process = "cloudd" AND
                        message contains[cd] "iCloud.com.example.CloudKitCoreDataDemo"'

# Push
log stream --predicate 'process = "apsd" AND message contains[cd] "CoreDataCloudKitDemo"'

# Scheduling
log stream --predicate 'process = "dasd" AND 
                        message contains[cd] "com.apple.coredata.cloudkit.activity" AND
                        message contains[cd] "CEF8F02F-81DC-48E6-B293-6FCD357EF80F"'

关键点:

  • App predicate 关注 CoreDataCloudKitDemo 进程里的 Core Data 和 CloudKit 日志。
  • cloudd predicate 用 container identifier 过滤 CloudKit 服务端相关操作。
  • apsd predicate 观察推送通知是否到达设备。
  • dasd predicate 观察 NSPersistentCloudKitContainer 调度的后台 activity。
  • 最后一段里的 UUID 是 store identifier,能把日志收敛到具体 persistent store。

6. 把诊断资料变成可共享的反馈

如果问题出现在设备上,Session 给出三步资料收集流程:安装 CloudKit logging profile,复现问题并抓取 sysdiagnose,如果能接触设备,再用 Xcode Device Organizer 下载 App container 里的 store 文件。(21:59

拿到 sysdiagnose 后,可以用 log showsystem_logs.logarchive 里查询日志。(24:36

log show --info --debug
    --predicate 'process = "apsd" AND
                 message contains[cd] "iCloud.com.example.CloudKitCoreDataDemo"'
    system_logs.logarchive

log show --info --debug
    --start "2022-06-04 09:40:00"
    --end "2022-06-04 09:42:00"
    --predicate 'process = "apsd" AND 
                 message contains[cd] "iCloud.com.example.CloudKitCoreDataDemo"'
    system_logs.logarchive

关键点:

  • --info --debug 打开更详细的日志级别。
  • --predicate 复用开发阶段的过滤条件。
  • system_logs.logarchive 指向 sysdiagnose 中的日志归档。
  • --start--end 把查询限制在复现问题的时间窗口内。

也可以把四类日志合并成一个 predicate,作为反馈报告的一部分交给队友或 Apple。(25:17

log show --info --debug
    --start "2022-06-04 09:40:00" --end "2022-06-04 09:42:00"
    --predicate '(process = "CoreDataCloudKitDemo" AND
                      (sender = "CoreData" or sender = "CloudKit")) OR
                 (process = "cloudd" AND
                      message contains[cd] "iCloud.com.example.CloudKitCoreDataDemo") OR
                 (process = "apsd" AND message contains[cd] "CoreDataCloudKitDemo") OR 
                 (process = "dasd" AND
                     message contains[cd] "com.apple.coredata.cloudkit.activity" AND
                     message contains[cd] "CEF8F02F-81DC-48E6-B293-6FCD357EF80F")'
    system_logs.logarchive

关键点:

  • 一个命令覆盖 App、clouddapsddasd 四条链路。
  • 时间窗口把日志量控制在复现区间。
  • container identifier 和 store identifier 让反馈指向具体 App 与具体 store。
  • 这条命令本身可以随反馈报告共享,让接收者复用同一组过滤条件。

核心启发

  • 做什么:为你的同步 App 增加一个调试数据生成器。
    为什么值得做:Session 里的 LargeDataGenerator 让 10GB 级别的数据集可以在测试和 UI 中复现。
    怎么开始:写一个接收 NSManagedObjectContextgenerateData(context:),先生成最容易出问题的数据形状,例如大图片、多附件、空字段或极端关系数量。

  • 做什么:把 NSPersistentCloudKitContainer 的同步事件接入 XCTest。
    为什么值得做:导出和首次导入都有明确的结束事件,可以减少“等一会儿看看”的手工验证。
    怎么开始:监听 NSPersistentCloudKitContainer.eventChangedNotification,用 event.typeevent.storeIdentifierevent.endDate 过滤目标事件。

  • 做什么:给大数据测试建立 Time Profiler 和 Allocations 基线。
    为什么值得做:Apple 示例中,Time Profiler 找到多余缩略图生成,Allocations 找到 verifier 保留大图片的问题。
    怎么开始:在 Xcode 里对单个 XCTest 选择 Profile,先看最重调用栈,再用 Allocations 查看大对象的分配和释放栈。

  • 做什么:准备一组同步日志命令。
    为什么值得做:Core Data + CloudKit 的同步跨过 App、dasdclouddapsd,单看 App 日志很容易漏掉调度或推送问题。
    怎么开始:为你的 bundle、CloudKit container identifier 和 persistent store identifier 改写本文的 log stream predicate。

  • 做什么:把客户设备诊断流程写进团队 playbook。
    为什么值得做:Session 明确展示了 CloudKit logging profile、sysdiagnose、Device Organizer 下载 container 三类资料。
    怎么开始:记录 profile 安装步骤、sysdiagnose 按键方式、问题复现时间点,以及 log show 查询命令。


关联 Session

评论

GitHub Issues · utterances