【听见课堂 HarmonyOS NEXT 实战系列 14】HarmonyOS 数据库升级实战:从 Schema v1 迁移到 v2
【听见课堂 HarmonyOS NEXT 实战系列 14】HarmonyOS 数据库升级实战:从 Schema v1 迁移到 v2
第一次安装时建表很容易,真正困难的是已经有用户数据后再改表。直接把 CREATE TABLE 增加两个字段,只对新数据库有效;旧设备上的表结构不会自动变化。如果应用读取不存在的列,轻则页面报错,重则用户无法进入应用。
听见课堂当前把 RelationalStore schema 提升到 v2:课程新增 color_token,任务新增 due_at_ms,并将旧任务的自然语言截止时间回填为时间戳。本文按照 migrateAndSeed() 和 migrateV1ToV2() 的真实实现,拆解版本检测、事务、回填和测试矩阵。

一、为什么需要 v2
schema v1 已经能保存课程、字幕、板书和任务,但后续出现两个新需求。
1. 课程需要稳定的颜色语义
如果页面只根据列表位置分配颜色,课程排序变化后同一课程会变色。v2 在 courses 增加:
color_token TEXT NOT NULL DEFAULT 'ocean_blue'
存的是主题 token,而不是固定色值,方便暗色和高对比模式在 UI 层映射。
2. 任务中心需要真实时间排序
v1 只有 due_text,例如“今晚”“明天 20:00 前”“本周五前”。每次打开页面重新解析会让“明天”不断向后移动,也无法稳定判断是否过期。v2 增加:
due_at_ms INTEGER NOT NULL DEFAULT 0
迁移时以同一个基准时刻解析旧文本,之后排序、日期分组和过期判断都读取固定时间戳。
二、版本号必须是代码常量和数据库记录的组合
当前代码声明:
const SCHEMA_VERSION: number = 2;
const META_SCHEMA_VERSION: string = 'schema_version';
代码常量表示“当前应用能够理解的最高版本”,schema_meta 中的值表示“这个数据库已经迁移到哪个版本”。启动时比较二者,才能决定首次建库、升级、正常打开还是拒绝降级读取。
只修改常量不写迁移,旧表不会变化;只改表不更新元数据,迁移可能每次启动重复执行。
三、migrateAndSeed() 的完整执行顺序
当前初始化流程可以归纳为:
beginTransaction
-> 创建 schema_meta
-> 读取旧版本
-> 版本过新则拒绝
-> CREATE TABLE IF NOT EXISTS 当前结构
-> version == 1 时执行 v1 -> v2
-> 首次需要时写入种子
-> 写入 schema_version = 2
commit
任何异常 -> rollBack -> 初始化失败 -> 上层切换内存降级
对应的核心代码:
private async migrateAndSeed(): Promise<void> {
const store: relationalStore.RdbStore = this.getStore();
store.beginTransaction();
try {
await store.executeSql(CREATE_SCHEMA_META_SQL);
const version: number = await this.readSchemaVersion();
if (version > SCHEMA_VERSION) {
throw new Error('Database schema is newer than this application.');
}
await store.executeSql(CREATE_COURSES_SQL);
await store.executeSql(CREATE_TRANSCRIPT_SQL);
await store.executeSql(CREATE_SCANS_SQL);
await store.executeSql(CREATE_TASKS_SQL);
if (version === 1) {
await this.migrateV1ToV2();
}
// seed 与 meta 写入
store.commit();
} catch (error) {
store.rollBack();
throw new Error('Failed to migrate classroom relational store.');
}
}
版本号只在所有步骤成功后更新为 2,避免迁移做到一半却被标记为完成。

四、为什么先执行 CREATE TABLE IF NOT EXISTS
对全新数据库,schema_meta 中没有版本,读取结果为 0。当前 CREATE_* SQL 已经包含 v2 字段,因此直接创建最新结构,不需要从 v1 绕一圈。
对 v1 数据库,表已经存在,CREATE TABLE IF NOT EXISTS 不会改变旧表,然后由 migrateV1ToV2() 增加字段。
这形成两条路径:
| 数据库状态 | version | 动作 |
|---|---|---|
| 全新安装 | 0 | 直接创建 v2 表并写种子 |
| 已有 v1 | 1 | 保留数据并执行 ALTER/回填 |
| 已有 v2 | 2 | 跳过迁移,正常读取 |
| 来自未来版本 | >2 | 拒绝打开,避免旧应用破坏新结构 |
五、v1 到 v2 的两个 ALTER TABLE
迁移方法先增加字段:
await store.executeSql(
`ALTER TABLE courses ADD COLUMN color_token TEXT NOT NULL DEFAULT 'ocean_blue'`
);
await store.executeSql(
`ALTER TABLE tasks ADD COLUMN due_at_ms INTEGER NOT NULL DEFAULT 0`
);
两个字段都提供 NOT NULL DEFAULT,这样已有行会获得可读值。没有默认值时,为含数据的旧表新增非空列往往会失败。
color_token 使用统一默认值可以保证页面立即可渲染;后续若要按旧课程特征分配不同 token,应另写确定性回填规则。
六、为什么旧任务必须回填 due_at_ms
仅增加默认值 0 虽然能完成建表,但所有旧任务都会变成“未指定日期”,任务中心无法判断过期和本周分组。因此迁移读取旧 ID 与 due_text:
const resultSet = await store.querySql(
`SELECT id, due_text FROM tasks ORDER BY sort_order`
);
const taskIds: Array<string> = [];
const dueTexts: Array<string> = [];
try {
while (resultSet.goToNextRow()) {
taskIds.push(this.getText(resultSet, 'id'));
dueTexts.push(this.getText(resultSet, 'due_text'));
}
} finally {
resultSet.close();
}
先把结果复制到普通数组并关闭 ResultSet,再逐条更新,资源边界更清晰。
七、所有旧任务必须共享同一个迁移时刻
迁移代码只调用一次 Date.now():
const migrationNowMillis: number = Date.now();
for (let index: number = 0; index < taskIds.length; index++) {
await this.updateById(TABLE_TASKS, taskIds[index], {
due_at_ms: TaskDateResolver.resolveDueText(
dueTexts[index],
migrationNowMillis
)
});
}
如果每条任务各取一次当前时间,迁移跨过午夜时,“今天”和“明天”可能落到不同基准日。统一基准时刻保证同批回填一致。
八、自然语言回填不是无损转换
TaskDateResolver 当前支持“今晚/今天”“明天”“周一至周日”“已过期/昨天/上周”等有限表达,不支持任意中文日期。
因此:
- 可识别文本得到本地时间戳;
- 不可识别文本返回 0;
- “本周五”在不同迁移日期可能落在过去或未来;
- 没有年份、时区和原始创建时间时,语义无法完全还原。
这不是迁移代码可以凭空解决的问题。更完善的 v1 设计应同时保存任务创建时间或原始解析基准;当前文章必须把 due_at_ms=0 视为有效降级,而不是伪造日期。
九、为什么要拒绝“数据库版本过新”
用户可能先安装新版产生 schema v3,之后回退到只支持 v2 的旧应用。旧代码不了解新字段、新约束和新状态,继续写入可能破坏数据。
当前保护是:
if (version > SCHEMA_VERSION) {
throw new Error('Database schema is newer than this application.');
}
异常向上传递后,应用切到可见的内存降级。更理想的页面提示是“当前版本无法读取已有数据,请升级应用”,而不是自动删库。
十、事务能保护什么,不能保护什么
事务目标是让建表、ALTER、回填、种子和版本更新作为一个整体提交。失败时执行 rollBack(),上层不应继续把数据库当作 v2 使用。
但工程上仍需在目标 HarmonyOS 版本验证 DDL 在事务中的真实回滚行为,不能只根据代码结构假设所有设备完全一致。尤其要测试:
- 第一个 ALTER 成功、第二个失败;
- ALTER 成功、回填中断;
- 回填成功、写 meta 前进程结束;
- 再次启动是否能安全恢复。
如果目标环境对部分 DDL 回滚有限制,就需要增加“列是否存在”探测或分阶段迁移状态,避免重复 ADD COLUMN。
十一、四类数据库状态必须分开测试
1. 首次安装
没有数据库文件和 meta。应直接得到 v2 表、默认课程颜色、任务时间戳和一次性种子。
2. 真实 v1 升级
先用 v1 schema 创建数据库并写入自定义课程、字幕、板书和任务,再安装 v2。验证原记录数量、正文和确认状态不变,新字段完成回填。
3. 空库
表存在但没有业务数据。迁移不能因为查询结果为空而失败,也不能在用户明确清空后每次启动重新注入种子。
4. 脏数据
至少覆盖空截止文本、无法解析日期、重复 ID、缺少 meta、版本过新和部分字段异常。结果可以降级或阻断,但不能静默删除用户内容。
十二、迁移后的验证不能只查版本号
schema_version=2 只是一个信号。迁移完成后还应检查:
courses.color_token和tasks.due_at_ms列存在;- 旧课程、字幕、扫描和任务行数保持;
- 已确认/已完成状态未改变;
- 可识别的截止文本得到合理时间戳;
- 不可识别文本保持
due_at_ms=0并显示“未指定”; - P09 的排序、过期和日历分组符合预期;
- 删除课程和清空全部数据事务仍有效;
- 强停重启后仍读取 v2,不重复迁移。
可以把验证拆成结构、数据、业务三层,而不是只执行一条 SELECT schema_version。
十三、上下文文档与源码版本漂移怎么处理
项目早期架构文档可能仍写“当前 schema version 为 1”,而当前源码常量已经是 2。写技术文章或交付报告时,应明确采用当前源码和本轮验证结果,历史文档只作为当时基线。
正确做法是同步更新上下文或记录漂移,不应为了让材料一致而把源码事实写回 v1。可审计项目允许历史存在,但必须标注时间和适用版本。
十四、后续 v3 应怎样扩展
当 v3 到来时,不建议继续堆一个大函数。可以按版本逐级迁移:
let version: number = await readSchemaVersion();
if (version < 2) {
await migrateV1ToV2();
version = 2;
}
if (version < 3) {
await migrateV2ToV3();
version = 3;
}
每一步只理解相邻版本,配套独立夹具和后置条件。版本更新仍要在该步成功后发生。若数据量增大,还要考虑批量更新、进度提示、超时和可恢复中断。
十五、迁移验收清单
- 当前 schema 常量与目标结构一致;
- 全新安装直接创建最新结构;
- v1 数据库通过 ALTER 和回填升级;
- 新增非空列有合理默认值;
- 所有旧任务共享同一迁移基准时刻;
- ResultSet 在
finally中关闭; - 版本过新时拒绝写入而不是删库;
- 迁移失败切换为可见降级;
- 首装、升级、空库、脏数据分别测试;
- 验证字段、数据和页面业务,而不只检查版本号;
- 没有把“代码存在迁移”写成“目标设备迁移已验证”。
十六、总结
数据库升级的本质是保护已有用户事实。听见课堂的 v1→v2 迁移在一个初始化事务中完成版本检测、字段新增、截止时间回填、种子控制和版本写入,并对未来版本设置拒绝保护。
这套实现已经具备清晰主链,但仍需要用真实 v1 数据库夹具验证 DDL 中断、脏数据和设备差异。只有迁移脚本存在、目标环境执行通过、迁移后业务页面正确三者同时成立,才能把数据库升级标记为 passed。
更多推荐



所有评论(0)