Use ModelContainerPreview to Help SwiftData Preview
搞懂 SwiftData Preview 為什麼需要 ModelContainerPreview
Apple 的 SwiftData 範例專案(SwiftDataAnimals)裡有兩個 Preview helper 檔案:
ModelContainerPreview.swift— 一個泛型 wrapper ViewPreview+ModelContainer.swift—ModelContainer的 static extension
官方文件幾乎沒有解釋這兩個東西為什麼存在。你把它們複製到自己的專案裡,Preview 能跑,但總覺得不踏實——不知道它們到底解決了什麼問題,也不知道如果不用它們會怎樣。
這篇把它們拆開來講清楚。
先看問題:Preview 需要什麼?
用了 SwiftData 的 View 通常長這樣:
1
2
3
4
5
6
7
8
9
10
struct AnimalListView: View {
@Query(sort: \Animal.name) private var animals: [Animal]
@Environment(\.modelContext) private var modelContext
var body: some View {
List(animals) { animal in
Text(animal.name)
}
}
}
這個 View 有兩個隱性依賴:
@Query需要ModelContainer— 如果 SwiftUI environment 裡沒有ModelContainer,@Query會 crash@Environment(\.modelContext)需要ModelContainer— 同上
所以 Preview 一定要提供一個 ModelContainer。問題是:怎麼提供?
最直覺的寫法(能跑,但有問題)
1
2
3
4
#Preview {
AnimalListView()
.modelContainer(for: Animal.self)
}
這樣能跑,但有兩個問題:
問題 1:資料是空的
.modelContainer(for:) 會建立一個空的 container。Preview 畫面上什麼都沒有,你沒辦法測試「有資料時的 UI 長什麼樣」。
問題 2:資料會寫到磁碟
預設的 ModelConfiguration 會把資料持久化到磁碟。Preview 每次跑都會累積殘留資料,而且不同 Preview 之間可能互相干擾。
所以需要兩件事
- In-memory container — 每次 Preview 都從零開始,不殘留
- 預填假資料 — Preview 一開就有東西看
這就是那兩個 helper 在做的事。
Preview+ModelContainer.swift:建 container + 塞資料
1
2
3
4
5
6
7
8
9
extension ModelContainer {
@MainActor static let sample: () throws -> ModelContainer = {
let schema = Schema([AnimalCategory.self, Animal.self])
let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: schema, configurations: [configuration])
AnimalCategory.insertSampleData(modelContext: container.mainContext)
return container
}
}
這段做了三件事:
| 步驟 | 做了什麼 | 為什麼 |
|---|---|---|
isStoredInMemoryOnly: true | container 只存在記憶體 | 每次 Preview 都是乾淨的,不會殘留髒資料 |
| 明確列出 Schema | Schema([AnimalCategory.self, Animal.self]) | 確保 Preview 的 Schema 跟正式 App 一致 |
insertSampleData | 插入假資料 | Preview 一開就有東西可以看 |
注意 static let sample 的型別是 () throws -> ModelContainer(closure),不是直接回傳 container。原因等下說。
ModelContainerPreview.swift:處理 container 的建立時機
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct ModelContainerPreview<Content: View>: View {
var content: () -> Content
@State var container: ModelContainer
init(
_ modelContainer: @escaping () throws -> ModelContainer,
@ViewBuilder content: @escaping () -> Content
) {
self.content = content
do {
self.container = try MainActor.assumeIsolated(modelContainer)
} catch {
fatalError("Failed to create the model container: \(error.localizedDescription)")
}
}
var body: some View {
content()
.modelContainer(container)
}
}
這個 wrapper 解決了一個微妙的問題:ModelContainer 的建立需要在 main actor 上,而且需要同步完成。
為什麼用 MainActor.assumeIsolated?
SwiftData 的 ModelContainer init 需要在 main thread 上執行。但 #Preview macro 的 init 有時候跑在不可預期的 context。MainActor.assumeIsolated 告訴 Swift 「我保證現在就在 main actor 上」,避免 concurrency 警告。
為什麼 sample 是 closure 而不是直接回傳值?
如果寫成:
1
static let sample = try! ModelContainer(...) // 不行
static let 在 Swift 裡是 lazy 初始化,而且只會執行一次。這代表所有 Preview 會共用同一個 container,如果某個 Preview 改了資料,其他 Preview 會看到被改過的狀態。
用 closure () throws -> ModelContainer 可以讓每個 Preview 各自建立自己的 container,互不干擾。
使用方式
1
2
3
4
5
6
7
8
#Preview("AnimalListView") {
ModelContainerPreview(ModelContainer.sample) {
NavigationStack {
AnimalListView(animalCategoryName: "Mammal")
.environment(NavigationContext())
}
}
}
展開來看:
ModelContainer.sample是一個 closureModelContainerPreview在 init 裡呼叫這個 closure,建立 in-memory container 並塞入假資料body裡用.modelContainer(container)注入,讓子 View 的@Query和@Environment(\.modelContext)都能正常運作
如果不用它們會怎樣?
以下是幾種「不用」的情境和後果:
情境 A:完全不提供 ModelContainer
1
2
3
#Preview {
AnimalListView() // 沒有 .modelContainer
}
結果:crash。 @Query 找不到 ModelContainer,直接炸掉。
情境 B:用 .modelContainer(for:) 但沒有假資料
1
2
3
4
#Preview {
AnimalListView()
.modelContainer(for: Animal.self)
}
結果:空畫面。 Preview 能跑,但沒有任何資料可以看。你沒辦法驗證「有 10 筆資料時 List 長什麼樣」「名字很長時會不會被截斷」這類視覺問題。
情境 C:直接在 #Preview 裡建 container
1
2
3
4
5
6
7
8
9
#Preview {
let schema = Schema([Animal.self])
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try! ModelContainer(for: schema, configurations: [config])
// 塞資料...
return AnimalListView()
.modelContainer(container)
}
結果:能跑,但重複。 每個 View 的 #Preview 都要寫一遍這段 boilerplate。而且如果 Schema 改了(加了新 Model),每個 Preview 都要跟著改。
ModelContainerPreview + ModelContainer.sample 就是把這段 boilerplate 集中管理。
情境 D:只用 ModelContainer.sample 不用 ModelContainerPreview
1
2
3
4
#Preview {
AnimalListView()
.modelContainer(try! ModelContainer.sample())
}
結果:能跑。 事實上 SwiftDataAnimals 的 ContentView Preview 就是這樣寫的。ModelContainerPreview wrapper 不是必須的——它只是提供更乾淨的語法和更安全的錯誤處理(不用 try!)。
社群的其他做法
Apple 的 ModelContainerPreview 不是唯一的寫法。社群裡至少有三種不同的 pattern,各有取捨。
做法 1:全域 previewContainer 變數
AppCoda 的教學 用最直接的方式——一個 @MainActor let 全域變數:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@MainActor
let previewContainer: ModelContainer = {
do {
let container = try ModelContainer(
for: ToDoItem.self,
configurations: .init(isStoredInMemoryOnly: true)
)
for _ in 1...10 {
container.mainContext.insert(generateRandomTodoItem())
}
return container
} catch {
fatalError("Failed to create container")
}
}()
1
2
3
4
#Preview {
ContentView()
.modelContainer(previewContainer)
}
優點: 最簡單,一看就懂。 問題: static let 全域變數只會初始化一次。所有 Preview 共用同一個 container——如果某個 Preview 改了資料(例如測試刪除功能),其他 Preview 會看到被改過的狀態。Apple 範例用 closure 就是為了避免這個問題。
做法 2:自訂 Preview struct
Apple Developer Academy (Medium) 建一個專門的 Preview struct:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct Preview {
let modelContainer: ModelContainer
init() {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
modelContainer = try! ModelContainer(
for: GroceryItem.self,
configurations: config
)
}
func addExamples(_ items: [GroceryItem]) {
for item in items {
modelContainer.mainContext.insert(item)
}
}
}
1
2
3
4
5
6
#Preview {
let preview = Preview()
preview.addExamples(GroceryItem.sampleItems)
return ContentView()
.modelContainer(preview.modelContainer)
}
優點: 每次 Preview() 都建新 container,不會互相干擾。addExamples() 讓不同 Preview 可以塞不同假資料。
問題: 每個 #Preview block 要手動呼叫 addExamples(),比 Apple 的 wrapper 多一步。
做法 3:PreviewModifier(iOS 18+)
WWDC24 新推的 PreviewModifier protocol。Fatbobman 的文章 有介紹這個做法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct SampleDataPreviewModifier: PreviewModifier {
static func makeSharedContext() async throws -> ModelContainer {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: Animal.self, configurations: config)
Animal.insertSampleData(modelContext: container.mainContext)
return container
}
func body(content: Content, context: ModelContainer) -> some View {
content.modelContainer(context)
}
}
extension PreviewTrait where T == Preview.ViewTraits {
@MainActor static var sampleData: Self = .modifier(SampleDataPreviewModifier())
}
1
2
3
#Preview(traits: .sampleData) {
ContentView()
}
優點: 語法最乾淨——traits: .sampleData 一行搞定。Apple 官方推的新方向。
問題: 只支援 iOS 18+ / macOS 15+。如果專案要支援舊版系統,沒辦法用。
四種做法比較
| 全域變數 | Preview struct | ModelContainerPreview | PreviewModifier | |
|---|---|---|---|---|
| 來源 | AppCoda | Academy (Medium) | Apple 範例 | WWDC24 / Fatbobman |
| Container 隔離 | 共用 | 各自獨立 | 各自獨立 | 可共享或獨立 |
| 假資料注入 | 寫在全域變數裡 | 手動 addExamples() | 寫在 sample closure 裡 | 寫在 makeSharedContext() 裡 |
| 最低版本 | iOS 17 | iOS 17 | iOS 17 | iOS 18 |
| 語法簡潔度 | 簡單 | 中等 | 中等 | 最簡潔 |
如果你的專案只支援 iOS 17,ModelContainerPreview 是最妥當的做法。但如果你的專案已經支援 iOS 18+,Apple 在 WWDC24 推出了官方替代方案 PreviewModifier,可以直接取代 ModelContainerPreview。詳見下一篇:用 PreviewModifier 取代 ModelContainerPreview。
什麼時候不需要這些 helper?
不管用 ModelContainerPreview 還是 PreviewModifier,前提都是 View 依賴 SwiftData。
如果你的 View 不使用 @Query 也不使用 @Environment(\.modelContext),就不需要提供 ModelContainer。例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
// 這個 View 只接收已經準備好的資料,不依賴 SwiftData
struct DayBandChart: View {
let day: DayData
let focus: ChartLayer?
let chartHeight: CGFloat
// ...
}
#Preview {
DayBandChart(day: .sample(), focus: nil, chartHeight: 280)
.preferredColorScheme(.dark)
// 不需要 traits: .sampleData!
}
這也是為什麼把元件拆小很重要——越多元件能脫離 ModelContainer 獨立 Preview,開發迴圈就越快。