iOS 数据持久化 SwiftData 与 Core Data

SwiftData 与 Core Data 是 iOS 本地持久化的两条路线。本文从 @Model、ModelContainer、ModelContext、@Query 讲清 SwiftData(iOS 17/18)的声明式模型与 SwiftUI 集成,覆盖关系与级联删除、Schema 迁移 MigrationPlan、@ModelActor 后台并发、Predicate 谓词与批量操作性能;再对比 Core Data 的成熟能力与互操作边界,给出选型结论与迁移失败、上下文线程、N+1 等常见坑清单。

本地持久化是几乎所有真实 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 两者的技术底座

维度SwiftDataCore Data
首次可用版本iOS 17 / macOS 14iOS 3
模型定义方式@Model 宏,纯 Swift.xcdatamodeld 可视化编辑器
查询 API@Query / FetchDescriptor / #PredicateNSFetchRequest / NSPredicate
并发模型ModelActor,Sendable 友好NSManagedObjectContext 队列
SwiftUI 集成一等公民,modelContainer 修饰器需 @FetchRequest 桥接
底层存储SQLite(复用 Core Data 栈)SQLite / 二进制 / 内存
迁移工具SchemaMigrationPlanNSEntityMapping / 轻量迁移
最低部署目标iOS 17iOS 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、是否用批量删除。用对了两者都快,用错了两者都慢。

相关阅读

小结

SwiftData 用 @Model 宏、ModelContainer/ModelContext 和 @Query 把 iOS 持久化的样板代码砍掉了一大截,特别适合纯 SwiftUI 的新项目。它的底层仍是 Core Data 存储引擎,所以两者能力有重叠但定位不同:SwiftData 换的是开发效率,Core Data 保留的是完整性和兼容性。

选型的三条判断线:部署目标是否 iOS 17+、是否需要复杂迁移、是否纯 SwiftUI。三个「是」就上 SwiftData,否则 Core Data。

动手时最该盯死三件事:上下文绝不跨线程共享、schema 变更必须配迁移计划并实测升级路径、查询谓词尽量下推到数据库避免 N+1。这三点做到了,SwiftData 就能稳稳扛住生产环境的持久化需求。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「iOS 开发」更多文章

  1. Swift Package Manager 与模块化拆分
  2. Core Animation 与 SwiftUI 动画
  3. iOS 安全:Keychain、生物识别与传输安全