Core Data @NSManaged 的 Optional 陷阱
Core Data @NSManaged 的 Optional 陷阱
Core Data + CloudKit 的 managed object class,@NSManaged 屬性該宣告 Optional 還是 Non-Optional?直覺會想:「xcdatamodeld 設 Non-Optional + default value,那 Swift 側就寫 Non-Optional 囉」。踩到這個陷阱會看到:
1
2
3
4
Thread 1: EXC_BREAKPOINT (code=1, subcode=0x190b2c594)
Foundation.Date._unconditionallyBridgeFromObjectiveC(Optional<NSDate>) -> Date
at CareEventRowView.formattedTime.getter (line 59)
return formatter.string(from: event.occurredAt)
這篇講這個看似違反直覺的現象、為什麼會發生、以及正確的設計選擇。
最初的想法:xcdatamodeld 決定 Swift 型別
我原本以為這樣寫是對的:
xcdatamodeld:
1
2
3
4
<attribute name="occurredAt" attributeType="Date"
defaultDateTimeInterval="0"
usesScalarValueType="NO"
optional="NO"/>
CareEvent.swift:
1
@NSManaged public var occurredAt: Date // Non-Optional!
Non-Optional + default value 應該保證永遠有值。convenience init 裡也一定會賦值:
1
2
3
4
5
6
7
convenience init(context: NSManagedObjectContext,
eventType: EventType,
occurredAt: Date = .now) {
self.init(context: context)
self.occurredAt = occurredAt // 永遠非 nil
// ...
}
邏輯上無懈可擊。結果一跑就 crash:
1
Fatal error: Foundation.Date._unconditionallyBridgeFromObjectiveC(Optional<NSDate>)
意思是 Core Data 底層的 NSDate? 是 nil,但 Swift 側宣告 Date 非 Optional,bridge 時強制 unwrap 失敗、crash。
為什麼底層會是 nil
情境:app 透過 CloudKit 同步收到一筆來自舊版 app 或不同 schema 的 record。那筆 record 在雲端的 CD_occurredAt 欄位沒有值(或型別不相容),Core Data 同步下來後該欄位在本地 SQLite 是 NULL。
儘管 xcdatamodeld 寫 optional="NO":
- 本地新建 object 會受 default value 保護(寫入前如果沒設,用
defaultDateTimeInterval填 0 = 2001-01-01) - 本地從 CloudKit 同步下來 的 record 不走 default 流程——雲端沒值就本地也沒值
所以 optional="NO" 只在本地新建時有作用,沒辦法保證「任何時間 query 出來都非 nil」。
更糟的是這狀況在自己開發環境測不出來:
- 自己的 dev schema + Xcode debug build 建立的 record 永遠有值
- 上 TestFlight + 跨帳號共享才觸發(別人裝置寫入的 legacy record 同步下來)
- 直接在自己手機測是過的、上架前找不到的那種 bug
正確選擇:@NSManaged 全部 Optional + Safe wrapper
結論是所有 @NSManaged 屬性都宣告 Optional,即便 xcdatamodeld 設 Non-Optional。
1
2
3
4
5
6
7
8
@objc(CareEvent)
public class CareEvent: NSManagedObject {
@NSManaged public var id: String? // 全 Optional
@NSManaged public var occurredAt: Date?
@NSManaged public var updatedAt: Date?
@NSManaged public var eventTypeRaw: String?
// ...
}
然後在 extension 加非 Optional 的 safe wrapper 給 View 層用:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
extension CareEvent {
/// `occurredAt` 的非 Optional 版本,nil 時回 `.distantPast` 當 sentinel。
var occurredAtSafe: Date {
get { occurredAt ?? .distantPast }
set { occurredAt = newValue }
}
var updatedAtSafe: Date {
get { updatedAt ?? .distantPast }
set { updatedAt = newValue }
}
var idSafe: String {
id ?? ""
}
}
View 層全部改用 *Safe 版本:
1
2
3
4
5
6
7
8
// CareEventRowView.swift
Text(formatter.string(from: event.occurredAtSafe)) // 不會 crash
// DailyTimelineView.swift filter
allEvents.filter { $0.occurredAtSafe >= startOfDay && $0.occurredAtSafe < endOfDay }
// CareEventEditView.swift DatePicker binding
DatePicker("發生時間", selection: $event.occurredAtSafe)
Sentinel 選擇:
Date用.distantPast(= 西元前很久),一看就知道是「不該發生」的 fallback 值String用""(空字串),視覺上能識別
convenience init 裡 owner 新建的 record 一定會 set,這些 fallback 只在 CloudKit 下 legacy record 殘缺時會用到——app 不會 crash,使用者看到 sentinel value(0001-01-01 之類)也能推斷資料有問題。
延伸:ForEach 用 \.objectID 不用 \.id
把 id 改 Optional 後又踩到 SwiftUI 的警告:
1
2
3
ForEach<Array<CareEvent>, Optional<String>, ...>:
the ID nil occurs multiple times within the collection,
this will give undefined results!
原因:
1
ForEach(events, id: \.id) { event in ... }
\.id 是 KeyPath<CareEvent, String?>,Optional String。CloudKit 同步中途若多筆 id 同時是 nil,SwiftUI 認為它們是同一個 view,render 就錯。
修法:用 \.objectID 做為 ForEach 的唯一性鍵:
1
ForEach(events, id: \.objectID) { event in ... }
NSManagedObjectID 永遠非 nil、永遠唯一(由 Core Data 管理、每個 object 建立時立刻賦值)。用它當 key 穩定得多。
要在 CareEvent 上提供 Identifiable 也是一樣——\.objectID 才是可靠的 ID:
1
2
3
4
5
6
extension CareEvent: Identifiable {}
// 預設 id 是 .id(String?),不理想
// 可以明確指定:
extension CareEvent {
public var id: NSManagedObjectID { objectID }
}
不過這樣 @NSManaged id 跟 Identifiable.id 命名衝突,得加 override 或重新命名。簡單的做法就是 ForEach 明確寫 id: \.objectID。
為什麼這個選擇違反直覺
如果從Swift 的習慣來看,Non-Optional 是正確選擇:
- 業務邏輯要求
occurredAt永遠有值 - 宣告 Optional 會讓 call site 到處寫
?? someDefault、看起來不乾淨 - 宣告 Non-Optional 型別系統會保護我們
但 Core Data + CloudKit 的真實行為打破這個假設:
- 雲端同步下來的 record 可能帶 nil 欄位
optional="NO"在雲端資料上不是有效約束- Non-Optional 的
@NSManaged變成「型別系統說謊」——實際 runtime 可能 nil,但 Swift 覺得一定有值
兩難之間,選安全:@NSManaged Optional,Swift API 層透過 wrapper 補上 Non-Optional 語意。
Takeaways
| 教訓 | 內容 |
|---|---|
@NSManaged 一律宣告 Optional,即便 xcdatamodeld 是 Non-Optional | 保護對 CloudKit 同步下來的 legacy record 殘缺 |
用 *Safe wrapper 提供 Non-Optional 語意給 View 層 | Call site 看起來跟原本一樣乾淨 |
| Sentinel 值要挑得「顯眼」 | .distantPast / "" 比 .now / "Unknown" 容易在 UI 上看出「這筆有問題」 |
ForEach 用 \.objectID | NSManagedObjectID 永遠唯一非 nil,比 Optional String id 可靠 |
| 邊緣案例只在跨帳號共享時才現形 | 自己手機測不出來,一定要做真的跨帳號整合測試 |
Core Data 在這些細節上不算友善——每個地方都有「看起來正確但實際出錯」的風險。這種地方越多,越要把 pattern(全 Optional + Safe wrapper + objectID)變成肌肉記憶,不要每次都重新思考。
這篇是這個系列的最後一篇。四篇的脈絡:
- SwiftData → Core Data 的遷移實戰 — 整體決策與遷移框架
- Transformable 欄位跨帳號同步失效 — 數值欄位用原生型別
- 雙 store 的 store routing 地雷 — 新建 object 顯式 assign
- 本篇 —
@NSManagedOptional 陷阱
Core Data + CloudKit 的學習曲線陡峭、坑比想像中多,但走過一遍之後,類似的共享情境 app 就能重用這套心智模型。祝踩雷愉快。