跳到主要内容
教程目录

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 上确认数据存下来了?

数据在重启和覆盖安装后还在

  1. 按 F5,KXBuild 构建、签名、安装并启动 App。添加几条待办,勾选一条,进编辑页改一个标题。
  2. 在手机上上滑把 App 从后台划掉,再点图标打开,刚才的改动都在。
  3. 改一行代码,例如把 navigationTitle("待办") 改成 navigationTitle("我的待办"),按 Shift+F5 停止调试再按 F5。这次是覆盖安装,数据仍然在。
  4. 装过 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    }

在真机上测迁移

  1. 先用 V1 的代码按 F5 装到手机上,添加几条待办。
  2. 换成 V2 和迁移计划的代码,再按 F5 覆盖安装。这一步模拟的就是用户从旧版本升级。
  3. 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。