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 复用:

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 里必填的字段有两个:namesecurityLevelname 好懂,就是数据库文件名。securityLevel 我一开始随手抄了 S3,因为看到官方示例里写 S3。后来才反应过来这不是"越高越好"的字段。

securityLevelS1S4,本质是告诉系统这份数据的敏感度,用来决定加密和跨设备同步的策略。你选得越高,未来想接分布式数据同步(比如手机 + 平板 + 手表)时限制越多,数据可能被拒绝跨端流转。

我的 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/dbentry/src/main/ets/data/repos),如果有类似场景直接抄或改一改都 OK。

有一个 API 我一直没用上但想推荐给你看看:RdbStore.beginTransaction / commit / rollBack。目前没有需要跨表原子操作的场景,所以省了。你要做类似"扣钱 + 加账单"这种,一定要开事务。

相关推荐
绝世番茄19 小时前
鸿蒙原生 ArkTS 布局方式之 Button+Shake 抖动按钮实战全解
华为·harmonyos·鸿蒙
●VON19 小时前
鸿蒙 PC Markdown 编辑器换行兼容:LF、CRLF 与混合换行归一化
华为·架构·编辑器·harmonyos·鸿蒙
qizayaoshuap19 小时前
# [特殊字符] 骰子模拟器 — 鸿蒙ArkTS随机算法与动画系统设计
算法·华为·harmonyos
ldsweet19 小时前
《HarmonyOS技术精讲-Basic Services Kit》电源管理进阶:亮度调节与休眠控制
华为·harmonyos
千逐6819 小时前
鸿蒙新特性 | 页面路由——router 怎么跳怎么传参
华为·harmonyos·鸿蒙
●VON20 小时前
鸿蒙 PC Markdown 编辑器第二阶段工程复盘:从可用原型到 Alpha 基线
华为·编辑器·harmonyos·鸿蒙
2301_7681034920 小时前
HarmonyOS趣味相机实战第19篇:CameraKit输出Profile协商、宽高比评分与会话提交
harmonyos·arkts·camerakit·photosession·设备适配
熊猫钓鱼>_>20 小时前
ArkTS 方舟编程语言 · 原创快速入门教程
运维·架构·ts·harmonyos·arkts·鸿蒙·js
小时代的大玩家21 小时前
HarmonyOS新特性-沉浸光感在叠叠消小游戏中的落地实践
前端·harmonyos