列表頁偶爾會讀取到已失效的 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 專用測試設定,不應放入正式發佈組態。
為什麼一般測試通過後仍可能存在跨佇列問題?
一般測試未必產生需要的競爭順序。應在同一情境反覆執行背景寫入、主佇列讀取、objectID 傳遞與 context 合併。
CI 失敗時應優先保留哪些證據?
至少保留完整 xcodebuild 日誌、xcresult、失敗測試名稱、提交識別碼與啟動參數。若有崩潰報告,移除敏感資料後一併保存。
為下一次建置準備獨享實體 Mac
依任務選擇機型、機房與租用期間。節點全年正常運作,實際可用狀態以控制台即時回傳為準。