Post

排查 SwiftData + CloudKit 分享失敗:當 UICloudSharingController 靜默失敗

排查 SwiftData + CloudKit 分享失敗:當 UICloudSharingController 靜默失敗

當 SwiftData + CKShare 在 TestFlight 上「什麼錯都不報」

這篇記錄一次從「分享功能完全不動、沒錯誤訊息」開始,一路排查到真正原因的過程。牽涉的技術是 SwiftData + CloudKit + UICloudSharingController + TestFlight,每一層都有自己的陷阱,疊起來讓錯誤訊息被完全吃掉。

症狀

開發階段用 cloudKitDatabase: .automatic 的 SwiftData app,做了 CKShare 分享功能。Xcode debug build 看似正常,上 TestFlight 之後:

  1. 按「分享 Private zone」按鈕
  2. UICloudSharingController 正常彈出、使用者能輸入收件人
  3. 傳出去 → 收件人收到 iMessage 卡片,但卡片預覽無限轉圈、永遠不會渲染
  4. 點「拷貝連結」→ 剪貼簿是空的
  5. 用 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 寫資料時會自動註冊新 typeXcode debug build
Production唯讀、append-only;只能加 type / field,不能改不能刪TestFlight、App Store

cloudkit.share 是 CloudKit 內建的「系統 type」,但是不會自動出現在新 container 的 schema 裡——只有當你在 Development 環境第一次成功建立 CKShare 時,它才會被註冊進 Development schema。之後再 deploy 到 Production,Production 才會有這個 type。

所以時序上的陷阱是:

  1. 開發時寫 CKShare 相關程式碼,但一直是 Xcode debug(走 Development)測試
  2. Development 的 cloudkit.share 可能從來沒被「真正用過」,就沒進 dev schema
  3. Deploy schema 到 Production(但你 deploy 的只有 CD_CareEvent 等自訂 type,沒有 cloudkit.share
  4. 上 TestFlight → 走 Production schema → 第一次建 CKShare → server 拒絕「這個 type 在 prod 不存在」

解法步驟:

  1. Xcode debug 跑實機(走 Development 環境)
  2. 在 app 裡操作到成功建立一個 CKShare(程式碼要能真的跑通)
  3. CloudKit Dashboard 確認 Development schema 的 Record Types 列表裡有 cloudkit.share
  4. 按「Deploy Schema to Production」,這次 diff 會顯示要新增 cloudkit.share type
  5. 確認部署後,TestFlight 就能正常建 share 了

後遺症:「record already exists」

上面修好之後又遇到一個新錯誤:

1
2
3
code: 14 (.serverRecordChanged)
desc: record to insert already exists
recordName: cloudkit.zoneshare

這是前面幾輪失敗測試留下的副作用:當時 modifyRecords 錯誤被吃掉、沒真的完成但其實 server 已經寫進一個半成品 share record。下次再試時:

  1. 本機的 careEventGroup.zone.share 欄位是 nil(因為本機 zone metadata 沒同步到)
  2. 程式判斷「zone 沒 share、我要建新的」
  3. 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,不要看本機
失敗的測試會留下半成品 recordFetch-first pattern,避免重複 insert

這些雷幾乎每一個都「程式看起來完全沒問題、也沒 compile error、也沒 runtime exception」——但一串起來就讓分享功能完全不動。CloudKit 的 debug 體驗跟它的能力相比,還有很大的進步空間;只能靠前人踩過的雷、加上自己寫出足夠的 observability,才能在合理時間內找到原因。

This post is licensed under CC BY 4.0 by the author.