排查 SwiftData + CloudKit 分享失敗:當 UICloudSharingController 靜默失敗
當 SwiftData + CKShare 在 TestFlight 上「什麼錯都不報」
這篇記錄一次從「分享功能完全不動、沒錯誤訊息」開始,一路排查到真正原因的過程。牽涉的技術是 SwiftData + CloudKit + UICloudSharingController + TestFlight,每一層都有自己的陷阱,疊起來讓錯誤訊息被完全吃掉。
症狀
開發階段用 cloudKitDatabase: .automatic 的 SwiftData app,做了 CKShare 分享功能。Xcode debug build 看似正常,上 TestFlight 之後:
- 按「分享 Private zone」按鈕
UICloudSharingController正常彈出、使用者能輸入收件人- 傳出去 → 收件人收到 iMessage 卡片,但卡片預覽無限轉圈、永遠不會渲染
- 點「拷貝連結」→ 剪貼簿是空的
- 用 Line 送 → 彈出輸入 email 的欄位、填完送出 → 安靜失敗
程式端完全拿不到任何錯誤——不管是 try await modifyRecords 的 catch block、還是 UICloudSharingControllerDelegate.cloudSharingController(_:failedToSaveShareWithError:),都沒被觸發。
第一層陷阱:TestFlight 看不到 console
排查的第一個障礙是 TestFlight 環境下完全看不到 print() 輸出。不像 Xcode debug build 能直接看 console log,TestFlight 必須:
- 用 Mac 連接裝置跑 Console.app 過濾特定 process
- 或接裝置跑 Xcode 的 Device & Simulator log
這兩個都要使用者端配合,而且只能抓到「跟它同時發生」的事件。若錯誤發生在使用者回報之前,你已經沒有 log 可看。
第一個收穫:CloudKit / CKShare 相關的 catch block 不能留空或只 print。必須把 NSError 的 domain / code / userInfo / NSUnderlyingError / CKErrorDescription 全部拼成字串,直接用 SwiftUI alert 顯示在 UI 上。TestFlight 使用者能截圖回報,你才有辦法 debug。
這是常被忽略的點——NSError.localizedDescription 只給你最表層訊息,真正的原因常常埋在 userInfo[NSUnderlyingErrorKey] 的 underlying error 裡。以 CloudKit 為例,外層可能只說「儲存失敗」,要看 underlying 才能看到「cloudkit.share type not found in production schema」這種具體到能修的訊息。
具體做法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private func presentShareError(_ error: Error, prefix: String) {
let ns = error as NSError
var lines: [String] = []
lines.append("[\(prefix)]")
lines.append("domain: \(ns.domain)")
lines.append("code: \(ns.code)")
lines.append("desc: \(ns.localizedDescription)")
if let underlying = ns.userInfo[NSUnderlyingErrorKey] as? NSError {
lines.append("underlying: \(underlying.domain) #\(underlying.code) – \(underlying.localizedDescription)")
}
if let ckErrCode = ns.userInfo["CKErrorDescription"] as? String {
lines.append("CKErrorDescription: \(ckErrCode)")
}
shareErrorMessage = lines.joined(separator: "\n")
showShareError = true
}
第二層陷阱:UICloudSharingControllerDelegate 不會回報所有錯誤
加了 alert 之後,按「分享」的 alert 沒彈出來——代表 fetchOrCreateShare 這段沒錯。但分享結果還是失敗。
查文件才發現,cloudSharingController(_:failedToSaveShareWithError:) delegate 只在「儲存 share 失敗」時觸發。可是這次失敗的是產生 share URL 那一步——明明 share 已經存進 server 了,但用 UICloudSharingController 帶出來的「拷貝連結 / Messages / Mail」全都拿不到實際的 URL。這個階段的失敗不會 call 到那個 delegate。
所以 delegate 必須加上 log,但拿不到錯誤不代表沒事,要配合其他方式診斷。
第三層陷阱:modifyRecords 整體不丟錯、個別 record 靜默失敗
再看 share 創建的程式碼:
1
2
3
let share = CKShare(recordZoneID: careEventGroup.zone.zoneID)
share[CKShare.SystemFieldKey.title] = "MeowCare: \(careEventGroup.name)"
_ = try await database.modifyRecords(saving: [share], deleting: [])
這段看似沒問題,但 modifyRecords(saving:deleting:) 的 API 有個陷阱——整個 API call 不丟錯不代表每個 record 都存成功。它回傳 (saveResults: [CKRecord.ID: Result<CKRecord, Error>], ...),個別 record 的結果藏在 saveResults 裡。
正確寫法要拆出 per-record 結果:
1
2
3
4
5
6
7
8
9
10
11
12
13
let result = try await database.modifyRecords(saving: [share], deleting: [])
guard let saveResult = result.saveResults[share.recordID] else {
throw ViewModelError.invalidRemoteShare
}
switch saveResult {
case .success(let record):
guard let savedShare = record as? CKShare else {
throw ViewModelError.invalidRemoteShare
}
return (savedShare, container)
case .failure(let error):
throw error
}
不檢查這個的話,即使 server 端拒絕儲存 share(例如 schema 問題),程式端還是看不到任何錯誤,繼續把本機那個半成品 share 交給 UICloudSharingController,就會出現「share UI 能開、但連結永遠生不出來」的症狀。
另外還有一個 subtle bug:就算沒有錯誤,modifyRecords 回傳的 share 才帶有 server 寫好的 recordChangeTag。本機那個 share 物件沒有。UICloudSharingController 後續要更新 share(加參與者、改 permission)時需要這個 tag,否則 server 會拒絕。
修法:一定要用 saveResults 裡回傳的那份 share,不要用本機的。
真兇現身:production schema 沒有 cloudkit.share
把上面兩層修好之後,alert 終於彈出來:
1
2
3
domain: CKErrorDomain
code: 12 (.serverRejectedRequest)
desc: Cannot create new type cloudkit.share in production schema
這就是真正的原因:production schema 裡面沒有 cloudkit.share 這個系統 record type。
CloudKit 的 schema 分 Development / Production 兩個環境:
| 環境 | 特性 | 誰會用到 |
|---|---|---|
| Development | 可以隨便加 / 改 / 刪 record type,app 寫資料時會自動註冊新 type | Xcode debug build |
| Production | 唯讀、append-only;只能加 type / field,不能改不能刪 | TestFlight、App Store |
cloudkit.share 是 CloudKit 內建的「系統 type」,但是不會自動出現在新 container 的 schema 裡——只有當你在 Development 環境第一次成功建立 CKShare 時,它才會被註冊進 Development schema。之後再 deploy 到 Production,Production 才會有這個 type。
所以時序上的陷阱是:
- 開發時寫 CKShare 相關程式碼,但一直是 Xcode debug(走 Development)測試
- Development 的
cloudkit.share可能從來沒被「真正用過」,就沒進 dev schema - Deploy schema 到 Production(但你 deploy 的只有
CD_CareEvent等自訂 type,沒有cloudkit.share) - 上 TestFlight → 走 Production schema → 第一次建 CKShare → server 拒絕「這個 type 在 prod 不存在」
解法步驟:
- Xcode debug 跑實機(走 Development 環境)
- 在 app 裡操作到成功建立一個 CKShare(程式碼要能真的跑通)
- 去 CloudKit Dashboard 確認 Development schema 的 Record Types 列表裡有
cloudkit.share - 按「Deploy Schema to Production」,這次 diff 會顯示要新增
cloudkit.sharetype - 確認部署後,TestFlight 就能正常建 share 了
後遺症:「record already exists」
上面修好之後又遇到一個新錯誤:
1
2
3
code: 14 (.serverRecordChanged)
desc: record to insert already exists
recordName: cloudkit.zoneshare
這是前面幾輪失敗測試留下的副作用:當時 modifyRecords 錯誤被吃掉、沒真的完成但其實 server 已經寫進一個半成品 share record。下次再試時:
- 本機的
careEventGroup.zone.share欄位是nil(因為本機 zone metadata 沒同步到) - 程式判斷「zone 沒 share、我要建新的」
modifyRecords(saving: [new share])→ server 說「這個 zone 早就有 share record 了」 → 拒絕
根本解法是不要依賴本機 zone metadata 做判斷。zone-wide share 在 server 的 recordName 是固定的 "cloudkit.zoneshare",直接去 server fetch:
1
2
3
4
5
6
7
8
9
10
11
12
let shareID = CKRecord.ID(recordName: "cloudkit.zoneshare", zoneID: zoneID)
do {
if let existing = try await database.record(for: shareID) as? CKShare {
return (existing, container)
}
} catch let error as CKError where error.code == .unknownItem {
// Server 確實沒有 share,往下建立新的
}
let share = CKShare(recordZoneID: zoneID)
// ...
Fetch 到就直接用既有 share(還帶著 server 寫好的 recordChangeTag,直接能給 UICloudSharingController 用);fetch 拿到 .unknownItem 才 create。這個 pattern 比 zone.share != nil 的檢查可靠得多。
Takeaways
整理一下這次踩到的雷跟對應的防呆:
| 踩雷 | 防呆 |
|---|---|
| TestFlight 看不到 console | 用 alert 把 NSError 完整欄位(domain / code / NSUnderlyingError / CKErrorDescription)顯示出來 |
UICloudSharingControllerDelegate 不會回報所有錯誤 | Delegate 要 log,但不能依賴它抓所有錯誤 |
modifyRecords 整體不丟錯、個別 record 可能失敗 | 一定要檢查 saveResults 的每個 Result |
| 用本機 share 物件而不是 server 回傳的 | 用 saveResults 裡的 record,帶 recordChangeTag |
cloudkit.share 不會自動在 Production schema 出現 | Dev 成功用一次 → 到 Dashboard 檢查 → Deploy 到 Production |
本機 zone.share metadata 可能沒同步 | 直接用 well-known recordName (cloudkit.zoneshare) 去 server fetch,不要看本機 |
| 失敗的測試會留下半成品 record | Fetch-first pattern,避免重複 insert |
這些雷幾乎每一個都「程式看起來完全沒問題、也沒 compile error、也沒 runtime exception」——但一串起來就讓分享功能完全不動。CloudKit 的 debug 體驗跟它的能力相比,還有很大的進步空間;只能靠前人踩過的雷、加上自己寫出足夠的 observability,才能在合理時間內找到原因。