我最近突发奇想,想做个超简单的 ToDo 应用——就两个功能:添加任务、勾选完成。
听起来再普通不过吧?
可当我真正打开 Xcode 想写点东西的时候,脑袋里却浮现了一个熟悉的问题:
“那我这些任务……要存哪里?”
是啊,总不能让用户每次打开 App 都重新输入任务吧。
在 iOS 里,数据存储方式可不是只有一个,而是像开盲盒一样,有好几种选项摆在你面前:
- UserDefaults
- 本地 JSON 文件(FileManager + Codable)
- Core Data
- SwiftData
- SQLite
- Realm
你可能会想:“这些我都听过一点,但……到底用哪个合适?”
别急,我做了个小实验:用同一个 ToDo App 的原型,分别实现了一遍上面几种存储方式,然后……踩了一些坑,收获了一些经验,现在来跟你慢慢聊聊。
首先我们创建一个界面,只需要一个输入框和一个 todo 列表。
import SwiftUI
struct ContentView: View {
@State private var todoContent = ""
struct Todo: Identifiable {
let id = UUID()
let content: String
let createdAt: Date
var isDone: Bool = false
}
@State private var todoList: [Todo] = []
// init() {
// let fakeTodos = (1...12).map {
// Todo(content: "Fake Todo #\($0)", createdAt: Date())
// }
// _todoList = State(initialValue: fakeTodos)
// }
var body: some View {
VStack() {
List(){
ForEach($todoList) { $item in
TodoRow(todo: $item)
}
}
.padding(0)
TextField("Type your todo...", text: $todoContent)
.padding()
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.gray, lineWidth: 1)
)
.onSubmit {
addTodo()
}
Button("Add") {
addTodo()
}
.disabled(todoContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
.padding(.top, 8)
}
.padding()
}
private func addTodo() {
let trimmedContent = todoContent.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedContent.isEmpty else { return }
let newTodo = Todo(content: trimmedContent, createdAt: Date())
todoList.append(newTodo)
todoContent = ""
}
}
struct TodoRow: View {
@Binding var todo: ContentView.Todo
var body: some View {
Button(action: {
todo.isDone.toggle()
}) {
HStack(alignment: .top) {
Image(systemName: todo.isDone ? "checkmark.square" : "square")
VStack(alignment: .leading) {
Text(todo.content)
Text(todo.createdAt, style: .date)
.font(.caption)
.foregroundColor(.gray)
}
}
}
.buttonStyle(.plain)
}
}
#Preview {
ContentView()
}
1. UserDefaults
UserDefaults 类似 web 中的 local storage,存储简单键值对(key-value),简单、高效但功能有限。
简单数据存储
- Bool
- Int, Float, Double
- String
- Date
- URL
- Data
- Array, Dictionary(只能包含上面这些数据类型)
// add & change
UserDefaults.standard.set(true, forKey: "isFirstLaunch")
// get
let isFirstLaunch = UserDefaults.standard.bool(forKey: "isFirstLaunch")
// delete
UserDefaults.standard.removeObject(forKey: "isFirstLaunch")
复杂数据存储
除了上面提到的简单类型外,其他类型都需要手动转换为 Data 类型才能存储到 UserDefaults 中,最常用的方式就是 JSONEncoder 和 JSONDecoder。
// 给 Struct 添加 Codable 协议
Struct Todo: Codable {
let id: UUID
let content: String
var isDone: Bool
}
var todoList: [Todo] = []
func saveTodoList(todos: [Todo]) {
if let data = try? JSONEncoder().encode(todos) {
UserDefaults.standard.set(data, forKey: "todoList")
}
}
func loadTodoList() -> [Todo] {
if let data = try? UserDefaults.standard.data(forKey: "todoList"),
let todos = try? JSONDecoder().decode([Todo].self, from: data) {
return todos
}
return []
}
UserDefaults 虽然很方便,但也存在缺点:
- 不支持查询/筛选,你得整个 array decode 出来再手动找
- 没有版本控制、迁移机制,数据结构一变容易崩
- 存太多会影响性能(不是为“大数据量”设计的)
如果你只是想保存个 “是否开启暗色模式” 这样的用户设置,那它超级适合!
但如果你真的要用它来保存几十上百个任务……那你真的很勇(别问我怎么知道的 😭)
2. FileManager + Codable
iOS 里最原始也最朴素的文件系统操作,就是 FileManager。配合 Swift 的 Codable,我们可以优雅地把对象写成 JSON 文件保存到沙盒里。
和 UserDefaults 相比,这种方法就可以把存到 UserDefaults 中数据变成了一个文件,存到系统中。
// 1. 确定存储位置
func getTodosFileURL() -> URL {
FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent("todos.json")
}
func saveTodos(_ todos: [Todo]) {
do {
let data = try JSONEncoder().encode(todos)
let url = getTodosFileURL()
try data.write(to: url)
} catch {
print("保存失败: \(error)")
}
}
func loadTodos() -> [Todo] {
let url = getTodosFileURL()
if FileManager.default.fileExists(atPath: url.path),
let data = try? Data(contentsOf: url),
let todos = try? JSONDecoder().decode([Todo].self, from: data) {
return todos
} else {
print("读取失败或文件不存在")
return []
}
}
就是这么简单,纯纯的“增删改查手动挡”。
🪛 优点
- 数据格式清晰、易调试(你甚至能用 Finder 找到那个 JSON)
- 没有黑盒,控制权完全在你手上
- Codable 写起来非常优雅
🤕 缺点
- 要自己处理文件不存在、数据同步等问题
- 并发读写容易出锅(建议加 DispatchQueue 或 actor)
- 缺乏“高级功能”:比如筛选、索引、关系模型等
💡适合谁?
如果你在做一个不太复杂的 App,比如一个离线备忘录、读书笔记,或者“任务草稿箱”,FileManager + Codable 是一个透明又可靠的选择。
但一旦数据结构复杂了、数据量上来了,你就会开始怀念那些 ORM 的好。
下一节我们就来看看 Apple 亲儿子——Core Data,一言不合就上数据模型、NSManagedObject、上下文和魔法同步。
3. Core Data
Core Data 是 Apple 提供的一套强大的本地数据持久化框架,它不仅是数据库(其实底层默认是 SQLite),还是一整套数据模型、对象关系映射、生命周期管理的解决方案。
😵 初看上去有点吓人
你会看到很多新名词:
- NSManagedObjectContext
- NSPersistentContainer
- FetchRequest
- Entity
- …
没错,它不像 UserDefaults 那样即拿即用,Core Data 更像是——
“你得先搭个棚子,把舞台布好,灯光音响调试完,再开始演戏。”
🏗 设置 Core Data 的流程
Xcode 很贴心地可以在建工程时勾选“Use Core Data”,会自动帮你配置 CoreDataStack。如果你是手动添加,大致流程是这样的:
1. 描述数据结构 - Model
当我们在学习和理解 Core Data 时,需要牢记 CoreData 本质上就是在使用底层的 SQLite。所以我们的第一步是要告诉 CoreData,我们想要创建一个怎样的数据,这就是 Data Model 的作用。
通过 cmd + n 创建一个 Core Data 的 Data Model 文件: XxxModel.xcdatamodeld
。
配置 Entity,比如 TodoItem,这个相当于是数据表。
2. 启动接入 Core Data - PersistenceController
import CoreData
struct PersistenceController {
// 单例共享实例
static let shared = PersistenceController()
// Core Data 核心容器
let container: NSPersistentContainer
// 初始化(可选择是否为内存数据库)
init(inMemory: Bool = false) {
container = NSPersistentContainer(name: "TodoModel") // 这里是 .xcdatamodeld 的名字
// 如果是内存数据库,用于测试
if inMemory {
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
}
// 加载数据库(失败直接崩溃,开发时可接受)
container.loadPersistentStores { _, error in
if let error = error {
fatalError("加载 Core Data 失败: \(error)")
}
}
// 自动合并来自其他上下文的更改(用于 SwiftUI 多线程支持)
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
// 保存方法(仅在有变更时保存)
func save() {
let context = container.viewContext
if context.hasChanges {
do {
try context.save()
} catch {
print("保存失败: \(error.localizedDescription)")
}
}
}
}
3. 注入项目 - .environment
import SwiftUI
@main
struct TodoAppSwiftApp: App {
let persistenceController = PersistenceController.shared
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.managedObjectContext, persistenceController.container.viewContext)
}
}
}
4. 调用
import CoreData
import SwiftUI
struct ContentView: View {
// 获取托管对象上下文
@Environment(\.managedObjectContext) private var viewContext
// 创建获取请求,按创建时间降序排列
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \TodoItem.createdAt, ascending: false)],
animation: .default)
private var todoItems: FetchedResults<TodoItem>
@State private var todoContent = ""
var body: some View {}
private func addTodo() {
let trimmedContent = todoContent.trimmingCharacters(
in: .whitespacesAndNewlines
)
guard !trimmedContent.isEmpty else { return }
withAnimation {
// 创建新的 TodoItem 对象
let newTodo = TodoItem(context: viewContext)
newTodo.id = UUID()
newTodo.content = trimmedContent
newTodo.createdAt = Date()
newTodo.isDone = false
// 保存到数据库
saveTodos()
todoContent = ""
}
}
private func deleteTodos(offsets: IndexSet) {
withAnimation {
offsets.map { todoItems[$0] }.forEach(viewContext.delete)
saveTodos()
}
}
private func saveTodos() {
do {
try viewContext.save()
} catch {
let nsError = error as NSError
print("无法保存: \(nsError), \(nsError.userInfo)")
}
}
}
4. SwiftData
如果你还是觉得 CoreData 有点繁琐,这个时候就不得不提 SwiftData 了。
使用 SwiftData 我们不再需要定义额外的文件来接入数据库,SwiftData 给我们提供了更加方便的形式。
1. 定义模型类
不再使用 .xcdatamodeld,你只需要 Swift 原生类 + @Model。
import SwiftData
@Model
class TodoItem {
var content: String
var createdAt: Date
var isDone: Bool = false
init(content: String, createdAt: Date = .now, isDone: Bool = false) {
self.content = content
self.createdAt = createdAt
self.isDone = isDone
}
}
2. 创建 SwiftData 的 ModelContainer
import SwiftData
@main
struct TodoAppSwiftApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: TodoItem.self) // SwiftData 注入
}
}
3. 使用 @Query 获取数据
struct ContentView: View {
@Query(sort: \TodoItem.createdAt, order: .reverse)
private var todoItems: [TodoItem]
// ...
}
4. 使用 @Environment(.modelContext) 操作数据
struct ContentView: View {
@Query(sort: \TodoItem.createdAt, order: .reverse)
private var todoItems: [TodoItem]
@Environment(\.modelContext) private var modelContext
// ...
private func addTodo() {
let trimmedContent = todoContent.trimmingCharacters(
in: .whitespacesAndNewlines
)
guard !trimmedContent.isEmpty else { return }
withAnimation {
// 创建新的 TodoItem 对象
let newTodo = TodoItem( content: trimmedContent, createdAt: Date())
modelContext.insert(newTodo)
todoContent = ""
}
}
private func deleteTodos(offsets: IndexSet) {
withAnimation {
offsets.map { todoItems[$0] }.forEach(modelContext.delete)
}
}
}
5. 在子视图中直接使用模型类(无须 @ObservedObject)
struct TodoRow: View {
let todoItem: TodoItem
@Environment(\.modelContext) private var modelContext
var body: some View {
Button(action: {
todoItem.isDone.toggle()
try? modelContext.save() // 可选保存
}) {
...
}
}
}
但是 SwiftData 目前无法在 preview 模式下存储数据,这一点确实让人有点头疼。
小结
使用 Swift 存储数据的方式其实还有,但是我觉得目前对我来说已经差不多了,现在我要返回自己的项目中去了。