专栏 :iOS功能实战30Days
编号 :B01 · 系列第 1 篇
字数 :约 5500 字
标签:iOS / SwiftUI / SQLite / Repository 模式 / 数据持久化
前言
从今天开始,我们开启一个新的系列:30 Apps,30 天,30 个真实可上线的 iOS 功能。
第一天,我们从一个最简单的 App 入手:待办清单(Todo List)。但别被「待办清单」这个名字骗了------这个 App 的数据层设计,足以应对一个中等规模 App 的所有持久化需求。
我们将完成:
- 持久化方案选型:SQLite.swift / Realm / Core Data 对比
- 数据模型设计:Task 的完整结构
- Repository 模式:解耦数据层与业务层
- 数据库迁移策略:Schema 演进的最佳实践
- 完整的 SQLite.swift 封装:可直接复用到任何项目
一、项目概述与功能需求
1.1 我们要做什么
待办清单 App 的核心功能:
- 创建、编辑、删除待办事项
- 标记完成/未完成
- 按优先级排序
- 按分类筛选
- 搜索功能
- 数据持久化存储
1.2 数据模型
swift
struct Task: Identifiable, Codable, Equatable {
var id: UUID
var title: String
var content: String // 详细描述
var priority: Priority // 优先级
var status: Status // 完成状态
var category: Category // 分类
var dueDate: Date? // 截止日期
var createdAt: Date
var updatedAt: Date
var completedAt: Date? // 完成时间
var isPinned: Bool // 置顶
enum Priority: Int, Codable, CaseIterable {
case low = 0
case medium = 1
case high = 2
}
enum Status: Int, Codable {
case pending = 0
case completed = 1
}
enum Category: String, Codable, CaseIterable {
case work = "work"
case life = "life"
case study = "study"
case health = "health"
}
}
二、持久化方案选型
2.1 主流方案对比
| 维度 | SQLite.swift | Realm | Core Data | UserDefaults |
|---|---|---|---|---|
| 适用数据量 | 10万+ 条 | 10万+ 条 | 10万+ 条 | < 1000 条 |
| 关系查询 | 支持 JOIN | 支持 | 支持 | 不支持 |
| 线程安全 | 需要小心处理 | 自动线程安全 | 需要小心处理 | 主线程 |
| 学习曲线 | 低 | 中 | 高 | 无 |
| 包体积 | ~2MB | ~30MB | 内置 | 无 |
| Swift 友好度 | 高 | 极高 | 中 | 简单 |
| Schema 迁移 | 手动 | 自动 | 复杂 | 无 |
| 实时通知 | 无(需手动轮询) | 有 | 有(NSFetchedResultsController) | 无 |
2.2 我们的选择:SQLite.swift
选择 SQLite.swift 的理由:
- 包体积小:2MB,对 App 大小影响可忽略
- 性能优秀:原生 C 实现,比 ORM 快 10x
- 灵活性强:SQL 查询解决所有复杂查询场景
- Swift 原生:API 设计风格接近 Swift 标准库
- 无供应商绑定:纯 SQLite,不依赖任何框架
- 学习价值:理解 SQL 是每个工程师的必修课
三、项目搭建
3.1 创建项目
使用 Xcode 创建新的 SwiftUI 项目,命名为 TodoApp。
3.2 添加依赖
使用 Swift Package Manager 添加 SQLite.swift:
swift
// 在 Xcode 中:File → Add Package Dependencies
// 输入:https://github.com/stephencelis/SQLite.swift
// 选择最新版本(>= 0.15.0)
3.3 项目结构
arduino
TodoApp/
├── App/
│ └── TodoAppApp.swift
├── Models/
│ └── Task.swift
├── Data/
│ ├── Database/
│ │ ├── DatabaseManager.swift // 数据库初始化
│ │ └── TaskTable.swift // Task 表定义
│ └── Repositories/
│ └── TaskRepository.swift // 数据访问层
├── ViewModels/
│ └── TaskListViewModel.swift
├── Views/
│ ├── ContentView.swift
│ ├── TaskRowView.swift
│ └── TaskEditorView.swift
└── Extensions/
└── Date+Extensions.swift
四、数据库层实现
4.1 数据库管理器
swift
import Foundation
import SQLite
final class DatabaseManager {
static let shared = DatabaseManager()
private(set) var db: Connection!
private init() {}
func setup() throws {
let path = try FileManager.default
.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
.appendingPathComponent("todo.sqlite3")
.path
db = try Connection(path)
// 启用外键约束
try db.execute("PRAGMA foreign_keys = ON;")
// 初始化表
try TaskTable.create(db: db)
}
func resetDatabase() throws {
try db.execute("DELETE FROM tasks")
try db.execute("VACUUM")
}
}
4.2 表定义
swift
import Foundation
import SQLite
enum TaskTable {
static let table = Table("tasks")
// 列定义
static let id = Expression<String>("id")
static let title = Expression<String>("title")
static let content = Expression<String?>("content")
static let priority = Expression<Int>("priority")
static let status = Expression<Int>("status")
static let category = Expression<String>("category")
static let dueDate = Expression<Double?>("due_date")
static let createdAt = Expression<Double>("created_at")
static let updatedAt = Expression<Double>("updated_at")
static let completedAt = Expression<Double?>("completed_at")
static let isPinned = Expression<Bool>("is_pinned")
static func create(db: Connection) throws {
try db.run(table.create(ifNotExists: true) { t in
t.column(id, primaryKey: true)
t.column(title)
t.column(content)
t.column(priority, defaultValue: 1)
t.column(status, defaultValue: 0)
t.column(category, defaultValue: "work")
t.column(dueDate)
t.column(createdAt)
t.column(updatedAt)
t.column(completedAt)
t.column(isPinned, defaultValue: false)
})
// 创建索引,加速常见查询
try db.run(table.createIndex(status, ifNotExists: true))
try db.run(table.createIndex(priority, ifNotExists: true))
try db.run(table.createIndex(category, ifNotExists: true))
try db.run(table.createIndex(createdAt, ifNotExists: true))
try db.run(table.createIndex(isPinned, ifNotExists: true))
}
// 从数据库行映射到模型
static func rowToTask(_ row: Row) -> Task {
Task(
id: UUID(uuidString: row[id]) ?? UUID(),
title: row[title],
content: row[content],
priority: Task.Priority(rawValue: row[priority]) ?? .medium,
status: Task.Status(rawValue: row[status]) ?? .pending,
category: Task.Category(rawValue: row[category]) ?? .work,
dueDate: row[dueDate].map { Date(timeIntervalSince1970: $0) },
createdAt: Date(timeIntervalSince1970: row[createdAt]),
updatedAt: Date(timeIntervalSince1970: row[updatedAt]),
completedAt: row[completedAt].map { Date(timeIntervalSince1970: $0) },
isPinned: row[isPinned]
)
}
}
五、Repository 模式实现
5.1 为什么需要 Repository
scss
┌─────────────┐ ┌──────────────┐ ┌────────────────┐
│ Views │────▶│ ViewModels │────▶│ Repository │
│ (SwiftUI) │ │ (Combine) │ │ (TaskRepository) │
└─────────────┘ └──────────────┘ └───────┬────────┘
│
┌──────▼────────┐
│ DatabaseManager│
│ (SQLite.swift) │
└───────┬────────┘
│
┌──────▼────────┐
│ todo.sqlite3 │
└───────────────┘
Repository 模式的优势:
- 数据源可替换:可以从 SQLite 切换到 Core Data,不需要修改任何业务代码
- 测试方便:Mock Repository 不需要真实的数据库
- 职责单一:ViewModel 只关心业务逻辑,Repository 只关心数据访问
- 复用性:同一个 Repository 可被多个 ViewModel 使用
5.2 Repository 接口设计
swift
import Foundation
import Combine
protocol TaskRepositoryProtocol {
// CRUD 操作
func fetchAllTasks() async throws -> [Task]
func fetchTask(by id: UUID) async throws -> Task?
func insertTask(_ task: Task) async throws
func updateTask(_ task: Task) async throws
func deleteTask(by id: UUID) async throws
func deleteAllCompletedTasks() async throws -> Int
// 高级查询
func fetchTasks(
status: Task.Status?,
category: Task.Category?,
searchQuery: String?,
sortBy: SortOption
) async throws -> [Task]
// 聚合查询
func countTasks(status: Task.Status?) async throws -> Int
func fetchOverdueTasks() async throws -> [Task]
}
enum SortOption: String, CaseIterable {
case createdDesc = "最新创建"
case createdAsc = "最早创建"
case priorityDesc = "优先级最高"
case dueDateAsc = "截止日期最近"
case dueDateDesc = "截止日期最远"
}
5.3 Repository 实现
swift
final class TaskRepository: TaskRepositoryProtocol {
private let db: Connection
init(db: Connection = DatabaseManager.shared.db) {
self.db = db
}
// MARK: - CRUD
func fetchAllTasks() async throws -> [Task] {
let rows = try db.prepare(TaskTable.table)
return rows.map { TaskTable.rowToTask($0) }
}
func fetchTask(by id: UUID) async throws -> Task? {
let query = TaskTable.table.filter(TaskTable.id == id.uuidString)
guard let row = try db.pluck(query) else {
return nil
}
return TaskTable.rowToTask(row)
}
func insertTask(_ task: Task) async throws {
try db.run(TaskTable.table.insert(
TaskTable.id <- task.id.uuidString,
TaskTable.title <- task.title,
TaskTable.content <- task.content,
TaskTable.priority <- task.priority.rawValue,
TaskTable.status <- task.status.rawValue,
TaskTable.category <- task.category.rawValue,
TaskTable.dueDate <- task.dueDate?.timeIntervalSince1970,
TaskTable.createdAt <- task.createdAt.timeIntervalSince1970,
TaskTable.updatedAt <- task.updatedAt.timeIntervalSince1970,
TaskTable.completedAt <- task.completedAt?.timeIntervalSince1970,
TaskTable.isPinned <- task.isPinned
))
}
func updateTask(_ task: Task) async throws {
let target = TaskTable.table.filter(TaskTable.id == task.id.uuidString)
try db.run(target.update(
TaskTable.title <- task.title,
TaskTable.content <- task.content,
TaskTable.priority <- task.priority.rawValue,
TaskTable.status <- task.status.rawValue,
TaskTable.category <- task.category.rawValue,
TaskTable.dueDate <- task.dueDate?.timeIntervalSince1970,
TaskTable.updatedAt <- task.updatedAt.timeIntervalSince1970,
TaskTable.completedAt <- task.completedAt?.timeIntervalSince1970,
TaskTable.isPinned <- task.isPinned
))
}
func deleteTask(by id: UUID) async throws {
let target = TaskTable.table.filter(TaskTable.id == id.uuidString)
try db.run(target.delete())
}
func deleteAllCompletedTasks() async throws -> Int {
let query = TaskTable.table.filter(TaskTable.status == Task.Status.completed.rawValue)
return try db.run(query.delete())
}
// MARK: - 高级查询
func fetchTasks(
status: Task.Status? = nil,
category: Task.Category? = nil,
searchQuery: String? = nil,
sortBy: SortOption = .createdDesc
) async throws -> [Task] {
var query = TaskTable.table
// 动态添加过滤条件
if let status {
query = query.filter(TaskTable.status == status.rawValue)
}
if let category {
query = query.filter(TaskTable.category == category.rawValue)
}
if let searchQuery, !searchQuery.isEmpty {
let pattern = "%\(searchQuery)%"
query = query.filter(TaskTable.title.like(pattern) || TaskTable.content.like(pattern))
}
// 排序:置顶任务始终在最前
query = query.order(
TaskTable.isPinned.desc,
sortExpression(for: sortBy)
)
return try db.prepare(query).map { TaskTable.rowToTask($0) }
}
private func sortExpression(for option: SortOption) -> Expression<Double> {
switch option {
case .createdDesc: return TaskTable.createdAt.desc
case .createdAsc: return TaskTable.createdAt.asc
case .priorityDesc: return TaskTable.priority.desc
case .dueDateAsc: return TaskTable.dueDate.asc
case .dueDateDesc: return TaskTable.dueDate.desc
}
}
// MARK: - 聚合查询
func countTasks(status: Task.Status?) async throws -> Int {
var query = TaskTable.table
if let status {
query = query.filter(TaskTable.status == status.rawValue)
}
return try db.scalar(query.count)
}
func fetchOverdueTasks() async throws -> [Task] {
let now = Date().timeIntervalSince1970
let query = TaskTable.table
.filter(TaskTable.status == Task.Status.pending.rawValue)
.filter(TaskTable.dueDate < now)
.order(TaskTable.dueDate.asc)
return try db.prepare(query).map { TaskTable.rowToTask($0) }
}
}
六、数据库迁移策略
6.1 为什么需要迁移策略
App 发布后,用户会升级到新版本。如果新版本修改了数据库结构(增加列、修改类型、创建新表),直接升级会导致老用户的数据丢失或崩溃。
6.2 轻量级迁移方案
swift
final class DatabaseMigration {
private let db: Connection
private let versionKey = "database_version"
private let currentVersion = 1
init(db: Connection) {
self.db = db
}
func migrate() throws {
let storedVersion = UserDefaults.standard.integer(forKey: versionKey)
guard storedVersion < currentVersion else { return }
if storedVersion < 1 {
try migrateToV1()
}
// 未来新版本的迁移写在这里
// if storedVersion < 2 {
// try migrateToV2()
// }
UserDefaults.standard.set(currentVersion, forKey: versionKey)
}
private func migrateToV1() throws {
// V1: 添加 isPinned 列(如果不存在)
// 注意:SQLite 不支持 ADD COLUMN IF NOT EXISTS 语法
// 我们用 PRAGMA table_info 来检查列是否存在
let columns = try db.prepare("PRAGMA table_info(tasks)").map { $0[1] as! String }
if !columns.contains("is_pinned") {
try db.execute("ALTER TABLE tasks ADD COLUMN is_pinned INTEGER DEFAULT 0")
}
// V1: 添加 completedAt 列
if !columns.contains("completed_at") {
try db.execute("ALTER TABLE tasks ADD COLUMN completed_at REAL")
}
}
}
6.3 集成迁移
在 DatabaseManager.setup() 中集成迁移:
swift
func setup() throws {
let path = try FileManager.default
.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
.appendingPathComponent("todo.sqlite3")
.path
db = try Connection(path)
try db.execute("PRAGMA foreign_keys = ON;")
try TaskTable.create(db: db)
// 添加迁移
try DatabaseMigration(db: db).migrate()
}
七、完整的使用示例
7.1 App 入口集成
swift
@main
struct TodoAppApp: App {
init() {
do {
try DatabaseManager.shared.setup()
} catch {
fatalError("Database setup failed: \(error)")
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
7.2 ViewModel 调用
swift
@MainActor
class TaskListViewModel: ObservableObject {
@Published var tasks: [Task] = []
@Published var isLoading = false
@Published var error: Error?
@Published var selectedStatus: Task.Status?
@Published var selectedCategory: Task.Category?
@Published var searchQuery = ""
@Published var sortOption: SortOption = .createdDesc
private let repository: TaskRepositoryProtocol
init(repository: TaskRepositoryProtocol = TaskRepository()) {
self.repository = repository
}
func loadTasks() async {
isLoading = true
defer { isLoading = false }
do {
tasks = try await repository.fetchTasks(
status: selectedStatus,
category: selectedCategory,
searchQuery: searchQuery.isEmpty ? nil : searchQuery,
sortBy: sortOption
)
} catch {
self.error = error
}
}
func createTask(_ task: Task) async {
do {
try await repository.insertTask(task)
await loadTasks()
} catch {
self.error = error
}
}
func toggleTaskStatus(_ task: Task) async {
var updated = task
updated.status = task.status == .pending ? .completed : .pending
updated.completedAt = updated.status == .completed ? Date() : nil
updated.updatedAt = Date()
do {
try await repository.updateTask(updated)
await loadTasks()
} catch {
self.error = error
}
}
func deleteTask(_ task: Task) async {
do {
try await repository.deleteTask(by: task.id)
await loadTasks()
} catch {
self.error = error
}
}
func deleteAllCompleted() async {
do {
let count = try await repository.deleteAllCompletedTasks()
print("Deleted \(count) completed tasks")
await loadTasks()
} catch {
self.error = error
}
}
}
八、今天的代码架构总结
scss
数据层架构
│
├── DatabaseManager (单例,数据库连接管理)
│ └── DatabaseMigration (版本迁移管理)
│
├── TaskTable (表定义与行映射)
│ ├── create(): 建表 + 索引
│ └── rowToTask(): Row → Task
│
└── TaskRepository (数据访问层,异步接口)
├── fetchAllTasks()
├── fetchTasks(status:category:search:sort:)
├── insertTask()
├── updateTask()
├── deleteTask()
├── countTasks()
└── fetchOverdueTasks()
关键设计原则:
- Repository 抽象数据源:ViewModel 不知道数据来自 SQLite 还是网络
- 异步所有 IO 操作:数据库操作绝不阻塞主线程
- 类型安全的 SQL:SQLite.swift 的 Expression 防止 SQL 注入
- 显式迁移:每个 Schema 变更都有对应的迁移函数
- 索引加速查询:status、priority、category、createdAt、isPinned 都有索引
下篇预告
明天我们将完成这个待办清单 App 的 UI 层:使用 SwiftUI + MVVM + Combine 实现完整的响应式界面,包括列表展示、滑动操作、筛选排序等交互。
往期回顾:无(系列第一篇)
如果你完成了今天的代码编写,欢迎在评论区分享你遇到的问题或优化思路。30 天,我们一起坚持。