各好,又回~
上周三我在一个已经上架的鸿蒙 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 复用:
typescript
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 就够了:
typescript
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。这样对老用户(表已存在)来说是空操作,不会报错。
typescript
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 掉即可:
typescript
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:
typescript
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 转成业务模型。这块我封了两个私有方法处理可选字段:
typescript
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:
typescript
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。目前没有需要跨表原子操作的场景,所以省了。你要做类似"扣钱 + 加账单"这种,一定要开事务。
