GRDB.swift 驱动 SwiftUI 应用实战:GRDBDemo 的数据库架构、ValueObservation 实时列表与测试设计
GRDB.swift 驱动 SwiftUI 应用实战GRDBDemo 的数据库架构、ValueObservation 实时列表与测试设计【免费下载链接】GRDB.swiftA toolkit for SQLite databases, with a focus on application development项目地址: https://gitcode.com/GitHub_Trending/gr/GRDB.swiftGRDBDemo 是 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 覆盖的主题包括如何在 iOS 应用中搭建数据库如何定义简单的 Codable Record如何用 ValueObservation 跟踪数据库变化并让 SwiftUI List 以动画方式实时更新如何落实 Recommended Practices for Designing Record Types 的推荐做法即仓库内的GRDB/Documentation.docc/RecordRecommendedPractices.md如何用瞬态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取到数据库实例例如后面的PlayerCreationSheet、PlayerEditionView都是这样消费的。这种环境注入的方式与依赖注入容器相比更贴合 SwiftUI 的声明式模型也让预览变得容易只需在#Preview里换成.appDatabase(.random())或.appDatabase(.empty())即可。三、核心封装AppDatabase 与 DatabaseMigratorAppDatabase.swift 是整个数据层的核心类型。它的设计遵循了 GRDB 文档推荐的做法用一个AppDatabase结构体封装所有数据库访问对外只暴露业务方法不暴露底层DatabaseWriter细节。struct AppDatabase: Sendable { private let dbWriter: any DatabaseWriter ... }从源码结构看AppDatabase由四个清晰的extension分区组成每一块职责单一这是值得读者在自己的项目中复用的组织模式核心类型与迁移init(_:)接收任意DatabaseWriter并在初始化时立即执行migrator.migrate(dbWriter)从而保证数据库创建完成即 schema 就绪。官方注释特别强调必须使用makeConfiguration()返回的配置来创建DatabaseWriter。数据库配置makeConfiguration(_:)静态方法。写访问一组以dbWriter.write { db in ... }包裹的事务方法。读访问暴露只读的reader属性。3.1 用 DatabaseMigrator 定义 schema 并支持版本演进schema 定义在migrator属性中它使用 GRDB 的 DatabaseMigrator 类型对应文档 Migrations.mdprivate 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的签名Player以inout传入。这是因为插入成功后主键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预览库用内存DatabaseQueueSwiftUI 预览要求快速、无副作用、无持久化内存库是天然选择try!在预览上下文中是可接受的失败即崩溃便于开发期暴露问题错误处理makeShared的 catch 分支列出了典型失败原因父目录不可写、设备锁定时数据库不可访问、磁盘空间不足、迁移失败正式产品应替换为合适的错误上报策略而非fatalError启动即播种shared与random都调用createRandomPlayersIfEmpty()空库时插入 8 个随机球员见 AppDatabase.swift保证 Demo 一打开就有可玩的数据。五、Codable RecordPlayer 的数据模型设计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.mdInt64?主键autoIncrementedPrimaryKey对应的类型约定是Int64id为nil表示尚未插入数据库插入成功后由didInsert(_:)回填inserted.rowIDEquatable支持源码注释点明其双重用途——支撑 SwiftUI 列表动画与测试断言Codable FetchableRecord MutablePersistableRecordCodable 让模型天然获得从数据库行解码 / 编码成数据库行的能力即文档所称 Codable Records对应源码 FetchableRecordDecodable.swift 与 EncodableRecordEncodable.swiftColumns枚举把CodingKeys映射为类型安全的Column对象供查询接口排序、筛选使用模型扩展保持纯 Swiftnew()、makeRandom()、randomName()、randomScore()等工厂方法放在独立 extension 中数据库相关的Codable/Record协议遵循放在另一个 extension代码组织上领域模型与持久化能力清晰分离。配套类型是视图层的 PlayerForm.swiftPlayerForm { 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 } } ... }其工作机制可以拆解为三层ValueObservation.tracking声明观察什么闭包里的查询Player.order{...}.fetchAll(db)既定义了要展示的数据也定义了 GRDB 需要跟踪的数据库区域——任何影响该查询结果的事务提交都会触发回调.start(in:scheduling:onChange:)建立订阅scheduling: .immediate表示首次变更在当前线程立即投递MainActor模型下即主线程返回的AnyDatabaseCancellable保存为属性以保持订阅存活再次调用observePlayers()时旧订阅自动取消didSet里重新观察即依赖此行为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()启动观察空列表时展示ContentUnavailableViewThe team is empty! 空状态非空时展示列表底部工具栏提供清空 / 刷新 / tornado50 次并发刷新按钮。PlayerListView.swiftListForEach(model.players, id: \.id)依赖Player.Identifiable-like 的id键路径支撑动画每行是跳转编辑页的NavigationLink支持.onDelete滑动删除.navigationTitle(\(model.players.count) Players)动态显示人数。PlayerFormView.swift可复用的表单名称/分数两个输入框用FocusState管理焦点流转名称输入完成自动跳到分数。PlayerCreationSheet.swift新建球员的 sheetCancel/Save 工具栏Save 时构造Player(name:score:)调用savePlayer后dismiss()。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: EquatabledeleteAll测试通过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 最有复用价值的设计决策归纳如下AppDatabase是唯一的数据访问门面初始化即迁移、读写方法按事务组织、只读能力单独暴露任何业务代码不直接操作DatabaseQueue/DatabasePool迁移是 schema 的单一事实来源新版本只追加registerMigrationDEBUG 下开启eraseDatabaseOnSchemaChange加速开发实例化策略与场景解耦.shared磁盘DatabasePool跑正式数据.empty()/.random()内存DatabaseQueue服务预览与测试三者共享makeConfiguration()Codable Record 三件套Codable FetchableRecord MutablePersistableRecordInt64?主键 didInsert回填Columns枚举提供类型安全查询列Observable模型 ValueObservation 是 SwiftUI 实时列表的推荐组合模型层持有AnyDatabaseCancellablescheduling: .immediate保证主线程投递排序变化通过didSet重新观察实现预览与测试共享内存库路径既保证了开发体验有数据、可交互又保证了测试的确定性与速度注意官方明确本 Demo不是项目模板读者应将其作为架构灵感在自己的工程中按需取舍例如把reader的完全暴露换成聚焦的读取方法、把fatalError换成正式的错误处理。如果需要进一步深入可以在当前仓库中继续研读GRDB 的完整 API 说明见 GRDB.docc迁移机制见 DatabaseMigrator.swiftValueObservation 实现见 ValueObservation.swift 及其 ReducersValueReducer.swift并发模型见 Concurrency.md 与 DatabasePool.swiftRecord 协议族见 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),仅供参考