Chapter 09

数据持久化:UserDefaults 与 SwiftData

用 @AppStorage 保存用户偏好,用 SwiftData 构建完整的本地数据库,让数据跨越应用重启永久保存

9.1 为什么需要持久化?

应用内存中的数据(@State、变量等)在应用退出后会全部消失。持久化(Persistence)是将数据保存到设备存储中,使数据能够在应用重启后依然存在。

iOS 常见持久化方案对比

方案适合场景数据量查询能力安全性
UserDefaults用户偏好、简单配置极小(KB级)仅按 key 读取明文存储,不安全
SwiftData结构化数据、列表、关系大(GB级)强大的谓词查询沙盒内,较安全
文件系统图片、文档、大文件无限制按文件路径沙盒保护
Keychain密码、Token、敏感数据极小按 key 读取硬件加密,最安全
CloudKit / iCloud跨设备同步支持查询Apple 加密
ℹ️
选型原则

简单的开关、主题、上次浏览位置 → UserDefaults;结构化的笔记、任务、联系人 → SwiftData;密码、访问令牌 → Keychain;大文件 → 文件系统

9.2 UserDefaults — 轻量偏好存储

UserDefaults 是 iOS 提供的键值存储系统,底层是一个 .plist 文件。它只适合存储少量、简单的数据(布尔值、数字、字符串、小型数组等),不适合存储大量数据或复杂对象。

UserDefaults.standard 系统提供的标准共享实例,大多数情况下使用它即可。也可以使用 App Groups 创建多应用共享的 UserDefaults 实例(用于主应用与插件共享数据)。
Property List Types UserDefaults 只能直接存储属性列表类型:StringIntDoubleBoolDateDataArrayDictionary。自定义对象需先编码为 Data
// 直接使用 UserDefaults
let defaults = UserDefaults.standard

// 写入
defaults.set(true, forKey: "hasSeenOnboarding")
defaults.set(42, forKey: "highScore")
defaults.set("dark", forKey: "colorTheme")
defaults.set(Date(), forKey: "lastLaunchDate")

// 读取(需要提供类型)
let seen = defaults.bool(forKey: "hasSeenOnboarding")  // false(不存在时的默认值)
let score = defaults.integer(forKey: "highScore")
let theme = defaults.string(forKey: "colorTheme") ?? "light"

// 删除
defaults.removeObject(forKey: "highScore")

// 存储自定义 Codable 对象
struct UserProfile: Codable {
    var name: String
    var avatar: String
}

// 编码后存储
let profile = UserProfile(name: "小明", avatar: "avatar1")
if let data = try? JSONEncoder().encode(profile) {
    defaults.set(data, forKey: "userProfile")
}

// 读取后解码
if let data = defaults.data(forKey: "userProfile"),
   let savedProfile = try? JSONDecoder().decode(UserProfile.self, from: data) {
    print(savedProfile.name)
}

9.3 @AppStorage — SwiftUI 的 UserDefaults 绑定

@AppStorage 是 SwiftUI 对 UserDefaults 的原生封装,可以直接在视图中像 @State 一样使用,值变化时自动刷新 UI,UI 修改时自动同步到 UserDefaults

💡
@AppStorage 的优势

不需要手动调用 defaults.set()defaults.bool(forKey:),直接像 @State 一样读写,且多个视图引用同一 key 时会自动同步。

struct SettingsView: View {
    // @AppStorage("key") 与 UserDefaults.standard 的 "key" 自动绑定
    @AppStorage("isDarkMode") private var isDarkMode = false
    @AppStorage("fontSize") private var fontSize = 16.0
    @AppStorage("username") private var username = ""
    @AppStorage("notificationsEnabled") private var notificationsEnabled = true

    var body: some View {
        Form {
            Section("外观") {
                // Toggle 直接绑定 @AppStorage,无需额外代码
                Toggle("深色模式", isOn: $isDarkMode)

                VStack(alignment: .leading) {
                    Text("字号:\(Int(fontSize)) pt")
                    Slider(value: $fontSize, in: 12...24, step: 1)
                }
            }

            Section("账户") {
                TextField("用户名", text: $username)
            }

            Section("通知") {
                Toggle("接收通知", isOn: $notificationsEnabled)
            }
        }
        // 全局应用深色模式(读取 @AppStorage 的值)
        .preferredColorScheme(isDarkMode ? .dark : .light)
    }
}

// 在另一个视图中读取同一 key,值会自动同步
struct ProfileView: View {
    @AppStorage("username") private var username = ""
    // 当 SettingsView 修改了 username,这里会自动更新
    var body: some View {
        Text("欢迎,\(username.isEmpty ? "游客" : username)")
    }
}

// 使用自定义 suite(App Groups 场景)
@AppStorage("sharedKey", store: UserDefaults(suiteName: "group.com.myapp"))
private var sharedValue = ""

9.4 SwiftData — iOS 17 新一代数据框架

SwiftData 是 Apple 在 iOS 17 发布的全新本地数据库框架,使用 Swift 宏(Macro)大幅简化了代码,是 CoreData 的现代化替代品。它基于 CoreData 构建,但 API 更简洁,与 SwiftUI 的集成更加无缝。

@Model 宏 将一个普通 Swift 类标记为 SwiftData 的数据模型,自动添加持久化支持、变更追踪等能力。等价于 CoreData 中的 NSManagedObject,但无需 .xcdatamodeld 文件。
ModelContainer 管理整个数据库的生命周期,包括数据库文件的位置、迁移策略等。一个应用通常只有一个 ModelContainer
ModelContext 执行实际增删改查操作的"工作区",类似事务(Transaction)。从 ModelContainer 中获取,@Environment(\.modelContext) 在视图中访问。
@Query 宏 在视图中声明式地查询数据,数据变化时自动更新视图。类似 Combine 中的 Publisher,但更简洁。
PersistentIdentifier 每个 @Model 对象的唯一标识符,等价于数据库中的主键,由 SwiftData 自动管理。

9.5 @Model 宏:定义数据模型

// 只需在普通 class 前加 @Model,SwiftData 自动处理持久化
@Model
class Note {
    var title: String
    var content: String
    var createdAt: Date
    var isPinned: Bool
    var tags: [String]

    init(title: String, content: String = "",
         isPinned: Bool = false, tags: [String] = []) {
        self.title = title
        self.content = content
        self.createdAt = Date()
        self.isPinned = isPinned
        self.tags = tags
    }
}

// @Attribute 控制字段行为
@Model
class User {
    @Attribute(.unique) var email: String  // 唯一约束,不允许重复
    @Attribute(.externalStorage) var avatar: Data?  // 大数据存外部文件
    @Attribute(.spotlight) var name: String  // 支持 Spotlight 搜索
    @Transient var temporaryFlag = false    // 不持久化此属性

    init(email: String, name: String) {
        self.email = email
        self.name = name
    }
}

9.6 配置 ModelContainer

// 在 App 入口配置 ModelContainer(整个应用共享)
@main
struct NotesApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        // .modelContainer 修饰符将 container 注入整个视图层级
        // 所有子视图可通过 @Environment(\.modelContext) 访问
        .modelContainer(for: [Note.self, User.self])
    }
}

// 自定义配置(指定存储位置、是否内存存储等)
let schema = Schema([Note.self, User.self])
let config = ModelConfiguration(
    schema: schema,
    url: URL.documentsDirectory.appending(path: "notes.store"),
    isStoredInMemoryOnly: false  // true 则不写磁盘(用于预览和测试)
)
let container = try! ModelContainer(for: schema, configurations: config)

// Preview 中使用内存存储(不影响真实数据)
#Preview {
    NoteListView()
        .modelContainer(for: Note.self, inMemory: true)
}

9.7 @Query — 自动查询响应

@Query 是 SwiftUI 中查询 SwiftData 数据的最优雅方式。它会自动监听数据变化并刷新视图,无需手动订阅通知。

struct NoteListView: View {
    // 查询所有 Note,按 createdAt 降序排列
    @Query(sort: \Note.createdAt, order: .reverse)
    private var notes: [Note]

    // 带筛选条件的查询
    @Query(filter: #Predicate<Note> { $0.isPinned == true },
           sort: \Note.createdAt)
    private var pinnedNotes: [Note]

    var body: some View {
        List(notes) { note in
            NoteRowView(note: note)
        }
    }
}

// 动态 @Query(根据搜索词或过滤条件变化)
struct FilterableNoteList: View {
    @State private var searchText = ""

    var body: some View {
        NoteListWrapper(searchText: searchText)
            .searchable(text: $searchText)
    }
}

// 将 @Query 封装在子视图中,通过 init 传入动态谓词
struct NoteListWrapper: View {
    @Query private var notes: [Note]

    init(searchText: String) {
        let predicate = #Predicate<Note> { note in
            searchText.isEmpty || note.title.contains(searchText)
        }
        _notes = Query(filter: predicate, sort: \Note.createdAt, order: .reverse)
    }

    var body: some View {
        List(notes) { note in Text(note.title) }
    }
}

9.8 CRUD 完整操作

CRUD 是增(Create)、读(Read)、改(Update)、删(Delete)四种基本数据操作的缩写。

struct NoteEditorView: View {
    // 通过 @Environment 获取 ModelContext,用于执行增删改操作
    @Environment(\.modelContext) private var context

    @Query(sort: \Note.createdAt, order: .reverse) private var notes: [Note]

    @State private var newTitle = ""

    var body: some View {
        VStack {
            // 创建(Create)
            HStack {
                TextField("新笔记标题", text: $newTitle)
                    .textFieldStyle(.roundedBorder)
                Button("添加") {
                    createNote()
                }
                .disabled(newTitle.isEmpty)
            }
            .padding()

            // 读取(Read):@Query 已自动完成
            List {
                ForEach(notes) { note in
                    NoteRow(note: note)
                }
                // 删除(Delete):滑动删除
                .onDelete(perform: deleteNotes)
            }
        }
    }

    // 增:插入新对象到 context
    private func createNote() {
        let note = Note(title: newTitle)  // 1. 创建对象
        context.insert(note)              // 2. 插入 context(自动保存)
        newTitle = ""                      // 3. 清空输入框
    }

    // 删:从 context 删除对象
    private func deleteNotes(at offsets: IndexSet) {
        offsets.forEach { index in
            context.delete(notes[index])  // SwiftData 自动保存更改
        }
    }
}

// 改(Update):直接修改对象属性,SwiftData 自动检测并持久化
struct NoteRow: View {
    let note: Note

    var body: some View {
        HStack {
            VStack(alignment: .leading) {
                Text(note.title).font(.headline)
                Text(note.createdAt, style: .date).font(.caption)
            }
            Spacer()
            Button {
                // 直接改属性,无需调用 save(),SwiftData 自动处理
                note.isPinned.toggle()
            } label: {
                Image(systemName: note.isPinned ? "pin.fill" : "pin")
            }
        }
    }
}

// 手动保存(通常不需要,SwiftData 会自动在合适时机保存)
do {
    try context.save()
} catch {
    print("保存失败:\(error)")
}

9.9 关系(@Relationship)— 一对多示例

真实世界的数据往往有关联关系。SwiftData 通过 @Relationship 宏定义对象间的关联,并自动处理关联的持久化。

一对多(One-to-Many) 一个文件夹包含多篇笔记。在 Swift 中用数组表示"多"的一端。
多对一(Many-to-One) 多篇笔记属于一个文件夹。在 Swift 中用可选属性表示"一"的一端。
deleteRule(级联删除) 定义当父对象被删除时,子对象的处理方式:.cascade 一并删除,.nullify 置 nil,.deny 禁止删除有子对象的父对象。
// 文件夹 ←→ 笔记(一对多关系)
@Model
class Folder {
    var name: String
    var colorHex: String

    // @Relationship 定义关系
    // deleteRule: .cascade 表示删除文件夹时,其中的笔记也一并删除
    // inverse: \.folder 指定反向关系(Note 中的 folder 属性)
    @Relationship(deleteRule: .cascade, inverse: \Note.folder)
    var notes: [Note] = []

    init(name: String, colorHex: String = "#007AFF") {
        self.name = name
        self.colorHex = colorHex
    }
}

@Model
class Note {
    var title: String
    var content: String
    var createdAt: Date
    var isPinned: Bool

    // 多对一关系:每篇笔记可选择属于一个文件夹
    var folder: Folder?

    init(title: String, content: String = "") {
        self.title = title
        self.content = content
        self.createdAt = Date()
        self.isPinned = false
    }
}

// 使用关系
func moveNote(_ note: Note, to folder: Folder) {
    note.folder = folder          // 建立关系,SwiftData 自动维护 folder.notes
}

func removeFromFolder(_ note: Note) {
    note.folder = nil             // 断开关系
}

// 查询某文件夹下的所有笔记
let folderPredicate = #Predicate<Note> { note in
    note.folder?.name == "工作"
}

9.10 Keychain — 敏感数据安全存储

Keychain 是 iOS 提供的加密密钥存储服务,由 Secure Enclave(安全芯片)保护。即使设备被越狱,Keychain 中的数据也很难被提取。密码、OAuth Token、API Key 等敏感信息必须存 Keychain,绝对不能存 UserDefaults。

🚫
安全红线

永远不要将用户密码、访问令牌、银行卡号等敏感信息存入 UserDefaults 或普通文件——它们以明文存储在沙盒中,相对容易被读取。必须使用 Keychain。

// Keychain 访问(系统 API 较底层,通常使用第三方封装如 KeychainSwift)
// 下面是直接使用 Security 框架的示例
import Security

struct KeychainHelper {

    // 存储
    @discardableResult
    static func save(key: String, value: String) -> Bool {
        let data = value.data(using: .utf8)!
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecValueData as String: data
        ]
        SecItemDelete(query as CFDictionary)  // 先删除旧值
        let status = SecItemAdd(query as CFDictionary, nil)
        return status == errSecSuccess
    }

    // 读取
    static func read(key: String) -> String? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecReturnData as String: kCFBooleanTrue!,
            kSecMatchLimit as String: kSecMatchLimitOne
        ]
        var item: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &item)
        guard status == errSecSuccess, let data = item as? Data else { return nil }
        return String(data: data, encoding: .utf8)
    }

    // 删除
    static func delete(key: String) {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key
        ]
        SecItemDelete(query as CFDictionary)
    }
}

// 使用
KeychainHelper.save(key: "userToken", value: "eyJhbGciOiJIUzI1NiJ9...")
let token = KeychainHelper.read(key: "userToken")

9.11 实战示例:笔记应用(SwiftData 增删改查)

用 SwiftData 构建一个完整的笔记应用,支持创建、编辑、置顶和删除笔记。

数据模型

// Models/Note.swift
@Model
class Note {
    var title: String
    var content: String
    var createdAt: Date
    var modifiedAt: Date
    var isPinned: Bool
    var colorTag: String  // 颜色标签 "red", "blue", "green"

    var preview: String {  // 计算属性,不持久化,用于列表预览
        content.isEmpty ? "无内容" : String(content.prefix(60))
    }

    init(title: String = "新笔记", content: String = "",
         colorTag: String = "default") {
        self.title = title
        self.content = content
        self.createdAt = Date()
        self.modifiedAt = Date()
        self.isPinned = false
        self.colorTag = colorTag
    }
}

主列表视图

// Views/NoteListView.swift
struct NoteListView: View {
    @Environment(\.modelContext) private var context
    @State private var searchText = ""
    @State private var selectedNote: Note?
    @State private var showingEditor = false

    // 置顶笔记:isPinned == true,按修改时间降序
    @Query(filter: #Predicate { $0.isPinned == true },
           sort: \Note.modifiedAt, order: .reverse)
    private var pinnedNotes: [Note]

    // 普通笔记:isPinned == false,按修改时间降序
    @Query(filter: #Predicate { $0.isPinned == false },
           sort: \Note.modifiedAt, order: .reverse)
    private var regularNotes: [Note]

    var body: some View {
        NavigationStack {
            List {
                if !pinnedNotes.isEmpty {
                    Section("置顶") {
                        ForEach(pinnedNotes) { note in
                            NoteRowView(note: note)
                                .onTapGesture { selectedNote = note }
                        }
                        .onDelete { delete(from: pinnedNotes, at: $0) }
                    }
                }

                Section(pinnedNotes.isEmpty ? "" : "全部笔记") {
                    ForEach(regularNotes) { note in
                        NoteRowView(note: note)
                            .onTapGesture { selectedNote = note }
                    }
                    .onDelete { delete(from: regularNotes, at: $0) }
                }
            }
            .navigationTitle("笔记")
            .searchable(text: $searchText, prompt: "搜索笔记")
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button {
                        createNewNote()
                    } label: {
                        Image(systemName: "square.and.pencil")
                    }
                }
            }
            .sheet(item: $selectedNote) { note in
                NoteEditorView(note: note)
            }
        }
    }

    private func createNewNote() {
        let note = Note()
        context.insert(note)
        selectedNote = note  // 创建后立即打开编辑
    }

    private func delete(from notes: [Note], at offsets: IndexSet) {
        offsets.forEach { context.delete(notes[$0]) }
    }
}

// 笔记行视图
struct NoteRowView: View {
    let note: Note

    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            Text(note.title).font(.headline).lineLimit(1)
            Text(note.preview).font(.subheadline).foregroundStyle(.secondary).lineLimit(2)
            Text(note.modifiedAt, style: .relative).font(.caption2).foregroundStyle(.tertiary)
        }
        .padding(.vertical, 4)
        .swipeActions(edge: .leading) {
            Button {
                withAnimation { note.isPinned.toggle() }
            } label: {
                Label(note.isPinned ? "取消置顶" : "置顶",
                      systemImage: note.isPinned ? "pin.slash" : "pin")
            }
            .tint(.orange)
        }
    }
}

// 笔记编辑视图
struct NoteEditorView: View {
    let note: Note
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        NavigationStack {
            VStack(alignment: .leading, spacing: 0) {
                TextField("标题", text: Bindable(note).title)
                    .font(.title2.bold())
                    .padding()

                Divider()

                TextEditor(text: Bindable(note).content)
                    .padding(.horizontal, 12)
            }
            .navigationTitle("")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .confirmationAction) {
                    Button("完成") {
                        note.modifiedAt = Date()  // 更新修改时间
                        dismiss()
                    }
                }
            }
        }
    }
}
本章小结

持久化选型一句话总结:偏好设置用 @AppStorage(背后是 UserDefaults),结构化数据用 SwiftData(@Model + @Query),敏感数据用 Keychain。SwiftData 的 @Model + ModelContainer + @Query + ModelContext 四件套构成了完整的数据层解决方案,代码量比 CoreData 减少了 70%。