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 天,我们一起坚持。

相关推荐
晴天丨2 小时前
🔔 如何实现一个优雅的通知中心?(Vue 3 + 消息队列实战)
前端·vue.js
不思进取的程序猿2 小时前
前端性能调优实战指南 — 22 条优化策略
前端
yuki_uix2 小时前
HTTP 缓存策略:新鲜度与速度的权衡艺术
前端·面试
哈撒Ki2 小时前
快速入门 Dart 语言
前端·flutter·dart
ZC跨境爬虫2 小时前
3D 地球卫星轨道可视化平台开发 Day5(简介接口对接+规划AI自动化卫星数据生成工作流)
前端·人工智能·3d·ai·自动化
毛骗导演2 小时前
Claude Code Agent 实现原理深度剖析
前端·架构
星晨雪海2 小时前
若依框架原有页面功能进行了点位管理模块完整改造(3)
开发语言·前端·javascript
2501_915909062 小时前
Xcode从入门到精通:全面解析iOS开发IDE的核心功能与实际应用指南
ide·vscode·ios·个人开发·xcode·swift·敏捷流程
morethanilove2 小时前
新建vue3 + ts +vite 项目
前端·javascript·vue.js