Post

Core Data + CloudKit:Transformable 欄位跨帳號同步失效

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 型別

問題出在跨帳號共享這個邊界

  1. Owner 的 CareEvent 存到私有資料庫(private database),Transformable 欄位序列化成 binary
  2. Owner 建立 CKShare、對方接受
  3. Core Data 把這個 record 從 private database 的 zone 搬到 shared zone(zone sharing)
  4. Recipient 同步 shared zone 下來,本地 Core Data 試著 unarchive 那個 binary blob
  5. 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)
valueRawvalueDouble
plannedAmountRawplannedAmountDouble
actualAmountRawactualAmountDouble
sequenceNumberRawsequenceNumberInt
associatedGlucoseRawassociatedGlucoseDouble
amountOfferedRawamountOfferedDouble
amountEatenRawamountEatenDouble
durationRawdurationDouble

從 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 匯出備份當橋樑:

  1. 升級到新版本前(如果可能)先匯出 JSON
  2. 升級後,匯入 JSON → 數字填入新的 valueDouble 欄位
  3. 新數字自動同步到 CloudKit CD_valueDouble
  4. Recipient 那台裝新版本 → 同步 CD_valueDouble → 正常顯示數字

這是為什麼不管遷移哪種 persistence,一開始就有 JSON 匯出匯入功能會在這種時刻救自己一命。

部署時的額外一步

改完 xcdatamodeld 還沒結束——Production schema 必須部署新欄位:

  1. Xcode Debug 跑實機一次(讓 Development schema 自動加上新欄位)
  2. CloudKit Dashboard → container → Schema → Deploy Schema to Production
  3. diff 會顯示要加 8 個新 Double 欄位到 CD_CareEvent
  4. 確認 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 地雷

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