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。 eventChangedNotification是NSPersistentCloudKitContainer发出的事件通知。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 日志。 clouddpredicate 用 container identifier 过滤 CloudKit 服务端相关操作。apsdpredicate 观察推送通知是否到达设备。dasdpredicate 观察NSPersistentCloudKitContainer调度的后台 activity。- 最后一段里的 UUID 是 store identifier,能把日志收敛到具体 persistent store。
6. 把诊断资料变成可共享的反馈
如果问题出现在设备上,Session 给出三步资料收集流程:安装 CloudKit logging profile,复现问题并抓取 sysdiagnose,如果能接触设备,再用 Xcode Device Organizer 下载 App container 里的 store 文件。(21:59)
拿到 sysdiagnose 后,可以用 log show 从 system_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、
cloudd、apsd、dasd四条链路。 - 时间窗口把日志量控制在复现区间。
- container identifier 和 store identifier 让反馈指向具体 App 与具体 store。
- 这条命令本身可以随反馈报告共享,让接收者复用同一组过滤条件。
核心启发
-
做什么:为你的同步 App 增加一个调试数据生成器。
为什么值得做:Session 里的LargeDataGenerator让 10GB 级别的数据集可以在测试和 UI 中复现。
怎么开始:写一个接收NSManagedObjectContext的generateData(context:),先生成最容易出问题的数据形状,例如大图片、多附件、空字段或极端关系数量。 -
做什么:把
NSPersistentCloudKitContainer的同步事件接入 XCTest。
为什么值得做:导出和首次导入都有明确的结束事件,可以减少“等一会儿看看”的手工验证。
怎么开始:监听NSPersistentCloudKitContainer.eventChangedNotification,用event.type、event.storeIdentifier和event.endDate过滤目标事件。 -
做什么:给大数据测试建立 Time Profiler 和 Allocations 基线。
为什么值得做:Apple 示例中,Time Profiler 找到多余缩略图生成,Allocations 找到 verifier 保留大图片的问题。
怎么开始:在 Xcode 里对单个 XCTest 选择 Profile,先看最重调用栈,再用 Allocations 查看大对象的分配和释放栈。 -
做什么:准备一组同步日志命令。
为什么值得做:Core Data + CloudKit 的同步跨过 App、dasd、cloudd、apsd,单看 App 日志很容易漏掉调度或推送问题。
怎么开始:为你的 bundle、CloudKit container identifier 和 persistent store identifier 改写本文的log streampredicate。 -
做什么:把客户设备诊断流程写进团队 playbook。
为什么值得做:Session 明确展示了 CloudKit logging profile、sysdiagnose、Device Organizer 下载 container 三类资料。
怎么开始:记录 profile 安装步骤、sysdiagnose 按键方式、问题复现时间点,以及log show查询命令。
关联 Session
- Evolve your Core Data schema — 继续学习 Core Data schema 迁移,以及 schema 变化如何影响 CloudKit 同步。
- Track down hangs with Xcode and on-device detection — 学习用 Xcode 和设备诊断数据定位真实应用中的卡顿。
- Diagnose performance issues with the Xcode Organizer — 了解如何用 Xcode Organizer 查看真实设备上的聚合性能数据。
评论
GitHub Issues · utterances