GRDB.swift 驱动 SwiftUI 应用实战:GRDBDemo 的数据库架构、ValueObservation 实时列表与测试设计
2026/9/16 11:59:10 网站建设 项目流程

GRDB.swift 驱动 SwiftUI 应用实战:GRDBDemo 的数据库架构、ValueObservation 实时列表与测试设计

【免费下载链接】GRDB.swiftA toolkit for SQLite databases, with a focus on application development项目地址: https://gitcode.com/GitHub_Trending/gr/GRDB.swift

GRDBDemo 是 GRDB.swift 官方仓库内置的完整演示应用,展示了 GRDB 如何为 SwiftUI 应用提供端到端的数据支撑:从数据库连接与迁移、Codable Record 建模,到借助 ValueObservation 让 SwiftUI 列表随数据库变化实时刷新并带动画,再到用瞬态内存数据库喂给 SwiftUI 预览与单元测试。阅读本文后,你将掌握这套可复制的 SwiftUI + GRDB 应用骨架,理解AppDatabase封装模式、@Observable观察模型以及"预览/测试用内存库、运行用磁盘库"的差异化实例化策略,并清楚每一个设计决策背后的源码依据。

一、Demo 概览:GRDB 如何支撑一个完整的 SwiftUI 应用

根据官方说明,GRDBDemo 的核心定位是"demonstrates how GRDB can fuel a SwiftUI application"(演示 GRDB 如何驱动 SwiftUI 应用)。官方明确强调:它不是项目模板——官方建议读者新建工程后按 README.md 中的安装方式集成 GRDB,再把 Demo 当作灵感来源,而不是直接复制它作为项目起点。这一点在架构上是刻意的:Demo 刻意保持简单直白,方便读者看清每个环节的职责边界。

Demo 覆盖的主题包括:

  1. 如何在 iOS 应用中搭建数据库;
  2. 如何定义简单的 Codable Record;
  3. 如何用 ValueObservation 跟踪数据库变化并让 SwiftUI List 以动画方式实时更新;
  4. 如何落实 Recommended Practices for Designing Record Types 的推荐做法(即仓库内的GRDB/Documentation.docc/RecordRecommendedPractices.md);
  5. 如何用瞬态(transient)数据库喂给 SwiftUI 预览。

与之对应的工程文件(GRDBDemo.xcodeproj)分为三个部分:应用源码(GRDBDemo/)、测试目标(GRDBDemoTests/,包含 AppDatabaseTests.swift 与 PlayerListModelTests.swift)以及资源文件。应用层代码只有 6 个视图文件、3 个数据库相关文件加 1 个入口文件,体量虽小却完整覆盖了"读写、观察、迁移、预览、测试"全部关键环节,非常适合作为学习 GRDB + SwiftUI 组合的第一手范本。

二、应用入口:通过 SwiftUI 环境注入数据库

Demo 的入口 GRDBDemoApp.swift 只有寥寥数行,但传递了一个重要架构决策:数据库通过 SwiftUI 环境(Environment)在整棵视图树中传递

@main struct GRDBDemoApp: App { var body: some Scene { WindowGroup { PlayersNavigationView().appDatabase(.shared) } } } // MARK: - Give SwiftUI access to the database extension EnvironmentValues { @Entry var appDatabase = AppDatabase.empty() } extension View { func appDatabase(_ appDatabase: AppDatabase) -> some View { self.environment(\.appDatabase, appDatabase) } }

要点拆解:

  • AppDatabase.empty()作为@Entry的默认值,保证即使某个视图没有显式注入环境值,也能拿到一个可用的(空的、内存中的)数据库,避免环境值缺失导致崩溃——这正是"空库"在预览之外的又一用途;
  • .appDatabase(_:)是一个自定义 View 扩展,本质是对environment(_:_:)的语法糖封装;
  • 任何视图都可以通过@Environment(\.appDatabase) var appDatabase取到数据库实例,例如后面的PlayerCreationSheetPlayerEditionView都是这样消费的。

这种"环境注入"的方式与依赖注入容器相比更贴合 SwiftUI 的声明式模型,也让预览变得容易:只需在#Preview里换成.appDatabase(.random()).appDatabase(.empty())即可。

三、核心封装:AppDatabase 与 DatabaseMigrator

AppDatabase.swift 是整个数据层的核心类型。它的设计遵循了 GRDB 文档推荐的做法:用一个AppDatabase结构体封装所有数据库访问,对外只暴露业务方法,不暴露底层DatabaseWriter细节

struct AppDatabase: Sendable { private let dbWriter: any DatabaseWriter ... }

从源码结构看,AppDatabase由四个清晰的extension分区组成,每一块职责单一,这是值得读者在自己的项目中复用的组织模式:

  1. 核心类型与迁移init(_:)接收任意DatabaseWriter,并在初始化时立即执行migrator.migrate(dbWriter),从而保证"数据库创建完成即 schema 就绪"。官方注释特别强调:必须使用makeConfiguration()返回的配置来创建DatabaseWriter
  2. 数据库配置makeConfiguration(_:)静态方法。
  3. 写访问:一组以dbWriter.write { db in ... }包裹的事务方法。
  4. 读访问:暴露只读的reader属性。

3.1 用 DatabaseMigrator 定义 schema 并支持版本演进

schema 定义在migrator属性中,它使用 GRDB 的 DatabaseMigrator 类型(对应文档 Migrations.md):

private var migrator: DatabaseMigrator { var migrator = DatabaseMigrator() #if DEBUG // Speed up development by nuking the database when migrations change migrator.eraseDatabaseOnSchemaChange = true #endif migrator.registerMigration("v1") { db in // Create a table try db.create(table: "player") { t in t.autoIncrementedPrimaryKey("id") t.column("name", .text).notNull() t.column("score", .integer).notNull() } } // Migrations for future application versions will be inserted here: // migrator.registerMigration(...) { db in // ... // } return migrator }

这一段蕴含三个实战要点:

  • 迁移(migration)是 schema 演进的唯一入口:未来的应用版本只需在注释位置追加新的registerMigration块,DatabaseMigrator会记录已执行迁移并增量执行新迁移,开发者无需手工维护建表 SQL 的历史版本;
  • eraseDatabaseOnSchemaChange = true是 DEBUG 构建专属的加速开关:当已有迁移定义发生变化时,开发阶段会直接抹掉旧库重建,省去手动删 App 的麻烦;注意它被包裹在#if DEBUG中,release 构建不受影响;
  • 表定义使用 GRDB 的 schema 构造器autoIncrementedPrimaryKey("id")生成INTEGER PRIMARY KEY AUTOINCREMENT主键,与后面Player使用Int64?承载主键的类型约定一一对应(见第四节)。

AppDatabase初始化时同步迁移,这也意味着测试中构造"空库"后 schema 总是可用的,AppDatabaseTests因此无需额外准备建表步骤。

3.2 makeConfiguration:配置扩展点

makeConfiguration(_:) 目前是一个"几乎空"的配置方法,但源码注释以开关形式提供了三个高频扩展点,读者可按需开启:

  • 自定义 SQL 函数/排序规则:在config.prepareDatabase { db in ... }中调用db.add(function:)等;
  • SQL 日志追踪:通过SQL_TRACE环境变量启用db.trace { event in ... },且特别提醒"语句参数属于敏感信息,除非设置config.publicStatementArguments否则不会出现在日志中";
  • DEBUG 下公开语句参数config.publicStatementArguments = true便于调试时看到完整 SQL 参数。

把配置集中在这个方法里,好处是创建任何形态的数据库(磁盘库、内存库、测试库)都能复用同一套配置策略,避免散落各处产生漂移。

3.3 写访问:以事务为单位的业务方法

AppDatabase的写方法统一走dbWriter.write { db in ... },这保证了每个写操作都是完整的数据库事务(对应文档 Concurrency.md 与 Transactions.md)。Demo 提供了四个方法:

func savePlayer(_ player: inout Player) throws { try dbWriter.write { db in try player.save(db) } } func deletePlayers(ids: [Int64]) throws { try dbWriter.write { db in _ = try Player.deleteAll(db, keys: ids) } } func deleteAllPlayers() throws { try dbWriter.write { db in _ = try Player.deleteAll(db) } }

源码注释特别说明了设计意图:"The write methods execute invariant-preserving database transactions."(写方法执行保持不变量的事务),并把写访问与读访问刻意分开——这是 Record 设计推荐实践中"以应用为中心封装读写"的体现。

值得注意的还有savePlayer的签名:Playerinout传入。这是因为插入成功后主键id会被回填(见第四节didInsert),调用方需要拿回更新后的值,测试AppDatabaseTests.insert正是通过insertedPlayer.id != nil验证这一点。

此外 refreshPlayers() 是演示专用的"随机扰动"方法:空库时插入 8 个随机球员,非空时随机执行插入、删除(Player.order(sql: "RANDOM()").limit(1).deleteAll(db))与更新(updateChanges)操作。它被设计成async throws版本,由并发模型(见第六节)调用。

3.4 读访问:只读接口的最小化暴露

extension AppDatabase { /// Provides a read-only access to the database. var reader: any GRDB.DatabaseReader { dbWriter } }

Demo 没有提供任何"专用的读取方法",而是把只读的DatabaseReader原样暴露给上层。源码注释给出了这一取舍的说明:Demo 选择"给应用其余部分无限制的只读访问权";而正式项目中,读者完全可以改成"定义聚焦的读取方法"的另一种路径。这体现了 GRDB 文档反复强调的灵活性:只读访问可以通过reader让 GRDB 在需要时自动调度到DatabasePool的读连接上

四、三种数据库实例:磁盘库、空内存库、随机数据内存库

Persistence.swift 负责按场景实例化不同的AppDatabase,是"同一套代码、不同数据库"的工厂层。

extension AppDatabase { /// The database for the application static let shared = makeShared() private static func makeShared() -> AppDatabase { do { // Create the "Application Support/Database" directory if needed let fileManager = FileManager.default let appSupportURL = try fileManager.url( for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) let directoryURL = appSupportURL.appendingPathComponent("Database", isDirectory: true) try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) // Open or create the database let databaseURL = directoryURL.appendingPathComponent("db.sqlite") let config = AppDatabase.makeConfiguration() let dbPool = try DatabasePool(path: databaseURL.path, configuration: config) // Create the AppDatabase let appDatabase = try AppDatabase(dbPool) // Populate the database if it is empty, for better demo purpose. try appDatabase.createRandomPlayersIfEmpty() return appDatabase } catch { // ... fatalError("Unresolved error \(error)") } } /// Creates an empty database for SwiftUI previews static func empty() -> AppDatabase { let dbQueue = try! DatabaseQueue(configuration: AppDatabase.makeConfiguration()) return try! AppDatabase(dbQueue) } /// Creates a database full of random players for SwiftUI previews static func random() -> AppDatabase { let appDatabase = empty() try! appDatabase.createRandomPlayersIfEmpty() return appDatabase } }

三种形态各有用途:

工厂方法存储介质数据用途
.shared磁盘,位于Application Support/Database/db.sqlite持久化,空库时自动填充随机球员正式运行
.empty()内存DatabaseQueue空表(schema 已迁移就绪)SwiftUI 预览、"空团队"状态展示、作为环境默认值
.random()内存DatabaseQueue8 名随机球员SwiftUI 预览的"有数据"状态

细节与依据:

  • 运行库用DatabasePool:应用选择DatabasePool(读写并发、多读单写)而非DatabaseQueue,这是 GRDB 文档关于数据库连接选型的推荐(见 Concurrency.md 与 DatabaseConnections.md);目录创建遵循 iOS 惯例,先定位.applicationSupportDirectory再追加Database子目录,最后拼接db.sqlite
  • 预览库用内存DatabaseQueue:SwiftUI 预览要求快速、无副作用、无持久化,内存库是天然选择,try!在预览上下文中是可接受的(失败即崩溃,便于开发期暴露问题);
  • 错误处理makeShared的 catch 分支列出了典型失败原因(父目录不可写、设备锁定时数据库不可访问、磁盘空间不足、迁移失败),正式产品应替换为合适的错误上报策略而非fatalError
  • 启动即播种sharedrandom都调用createRandomPlayersIfEmpty()(空库时插入 8 个随机球员,见 AppDatabase.swift),保证 Demo 一打开就有可玩的数据。

五、Codable Record:Player 的数据模型设计

Player.swift 演示了如何把普通 Swift 结构体变成"既能在内存中流畅使用、又能读写数据库"的 Record 类型。

struct Player: Equatable { /// Int64 is the recommended type for auto-incremented database ids. /// Use nil for players that are not inserted yet in the database. var id: Int64? var name: String var score: Int } extension Player: Codable, FetchableRecord, MutablePersistableRecord { // Define database columns from CodingKeys enum Columns { static let name = Column(CodingKeys.name) static let score = Column(CodingKeys.score) } /// Updates a player id after it has been inserted in the database. mutating func didInsert(_ inserted: InsertionSuccess) { id = inserted.rowID } }

这里浓缩了 GRDB Record 设计的多个推荐实践(详见 RecordRecommendedPractices.md):

  • Int64?主键autoIncrementedPrimaryKey对应的类型约定是Int64idnil表示"尚未插入数据库",插入成功后由didInsert(_:)回填inserted.rowID
  • Equatable支持:源码注释点明其双重用途——支撑 SwiftUI 列表动画与测试断言;
  • Codable + FetchableRecord + MutablePersistableRecord:Codable 让模型天然获得"从数据库行解码 / 编码成数据库行"的能力,即文档所称 Codable Records(对应源码 FetchableRecord+Decodable.swift 与 EncodableRecord+Encodable.swift);Columns枚举把CodingKeys映射为类型安全的Column对象,供查询接口排序、筛选使用;
  • 模型扩展保持纯 Swiftnew()makeRandom()randomName()randomScore()等工厂方法放在独立 extension 中,数据库相关的Codable/Record协议遵循放在另一个 extension,代码组织上"领域模型"与"持久化能力"清晰分离。

配套类型是视图层的 PlayerForm.swift(PlayerForm { name: String, score: Int? })——用可空 score 表达"尚未填写"的表单中间态,保存时才以form.score ?? 0落库。这是"编辑模型(表单)与持久化模型(Record)分离"的实用技巧。

六、ValueObservation:让列表随数据库实时刷新并带动画

PlayerListModel.swift 是数据层与界面层之间的"观察桥",也是整个 Demo 最值得研读的部分。

@Observable @MainActor final class PlayerListModel { enum Ordering { case byName case byScore } var ordering = Ordering.byScore { didSet { observePlayers() } } var players: [Player] = [] private let appDatabase: AppDatabase @ObservationIgnored private var cancellable: AnyDatabaseCancellable? func observePlayers() { // We observe all players, sorted according to `ordering`. let observation = ValueObservation.tracking { [ordering] db in switch ordering { case .byName: try Player .order { $0.name.collating(.localizedCaseInsensitiveCompare) } .fetchAll(db) case .byScore: try Player .order { [ $0.score.desc, $0.name.collating(.localizedCaseInsensitiveCompare), ] } .fetchAll(db) } } // Start observing the database. // Previous observation, if any, is cancelled. cancellable = observation.start(in: appDatabase.reader, scheduling: .immediate) { error in // Handle error } onChange: { [unowned self] players in self.players = players } } ... }

其工作机制可以拆解为三层:

  1. ValueObservation.tracking声明"观察什么":闭包里的查询(Player.order{...}.fetchAll(db))既定义了要展示的数据,也定义了 GRDB 需要跟踪的数据库区域——任何影响该查询结果的事务提交都会触发回调;
  2. .start(in:scheduling:onChange:)建立订阅scheduling: .immediate表示首次变更在当前线程立即投递(@MainActor模型下即主线程);返回的AnyDatabaseCancellable保存为属性以保持订阅存活,再次调用observePlayers()时旧订阅自动取消(didSet里重新观察即依赖此行为);
  3. onChange回填self.players:因为PlayerListModel@Observableplayers属性的任何更新都会自动通知依赖它的 SwiftUI 视图,配合 PlayerListView 中的.animation(.default, value: model.players)即可实现列表插入/删除/排序的平滑动画。

排序逻辑同样值得学习:collating(.localizedCaseInsensitiveCompare)让名称按本地化大小写不敏感排序,score.desc与名称排序构成复合排序——这些类型安全的查询表达式来自 GRDB 的 Query Interface(如 SQLOrdering.swift)。

PlayerListModel同时承载动作方法:deletePlayers(at:)依据IndexSet反查主键后调用appDatabase.deletePlayers(ids:)deleteAllPlayers()refreshPlayers()透传数据库层的随机扰动;refreshPlayersManyTimes()则用withThrowingTaskGroup并发发起50 次refreshPlayers(),用以演示DatabasePool的并发写入调度与 ValueObservation 在高压下的实时性(界面上的"tornado"按钮即触发此操作)。

七、视图层:导航、列表、表单与预览

视图层共 6 个文件,职责划分非常清晰:

  • PlayersNavigationView.swift:主导航视图。它从环境中取出AppDatabase,通过ContentView(私有结构)以@State持有PlayerListModel实例——注释说明这是"在 SwiftUI 环境中创建可观察对象"的标准技巧;onAppear时调用model.observePlayers()启动观察;空列表时展示ContentUnavailableView("The team is empty!" 空状态),非空时展示列表;底部工具栏提供"清空 / 刷新 / tornado(50 次并发刷新)"按钮。
  • PlayerListView.swiftList+ForEach(model.players, id: \.id)(依赖Player.Identifiable-like 的id键路径支撑动画),每行是跳转编辑页的NavigationLink,支持.onDelete滑动删除,.navigationTitle("\(model.players.count) Players")动态显示人数。
  • PlayerFormView.swift:可复用的表单(名称/分数两个输入框),用@FocusState管理焦点流转(名称输入完成自动跳到分数)。
  • PlayerCreationSheet.swift:新建球员的 sheet,Cancel/Save 工具栏,Save 时构造Player(name:score:)调用savePlayerdismiss()
  • PlayerEditionView.swift:编辑页,将player预填进PlayerForm;返回时(isPresented变为 false)自动保存——"无保存按钮"的编辑交互范式。

值得一提的是所有视图文件底部都附带了#Preview,并且预览一律通过.appDatabase(.random())/.appDatabase(.empty())注入瞬态数据库,例如:

#Preview("Populated") { PlayersNavigationView() .appDatabase(.random()) } #Preview("Empty") { PlayersNavigationView() .appDatabase(.empty()) }

这正是 README 所述"feed SwiftUI previews with a transient database"(用瞬态数据库喂养 SwiftUI 预览)的具体落点:预览不触碰磁盘、不污染数据、每次启动都有新鲜随机数据,且与正式运行的.shared共用同一套AppDatabase逻辑。

八、测试策略:数据库层与观察模型层的双轨验证

Demo 为最关键的两个类型都配备了测试,均使用 Swift Testing 框架(import Testing)与内存数据库。

8.1 AppDatabaseTests:数据层行为验证

AppDatabaseTests.swift 覆盖插入、更新、清空三个基本行为,采用统一的 Given/When/Then 风格:

@Test func insert() throws { // Given an empty database let appDatabase = try makeEmptyTestDatabase() // When we insert a player var insertedPlayer = Player(name: "Arthur", score: 1000) try appDatabase.savePlayer(&insertedPlayer) // Then the inserted player has an id #expect(insertedPlayer.id != nil) // Then the inserted player exists in the database let fetchedPlayer = try appDatabase.reader.read(Player.fetchOne) #expect(fetchedPlayer == insertedPlayer) }
  • makeEmptyTestDatabase()复用AppDatabase.makeConfiguration()与内存DatabaseQueue,与 Preview 的empty()构造路径完全一致——同一套工厂逻辑贯穿测试、预览、运行三种场景
  • 断言"插入后有 id"验证了didInsert回填机制;fetchedPlayer == insertedPlayer则依赖Player: Equatable
  • deleteAll测试通过Player.fetchCount验证清空结果。

8.2 PlayerListModelTests:观察模型的异步验证

PlayerListModelTests.swift 验证的是更棘手的异步行为——ValueObservation 的回调不是同步发生的,因此测试实现了pollUntil轮询辅助方法(每 10ms 检查一次条件,用confirmation收尾),并给观察类测试加上.timeLimit(.minutes(1))超时保护:

@Test(.timeLimit(.minutes(1))) @MainActor func observation_grabs_database_changes() async throws { // Given a PlayerListModel that has one player let appDatabase = try makeEmptyTestDatabase() var player1 = Player(name: "Arthur", score: 1000) try appDatabase.savePlayer(&player1) let model = PlayerListModel(appDatabase: appDatabase) model.observePlayers() try await pollUntil { model.players.count == 1 } // When we insert a second player var player2 = Player(name: "Barbara", score: 800) try appDatabase.savePlayer(&player2) // Then the model eventually has two players. try await pollUntil { model.players.count == 2 } }

三个测试分别验证:观察启动后能拿到当前数据库状态、后续数据库变更能被模型捕获、deleteAllPlayers能真实清空数据库。这套"内存库 + 轮询 + 超时"的组合,是测试任何基于 ValueObservation 的 SwiftUI 模型的可靠范式,可原样迁移到读者自己的项目。

九、从 Demo 到生产:设计要点与注意事项汇总

综合 README 声明与源码实现,将 GRDBDemo 最有复用价值的设计决策归纳如下:

  1. AppDatabase是唯一的数据访问门面:初始化即迁移、读写方法按"事务"组织、只读能力单独暴露,任何业务代码不直接操作DatabaseQueue/DatabasePool
  2. 迁移是 schema 的单一事实来源:新版本只追加registerMigration,DEBUG 下开启eraseDatabaseOnSchemaChange加速开发;
  3. 实例化策略与场景解耦.shared(磁盘DatabasePool)跑正式数据,.empty()/.random()(内存DatabaseQueue)服务预览与测试,三者共享makeConfiguration()
  4. Codable Record 三件套Codable + FetchableRecord + MutablePersistableRecordInt64?主键 +didInsert回填,Columns枚举提供类型安全查询列;
  5. @Observable模型 + ValueObservation 是 SwiftUI 实时列表的推荐组合:模型层持有AnyDatabaseCancellablescheduling: .immediate保证主线程投递,排序变化通过didSet重新观察实现;
  6. 预览与测试共享内存库路径:既保证了开发体验(有数据、可交互),又保证了测试的确定性与速度;
  7. 注意:官方明确本 Demo不是项目模板,读者应将其作为架构灵感,在自己的工程中按需取舍(例如把reader的完全暴露换成聚焦的读取方法、把fatalError换成正式的错误处理)。

如果需要进一步深入,可以在当前仓库中继续研读:GRDB 的完整 API 说明见 GRDB.docc,迁移机制见 DatabaseMigrator.swift,ValueObservation 实现见 ValueObservation.swift 及其 Reducers(ValueReducer.swift),并发模型见 Concurrency.md 与 DatabasePool.swift,Record 协议族见 FetchableRecord.swift 与 MutablePersistableRecord.swift。GRDBDemo 虽然只有十余个文件,却把"SwiftUI 应用 + SQLite 数据库"这条主线的每一个关键决策都做出了示范,是一份高质量的可读代码标本。

【免费下载链接】GRDB.swiftA toolkit for SQLite databases, with a focus on application development项目地址: https://gitcode.com/GitHub_Trending/gr/GRDB.swift

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询