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 文件。它只适合存储少量、简单的数据(布尔值、数字、字符串、小型数组等),不适合存储大量数据或复杂对象。
String、Int、Double、Bool、Date、Data、Array、Dictionary。自定义对象需先编码为 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。
不需要手动调用 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 的集成更加无缝。
NSManagedObject,但无需 .xcdatamodeld 文件。
ModelContainer。
ModelContainer 中获取,@Environment(\.modelContext) 在视图中访问。
@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 宏定义对象间的关联,并自动处理关联的持久化。
.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%。