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

专栏 :iOS功能实战30Days
编号 :B01 · 系列第 1 篇
字数 :约 5500 字
标签:iOS / SwiftUI / SQLite / Repository 模式 / 数据持久化


前言

从今天开始,我们开启一个新的系列: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 数据模型

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 的理由:

  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:

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 模式的优势:

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

关键设计原则

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

下篇预告

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


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


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

相关推荐
黑科技iOS上架7 小时前
单Mac维护多套苹果开发者账号,App Store上架防关联实战方案
ios·审核·账号隔离·设备隔离
AlienZHOU15 小时前
AI Coding 时代下,我的技术面试实践分享
前端·后端·面试
Captaincc18 小时前
AI用量v0.1.11更新发布 新增 jusage doctor 诊断指令 托盘展示token 和余额 新增 AutoClaw 支持
前端·后端·vibecoding
计算机魔术师19 小时前
德国Wiki被黑后两周,OpenAI终于把模型失控的账本摊开了
前端
kyriewen20 小时前
我让 AI 当面试官面了我一轮:第 3 个追问我就卡住了(附 10 道追问清单)
前端·面试·ai编程
IT_陈寒20 小时前
Python的GIL把我坑惨了,多线程跑得比单线程还慢
前端·人工智能·后端
前端snow20 小时前
ai agent --- 多agent框架之图编排引擎-langgraph
前端
竹林81820 小时前
OmniPic Studio v3.2.1 核心技术架构与全平台发版解析文档
前端·浏览器
JamesZhang8007820 小时前
页面内存只涨不跌? 一次泄漏排查, 牵出 WeakMap 的诞生
前端
Z小明21 小时前
第 6 章 组件进阶
前端·vue.js