Using ModelContainerPreview for SwiftData Previews
這篇是在整理一個實作選擇:SwiftData 的 SwiftUI Preview,到底要不要沿用 Apple WWDC 範例專案常見的 ModelContainerPreview / ModelContainer.sample 寫法?
先講結論:到 2026-06-30,這個方向仍然值得沿用。原因不是「舊範例一定比較好」,而是它把 Preview 需要的 SwiftData container 明確隔離起來,讓畫面本身不用知道 sample data 怎麼建立。
問題背景
SwiftData View 常常會依賴 @Environment(\.modelContext)、@Query,或是直接拿 @Model 物件進 Preview。
正式 App 會在 root scene 注入 container:
1
2
3
4
WindowGroup {
ContentView()
}
.modelContainer(sharedModelContainer)
但是 Preview 不是正式 App 啟動流程。如果 Preview 沒有注入 container,常見結果是:
@Query沒資料。@Environment(\.modelContext)取不到可用 context。- 需要 SwiftData model 的元件在 Preview 裡行為跟實機不同。
- 每個
#Preview自己建 container,最後 sample data 到處散落。
所以 Preview 需要一套「只給 Preview 用」的 in-memory container。
Apple API 目前還在
這件事的底層 API 仍然是現行做法:
ModelContainer:SwiftData 的 container。.modelContainer(_:):把 container 注入 SwiftUI view hierarchy。PreviewModifier:SwiftUI Preview 可用的 modifier 機制。
參考:
- Apple
ModelContainer文件:https://developer.apple.com/documentation/swiftdata/modelcontainer - Apple SwiftUI
.modelContainer(_:)文件:https://developer.apple.com/documentation/swiftui/view/modelcontainer(_) - Apple
PreviewModifier文件:https://developer.apple.com/documentation/swiftui/previewmodifier
所以問題不是「能不能用」,而是「專案要選哪一種 Preview 組織方式」。
兩種寫法
第一種是 PreviewModifier:
1
2
3
#Preview(traits: .sampleData) {
MonsterDexProgressView()
}
這種寫法簡潔,Preview 看起來乾淨。缺點是 sample data 的來源被藏在 trait 後面,新人讀單一 Preview 時,不一定馬上看得出 container 從哪裡來。
第二種是 Apple sample project 常見的 wrapper:
1
2
3
4
5
#Preview {
ModelContainerPreview(ModelContainer.sample) {
MonsterDexProgressView()
}
}
這種寫法稍微多幾行,但意圖很明確:這個 Preview 需要 SwiftData,而且使用 ModelContainer.sample。
為什麼我偏向 ModelContainerPreview
ModelContainerPreview 的價值在於它把三件事分開:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct ModelContainerPreview<Content: View>: View {
private let content: () -> Content
@State private var container: ModelContainer
init(
_ modelContainer: @escaping () throws -> ModelContainer,
@ViewBuilder content: @escaping () -> Content
) {
self.content = content
self.container = try! MainActor.assumeIsolated(modelContainer)
}
var body: some View {
content()
.modelContainer(container)
}
}
View 只管畫面。Preview wrapper 管 container 注入。Sample data factory 管資料建立。
這樣的分工接近正式 App:正式 App 在 root 注入 model container;Preview 也在 Preview root 注入 model container。
ModelContainer.sample 的角色
ModelContainer.sample 通常長這樣:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
extension ModelContainer {
@MainActor static let sample: () throws -> ModelContainer = {
let schema = Schema([
MonsterDexEntry.self,
MonsterTraitReward.self
])
let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: schema, configurations: [configuration])
seedPreviewData(into: container.mainContext)
return container
}
}
重點是 isStoredInMemoryOnly: true。Preview 不應該寫進正式資料庫,也不應該污染實機測試資料。它只需要一份可重建的 sample state。
PreviewModifier 不是錯
PreviewModifier 仍然是正統 API,也可以繼續用。它適合:
- Preview 數量很多,而且都共用同一種資料。
- 團隊已經習慣
#Preview(traits: .sampleData)。 - 想把 Preview 呼叫寫得最短。
但如果專案還在建立架構,或 SwiftData model 還會常常調整,我會偏向 wrapper。因為 wrapper 讓每個 Preview 的依賴更清楚。
對 ScoutDex 的判斷
ScoutDex 的畫面會大量依賴 SwiftData:
- 怪物列表需要
MonsterDexEntry。 - 詳細頁需要
MonsterTraitReward。 - 截圖匯入與候選確認需要
ScreenshotImport、MonsterEntryCandidate。 - 未來 zip 匯入、備份、成就時間線也會接 SwiftData。
因此 Preview 不該讓每個畫面自己建假資料。較妥當的做法是集中在:
1
2
3
ModelContainer.sample
ScoutDexPreviewSampleData.makeModelContainer()
ModelContainerPreview
然後 Preview 寫成:
1
2
3
4
5
6
7
#Preview {
ModelContainerPreview(ModelContainer.sample) {
NavigationStack {
MonsterDexProgressView()
}
}
}
這樣可以同時保留三個好處:
- Preview 不寫正式資料庫。
- SwiftData 注入位置清楚。
- sample data 集中管理。
最後的判斷
到 2026-06-30,我會繼續沿用這個模式。
不是因為 PreviewModifier 不好,而是 ScoutDex 這類 SwiftData app,更需要一個容易看懂、容易維護、接近正式 app container 注入方式的 Preview 架構。
對我來說,ModelContainerPreview(ModelContainer.sample) 的重點不是少寫幾行,而是讓 Preview 的資料邊界清楚:Preview 用 in-memory sample data,正式 App 用正式 container,兩者不混在一起。