Core Data + CloudKit:Transformable 欄位跨帳號同步失效
Core Data + CloudKit:Transformable 欄位跨帳號同步失效
踩到一個很詭異的 bug:自己手機看到的資料完整;分享給別人之後,別人手機只看得到事件類型跟時間,數字欄位全都空白。
具體情境:貓咪照護記錄 app,事件有血糖、胰島素、體重、灌食等。每筆事件的數字(血糖值、胰島素劑量、體重公斤、灌食 g 數)分享給配偶的手機後統統消失,只剩事件類型的 icon 跟時間戳。
這篇記錄為什麼、修法、以及為什麼 Transformable 欄位在跨帳號共享情境下不該用。
症狀的不對稱性
關鍵觀察:Owner 跟 Recipient 看到的不一樣。
| 欄位 | Owner 手機 | Recipient 手機 |
|---|---|---|
eventTypeRaw (String) | 有 | 有 |
occurredAt (Date) | 有 | 有 |
noteText (String?) | 有 | 有 |
brandName (String?) | 有 | 有 |
valueRaw (Transformable NSNumber?) | 有 | 全 nil |
plannedAmountRaw, durationRaw… 其他 Transformable | 有 | 全 nil |
規律很清楚:String / Date 欄位正常同步,Transformable 欄位全部掉光。Transformable 是用 NSSecureUnarchiveFromData 包 NSNumber 的欄位(Core Data 為了避開「Optional Double 會被自動 coerce 成 0」問題常用這招)。
為什麼 Transformable 不行
Transformable 的本質:
1
2
3
4
5
6
7
Swift NSNumber?
↓ (NSSecureCoding archiver)
Data(binary blob)
↓
Core Data SQLite 存成 BLOB
↓
CloudKit 同步 → 對 CloudKit 而言是 Asset 或 Bytes 型別
問題出在跨帳號共享這個邊界:
- Owner 的 CareEvent 存到私有資料庫(private database),Transformable 欄位序列化成 binary
- Owner 建立 CKShare、對方接受
- Core Data 把這個 record 從 private database 的 zone 搬到 shared zone(zone sharing)
- Recipient 同步 shared zone 下來,本地 Core Data 試著 unarchive 那個 binary blob
- unarchive 失敗或欄位映射不對,Recipient 本地的
valueRaw變 nil
Apple 沒有明確文件這個問題,但社群多年來陸陸續續回報:Transformable 在 CloudKit sharing 的 cross-account 場景下有 coverage 問題,尤其當兩邊帳號、裝置型號、iOS 版本稍有差異時。
修法:改用原生 Double(或 Integer)
Core Data 對 Optional 數值有兩種合理做法:
做法 A:Transformable NSNumber(出問題的寫法)
1
2
3
<attribute name="valueRaw" optional="YES" attributeType="Transformable"
valueTransformerName="NSSecureUnarchiveFromData"
customClassName="NSNumber"/>
1
2
3
4
5
6
7
@NSManaged public var valueRaw: NSNumber?
// wrapper
var value: Double? {
get { valueRaw?.doubleValue }
set { valueRaw = newValue.map(NSNumber.init(value:)) }
}
做法 B:原生 Double + Optional=YES + usesScalarValueType=NO(正確的寫法)
1
2
<attribute name="valueDouble" optional="YES" attributeType="Double"
usesScalarValueType="NO"/>
1
2
3
4
5
6
7
@NSManaged public var valueDouble: NSNumber?
// wrapper (一模一樣)
var value: Double? {
get { valueDouble?.doubleValue }
set { valueDouble = newValue.map(NSNumber.init(value:)) }
}
usesScalarValueType=NO 是關鍵。沒有這個 flag,Xcode 會把 Double + Optional 產生成非 Optional 的 Double(nil 被 coerce 成 0)。有 usesScalarValueType=NO,@NSManaged 就會是 NSNumber?,nil 的語意保留。
底層差別:
- 做法 A:CloudKit 存成 Bytes / Asset(binary blob)
- 做法 B:CloudKit 存成 Double(原生數值)
原生 Double 在 CloudKit 的跨 zone / 跨帳號傳輸穩定得多,因為沒有 binary 序列化的協定問題。
Production schema 的 append-only 詛咒
改寫時踩到另一個大坑:CloudKit Production schema 是 append-only。
既有的 CD_valueRaw 欄位在 Production 已經是 Bytes 型別,你不能把它改成 Double,只能新增欄位。
所以我們的做法不是「改型別」而是「用新名字」:
| 舊(Transformable) | 新(Double) |
|---|---|
valueRaw | valueDouble |
plannedAmountRaw | plannedAmountDouble |
actualAmountRaw | actualAmountDouble |
sequenceNumberRaw | sequenceNumberInt |
associatedGlucoseRaw | associatedGlucoseDouble |
amountOfferedRaw | amountOfferedDouble |
amountEatenRaw | amountEatenDouble |
durationRaw | durationDouble |
從 xcdatamodeld 移除舊欄位、加入新欄位。Production schema 裡舊的 CD_valueRaw 等欄位變成孤兒(佔空間但不再被新 app 讀寫),新的 CD_valueDouble 是唯一 truth source。
Swift 端:
1
2
3
4
5
// 舊
@NSManaged public var valueRaw: NSNumber?
// 新
@NSManaged public var valueDouble: NSNumber?
Wrapper(var value: Double? { ... })只要改底層欄位名即可,所有 View 層的 event.value API 完全不動。這是幸運的地方——全部改動壓縮在一個檔案的一個 section 裡。
既有資料怎麼辦
既有的 CD_valueRaw 在 CloudKit 裡還有資料,但新 app 不讀了;本機 SQLite 的舊 record 也沒了(xcdatamodeld 移除欄位後,SQLite migration 會清那欄)。結果:既有資料的數字欄位全失效,需要重新匯入。
我們有 JSON 匯出備份當橋樑:
- 升級到新版本前(如果可能)先匯出 JSON
- 升級後,匯入 JSON → 數字填入新的
valueDouble欄位 - 新數字自動同步到 CloudKit
CD_valueDouble - Recipient 那台裝新版本 → 同步
CD_valueDouble→ 正常顯示數字
這是為什麼不管遷移哪種 persistence,一開始就有 JSON 匯出匯入功能會在這種時刻救自己一命。
部署時的額外一步
改完 xcdatamodeld 還沒結束——Production schema 必須部署新欄位:
- Xcode Debug 跑實機一次(讓 Development schema 自動加上新欄位)
- CloudKit Dashboard → container → Schema → Deploy Schema to Production
- diff 會顯示要加 8 個新 Double 欄位到
CD_CareEvent - 確認 Deploy 之後才能上 TestFlight / App Store
漏做這步,TestFlight build 跑起來會 silently fail:寫入 CD_valueDouble 但 Production schema 不知道這欄位,CloudKit 就悶著不傳。
Takeaways
| 教訓 | 內容 |
|---|---|
| CloudKit 共享情境不要用 Transformable 存數字 | 原生 Double + Optional=YES + usesScalarValueType=NO 才是正確選擇 |
| Production schema 是 append-only | 不能改型別、只能新增欄位;改 schema 心裡要有準備 |
| JSON 匯出匯入是保險 | 任何 persistence 遷移、schema 變更,這個機制都能當過渡 |
| Wrapper pattern 降低改動影響範圍 | @NSManaged 換底層欄位名,但 Swift API 的 event.value: Double? 保持不變 |
| Owner/Recipient 症狀不對稱是關鍵線索 | 同一筆資料在不同角色下表現不同,幾乎一定是 CloudKit 同步 / 序列化層級的問題 |
這個 bug 花了比預期久的時間,因為它只在「上 TestFlight + 跨帳號」才觸發——Xcode Debug + 單機測試完全看不出來。類似邊界情況很多,下一篇來講 Core Data + CloudKit 雙 store 的 store routing 地雷。