本地持久化是几乎所有真实 App 绕不开的一环:登录态、离线缓存、草稿、历史记录,最后都要落到磁盘上。iOS 上长期只有一条官方路线 Core Data,它强大但 API 老派、样板代码多、并发模型需要靠 NSManagedObjectContext 的 perform 手工管理。SwiftData 在 iOS 17 随 Swift 宏一起登场,用声明式模型 + SwiftUI 集成把心智负担压下来。但「新」不等于「更好」,选型要看场景。
开篇
先给结论:新项目、纯 SwiftUI、iOS 17 起步,优先 SwiftData;需要复杂迁移、多平台共享栈、或要兼容 iOS 16 及以下,继续用 Core Data。这不是「新技术打败旧技术」,而是两套东西的定位不同。SwiftData 的底层其实仍然是 Core Data 的存储引擎,只是在上面包了一层现代 Swift 的皮。
本文把两条路线都讲透,重点是让你在动手写第一行 @Model 之前,就知道后面会踩哪些坑。
一、SwiftData 是什么,和 Core Data 什么关系
1.1 从 Core Data 到 SwiftData 的定位变化
Core Data 诞生于 2005 年,比 Swift 早了十年。它基于 Objective-C 运行时,模型靠 .xcdatamodeld 可视化文件描述,代码里用 NSManagedObject 子类、NSFetchRequest、NSPredicate 这些以 NS 打头的老 API。它的能力非常完整:增量迁移、批量更新、复杂的多对多、NSFetchedResultsController 驱动表格刷新。
SwiftData 是 Swift 原生重写的一层,核心变化:
- 模型用宏描述:
@Model标注一个 class,编译期自动生成持久化元数据,不再需要可视化编辑器。 - 上下文隐式传递:通过
modelContext环境值注入,视图里直接读写。 - 查询即声明:
@Query直接绑定到 SwiftUI 视图,数据变了自动刷新。 - 类型安全的谓词:
#Predicate宏在编译期检查字段名,写错字段编译不过。
1.2 两者的技术底座
| 维度 | SwiftData | Core Data |
|---|---|---|
| 首次可用版本 | iOS 17 / macOS 14 | iOS 3 |
| 模型定义方式 | @Model 宏,纯 Swift | .xcdatamodeld 可视化编辑器 |
| 查询 API | @Query / FetchDescriptor / #Predicate | NSFetchRequest / NSPredicate |
| 并发模型 | ModelActor,Sendable 友好 | NSManagedObjectContext 队列 |
| SwiftUI 集成 | 一等公民,modelContainer 修饰器 | 需 @FetchRequest 桥接 |
| 底层存储 | SQLite(复用 Core Data 栈) | SQLite / 二进制 / 内存 |
| 迁移工具 | SchemaMigrationPlan | NSEntityMapping / 轻量迁移 |
| 最低部署目标 | iOS 17 | iOS 3(几乎无下限) |
一句话:SwiftData 是 Core Data 的「Swift 门面」,能力上有取舍,覆盖了 80% 的常见场景,剩下 20% 的复杂需求还得回落 Core Data。
二、@Model 与模型定义
2.1 定义第一个模型
@Model 只能标注 class,因为持久化对象需要引用语义。所有存储属性默认都会持久化,var 表示可变字段。
import SwiftData
import Foundation
@Model
final class Note {
var title: String
var body: String
var createdAt: Date
var isPinned: Bool
// 与 Tag 的多对多关系
var tags: [Tag]
init(title: String, body: String = "", isPinned: Bool = false) {
self.title = title
self.body = body
self.createdAt = .now
self.isPinned = isPinned
self.tags = []
}
}
@Model
final class Tag {
@Attribute(.unique) var name: String
var color: String
// 反向关系,SwiftData 会自动推断
var notes: [Note]
init(name: String, color: String = "#4A90D9") {
self.name = name
self.color = color
self.notes = []
}
}
几个要点:
@Attribute(.unique)声明唯一约束,插入重复name会做 upsert(已存在的更新,不存在才插入)。- 关系属性用数组声明一对多,SwiftData 自动推断反向关系,不需要显式
inverse:。 - 关系默认是 optional 的语义,
[Note]不会为 nil,只是可能为空数组。
2.2 常用属性宏
@Model
final class UserProfile {
@Attribute(.unique) var userID: String
// 不持久化,只作为运行时缓存
@Transient var avatarImage: Data?
// 外部存储:大文件(图片、音频)落到独立文件而非数据库
@Attribute(.externalStorage) var avatar: Data?
// 级联删除:删 UserProfile 时一并删掉其 posts
@Relationship(deleteRule: .cascade, inverse: \Post.author)
var posts: [Post]
// 允许为 nil 的字段
var nickname: String?
init(userID: String) {
self.userID = userID
self.posts = []
}
}
@Model
final class Post {
var content: String
var author: UserProfile?
init(content: String) { self.content = content }
}
@Transient 和 @Attribute(.externalStorage) 是两个容易被忽略但很关键的宏:前者避免把不需要持久化的临时状态写库,后者避免把几 MB 的图片二进制塞进 SQLite 行里拖慢查询。
三、ModelContainer 与 ModelContext
3.1 容器是栈的入口
ModelContainer 管理整个持久化栈(store 文件、schema、配置)。一个 App 通常只建一个,通过 .modelContainer 注入到 SwiftUI 环境。
import SwiftUI
import SwiftData
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
// 注入容器,SwiftUI 自动创建并传递 modelContext
.modelContainer(for: [Note.self, Tag.self, UserProfile.self, Post.self])
}
}
需要精细控制(比如关闭云同步、指定存储位置、用内存库做测试)时,手工构建:
let schema = Schema([Note.self, Tag.self])
let config = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false, // 测试时可设 true
allowsSave: true,
cloudKitDatabase: .none // 关闭 CloudKit 同步
)
let container = try ModelContainer(for: schema, configurations: [config])
3.2 上下文是读写窗口
ModelContext 是操作对象的窗口,相当于 Core Data 的 NSManagedObjectContext。SwiftUI 里用环境值拿:
struct NoteListView: View {
@Environment(\.modelContext) private var context
func addNote() {
let note = Note(title: "新笔记")
context.insert(note)
// SwiftData 会自动保存,但显式 save 更可控
do {
try context.save()
} catch {
// 保存失败要处理,别静默吞掉
print("保存失败: \(error)")
}
}
}
context.insert(_:) 把对象加入上下文(此时标记为 inserted,但还没写盘),context.delete(_:) 标记删除,save() 才真正落盘。SwiftUI 环境下有自动保存机制,但涉及关键数据时显式 save() 并处理错误更稳妥。
四、@Query 与 SwiftUI 集成
@Query 是 SwiftData 最舒服的地方——查询结果直接驱动视图刷新。
struct NoteListView: View {
// 简单查询:按创建时间倒序
@Query(sort: \Note.createdAt, order: .reverse)
private var notes: [Note]
var body: some View {
List(notes) { note in
VStack(alignment: .leading) {
Text(note.title).font(.headline)
Text(note.body).font(.subheadline).foregroundStyle(.secondary)
}
}
}
}
带过滤条件时用 #Predicate:
struct PinnedNotesView: View {
@Query(
filter: #Predicate<Note> { $0.isPinned },
sort: \Note.createdAt,
order: .reverse
)
private var pinnedNotes: [Note]
var body: some View {
List(pinnedNotes) { Text($0.title) }
}
}
注意 #Predicate 里的表达式有严格限制:不能调用任意方法、不能用 Optional 的隐式解包、不能捕获闭包外的大部分引用。它会被翻译成 SQL,所以只能表达数据库能理解的东西。带参数时把外部值先取出来:
let keyword = "Swift"
let descriptor = FetchDescriptor<Note>(
predicate: #Predicate { $0.title.contains(keyword) }
)
FetchDescriptor 适合在非视图代码(比如 ModelActor)里做查询,@Query 则专供视图。
五、关系与级联删除
关系是 SwiftData 最容易出问题的地方。三种删除规则:
| 规则 | 行为 | 适用场景 |
|---|---|---|
.nullify | 删除对方时把关系置空(默认) | 弱关联,如文章作者被删 |
.cascade | 删除对方时级联删掉所有关联对象 | 强归属,如笔记的标签明细 |
.deny | 只要还有关联对象就不允许删除 | 需要保证引用完整性 |
.noAction | 什么都不做,可能留下悬空引用 | 极少数手工管理场景 |
@Model
final class Project {
var name: String
// 删 Project 时,它的 Task 一并删除
@Relationship(deleteRule: .cascade, inverse: \TaskItem.project)
var tasks: [TaskItem]
init(name: String) {
self.name = name
self.tasks = []
}
}
@Model
final class TaskItem {
var title: String
var project: Project?
init(title: String) { self.title = title }
}
坑点:反向关系只需在一侧声明 inverse:,两侧都写会编译报错或行为异常。另外,级联删除在删除大量对象时是逐条走的,十万条记录一次级联可能卡主线程,大删除应该放到后台上下文。
六、Schema 迁移与 MigrationPlan
加了新字段、改了类型,旧库就和新 schema 不匹配,启动直接崩。SwiftData 用 SchemaMigrationPlan 描述迁移路径。
轻量迁移(只加可选字段、加新模型)自动完成,不用写代码。需要重命名或改结构时,定义版本化 schema:
import SwiftData
// 第一版 schema
enum SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] { [Note.self] }
@Model
final class Note {
var title: String
init(title: String) { self.title = title }
}
}
// 第二版:新增 body 字段
enum SchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] { [Note.self] }
@Model
final class Note {
var title: String
var body: String
init(title: String, body: String = "") {
self.title = title
self.body = body
}
}
}
// 迁移计划:从 V1 到 V2
enum NoteMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] { [SchemaV1.self, SchemaV2.self] }
static var stages: [MigrationStage] {
[migrateV1toV2]
}
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: nil,
didMigrate: { context in
// 给老数据补默认值
let notes = try context.fetch(FetchDescriptor<SchemaV2.Note>())
for note in notes where note.body.isEmpty {
note.body = "(空)"
}
try context.save()
}
)
}
// 建容器时挂上迁移计划
let container = try ModelContainer(
for: Note.self,
migrationPlan: NoteMigrationPlan.self
)
关键点:
MigrationStage.lightweight处理可自动推导的变更,MigrationStage.custom处理需要写逻辑的。- 迁移在容器初始化时同步执行,大库迁移会阻塞启动,要评估耗时或考虑异步迁移策略。
- 迁移一旦上线,旧 schema 版本定义不能删,否则已升级用户的库无法识别。
七、并发与 @ModelActor
SwiftData 的并发模型基于 ModelActor。每个 actor 持有自己的 ModelContext,跨 actor 传对象要传 PersistentIdentifier 而不是对象本身。
import SwiftData
@ModelActor
actor NoteStore {
// 宏自动生成 modelContainer 和 modelExecutor
func importNotes(_ payloads: [(title: String, body: String)]) throws {
for p in payloads {
let note = Note(title: p.title, body: p.body)
modelContext.insert(note)
}
try modelContext.save() // 后台 actor 里落盘,不阻塞主线程
}
func fetchAll() throws -> [Note] {
try modelContext.fetch(FetchDescriptor<Note>())
}
}
// 使用
let store = NoteStore(modelContainer: container)
let notes = try await store.fetchAll()
坑点清单:
ModelContext不是线程安全的,绝不能在两个线程间共享同一个 context。SwiftData 没有 Core Data 的perform自动切换,共享 context 会随机崩溃。- 跨 actor 传
PersistentModel对象:对象绑定在创建它的 actor 上,传到别的 actor 访问属性会崩。用persistentModelID传递,在目标 actor 里用modelContext.model(for:)取回。 @ModelActor的 actor 是Sendable的,可以安全地跨并发域持有。
八、性能优化
8.1 谓词尽量下推到数据库
#Predicate 会翻译成 SQL,能下推的条件一定要放进谓词,而不是 fetch 出来再 filter。
// 好:过滤在数据库层完成
let descriptor = FetchDescriptor<Note>(
predicate: #Predicate { $0.createdAt > someDate && $0.isPinned },
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
// 差:把所有数据拉到内存再过滤
let all = try context.fetch(FetchDescriptor<Note>())
let filtered = all.filter { $0.isPinned } // 内存和 CPU 双浪费
分页用 fetchLimit 和 fetchOffset:
var descriptor = FetchDescriptor<Note>(
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
descriptor.fetchLimit = 50
descriptor.fetchOffset = 0 // 第 0 页
8.2 避免 N+1 查询
遍历关系属性时,每条记录都可能触发一次额外查询——这就是经典的 N+1。
// 危险:每个 project 都触发一次 tasks 查询
for project in projects {
print(project.tasks.count) // N 次查询
}
// 改善:一次性把关系取出来(SwiftData 对关系有预取优化,但显式批量更稳)
let allTasks = try context.fetch(FetchDescriptor<TaskItem>())
let grouped = Dictionary(grouping: allTasks) { $0.project?.persistentModelID }
另一个高频优化是批量删除:
// 用谓词批量删除,比逐个 delete 快得多
try context.delete(model: Note.self, where: #Predicate { $0.isPinned == false })
context.delete(model:where:) 直接在存储层做批量删除,不走对象图,是清理海量数据的首选。
九、与 Core Data 的互操作
SwiftData 和 Core Data 可以共用同一个 SQLite 存储文件,这是渐进迁移的关键。用 NSPersistentContainer 打开 SwiftData 建的库,或反过来,靠 NSPersistentContainer 的 managedObjectModel 对齐 schema。
// Core Data 侧读取 SwiftData 建的库
let container = NSPersistentContainer(name: "Model")
container.loadPersistentStores { _, error in
if let error { print("加载失败: \(error)") }
}
// 实体名与 SwiftData 的类名一致即可映射
但互操作有代价:两边对关系的建模方式、迁移版本管理方式不同,混用会显著增加复杂度。建议要么纯 SwiftData,要么纯 Core Data,只有存量 App 渐进替换时才混用,并且替换期锁死 schema 变更。
十、常见坑清单
- 上下文线程问题:
ModelContext只能在其所属线程/actor 上用,跨线程共享必崩。后台任务一律用@ModelActor。 - 迁移失败导致启动崩溃:改 schema 没写迁移计划,旧库启动即崩。上线前一定要用「旧版本 App 建的库」实测升级路径。
- 删除 schema 版本定义:迁移完成后删掉旧
VersionedSchema,老用户升级时找不到源版本,直接失败。 - N+1 查询:遍历关系属性未预取,数据量一大列表就卡。
- 把大二进制存进行内:图片、音频没加
.externalStorage,行体积暴涨,查询变慢。 - 级联删除主线程卡顿:大对象图级联删除要走后台上下文。
- 唯一约束冲突未处理:
@Attribute(.unique)冲突会走 upsert,业务逻辑若依赖「插入失败」需自行校验。 - CloudKit 开启后关系必须可选:SwiftData 与 CloudKit 同步时,非可选关系不被支持,容器初始化会报错。
FAQ
Q:SwiftData 能完全替代 Core Data 吗?
目前不能。复杂迁移、NSFetchedResultsController 级别的表格优化、多进程共享、iOS 16 兼容这些场景,Core Data 仍是唯一选择。SwiftData 覆盖日常增删改查足够。
Q:SwiftData 的性能比 Core Data 差吗?
底层同源,性能基本相当。差异主要来自使用方式:谓词是否下推、是否避免 N+1、是否用批量删除。用对了两者都快,用错了两者都慢。
相关阅读
- SwiftUI 声明式状态管理
—
@Query与 SwiftUI 状态体系如何协作 - Swift 内存管理与 ARC 循环引用 — 模型对象持有关系与内存释放的关系
- Flutter 数据库持久化 — 跨平台方案对比视角
小结
SwiftData 用 @Model 宏、ModelContainer/ModelContext 和 @Query 把 iOS 持久化的样板代码砍掉了一大截,特别适合纯 SwiftUI 的新项目。它的底层仍是 Core Data 存储引擎,所以两者能力有重叠但定位不同:SwiftData 换的是开发效率,Core Data 保留的是完整性和兼容性。
选型的三条判断线:部署目标是否 iOS 17+、是否需要复杂迁移、是否纯 SwiftUI。三个「是」就上 SwiftData,否则 Core Data。
动手时最该盯死三件事:上下文绝不跨线程共享、schema 变更必须配迁移计划并实测升级路径、查询谓词尽量下推到数据库避免 N+1。这三点做到了,SwiftData 就能稳稳扛住生产环境的持久化需求。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。