完全指南:用 SQLite 实现类型安全的全局配置存储)
GRDB.swift 单行表Single-Row Tables完全指南用 SQLite 实现类型安全的全局配置存储【免费下载链接】GRDB.swiftA toolkit for SQLite databases, with a focus on application development项目地址: https://gitcode.com/GitHub_Trending/gr/GRDB.swift本指南讲解 GRDB.swift 中「单行表」模式如何在数据库层保证一张表永远不超过一行并用它存储应用全局配置、用户偏好等状态。读完你将掌握单行表的建表 SQL 技巧、可空列与默认值的取舍、基于MutablePersistableRecord的读写 API 设计以及一套可直接复制使用的完整模板。为什么需要单行表数据库中存在一类特殊的表整个生命周期内最多只能有一行数据。它天然适合存储应用配置项flag、开关、阈值用户偏好设置引用其他业务表 ID 的全局状态如当前语言、主题、默认账户。在某些应用场景下单行表是UserDefaults的合适替代方案——尤其是当配置值需要与数据库中其他表建立关联、且数据库完整性外键约束至关重要的时候。UserDefaults与业务数据彼此隔离无法表达「配置引用了某条记录」这种关系而 SQLite 的外键可以。一种常见的做法是键值对表两列key、value每个配置项占一行。这种方案可行但有明显缺陷配置值类型五花八门字符串、整数、日期……需要自己处理类型转换与解析无法定义外键因为键值表里只有通用类型的 value 列无法引用具体表的具体主键。因此本指南不采用键值对表而是每个配置值一个列让数据库 schema 具备完整的外键表达能力。如果你更喜欢把所有配置塞进一个 JSON 列可以参考本指南的思路结合 JSON 支持文档 实现。此外本指南还实现了一个类似UserDefaults.register(defaults:)的特性为尚未落盘的配置提供默认值即使数据库中还没有这一行应用也能拿到一个合理的配置对象。建表让 SQLite 保证「最多一行」「一切从 schema 开始」是 SQLite 开发的黄金法则。把约束交给数据库引擎应用代码就更少、Bug 更少。我们要让 SQLite 自己保证这张表永远不可能出现两行。SQLite 无法保证表永不为空所以现实只有两种状态空表或恰好一行。这两个状态会引出一个应用层的麻烦问题默认情况下行已存在时INSERT会失败表为空时UPDATE会失败。为避免这些错误我们让 SQLite 在插入冲突时直接替换已有行。在 GRDB.swift 中建表工作放在迁移migration里执行因为应用会持续演进迟早需要新增配置列。完整的建表代码如下migrator.registerMigration(appConfiguration) { db in // CREATE TABLE appConfiguration ( // id INTEGER PRIMARY KEY ON CONFLICT REPLACE CHECK (id 1), // storedFlag BOOLEAN, // ...) try db.create(table: appConfiguration) { t in // Single row guarantee: have inserts replace the existing row, // and make sure the id column is always 1. t.primaryKey(id, .integer, onConflict: .replace) .check { $0 1 } // The configuration columns t.column(storedFlag, .boolean) // ... other columns } }这段代码背后由两条约束共同完成「单行保证」id INTEGER PRIMARY KEY ON CONFLICT REPLACE主键唯一性保证最多一行任何第二行都会撞上主键ON CONFLICT REPLACE让重复插入时直接替换旧行而不是报错。REPLACE本质上是DELETE INSERT被替换行的其他列会被新值覆盖。CHECK (id 1)所有行的 id 恒为 1避免出现一行 id1、另一行 id2 这种无法判定的状态。从源码看TableDefinition.primaryKey(_:_:onConflict:)定义于 TableDefinition.swift会把.integer主键的冲突策略、ColumnDefinition.primaryKey(onConflict:autoincrement:)定义于 ColumnDefinition.swift最终交给 SQL 生成器渲染。真正生成 SQL 的是 SQLColumnGenerator.swift 与 SQLTableGenerator.swift前者把PRIMARY KEY ON CONFLICT REPLACE拼进列定义后者把CHECK (...)拼进表定义。也就是说上面 Swift 代码最终生成的正是文档注释里展示的那条CREATE TABLE。关于迁移为什么必须在 migration 里建表因为后续添加新配置列也需要走迁移流程例如ALTER TABLE appConfiguration ADD COLUMN newFlag BOOLEAN。关于迁移的完整机制DatabaseMigrator、迁移注册与执行顺序请参考 Migrations 文档。为什么storedFlag是可空的上面的storedFlag BOOLEAN允许 NULL。通常可空布尔是坏味道但在这里是刻意为之有两个好处NULL 表示「用户尚未做出选择」运行时可以给默认值比如当作true处理实现类似UserDefaults.register(defaults:)的注册默认值语义为 schema 演进留余地应用迭代中新增配置列时往往无法在ALTER TABLE那一刻给所有存量用户一个合理的默认值但运行时处理 NULL 通常很容易。当然如果应用必须有一个值就不要削弱业务逻辑直接在数据库层拒绝 NULL// DO NOT hesitate requiring NOT NULL columns when the app requires it. migrator.registerMigration(appConfiguration) { db in try db.create(table: appConfiguration) { t in t.primaryKey(id, .integer, onConflict: .replace).check { $0 1 } t.column(flag, .boolean).notNull() // required } }notNull()对应源码 ColumnDefinition.swift 中的notNull(onConflict:)默认冲突策略为.abort生成NOT NULL约束。记录类型让默认值语义进入 Swift API表结构定义好之后定义访问单行的记录类型。为了支持默认值属性使用私有存储 公有计算属性的模式struct AppConfiguration: Codable { // Support for the single row guarantee private var id 1 // The stored properties private var storedFlag: Bool? // ... other properties }id写死为 1正好与数据库里的CHECK (id 1)对应保证任何持久化操作都在同一行上。storedFlag是私有的对外暴露带默认值的flag属性// Support for default values extension AppConfiguration { var flag: Bool { get { storedFlag ?? true /* the default value */ } set { storedFlag newValue } } mutating func resetFlag() { storedFlag nil } }flag的 getterstoredFlag nil时返回默认值trueflag的 setter写回底层存储resetFlag()把值清回 nil恢复默认值语义。如果列是非空NOT NULL的就不需要这套仪式直接暴露普通属性即可// The simplified setup for non-nullable columns struct AppConfiguration: Codable { // Support for the single row guarantee private var id 1 // The stored properties var flag: Bool // ... other properties }还需要一个「默认配置」常量供表为空时兜底extension AppConfiguration { /// The default configuration static let default AppConfiguration(flag: nil) }注意此时构造器参数是storedFlag: nil内部私有属性名这与「表为空」状态一一对应数据库中没有任何行等价于所有配置都处于「未选择」的默认状态。数据库访问读取与写入的完整设计让记录类型具备数据库访问能力extension AppConfiguration: FetchableRecord, PersistableRecord {处理「空表时更新失败」问题前面提到默认情况下表为空时UPDATE会抛错主键 id1 在表中不存在。为避免这个错误我们在更新前先插入默认配置。这通过 GRDB 的willUpdate(_:columns:)持久化回调实现协议定义见 MutablePersistableRecord.swift// Customize the default PersistableRecord behavior func willUpdate(_ db: Database, columns: SetString) throws { // Insert the default configuration if it does not exist yet. if try !exists(db) { try AppConfiguration.default.insert(db) } }其执行流程为exists(db)检查主键对应的行是否存在源码见 MutablePersistableRecord.swift它基于 DAO 生成SELECT ... WHERE id ?的存在性语句若不存在先插入AppConfiguration.default再继续执行 UPDATE。从源码调用链看MutablePersistableRecordUpdate.swiftupdateWithCallbacks会在执行UPDATE语句之前调用willUpdate(db, columns:)随后依次调用aroundUpdate和didUpdate。因此只要表为空willUpdate就会被先触发插入默认行后再更新update、updateChanges、save等方法都不会在空表上抛错。便捷读取find 方法GRDB 的标准方法fetchOne(_:)实现于 FetchableRecordTableRecord.swift执行SELECT * FROM appConfiguration LIMIT 1返回可选值表为空时为 nil。定义一个小工具方法把「空表」替换成「默认配置」让读取结果永远非空/// Returns the persisted configuration, or the default one if the /// database table is empty. static func find(_ db: Database) throws - AppConfiguration { try fetchOne(db) ?? .default } }日常读写用法至此单行表模式就完成了。应用中的读写非常自然// READ let config try dbQueue.read { db in try AppConfiguration.find(db) } if config.flag { // ... } // WRITE try dbQueue.write { db in // Update the config in the database var config try AppConfiguration.find(db) try config.updateChanges(db) { $0.flag true } // Other possible ways to save the config: var config try AppConfiguration.find(db) config.flag true try config.save(db) // all the same try config.update(db) // all the same try config.insert(db) // all the same try config.upsert(db) // all the same }这里的几种持久化方法的行为差异如下方法行为适用场景updateChanges(db) { $0.flag ... }先比对前后值只在确有变化时执行UPDATE实现见 MutablePersistableRecordUpdate.swift推荐首选避免无意义写入save(db)主键非空且行存在则UPDATE否则INSERT实现见 MutablePersistableRecordSave.swift通用保存update(db)直接UPDATE行不存在时抛RecordError.recordNotFound但因willUpdate已先插入默认行空表也能成功明确要更新insert(db)直接INSERT因主键带ON CONFLICT REPLACE重复插入会替换旧行而非报错明确要写入upsert(db)生成INSERT ... ON CONFLICT ... DO UPDATE语句需 SQLite 3.35/iOS 15见测试中的版本守卫需要 UPSERT 语义时无论哪种方式都离不开willUpdate在空表时先插入默认行所以它们全部能在「空表」「已有行」两种状态下稳定工作。id列上的ON CONFLICT REPLACE更是让insert在行已存在时直接覆盖不产生任何错误分支。一键复制的完整模板下面把以上所有片段整合成可直接使用的模板文件// Table creation try db.create(table: appConfiguration) { t in // Single row guarantee: have inserts replace the existing row, // and make sure the id column is always 1. t.primaryKey(id, .integer, onConflict: .replace) .check { $0 1 } // The configuration columns t.column(storedFlag, .boolean) // ... other columns }// // AppConfiguration.swift // import GRDB struct AppConfiguration: Codable { // Support for the single row guarantee private var id 1 // The stored properties private var storedFlag: Bool? // ... other properties } // Support for default values extension AppConfiguration { var flag: Bool { get { storedFlag ?? true /* the default value */ } set { storedFlag newValue } } mutating func resetFlag() { storedFlag nil } } extension AppConfiguration { /// The default configuration static let default AppConfiguration(storedFlag: nil) } // Database Access extension AppConfiguration: FetchableRecord, PersistableRecord { // Customize the default PersistableRecord behavior func willUpdate(_ db: Database, columns: SetString) throws { // Insert the default configuration if it does not exist yet. if try !exists(db) { try AppConfiguration.default.insert(db) } } /// Returns the persisted configuration, or the default one if the /// database table is empty. static func find(_ db: Database) throws - AppConfiguration { try fetchOne(db) ?? .default } }使用本模板时按需调整非空列去掉私有存储 计算属性的封装、直接声明var flag: Bool新增配置项只需「加一列 加一个属性」。源码与测试验证模式的正确性由仓库测试背书该单行表模式并非文档空谈仓库提供了完整的回归测试Tests/GRDBTests/Record/SingletonRecordTest.swift。它覆盖了单行表在各种操作下的全部关键路径测试用例场景验证要点test_fetch_from_empty_database空表读取find返回默认配置test_fetch_from_populated_database有数据读取find返回持久化值test_insert_in_empty_database空表插入成功行 [id: 1, text: ...]test_insert_in_populated_database已有行再插入旧行被替换id 恒为 1test_update_in_empty_database空表更新willUpdate先补插默认行更新成功test_update_in_populated_database已有行更新更新成功test_update_changes_in_empty_database/test_update_changes_in_populated_databaseupdateChanges两种状态均成功且 id 保持 1test_save_in_empty_database/test_save_in_populatedsave两种状态均成功test_upsert_in_empty_database/test_upsert_in_populated_databaseupsert两种状态均成功需 SQLite 3.35测试中有明确的版本与系统可用性守卫测试中所有「Then」断言都通过SELECT * FROM appConfiguration校验最终只剩一行且id 1从实测层面印证了「SQLite 层单行保证 应用层默认值语义」这套设计的完备性。小结单行表是 GRDB.swift 中处理全局配置的高价值模式其要点可归纳为四句话建表id INTEGER PRIMARY KEY ON CONFLICT REPLACE CHECK (id 1)把「最多一行」交给 SQLite 强制保证可空列用 NULL 表达「用户尚未选择」在 Swift 层用计算属性提供默认值实现类似UserDefaults.register(defaults:)的语义持久化通过willUpdate在空表时先插入默认行彻底消除「空表更新报错」读取find方法把fetchOne的 nil 结果归一化为默认配置让业务代码永远拿到非空对象。这套模式在测试中得到了系统验证可以直接迁移到你的项目中替代或补充UserDefaults让全局配置与业务数据共享同一个数据库的完整性与外键约束。【免费下载链接】GRDB.swiftA toolkit for SQLite databases, with a focus on application development项目地址: https://gitcode.com/GitHub_Trending/gr/GRDB.swift创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考