SwiftData 怎么用?SwiftData 本地存储教程:模型、查询与迁移
最后更新
SwiftData 怎么用?用 @Model 定义模型,在 App 入口挂上 .modelContainer,界面用 @Query 读数据、用 modelContext 增删,改模型时用 VersionedSchema 和 SchemaMigrationPlan 迁移旧数据。SwiftData 要求 iOS 17 及以上,要支持 iOS 16 及以下的 App 继续用 Codable 文件或 Core Data。
本文把第一个 SwiftUI App里用 JSON 文件存数据的待办清单 MyTodo 改成 SwiftData 本地存储。全部代码在部署目标 iOS 17、Swift 5 和 Swift 6 语言模式下编译通过。做完以后你会得到一个能搜索、能过滤、能编辑、带分组关系的待办 App,旧版本存在 todos.json 里的数据会在第一次启动时自动导入。
用 SwiftData 之前要准备什么?
- 工程。用 129 篇做好的 MyTodo,或者新建一个。新建时在命令面板运行
KXBuild: 新建项目,模板选「iOS 应用(SwiftUI)」,或在 PowerShell 执行下面的命令。
1kxbuild project create -t app_swiftui -n MyTodo -b com.yourname.mytodo -o D:\projects
-
部署目标改成 iOS 17。内置模板的部署目标是 iOS 15,而 SwiftData 从 iOS 17 起才有。在 KXBuild 视图中右键 target 选「KXBuild: 编辑目标设置」,在 General 页把 iOS Deployment Target 改成
17.0,按 Ctrl+S 保存,见工程设置。不改的话编译会报
'Model()' is only available in iOS 17 or newer。 -
证书和手机。真机运行需要的证书目录和设备连接,按 129 篇的「装到 iPhone 上运行」做好。
和 129 篇一样,代码都写在 ContentView.swift 和 MyTodoApp.swift 两个文件里,省去往工程里添加新文件的步骤。
第 1 步:定义模型和关系
把 ContentView.swift 的内容整个换掉,先写下面两个模型。129 篇里的 TodoStore 类不再需要,原来的 TodoItem 结构体在第五步改个名字留着导入旧数据用。
1import SwiftUI
2import SwiftData
3
4@Model
5final class TodoItem {
6 @Attribute(.unique) var id: UUID
7 var title: String
8 var done: Bool
9 var createdAt: Date
10 var group: TodoGroup?
11
12 init(id: UUID = UUID(), title: String, done: Bool = false, createdAt: Date = .now) {
13 self.id = id
14 self.title = title
15 self.done = done
16 self.createdAt = createdAt
17 }
18}
19
20@Model
21final class TodoGroup {
22 var name: String
23 @Relationship(deleteRule: .cascade, inverse: \TodoItem.group)
24 var items: [TodoItem] = []
25
26 init(name: String) {
27 self.name = name
28 }
29}
@Model把一个 class 变成可持久化的模型。存储属性都会存进数据库,类型本身也自动可被 SwiftUI 观察,属性一改界面就刷新。模型必须是 class,不能是 struct。@Attribute(.unique)让id唯一。插入一条id已存在的数据时,SwiftData 会更新原来那条而不是新增一条,后面导入旧数据时靠它防止重复。- 关系。
TodoItem.group是多对一,TodoGroup.items是一对多,inverse指明两边是同一个关系,改一边另一边自动跟着变。 - 不想存的属性加
@Transient,并且给默认值,例如只在界面上用的临时计数。
deleteRule 决定删掉一个分组时,组里的待办怎么处理。
| 删除规则 | 删除 TodoGroup 时 | 适合 |
|---|---|---|
.nullify(默认) |
组里每条待办的 group 变成 nil,待办保留 |
分组只是标签,删组不该丢数据 |
.cascade |
组里的待办一起删除 | 子数据离开父对象就没有意义,例如订单和订单行 |
.deny |
组里还有待办时不允许删除 | 必须先清空才能删的容器 |
.noAction |
不处理相关的待办 | 你自己维护引用,一般不用 |
这里用 .cascade,删一个分组连同里面的待办一起删。想保留待办就去掉 deleteRule: 参数,用默认的 .nullify。
第 2 步:挂上 modelContainer
ModelContainer 负责打开数据库文件,.modelContainer 修饰符把它放进 SwiftUI 环境,下面所有视图都能拿到。把 MyTodoApp.swift 换成下面的内容。
1import SwiftUI
2import SwiftData
3
4@main
5struct MyTodoApp: App {
6 var body: some Scene {
7 WindowGroup {
8 ContentView()
9 }
10 .modelContainer(for: TodoItem.self)
11 }
12}
只写 TodoItem.self 就够了,SwiftData 会顺着关系把 TodoGroup 也加进来。从环境里拿到的 modelContext 会定期检查有没有未保存的改动并自动保存,所以下面的增删改代码里不需要每次调用 save()。Apple 的说明见 Preserving your app’s model data across launches。
第 3 步:列表、增删改
在 ContentView.swift 模型的下面加上界面代码。顶部输入框添加待办,点圆圈切换完成,点标题进入编辑页,左滑删除。
1struct ContentView: View {
2 @Environment(\.modelContext) private var context
3 @State private var newTitle = ""
4 @State private var searchText = ""
5 @State private var hideDone = false
6
7 var body: some View {
8 NavigationStack {
9 List {
10 Section {
11 HStack {
12 TextField("新的待办", text: $newTitle)
13 .onSubmit(add)
14 Button("添加", action: add)
15 .disabled(newTitle.trimmingCharacters(in: .whitespaces).isEmpty)
16 }
17 Toggle("隐藏已完成", isOn: $hideDone)
18 }
19 TodoListSection(searchText: searchText, hideDone: hideDone)
20 }
21 .searchable(text: $searchText, prompt: "搜索待办")
22 .navigationTitle("待办")
23 .navigationDestination(for: TodoItem.self) { item in
24 TodoDetailView(item: item)
25 }
26 .toolbar { EditButton() }
27 .task {
28 LegacyImporter.importIfNeeded(into: context)
29 if let url = context.container.configurations.first?.url {
30 print("SwiftData 存储位置 \(url.path)")
31 }
32 }
33 }
34 }
35
36 private func add() {
37 let title = newTitle.trimmingCharacters(in: .whitespaces)
38 guard !title.isEmpty else { return }
39 context.insert(TodoItem(title: title))
40 newTitle = ""
41 }
42}
43
44struct TodoListSection: View {
45 @Environment(\.modelContext) private var context
46 @Query private var items: [TodoItem]
47
48 init(searchText: String, hideDone: Bool) {
49 let predicate = #Predicate<TodoItem> { item in
50 (searchText.isEmpty || item.title.localizedStandardContains(searchText))
51 && (!hideDone || !item.done)
52 }
53 _items = Query(filter: predicate, sort: \TodoItem.createdAt)
54 }
55
56 var body: some View {
57 Section {
58 ForEach(items) { item in
59 HStack {
60 Button {
61 item.done.toggle()
62 } label: {
63 Image(systemName: item.done ? "checkmark.circle.fill" : "circle")
64 .foregroundStyle(item.done ? .green : .secondary)
65 }
66 .buttonStyle(.plain)
67 NavigationLink(value: item) {
68 Text(item.title)
69 .strikethrough(item.done)
70 }
71 }
72 }
73 .onDelete { offsets in
74 for index in offsets {
75 context.delete(items[index])
76 }
77 }
78 } footer: {
79 Text("共 \(items.count) 条")
80 }
81 }
82}
83
84struct TodoDetailView: View {
85 @Bindable var item: TodoItem
86
87 var body: some View {
88 Form {
89 TextField("标题", text: $item.title)
90 Toggle("已完成", isOn: $item.done)
91 LabeledContent("创建时间", value: item.createdAt.formatted(date: .abbreviated, time: .shortened))
92 if let group = item.group {
93 LabeledContent("分组", value: group.name)
94 }
95 }
96 .navigationTitle("编辑待办")
97 }
98}
.task 里调用的 LegacyImporter 在第五步补上,现在先别编译。
四种操作分别对应下面的写法。
| 操作 | 写法 | 说明 |
|---|---|---|
| 增 | context.insert(TodoItem(title: title)) |
插入后 @Query 立即包含这一条,界面自动刷新 |
| 删 | context.delete(items[index]) |
有 .cascade 关系的会连带删除相关对象 |
| 改 | item.done.toggle()、item.title = "…" |
直接改属性,不需要 update 语句 |
| 查 | @Query、context.fetch(_:) |
视图里用 @Query,视图以外用 FetchDescriptor |
编辑页的 @Bindable 让你能对模型的属性写 $item.title 这样的绑定,TextField 里每输入一个字就改一次模型。@Query 只能在 SwiftUI 视图里用,放在普通的类里不会工作。
第 4 步:用 #Predicate 排序和过滤
@Query 写在属性上时条件是固定的。条件要跟着搜索框、开关变化,就像上面的 TodoListSection 一样,把条件作为初始化参数传进子视图,在 init 里给 _items 赋一个新的 Query。父视图的 searchText 一变,子视图重新创建,查询也跟着换。
#Predicate 里写的是 Swift 表达式,SwiftData 把它翻译成数据库查询。
- 能用的:比较运算、
&&、||、!,字符串的contains、localizedStandardContains(忽略大小写和变音符号),可选值和关系的比较,以及从外面捕获的常量,例如这里的searchText和hideDone。 - 不能用的:任意方法和计算属性。例如写
$0.title.uppercased() == s,编译直接报The uppercased() function is not supported in this predicate。需要按大小写无关搜索就用localizedStandardContains,需要复杂计算的条件可以先存成一个单独的属性。
排序传给 sort:,可以是一个 key path,也可以是 SortDescriptor 数组。在视图以外查数据,例如统计或导出,用 FetchDescriptor。
1@MainActor
2func todayUnfinished(context: ModelContext) throws -> [TodoItem] {
3 let todayStart = Calendar.current.startOfDay(for: .now)
4 let descriptor = FetchDescriptor<TodoItem>(
5 predicate: #Predicate { !$0.done && $0.createdAt >= todayStart },
6 sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
7 )
8 return try context.fetch(descriptor)
9}
只要数量时用 try context.fetchCount(descriptor),不必把对象都取出来。todayStart 要先算好放进常量,#Predicate 里不能调用 Calendar 的方法。
用关系查询和赋值
给待办设置分组,直接给 group 赋值,TodoGroup.items 会自动多出这一条。
1@MainActor
2func addWorkItem(context: ModelContext) {
3 let work = TodoGroup(name: "工作")
4 context.insert(work)
5 let item = TodoItem(title: "写周报")
6 item.group = work
7 context.insert(item)
8 print(work.items.count)
9}
这时调用 context.delete(work),因为删除规则是 .cascade,「写周报」也会一起删掉。
第 5 步:从 JSON 文件导入旧数据
129 篇的 MyTodo 把待办存在 Documents 目录的 todos.json 里。已经装过旧版本的手机升级后,要把这些数据一次性搬进 SwiftData。在 ContentView.swift 里加上下面的代码,LegacyTodoItem 就是原来的 TodoItem 结构体改了名字,字段和 JSON 的键保持一致。
1struct LegacyTodoItem: Codable {
2 var id: UUID
3 var title: String
4 var done: Bool
5}
6
7enum LegacyImporter {
8 @MainActor
9 static func importIfNeeded(into context: ModelContext) {
10 let fileURL = URL.documentsDirectory.appending(path: "todos.json")
11 guard FileManager.default.fileExists(atPath: fileURL.path) else { return }
12 do {
13 let data = try Data(contentsOf: fileURL)
14 let oldItems = try JSONDecoder().decode([LegacyTodoItem].self, from: data)
15 let start = Date.now.addingTimeInterval(-Double(oldItems.count))
16 for (index, old) in oldItems.enumerated() {
17 context.insert(TodoItem(
18 id: old.id,
19 title: old.title,
20 done: old.done,
21 createdAt: start.addingTimeInterval(Double(index))
22 ))
23 }
24 try context.save()
25 let doneURL = URL.documentsDirectory.appending(path: "todos.imported.json")
26 try? FileManager.default.removeItem(at: doneURL)
27 try FileManager.default.moveItem(at: fileURL, to: doneURL)
28 print("已从 todos.json 导入 \(oldItems.count) 条")
29 } catch {
30 print("导入失败 \(error)")
31 }
32 }
33}
这段代码保证只导入一次,也不会重复。
- 只跑一次。导入成功后把
todos.json改名为todos.imported.json,下次启动找不到原文件就直接返回。保留改名后的文件,万一导入有问题还能找回原始数据,确认没问题后的下一个版本再删掉。 - 不会重复。沿用旧数据的
id,加上@Attribute(.unique),即使导入中途被杀掉、下次重新导入,同一条也只会更新不会新增。 - 保持顺序。旧 JSON 没有创建时间,按数组顺序每条相差一秒生成
createdAt,列表按createdAt排序后和原来一样。 - 立即保存。导入后显式调用
save(),确认写进数据库后才改名原文件,不依赖自动保存的时机。
数据量很大时把导入挪到后台执行,见下一节。
SwiftData 怎么在后台线程批量写入?
context.container.mainContext 和环境里的 modelContext 都属于主线程。批量导入、同步服务器数据这种耗时的写入,用 @ModelActor 声明一个 actor,它自带一个只在这个 actor 里使用的 modelContext。
1@ModelActor
2actor TodoBackgroundImporter {
3 func importTitles(_ titles: [String]) throws -> Int {
4 for title in titles {
5 modelContext.insert(TodoItem(title: title))
6 }
7 try modelContext.save()
8 return titles.count
9 }
10}
11
12@MainActor
13func runBackgroundImport(container: ModelContainer) async {
14 let importer = TodoBackgroundImporter(modelContainer: container)
15 do {
16 let count = try await importer.importTitles(["示例 1", "示例 2"])
17 print("后台导入 \(count) 条")
18 } catch {
19 print("后台导入失败 \(error)")
20 }
21}
在视图里用 await runBackgroundImport(container: context.container) 调用。后台 context 不会自动保存,写完要调用 save(),否则数据不会写进数据库。
要在后台修改界面上的某一条,把它的 persistentModelID 传过去,在 actor 里用 modelContext.model(for:) 取回来再改,不要把 ModelContext 本身传到别的线程。Apple 的说明见 ModelActor。
怎么在 iPhone 上确认数据存下来了?
数据在重启和覆盖安装后还在
- 按 F5,KXBuild 构建、签名、安装并启动 App。添加几条待办,勾选一条,进编辑页改一个标题。
- 在手机上上滑把 App 从后台划掉,再点图标打开,刚才的改动都在。
- 改一行代码,例如把
navigationTitle("待办")改成navigationTitle("我的待办"),按 Shift+F5 停止调试再按 F5。这次是覆盖安装,数据仍然在。 - 装过 129 篇旧版本的手机,第一次运行时「输出 → KXBuild Console」里会打印
已从 todos.json 导入 N 条,列表里出现原来的待办。
数据只在卸载 App 时被删除。覆盖安装、升级版本都会保留同一个 Bundle ID 下的数据,所以模型改动必须考虑已有数据,见下一节的迁移。
查看数据库文件的位置
上面 .task 里打印了存储位置,运行后在「输出 → KXBuild Console」能看到类似下面的一行,具体路径以你手机上的输出为准。
1SwiftData 存储位置 /private/var/mobile/Containers/Data/Application/<UUID>/Library/Application Support/default.store
路径里 Application/<UUID>/ 之后的部分就是 App 沙盒里的位置。用 kxdevice 列出沙盒的 Library 目录,确认文件确实写到了手机上。一般是一个 .store 文件,旁边可能还有同名的 -wal、-shm 文件。
1kxdevice file --app com.yourname.mytodo tree /Library
这个命令只对你自己签名安装的 App 有效,用法见 kxdevice 命令参考。
用断点看数据
在 add() 的 context.insert(...) 这一行打断点,在手机上添加一条,停住后在调试控制台输入 po title 看要插入的标题,按 F10 单步越过这一行,再按 F5 继续,列表底部的「共 N 条」加一。切换「隐藏已完成」或在搜索框输入文字,底部的条数跟着变,说明过滤条件生效。断点和调试控制台的用法见运行与调试。
改了模型怎么做 SwiftData 迁移?
App 发布后再改模型,手机上已有的数据库还是旧结构。SwiftData 打开数据库时会比较新旧结构,能自动转换的直接转换,不能的就打开失败。
哪些改动能自动迁移
| 改动 | 迁移方式 | 做法 |
|---|---|---|
| 新增有默认值的属性 | 轻量迁移,自动完成 | var priority: Int = 0 |
| 新增可选属性 | 轻量迁移,自动完成 | var dueDate: Date? |
| 属性改名 | 轻量迁移,要标出旧名 | @Attribute(originalName: "done") var isCompleted: Bool |
| 修改关系的删除规则 | 轻量迁移 | 改 deleteRule: |
| 给已有数据加唯一约束 | 不能轻量迁移 | 用自定义迁移阶段先去重 |
| 新增非可选、无默认值的属性 | 旧数据没有值,迁移失败 | 加默认值或改成可选 |
| 改属性类型、拆分合并字段 | 需要自定义迁移 | 用 .custom 阶段转换数据 |
属性改名时只改名字不写 originalName,SwiftData 会当成删了一个属性、加了一个新属性,旧值就丢了。改名、删除规则可以轻量迁移,加唯一约束需要自定义阶段,这几条出自 Apple 的 WWDC23 讲座 Model your schema with SwiftData。
用 VersionedSchema 管理版本
只做轻量迁移时可以不写迁移代码。从第一次正式发版开始,建议把每个发布过的模型版本都存成一个 VersionedSchema,以后需要自定义迁移时版本链是完整的。
下面把已经发布的模型存为 V1,V2 新增优先级和截止日期两个字段。把 ContentView.swift 里第一步写的两个 @Model 类删掉,换成下面的代码,其他代码不用改。
1enum TodoSchemaV1: VersionedSchema {
2 static let versionIdentifier = Schema.Version(1, 0, 0)
3 static var models: [any PersistentModel.Type] {
4 [TodoItem.self, TodoGroup.self]
5 }
6
7 @Model
8 final class TodoItem {
9 @Attribute(.unique) var id: UUID
10 var title: String
11 var done: Bool
12 var createdAt: Date
13 var group: TodoGroup?
14
15 init(id: UUID = UUID(), title: String, done: Bool = false, createdAt: Date = .now) {
16 self.id = id
17 self.title = title
18 self.done = done
19 self.createdAt = createdAt
20 }
21 }
22
23 @Model
24 final class TodoGroup {
25 var name: String
26 @Relationship(deleteRule: .cascade, inverse: \TodoItem.group)
27 var items: [TodoItem] = []
28
29 init(name: String) {
30 self.name = name
31 }
32 }
33}
34
35enum TodoSchemaV2: VersionedSchema {
36 static let versionIdentifier = Schema.Version(2, 0, 0)
37 static var models: [any PersistentModel.Type] {
38 [TodoItem.self, TodoGroup.self]
39 }
40
41 @Model
42 final class TodoItem {
43 @Attribute(.unique) var id: UUID
44 var title: String
45 var done: Bool
46 var createdAt: Date
47 var priority: Int = 0
48 var dueDate: Date?
49 var group: TodoGroup?
50
51 init(id: UUID = UUID(), title: String, done: Bool = false, createdAt: Date = .now) {
52 self.id = id
53 self.title = title
54 self.done = done
55 self.createdAt = createdAt
56 }
57 }
58
59 @Model
60 final class TodoGroup {
61 var name: String
62 @Relationship(deleteRule: .cascade, inverse: \TodoItem.group)
63 var items: [TodoItem] = []
64
65 init(name: String) {
66 self.name = name
67 }
68 }
69}
70
71typealias TodoItem = TodoSchemaV2.TodoItem
72typealias TodoGroup = TodoSchemaV2.TodoGroup
73
74enum TodoMigrationPlan: SchemaMigrationPlan {
75 static var schemas: [any VersionedSchema.Type] {
76 [TodoSchemaV1.self, TodoSchemaV2.self]
77 }
78
79 static var stages: [MigrationStage] {
80 [migrateV1toV2]
81 }
82
83 static var migrateV1toV2: MigrationStage {
84 .lightweight(fromVersion: TodoSchemaV1.self, toVersion: TodoSchemaV2.self)
85 }
86}
- V1 必须和已发布的模型一字不差。属性、类型、关系、属性标注都要和用户手机上那一版完全一致,SwiftData 靠它认出旧数据库是哪个版本。
typealias指向最新版本。界面代码里的TodoItem自动指向 V2,原来的增删改查不用改。schemas按从旧到新排列,stages列出相邻两个版本之间怎么迁移。
然后把 MyTodoApp.swift 换成显式创建容器的写法,传入迁移计划。
1import SwiftUI
2import SwiftData
3
4@main
5struct MyTodoApp: App {
6 let container: ModelContainer
7
8 init() {
9 do {
10 container = try ModelContainer(
11 for: TodoItem.self, TodoGroup.self,
12 migrationPlan: TodoMigrationPlan.self
13 )
14 } catch {
15 fatalError("无法打开数据库:\(error)")
16 }
17 }
18
19 var body: some Scene {
20 WindowGroup {
21 ContentView()
22 }
23 .modelContainer(container)
24 }
25}
fatalError 会把具体的迁移错误打印到「输出 → KXBuild Console」。正式版本可以换成显示一个错误页面并上报日志,不要静默删库重建。
自定义迁移阶段
迁移时需要按旧数据计算新字段,例如把所有未完成的待办设成优先级 1,就用 .custom。willMigrate 在结构变更前执行,拿到的是旧版本的数据,适合去重、清理;didMigrate 在变更后执行,拿到的是新版本的数据,适合填充新字段。把 TodoMigrationPlan 里的 migrateV1toV2 换成下面这个。
1 static var migrateV1toV2: MigrationStage {
2 .custom(
3 fromVersion: TodoSchemaV1.self,
4 toVersion: TodoSchemaV2.self,
5 willMigrate: nil,
6 didMigrate: { context in
7 let items = try context.fetch(FetchDescriptor<TodoSchemaV2.TodoItem>())
8 for item in items where !item.done {
9 item.priority = 1
10 }
11 try context.save()
12 }
13 )
14 }
在真机上测迁移
- 先用 V1 的代码按 F5 装到手机上,添加几条待办。
- 换成 V2 和迁移计划的代码,再按 F5 覆盖安装。这一步模拟的就是用户从旧版本升级。
- App 正常启动、旧待办都在,迁移就是成功的。启动即崩溃时看「输出 → KXBuild Console」里的错误,再对照下面的报错表。
开发阶段模型还没发布,随便改字段后启动失败的话,删掉手机上的 App 重装就能从空数据库开始,命令是 kxdevice apps uninstall com.yourname.mytodo。发布之后不能这样做,必须写迁移。
SwiftData 常见报错怎么解决?
| 现象或报错 | 原因 | 解决 |
|---|---|---|
编译报 'Model()' is only available in iOS 17 or newer |
部署目标低于 iOS 17 | 在「编辑目标设置」的 General 页把 iOS Deployment Target 改成 17.0 |
编译报 main actor-isolated property 'mainContext' cannot be accessed from outside of the actor |
在 Task.detached 或后台队列里用了 mainContext |
后台写入改用 @ModelActor,主线程的 context 只在主线程用 |
编译报 non-Sendable type 'ModelContext' ... cannot exit main actor-isolated context,Swift 5 模式下是警告 |
把 ModelContext 带到了另一个线程 |
每个 actor 用自己的 context,跨线程只传 persistentModelID |
编译报 The uppercased() function is not supported in this predicate |
#Predicate 里调用了不支持的方法 |
改用 localizedStandardContains 这类支持的写法,或把计算结果存成属性 |
| 改了模型字段后启动即崩溃 | 新增了非可选、无默认值的属性,或改名、改类型时没有对应的迁移 | 新属性给默认值或改成可选,改名加 originalName,复杂改动写 .custom 阶段 |
加 @Attribute(.unique) 后旧数据打不开 |
唯一约束不是轻量迁移,旧数据里可能已有重复值 | 加一个新版本,在 .custom 的 willMigrate 里先去重 |
| 一插入数据或一显示列表就崩溃 | 没有挂 .modelContainer,或挂在了用到数据的视图之外 |
把 .modelContainer 挂在 WindowGroup 上 |
| 后台写入的数据重启后不见了 | 后台 context 没有调用 save() |
写完调用 try modelContext.save() |
| 每次启动旧数据都多一份 | 导入代码没有标记已完成,模型也没有唯一 id | 导入后改名原文件,并沿用旧数据的 id 加 @Attribute(.unique) |
@Query 的结果不随搜索框变化 |
条件写死在属性上 | 把条件作为参数传进子视图,在 init 里重建 Query |
崩溃时的完整调用栈和变量可以在调试视图里看,排查方法见 Windows 上真机调试 iOS App。并发相关报错的完整改法见 Swift 6 严格并发常见报错。
要支持 iOS 16 及以下怎么办?
SwiftData 的所有类型都要求 iOS 17,不能向下兼容,也没有可以单独打包的版本。最低版本低于 iOS 17 时按数据量选。
| 情况 | 建议 |
|---|---|
| 几十到几百条、结构简单 | 继续用 129 篇的 Codable + JSON 文件,ObservableObject 驱动界面 |
| 数据多、要按条件查询、有关系 | 用 Core Data,iOS 3 起就有,@FetchRequest 在 SwiftUI 里的用法和 @Query 类似 |
| 打算一两个版本后把最低版本升到 17 | 现在用 Codable 文件,升级时用第五步的导入代码一次性搬进 SwiftData |
不建议用 if #available(iOS 17, *) 在同一个 App 里同时维护 SwiftData 和另一套存储。两套代码要分别测试,升级系统的用户还要做跨存储迁移,复杂度远超收益。最低版本怎么定可以参考 SwiftUI 还是 UIKit:2026 年新项目怎么选。
常见问题
SwiftData 最低支持哪个 iOS 版本?
iOS 17。@Model、ModelContainer、@Query 都要求 iOS 17 及以上,部署目标更低时编译直接报错。#Unique、#Index 这类后来加的功能要求 iOS 18。
SwiftData 的数据库文件存在 App 的哪里?
默认存在 App 沙盒的 Library 目录下。最准确的办法是打印 context.container.configurations.first?.url,运行后在「输出 → KXBuild Console」查看,再用 kxdevice file --app <Bundle ID> tree /Library 确认文件在手机上。
改了 SwiftData 模型以后 App 启动就崩溃怎么办?
开发阶段直接卸载 App 重装,从空数据库开始。已经发布过的版本要给新属性加默认值或改成可选,改名用 @Attribute(originalName:),复杂改动用 VersionedSchema 和 SchemaMigrationPlan 写迁移,再用旧版本覆盖安装新版本测试。
SwiftData 要手动调用 save() 吗?
从环境拿到的 modelContext 和 mainContext 会自动保存,一般不用手动调用。自己用 @ModelActor 或 ModelContext(container) 创建的 context 写完要调用 save(),导入旧数据这种必须确认落盘的操作也建议显式保存。
SwiftData 和 Core Data 怎么选?
最低 iOS 17 的新项目用 SwiftData,模型和查询直接写在 Swift 代码里,和 SwiftUI 配合最省事。要支持 iOS 16 及以下,或者已经有成熟的 Core Data 模型,就继续用 Core Data。