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 以外讀 SwiftData | 用 FetchDescriptor + 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。它用 PreviewHelper 和 SampleData 分資料夾,並用 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 仍應依照產品需求處理錯誤。
兩者不是新舊互斥,而是各自適合不同情境:
| 官方來源 | 適合記住的重點 |
|---|---|
| SwiftDataAnimals | PreviewHelper / ModelContainerPreview / SampleData 的專案結構,以及把 sample data setup 放在 Preview container 層。 |
| Collect, model, and store data | 用 DataContainer 集中管理 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 app Apple Developer Documentation](https://developer.apple.com/documentation/swiftdata/adding-and-editing-persistent-data-in-your-app) [Collect, model, and store data Apple 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 queries Apple Developer Forums](https://developer.apple.com/forums/thread/811232)