30 Apps 第 1 天:待办清单 App —— 数据层完整设计

前言

从今天开始,我们开启一个新的系列:30 Apps,30 天,30 个真实可上线的 iOS 功能

第一天,我们从一个最简单的 App 入手:待办清单(Todo List)。但别被「待办清单」这个名字骗了——这个 App 的数据层设计,足以应对一个中等规模 App 的所有持久化需求。

我们将完成:

  1. 持久化方案选型:SQLite.swift / Realm / Core Data 对比
  2. 数据模型设计:Task 的完整结构
  3. Repository 模式:解耦数据层与业务层
  4. 数据库迁移策略:Schema 演进的最佳实践
  5. 完整的 SQLite.swift 封装:可直接复用到任何项目

一、项目概述与功能需求

1.1 我们要做什么

待办清单 App 的核心功能:

  • 创建、编辑、删除待办事项
  • 标记完成/未完成
  • 按优先级排序
  • 按分类筛选
  • 搜索功能
  • 数据持久化存储

1.2 数据模型

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.swiftRealmCore DataUserDefaults
适用数据量10万+ 条10万+ 条10万+ 条< 1000 条
关系查询支持 JOIN支持支持不支持
线程安全需要小心处理自动线程安全需要小心处理主线程
学习曲线
包体积~2MB~30MB内置
Swift 友好度极高简单
Schema 迁移手动自动复杂
实时通知无(需手动轮询)有(NSFetchedResultsController)

2.2 我们的选择:SQLite.swift

选择 SQLite.swift 的理由:

  1. 包体积小:2MB,对 App 大小影响可忽略
  2. 性能优秀:原生 C 实现,比 ORM 快 10x
  3. 灵活性强:SQL 查询解决所有复杂查询场景
  4. Swift 原生:API 设计风格接近 Swift 标准库
  5. 无供应商绑定:纯 SQLite,不依赖任何框架
  6. 学习价值:理解 SQL 是每个工程师的必修课

三、项目搭建

3.1 创建项目

使用 Xcode 创建新的 SwiftUI 项目,命名为 TodoApp。

3.2 添加依赖

使用 Swift Package Manager 添加 SQLite.swift:

// 在 Xcode 中:File → Add Package Dependencies
// 输入:https://github.com/stephencelis/SQLite.swift
// 选择最新版本(>= 0.15.0)

3.3 项目结构

目录结构

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 数据库管理器

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 表定义

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

graph TD
A[Views (SwiftUI)] --> B[ViewModels (Combine)]
B --> C[Repository (TaskRepository)]
C --> D[DatabaseManager (SQLite.swift)]
D --> E[todo.sqlite3]

Repository 模式的优势:

  1. 数据源可替换:可以从 SQLite 切换到 Core Data,不需要修改任何业务代码
  2. 测试方便:Mock Repository 不需要真实的数据库
  3. 职责单一ViewModel 只关心业务逻辑,Repository 只关心数据访问
  4. 复用性:同一个 Repository 可被多个 ViewModel 使用

5.2 Repository 接口设计

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
}

swift 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]


```swift
enum SortOption: String, CaseIterable {
    case createdDesc = "最新创建"
    case createdAsc = "最早创建"
    case priorityDesc = "优先级最高"
    case dueDateAsc = "截止日期最近"
    case dueDateDesc = "截止日期最远"
}

5.3 Repository 实现

final class TaskRepository: TaskRepositoryProtocol {
    private let db: Connection

    init(db: Connection = DatabaseManager.shared.db) {
        self.db = db
    }

    // MARK: - CRUD

    func fetchAllTasks() async throws -&amp;gt; [Task] {
        let rows = try db.prepare(TaskTable.table)
        return rows.map { TaskTable.rowToTask($0) }
    }

    func fetchTask(by id: UUID) async throws -&amp;gt; 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 &amp;lt;- task.id.uuidString,
            TaskTable.title &amp;lt;- task.title,
            TaskTable.content &amp;lt;- task.content,
            TaskTable.priority &amp;lt;- task.priority.rawValue,
            TaskTable.status &amp;lt;- task.status.rawValue,
            TaskTable.category &amp;lt;- task.category.rawValue,
            TaskTable.dueDate &amp;lt;- task.dueDate?.timeIntervalSince1970,
            TaskTable.createdAt &amp;lt;- task.createdAt.timeIntervalSince1970,
            TaskTable.updatedAt &amp;lt;- task.updatedAt.timeIntervalSince1970,
            TaskTable.completedAt &amp;lt;- task.completedAt?.timeIntervalSince1970,
            TaskTable.isPinned &amp;lt;- 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 &amp;lt;- task.title,
            TaskTable.content &amp;lt;- task.content,
            TaskTable.priority &amp;lt;- task.priority.rawValue,
            TaskTable.status &amp;lt;- task.status.rawValue,
            TaskTable.category &amp;lt;- task.category.rawValue,
            TaskTable.dueDate &amp;lt;- task.dueDate?.timeIntervalSince1970,
            TaskTable.updatedAt &amp;lt;- task.updatedAt.timeIntervalSince1970,
            TaskTable.completedAt &amp;lt;- task.completedAt?.timeIntervalSince1970,

```swift
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 轻量级迁移方案

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() 中集成迁移:

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 入口集成

@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 调用

@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
    }
}

八、今天的代码架构总结

体验**AI**代码助手

代码解读

复制代码

数据层架构
│
├── **DatabaseManager** (单例,数据库连接管理)
│   └── **DatabaseMigration** (版本迁移管理)
│
├── **TaskTable** (表定义与行映射)
│   ├── create(): 建表 + 索引
│   └── rowToTask(): Row → Task
│
└── **TaskRepository** (数据访问层,异步接口)
    ├── fetchAllTasks()
    ├── fetchTasks(status:category:search:sort:)
    ├── insertTask()
    ├── updateTask()
    ├── deleteTask()
    ├── countTasks()
    └── fetchOverdueTasks()

关键设计原则

  1. Repository 抽象数据源ViewModel 不知道数据来自 SQLite 还是网络
  2. 异步所有 IO 操作 :数据库操作绝不阻塞主线程
  3. 类型安全的 SQLSQLite.swiftExpression 防止 SQL 注入
  4. 显式迁移 :每个 Schema 变更都有对应的迁移函数
  5. 索引加速查询statusprioritycategorycreatedAtisPinned 都有索引

下篇预告

明天我们将完成这个待办清单 AppUI 层:使用 SwiftUI + MVVM + Combine 实现完整的响应式界面,包括列表展示、滑动操作、筛选排序等交互。


往期回顾 :无(系列第一篇)


如果你完成了今天的代码编写,欢迎在评论区分享你遇到的问题或优化思路。30 天,我们一起坚持。