Post

Using ModelContainerPreview for SwiftData Previews

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 機制。

參考:

所以問題不是「能不能用」,而是「專案要選哪一種 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
  • 截圖匯入與候選確認需要 ScreenshotImportMonsterEntryCandidate
  • 未來 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,兩者不混在一起。

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