Post

SwiftData Preview Data Setup

SwiftData Preview Data Setup

MonsterDexProgressView 在 Xcode Preview 會顯示畫面,但同時一直跳 crash 視窗。Crash report 停在 SwiftData / _SwiftData_SwiftUI,而這個 View 同時用了 @Query 讀資料,並在 .task 裡呼叫專案的初始資料補齊程式寫入 SwiftData。

重新查 Apple 文件與官方 sample 後,結論要分開看:不是「正式 App 和 Preview 都建議用 .task 讀資料」,也不是「sample data 一定要同步插入才是官方寫法」。比較精準的原則是:Preview 應該在 Preview 專用的 container / data setup 層準備 sample data,被 preview 的 View 再用 @Query 讀;不要讓 View 出現後才靠正式 App 的資料補齊流程把 Preview 補完整。

判斷原則

場景2026 查到的方向
View 出現時做 async 工作,例如打 API、讀遠端資料可以用 .task。Apple 的 .task 文件說它會在 View 出現前執行 async task,範例也是從遠端 URL 載入內容。
SwiftUI View 讀 SwiftData 列表優先用 @Query,因為它依賴 View environment 裡的 modelContext,並會隨 SwiftData model 變更更新畫面。
View 以外讀 SwiftDataFetchDescriptor + ModelContext.fetch;Apple DTS 在 2026 的 forum 回覆也提到,@Query 目前綁在 SwiftUI View。
SwiftData Preview 準備 sample data建立 in-memory ModelContainer,在 Preview 專用資料層載入 sample data,再把 container 套到 Preview。
Preview 靠 .task 跑正式 App 的初始資料補齊流程才補完整資料不建議。這會把正式 App 的資料初始化混進 Preview 渲染流程,也是這次踩到的設計問題。

Apple 官方寫法

Apple 目前能看到兩種官方寫法,兩者都值得記。

1. SwiftDataAnimals

來源是 Apple Developer 的 Adding and editing persistent data in your app。它用 PreviewHelperSampleData 分資料夾,並用 ModelContainerPreview 包 Preview content。這和 myPocodun 的結構幾乎一樣。

1
2
3
4
5
6
7
8
9
10
11
extension ModelContainer {
    static var sample: () throws -> ModelContainer = {
        let schema = Schema([AnimalCategory.self, Animal.self])
        let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
        let container = try ModelContainer(for: schema, configurations: [configuration])
        Task { @MainActor in
            AnimalCategory.insertSampleData(modelContext: container.mainContext)
        }
        return container
    }
}

這個 sample 的重點不是「View 用 .task 讀資料」,而是:Preview helper 建立 in-memory container,sample data setup 放在 ModelContainer.sample 這一層,View 本身仍然用 @Query 或 environment 的 modelContext

2. 較新的 Collect, model, and store data - Develop in Swift Tutorials

它建立一個 DataContainer,在 initializer 裡同步載入 sample data,並提供 .sampleDataContainer() 給 Preview 用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@MainActor
class DataContainer {
    let modelContainer: ModelContainer

    var context: ModelContext {
        modelContainer.mainContext
    }

    init(includeSampleMoments: Bool = false) {
        let schema = Schema([
            Moment.self,
        ])

        let modelConfiguration = ModelConfiguration(
            schema: schema,
            isStoredInMemoryOnly: includeSampleMoments
        )

        do {
            modelContainer = try ModelContainer(
                for: schema,
                configurations: [modelConfiguration]
            )

            if includeSampleMoments {
                loadSampleMoments()
            }

            try context.save()
        } catch {
            fatalError("Could not create ModelContainer: \(error)")
        }
    }

    private func loadSampleMoments() {
        for moment in Moment.sampleData {
            context.insert(moment)
        }
    }
}
1
2
3
4
5
6
7
8
private let sampleContainer = DataContainer(includeSampleMoments: true)

extension View {
    func sampleDataContainer() -> some View {
        self
            .modelContainer(sampleContainer.modelContainer)
    }
}

這種寫法的優點是 sample data 在 container 初始化期間就準備好;fatalError 只適合像 Preview / sample container 建立失敗這類開發期錯誤,正式 App 仍應依照產品需求處理錯誤。

兩者不是新舊互斥,而是各自適合不同情境:

官方來源適合記住的重點
SwiftDataAnimalsPreviewHelper / ModelContainerPreview / SampleData 的專案結構,以及把 sample data setup 放在 Preview container 層。
Collect, model, and store dataDataContainer 集中管理 app 與 Preview 的 container,並在 initializer 裡同步載入 sample data。

ScoutDex 的呼叫鏈

回到 ScoutDex。原本 Preview 的資料流是:MonsterDexProgressView 本身沒有直接呼叫 ScoutDexPreviewSampleData,它只用 @Query 讀目前 environment 裡的 ModelContainer。Preview 的 container 是從 ModelContainer.sample 來的:

1
2
3
4
5
6
7
#Preview {
    ModelContainerPreview(ModelContainer.sample) {
        NavigationStack {
            MonsterDexProgressView()
        }
    }
}

呼叫鏈是:

1
2
3
4
5
6
7
MonsterDexProgressView #Preview
-> ModelContainerPreview(ModelContainer.sample)
-> ModelContainer.sample
-> ScoutDexPreviewSampleData.makeModelContainer()
-> seedPreviewData(...)
-> insert MonsterDexEntry sample data
-> MonsterDexProgressView 的 @Query 讀到這些資料

seedPreviewData(...) 是 ScoutDex 專案裡的函式名稱;在一般說明上,它就是 Preview sample data setup。

原本錯誤流程

原本錯的地方是:Preview sample data 已經先塞進 in-memory container,但畫面出現後又跑正式 App 的初始資料補齊流程,寫回同一個 container。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct MonsterDexProgressView: View {
    @Environment(\.modelContext) private var modelContext
    @Query(sort: \MonsterDexEntry.monsterKey) private var entries: [MonsterDexEntry]
    @StateObject private var viewModel = MonsterDexProgressViewModel()

    // toolbar, grid columns, and navigation code omitted

    var body: some View {
        ScrollView {
            // entries from @Query
        }
        .task {
            viewModel.ensureFixedTargets(in: modelContext)
        }
    }
}

錯誤流程是同一個 #Preview 裡,先建立 sample container,接著 View 出現後又呼叫 FixedScoutTargetSeeder 寫回同一個 container:

flowchart TD
    preview["MonsterDexProgressView #Preview"]
    wrapper["ModelContainerPreview(ModelContainer.sample)"]
    sample["ModelContainer.sample"]
    factory["ScoutDexPreviewSampleData.makeModelContainer()"]
    setup["seedPreviewData(...) inserts sample data"]
    view["MonsterDexProgressView() appears"]
    query["@Query reads MonsterDexEntry"]
    task[".task calls ensureFixedTargets(in:)"]
    fixedSetup["FixedScoutTargetSeeder writes to the same container"]
    notify["SwiftData notifies @Query"]
    crash["Preview crash dialog"]

    preview --> wrapper
    wrapper --> sample
    sample --> factory
    factory --> setup
    setup --> view
    view --> query
    view --> task
    task --> fixedSetup
    fixedSetup --> notify
    notify --> query
    notify --> crash

這段在正式 App 可以接受,因為第一次進畫面時需要補齊固定資料;但在 Preview 裡,@Query 正在觀察 SwiftData model,.task 又在 Preview 渲染期間寫入同一個 container。這個設計把「正式 App 的資料初始化」混進「Preview sample data」流程,才是需要修掉的點。

修正後流程

修正方向是把正式 App 的初始資料補齊流程和 Preview sample data setup 分開:正式畫面仍在出現時補齊固定怪物資料,Preview 則不要在畫面出現後再寫 SwiftData。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct MonsterDexProgressView: View {
    @Environment(\.modelContext) private var modelContext
    @Query(sort: \MonsterDexEntry.monsterKey) private var entries: [MonsterDexEntry]
    @StateObject private var viewModel = MonsterDexProgressViewModel()

    private let seedsFixedTargetsOnAppear: Bool

    // toolbar, grid columns, and navigation code omitted

    init(seedsFixedTargetsOnAppear: Bool = true) {
        self.seedsFixedTargetsOnAppear = seedsFixedTargetsOnAppear
    }

    var body: some View {
        ScrollView {
            // @Query-driven content
        }
        .task {
            guard seedsFixedTargetsOnAppear else {
                return
            }

            viewModel.ensureFixedTargets(in: modelContext)
        }
    }
}

seedsFixedTargetsOnAppear 也是專案命名。它的意思是:這個 View 出現時,是否要補齊固定怪物資料。

Preview 改成關掉這段 runtime data setup:

1
2
3
4
5
6
7
#Preview {
    ModelContainerPreview(ModelContainer.sample) {
        NavigationStack {
            MonsterDexProgressView(seedsFixedTargetsOnAppear: false)
        }
    }
}

正確流程仍然從同一個 #Preview 開始,只是 Preview 傳入 seedsFixedTargetsOnAppear: false,所以 .task 不再寫 SwiftData:

flowchart TD
    preview["MonsterDexProgressView #Preview"]
    wrapper["ModelContainerPreview(ModelContainer.sample)"]
    sample["ModelContainer.sample"]
    factory["ScoutDexPreviewSampleData.makeModelContainer()"]
    setup["seedPreviewData(...) inserts complete sample data"]
    view["MonsterDexProgressView(seedsFixedTargetsOnAppear: false) appears"]
    query["@Query reads MonsterDexEntry"]
    task[".task checks seedsFixedTargetsOnAppear"]
    skip["Skip runtime data setup"]

    preview --> wrapper
    wrapper --> sample
    sample --> factory
    factory --> setup
    setup --> view
    view --> query
    view --> task
    task --> skip

來源

  • [Adding and editing persistent data in your appApple Developer Documentation](https://developer.apple.com/documentation/swiftdata/adding-and-editing-persistent-data-in-your-app)
  • [Collect, model, and store dataApple Developer Documentation](https://developer.apple.com/tutorials/develop-in-swift/collect-model-and-store-data)
  • [View.task(name:priority:file:line:_:)Apple Developer Documentation](https://developer.apple.com/documentation/swiftui/view/task%28name%3Apriority%3Afile%3Aline%3A_%3A%29)
  • [modelContainer(_:)Apple Developer Documentation](https://developer.apple.com/documentation/SwiftUI/View/modelContainer%28_%3A%29)
  • [Best practice for centralizing SwiftData queriesApple Developer Forums](https://developer.apple.com/forums/thread/811232)
This post is licensed under CC BY 4.0 by the author.