Post

Use ModelContainerPreview to Help SwiftData Preview

Use ModelContainerPreview to Help SwiftData Preview

搞懂 SwiftData Preview 為什麼需要 ModelContainerPreview

Apple 的 SwiftData 範例專案(SwiftDataAnimals)裡有兩個 Preview helper 檔案:

  • ModelContainerPreview.swift — 一個泛型 wrapper View
  • Preview+ModelContainer.swiftModelContainer 的 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 有兩個隱性依賴:

  1. @Query 需要 ModelContainer — 如果 SwiftUI environment 裡沒有 ModelContainer@Query 會 crash
  2. @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 之間可能互相干擾。

所以需要兩件事

  1. In-memory container — 每次 Preview 都從零開始,不殘留
  2. 預填假資料 — 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: truecontainer 只存在記憶體每次 Preview 都是乾淨的,不會殘留髒資料
明確列出 SchemaSchema([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())
        }
    }
}

展開來看:

  1. ModelContainer.sample 是一個 closure
  2. ModelContainerPreview 在 init 裡呼叫這個 closure,建立 in-memory container 並塞入假資料
  3. 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 structModelContainerPreviewPreviewModifier
來源AppCodaAcademy (Medium)Apple 範例WWDC24 / Fatbobman
Container 隔離共用各自獨立各自獨立可共享或獨立
假資料注入寫在全域變數裡手動 addExamples()寫在 sample closure 裡寫在 makeSharedContext()
最低版本iOS 17iOS 17iOS 17iOS 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,開發迴圈就越快。

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