一个列表页偶尔读到已失效的 NSManagedObject,本地连续运行几十次都正常,CI 却会在保存、合并或刷新界面时随机崩溃。此类问题通常不是数据库文件损坏,而是对象越过了所属 NSManagedObjectContext 的队列。与其增加重试,不如在云端 Mac 上启用 Core Data 并发调试,让违规访问在最接近源头的位置立即失败。
先确认问题边界
Core Data 的基本规则很明确:私有队列 context 只能在自己的 perform 或 performAndWait 中使用;主队列 context 只能由主队列访问。NSManagedObject 也不能直接传给另一条队列。跨队列传递时,应传稳定的 NSManagedObjectID,再由目标 context 重新获取对象。
先检查高风险入口:
- 网络回调把托管对象直接交给主线程更新界面。
Task.detached捕获了 context 或托管对象。- 后台保存完成后,把对象数组放进通知或闭包。
- 测试共享同一个持久化容器,且没有等待前一个异步写入结束。
- context 合并期间,界面仍持有已经删除或重置的对象。
不要把
perform当作普通异步包装器。它保护的是 context 及其对象图,而不只是某一条保存语句。
打开并发调试开关
在专用测试 Scheme 的启动参数中加入两项:
-com.apple.CoreData.ConcurrencyDebug
1
对于 UI 测试,建议由测试代码显式传入,避免开发者本地 Scheme 与 CI Scheme 配置不一致:
let app = XCUIApplication()
app.launchArguments += [
"-com.apple.CoreData.ConcurrencyDebug",
"1"
]
app.launch()
普通单元测试若依赖宿主应用,可在共享 Scheme 中配置同一参数,并把 .xcscheme 提交到仓库。不要把该参数写进发布配置;发现违规时,调试机制会主动终止进程,这正是测试环境需要的行为。
启用后,先运行最小测试集合。若一开始就执行完整套件,大量并发任务会掩盖第一个有效调用栈,也会增加日志分析成本。
把对象传递改成 ID 传递
典型错误是后台 context 创建对象后,直接把对象交给主队列。正确做法是先保存,取得永久 object ID,再在目标 context 中获取:
func importItem(
payload: Payload,
backgroundContext: NSManagedObjectContext,
viewContext: NSManagedObjectContext
) async throws {
let objectID = try await backgroundContext.perform {
let item = Item(context: backgroundContext)
item.title = payload.title
try backgroundContext.obtainPermanentIDs(for: [item])
try backgroundContext.save()
return item.objectID
}
try await viewContext.perform {
let item = try viewContext.existingObject(with: objectID) as! Item
item.lastViewedAt = Date()
try viewContext.save()
}
}
这里有三个检查点:返回前已经获得永久 ID;目标 context 使用 existingObject(with:) 重新解析;所有属性读写都位于对应 context 的闭包内。若只传临时 ID,目标 context 可能无法解析;若在闭包外读取属性,即使只读一行字符串,也仍然违反队列约束。
设计能稳定触发的测试
测试重点不是堆叠随机并发,而是固定冲突路径。可以把场景拆成四段:后台插入、后台保存、主 context 合并、主队列读取。每次使用独立的内存持久化存储,并在测试结束时等待所有任务完成。
| 检查项 | 失败信号 | 修复方向 |
|---|---|---|
| 后台写入 | 闭包外访问 context | 收回 perform 内 |
| 对象交接 | 闭包捕获托管对象 | 改传 object ID |
| 合并通知 | 来源 context 不明确 | 限定保存通知来源 |
| 测试清理 | 后续用例仍收到通知 | 移除观察者并销毁容器 |
不要用固定 sleep 等待保存。应通过 await、expectation 或持久化容器回调建立真实完成条件。固定等待只会把竞争窗口移动到别处。
对于偶发路径,可让同一小测试重复执行 20 至 50 次,但应设置总超时。重复次数用于扩大时序覆盖,不应替代确定性的状态同步。
在 CI 中保留第一现场
云端 Mac 作业应固定 Xcode 选择、模拟器目标和结果目录。下面的脚本在失败时保留完整日志与结果包:
#!/bin/zsh
set -euo pipefail
mkdir -p artifacts logs
rm -rf artifacts/CoreDataTests.xcresult
if ! xcodebuild test \
-workspace App.xcworkspace \
-scheme AppConcurrencyTests \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-resultBundlePath artifacts/CoreDataTests.xcresult \
> logs/coredata-tests.log 2>&1; then
grep -nE 'CoreData|Concurrency|NSManagedObjectContext' \
logs/coredata-tests.log || true
exit 1
fi
目标设备名应取自当前 Xcode 实际提供的模拟器,不要在所有镜像上假设完全相同。提交日志前还要移除业务数据、访问令牌和用户目录中的敏感片段。结果包、完整日志、失败测试名、提交号与启动参数应作为同一组证据归档。
如果作业需要并行测试,先让并发调试套件单独串行运行。它的目标是找出第一处越界访问,而不是追求最高吞吐。确认稳定后,再评估是否适合并入常规测试分片。
建立低噪声合并门禁
推荐把门禁分成两层。第一层只运行数据层单元测试和少量关键 UI 流程,始终启用并发调试;第二层执行完整回归,但不依赖它来发现基础队列错误。第一层失败时禁止自动重试,因为重试通过会把真实缺陷伪装成基础设施抖动。
合并前再核对以下项目:
- Scheme 已共享并纳入版本控制。
- 测试使用独立存储,不复用开发数据库。
- context、对象和 fetched results controller 均未跨队列传递。
- 通知观察者在测试结束时正确移除。
- CI 失败时仍会上传日志与 xcresult。
- 修复提交包含能在旧代码上失败的回归测试。
当跨队列对象全部改为 object ID 交接、异步完成条件清晰、结果证据可追踪后,这类“只能在线上偶遇”的崩溃就会变成普通的代码审查问题。
常见问题
Core Data 并发调试参数适合一直在线上启用吗?
不适合。它用于开发和测试进程,会在发现并发规则违规时主动终止进程;应在专用测试方案或 CI 作业中启用,不应写入正式发布配置。
为什么测试通过后仍可能出现跨队列访问问题?
普通测试未必覆盖竞争时序。应让后台写入、主队列读取、对象 ID 传递和上下文合并在同一测试中反复执行,并确认代码没有跨队列传递 NSManagedObject 实例。
CI 失败时最先保留哪些证据?
至少保留完整 xcodebuild 日志、xcresult 结果包、测试名称、提交号和使用的启动参数。若生成崩溃报告,也应一并归档,但提交前要先移除敏感路径和业务数据。
为下一次构建准备独享物理 Mac
按任务选择机型、机房与租期。节点全年正常运行,实际可用状态以控制台实时返回为准。