讲的是 HarmonyOS 里 relationalStore 真正上线的坑,单例、安全等级、加字段怎么不崩,踩过的人才写得出来。
文章分享了在 HarmonyOS 应用中使用 relationalStore 的5个工程决策,包括将 RdbStore 设计为应用级单例并复用初始化 promise 避免并发问题,合理选择 securityLevel(S1 适合普通业务数据),以及使用 CREATE TABLE IF NOT EXISTS 和 ALTER TABLE 处理 Schema 迁移。还提到 securityLevel 从 S1 到 S4 影响加密与跨设备同步,选低等级性能更好。最后指出设计阶段需预判未来加字段以避免老用户崩溃。
HarmonyOS 用 relationalStore 做本地数据库的完整实战 —— 一个真实项目的 5 个决策
各好,又回~ 上周三我在一个已经上架的鸿蒙 App 里改本地数据库结构,加两个字段。改完跑了一遍,App 起不来。日志说 no such column 。 其实是我忘了处理老用户的库。新库有字段,旧库没有,一 ALTER TABLE 已存在的列,SQLite 直接甩错。这类事情在原生 SQLite 上人人都懂怎么处理,但在 HarmonyOS 的 relationalStore 里,官方文档给的示例大多只到"建库 + 建表 + 查询",schema 升级、异常兜底、单例管理这些工程化的东西是要自己搭的。 这篇是把我在一个中等规模鸿蒙 App 里,围绕 relationalStore 做过的 5 个真实决策抠出来讲,包括我一开始判断错、后来推翻自己的过程。适合已经跑通 helloworld、准备把 rdb 用到线上 App 的开发者。 一、 RdbStore 该做成什么级别的单例 我一开始按官方文档写,每次要用数据库就 relationalStore.getRdbStore(...) 拿一次。跑本地没问题,上真机后有个偶发现象:第一次进 App 时如果快速切页面,会出现两个页面各自持有 rdbStore 的场景,一个页面 insert,另一个刚初始化到一半,UI 有几十毫秒空数据。 调了一下才发现, getRdbStore 是异步的,短时间连续调用会返回不同的 promise,而这些 promise 各自去初始化。看起来官方内部有做缓存,但缓存粒度和时机不完全可控。 我把它改成了应用级单例 + 初始化 promise 复用: import type Context from '@ohos.app.ability.common' ; import relationalStore from '@ohos.data.relationalStore' ; import { DB_NAME , PROGRAM_PROGRESS_COLUMNS , SESSION_COLUMNS , TABLES } from './RdbSchema' ; export class RdbStoreManager { private static store?: relationalStore. RdbStore ; private static initializing?: Promise <relationalStore. RdbStore >; static async getStore ( context : Context ): Promise <relationalStore. RdbStore > { if ( RdbStoreManager . store ) { return RdbStoreManager . store ; } if (! RdbStoreManager . initializing ) { const config : relationalStore. StoreConfig = { name : DB_NAME , securityLevel : relationalStore. SecurityLevel . S1 }; RdbStoreManager . initializing = ( async () => { try { const store = await relationalStore. getRdbStore (context, config); await RdbStoreManager . ensureTables (store); RdbStoreManager . store = store; return store; } catch (err) { RdbStoreManager . initializing = undefined ; throw new Error ( 'Failed to initialize database store.' ); } })(); } return RdbStoreManager . initializing ; } // ... } 关键就三件事: 第一, store 变量保存已经初始化好的实例,命中直接返回。第二, initializing 保存"正在初始化"的 Promise,如果 3 个页面同时调进来,只有第一个真的走 getRdbStore ,后两个 await 同一个 Promise。第三,一旦初始化失败,把 initializing 清掉,让下次调用可以重试,不至于永远卡在 rejected promise 上。 这套写法本身不复杂,但踩过一次才会想到把 initializing 加进来。 只有 store 单例、没有 initializing 单例的写法,在并发场景下等同于没做单例。 二、 securityLevel 该怎么选 StoreConfig 里必填的字段有两个: name 和 securityLevel 。 name 好懂,就是数据库文件名。 securityLevel 我一开始随手抄了 S3 ,因为看到官方示例里写 S3 。后来才反应过来这不是"越高越好"的字段。 securityLevel 从 S1 到 S4 ,本质是告诉系统这份数据的敏感度,用来决定加密和跨设备同步的策略。 你选得越高,未来想接分布式数据同步(比如手机 + 平板 + 手表)时限制越多,数据可能被拒绝跨端流转。 我的 App 存的是呼吸训练历史记录:什么时候练了什么模式、练了多久。这算个人使用数据,但不涉及金融、健康诊断这种硬敏感项。选 S1 就够了: const config : relationalStore. StoreConfig = { name : DB_NAME , securityLevel : relationalStore. SecurityLevel . S1 }; 选 S1 之后有个观察:应用启动 + 频繁读写的场景明显顺畅了一些,加密开销确实存在。对于一个每秒最多也就 insert 一两条记录的 App,这个差距肉眼不明显。但如果你的场景是要批量导入几万条数据(比如从 CSV 恢复),这个差距就要考虑了。 选择建议大致是:普通业务数据 S1 ;含个人身份识别(手机号、身份证号) S2 ;含健康、金融、行为轨迹 S3 ;含高敏感(生物识别原始数据) S4 。 在纠结的时候往下一级选,比往上多选一级要稳。 三、Schema 迁移:新版加字段怎么不让老用户崩 前面提到的"改完跑不起来"的坑就是这里。做法上其实很简单,但需要你 在设计阶段就想清楚:"我以后会不会加字段" 。答案基本是"会",所以老老实实为 schema 演进准备两件事。 第一件:建表用 CREATE TABLE IF NOT EXISTS ,别用 CREATE TABLE 。这样对老用户(表已存在)来说是空操作,不会报错。 const createSessions = `CREATE TABLE IF NOT EXISTS ${TABLES.sessions} (` + ` ${SESSION_COLUMNS.id} TEXT PRIMARY KEY, ` + ` ${SESSION_COLUMNS.timestamp} INTEGER NOT NULL, ` + ` ${SESSION_COLUMNS.modeId} TEXT NOT NULL, ` + ` ${SESSION_COLUMNS.duration} INTEGER NOT NULL` + `);` ; try { await store. executeSql (createSessions); } catch (_err) { // Ignore table creation failures. } 第二件:加字段时用 ALTER TABLE ... ADD COLUMN ,然后把错误吞掉。SQLite 里 ADD COLUMN 已存在的列会抛 "duplicate column" 错误,你 catch 掉即可: private static async ensureCompatibilityColumns ( store : relationalStore. RdbStore ): Promise < void > { const statements : string [] = [ `ALTER TABLE ${TABLES.sessions} ADD COLUMN ${SESSION_COLUMNS.courseId} TEXT` , `ALTER TABLE ${TABLES.sessions} ADD COLUMN ${SESSION_COLUMNS.programId} TEXT` , `ALTER TABLE ${TABLES.sessions} ADD COLUMN ${SESSION_COLUMNS.programDay} INTEGER` , `ALTER TABLE ${TABLES.sessions} ADD COLUMN ${SESSION_COLUMNS.preCheckin} TEXT` , `ALTER TABLE ${TABLES.sessions} ADD COLUMN ${SESSION_COLUMNS.postCheckin} TEXT` , ]; for ( const stmt of statements) { try { await store. executeSql (stmt); } catch (_err) { // Ignore "duplicate column" and keep startup resilient on old/new schemas. } } } 这个模式我叫它"幂等式迁移":每次 App 冷启动都跑一遍 ALTER,跑不通的就当没跑。对老用户来说,第一次冷启动会加上新列;对新用户来说, CREATE TABLE IF NOT EXISTS 已经把最新 schema 建好了, ALTER 全部会因为"列已存在"被 catch 掉,也没事。 这里有个更"优雅"的做法是用 store.version 做版本化迁移 ,官方 API 里有 RdbStore.version 字段可以读写。理论上更规范,但要写个 switch 处理每个版本号的迁移逻辑。我在这个项目里没用,理由很朴素:这个 App 的 schema 变更频率不高、每次改动都是加字段而不是改结构,加个 version 反而让代码里多出一整套没用几次的迁移分支,投入产出不划算。 如果你的 App schema 会长期演进、有可能要改列类型或删列,version 迁移是更好的选择。 四、 RdbPredicates 的用法比想象中好用 早期我写查询是这样的:拼一个 SQL 字符串, executeSql 执行。能跑,但类型不安全,改列名时容易漏。 后来看到 RdbPredicates 后基本全换了。核心思路是把 where、orderBy、limit 这些都用链式 API 表达,然后传给 store.query : async listRecent ( context : Context , limit : number = 50 ): Promise < SessionRecord []> { const store = await RdbStoreManager . getStore (context); const predicates = new relationalStore. RdbPredicates ( TABLES . sessions ) . orderByDesc ( SESSION_COLUMNS . timestamp ); if (limit > 0 ) { predicates. limitAs (limit); } const resultSet = await store. query (predicates, Object . values ( SESSION_COLUMNS )); // ... 遍历 resultSet } 好处是列名和表名都从常量文件( RdbSchema )里来的,改一次到处生效。缺点是 resultSet 的读取仍然是 SQLite 那套 getString / getLong / getColumnIndex ,得自己写 helper 转成业务模型。这块我封了两个私有方法处理可选字段: private optionalString ( resultSet : relationalStore. ResultSet , column : string ): string | undefined { try { const index = resultSet. getColumnIndex (column); if (resultSet. isColumnNull (index)) { return undefined ; } const value = resultSet. getString (index); return value. length ? value : undefined ; } catch (_err) { return undefined ; } } private optionalNumber ( resultSet : relationalStore. ResultSet , column : string ): number | undefined { try { const index = resultSet. getColumnIndex (column); if (resultSet. isColumnNull (index)) { return undefined ; } return Number (resultSet. getLong (index)); } catch (_err) { return undefined ; } } 为什么把空字符串也当 undefined 处理 :因为业务模型里的 preCheckin?: string ,值有可能是"用户跳过了这个可选步骤"(应该是 undefined),也可能是"用户填了个空字符串"(应该是 '')。数据库里没法区分这两种,我按第一种处理,反正 UI 层看到 undefined 就当没填。 五、Upsert:新增和更新用同一个方法 这个是最容易被忽略的一点。业务上很多"保存"操作其实是"如果已存在就更新,否则插入"。SQLite 有 INSERT OR REPLACE ,但会重置 rowid、破坏关联; INSERT ... ON CONFLICT 用 relationalStore 的 API 不好表达。 我最后选的写法是先查一遍,根据结果决定 insert 还是 update: async upsertProgress ( context : Context , record : ProgramProgressRecord ): Promise < void > { const store = await RdbStoreManager . getStore (context); const existing = await this . getProgress (context, record. programId ); const now = Date . now (); const values : relationalStore. ValuesBucket = {}; values[ PROGRAM_PROGRESS_COLUMNS . programId ] = record. programId ; values[ PROGRAM_PROGRESS_COLUMNS . startedAt ] = Math . floor (record. startedAt ?? existing?. startedAt ?? now); values[ PROGRAM_PROGRESS_COLUMNS . lastCompleted ] = Math . floor (record. lastCompleted ?? now); values[ PROGRAM_PROGRESS_COLUMNS . completedDays ] = this . serializeDays (record. completedDays ); if (existing) { const predicates = new relationalStore. RdbPredicates ( TABLES . programProgress ) . equalTo ( PROGRAM_PROGRESS_COLUMNS . id , existing. id ); await store. update (values, predicates); } else { values[ PROGRAM_PROGRESS_COLUMNS . id ] = ` ${ Date .now()} - ${ Math .floor( Math .random() * 100000 )} ` ; await store. insert ( TABLES . programProgress , values); } } 有两个细节容易漏: 一个是 startedAt 的取值: record.startedAt ?? existing?.startedAt ?? now 。第一次插入时用参数或 now;更新时如果参数没给,保留原 startedAt (不能被 now 覆盖,否则用户第 3 天完成时会以为程序是今天才开始的)。 一个是 id 生成。上面这段用 Date.now() + Math.random() ,够用,但 如果你的表有可能被多设备同步 ,要用 UUID 之类的全局唯一 id,不能用时间戳。项目里另有一处用了 crypto.randomUUID() 处理需要跨端稳定标识的场景。 尾声 关于 relationalStore 我最初的印象是"能用但不好用",官方 API 看起来像给系统开发者用的、不像给业务开发者用的。真正投入项目后发现,工程化的坑一个都跑不掉:单例、schema 迁移、异常兜底、类型安全、可选字段。 上面 5 个决策全部来自 App 的实际代码( entry/src/main/ets/data/db 和 entry/src/main/ets/data/repos ),如果有类似场景直接抄或改一改都 OK。 有一个 API 我一直没用上但想推荐给你看看: RdbStore.beginTransaction / commit / rollBack 。目前没有需要跨表原子操作的场景,所以省了。你要做类似"扣钱 + 加账单"这种,一定要开事务。