巡检草稿已经能落盘,下一次升级却未必能顺利打开。设想旧版本保存了一百条 inspection_draft,新版本要为每条记录新增“处理优先级”。如果直接把建表语句改成五列,安装升级包的老用户仍保留四列表结构;程序再去读取 priority,查询就会失败。更危险的是,迁移 SQL 执行了一半,代码却先把版本标成新版本,下次启动再也不会补跑应有的结构调整。

上一阶段确定的 inspection_draft(id, title, body, updated_at) 不能推倒重来。第二阶段要让它安全增加 priority 字段,并把升级结果变成可重试的状态:只有结构真正升级成功,数据库版本才向前推进。这种习惯不是把数据库当成配置文件,而是把 Schema 看作长期维护的接口契约。

离线巡检数据库迁移技术封面

先把兼容关系写成可检查的表

同一份应用二进制可能面对三种数据库:全新安装、刚从上一版升级、已经迁移过的数据库。三者都应该进入同一条安全路径,但动作不同。旧版表没有 priority,新版表增加 INTEGER NOT NULL DEFAULT 0,优先级 0 表示“未指定”。原有 id、title、body、updated_at 的含义完全不变,特别是主键不会重新编号。这一点直接决定了旧草稿在列表与详情页的引用是否稳定。

数据库状态实际结构本次启动动作完成后版本
新设备、版本0还没有表建旧版基础表并增列2
旧应用遗留、版本0已有四列建表语句跳过已有表,再增列2
已完成迁移、版本2已有五列不重复执行 ALTER TABLE2
高于当前代码的版本可能有新结构停止写操作并提示升级应用不改动

这里有个历史包袱:第一篇没有写数据库 version,它只创建表并保存数据,所以旧设备打开时版本可能仍为 0。不能贸然把 0 等同于全新库,也不能假设它一定有表。使用 CREATE TABLE IF NOT EXISTS 先建立最小结构,再执行一次确定性的增列,就能同时覆盖上述两种版本0状态。条件是旧版本确实采用第一篇的四列契约;如果用户设备曾安装过其他不兼容测试版本,要单独处理,不应靠自动忽略 SQL 错误掩盖差异。

版本0与版本2的Schema演进和分支判断

给仓储加一道真正的迁移门

前一篇的 DraftRepository 已经把页面与数据库隔开。本次保留 add、all、ready 三个公开使用点,仅把打开数据库后的建表动作换成迁移。文件仍位于 entry/src/main/ets/model/DraftRepository.ets。本例采用 @kit.ArkData 的 relationalStore,运行在 Stage 模型的应用沙箱内;基础关系型存储不需要额外的媒体、定位或网络权限。

下面是仓储文件的完整可替换版本。StoreConfig 的数据文件名、SecurityLevel.S1 沿用第一篇,避免无理由更换数据库位置。升级在 beginTransaction() 之后执行;失败时通过官方接口 rollBack()(注意大写 B)撤销尚未提交的 Schema 改动。RdbStore.version 是数据库版本属性,而不是臆造的 StoreConfig.onUpgrade 回调。迁移操作完成之后才设置它。

import { relationalStore } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';

export interface DraftRecord {
  id: number;
  title: string;
  body: string;
  updatedAt: number;
  priority: number;
}

const CONFIG: relationalStore.StoreConfig = {
  name: 'inspection_draft.db',
  securityLevel: relationalStore.SecurityLevel.S1
};
const TARGET_VERSION: number = 2;
const CREATE_V1: string =
  'CREATE TABLE IF NOT EXISTS inspection_draft (' +
  'id INTEGER PRIMARY KEY AUTOINCREMENT, ' +
  'title TEXT NOT NULL, body TEXT NOT NULL, ' +
  'updated_at INTEGER NOT NULL)';
const ADD_PRIORITY: string =
  'ALTER TABLE inspection_draft ' +
  'ADD COLUMN priority INTEGER NOT NULL DEFAULT 0';

export class DraftRepository {
  private store: relationalStore.RdbStore | null = null;
  private opening: Promise<relationalStore.RdbStore> | null = null;

  private async migrate(db: relationalStore.RdbStore): Promise<void> {
    if (db.version > TARGET_VERSION) {
      throw new Error('数据库版本高于当前应用支持范围');
    }
    if (db.version === TARGET_VERSION) {
      return;
    }
    db.beginTransaction();
    try {
      // 版本0可能是新库,也可能是第一篇已存在的旧四列表。
      if (db.version === 0) {
        await db.executeSql(CREATE_V1);
      }
      if (db.version < 2) {
        await db.executeSql(ADD_PRIORITY);
      }
      db.version = TARGET_VERSION;
      db.commit();
    } catch (error) {
      try {
        db.rollBack();
      } catch (rollbackError) {
        // 回滚也可能失败,此时不要继续开放写入。
      }
      throw error;
    }
  }

  private async ready(context: common.UIAbilityContext):
    Promise<relationalStore.RdbStore> {
    if (this.store !== null) return this.store;
    if (this.opening === null) {
      this.opening = (async (): Promise<relationalStore.RdbStore> => {
        const db = await relationalStore.getRdbStore(context, CONFIG);
        await this.migrate(db);
        this.store = db;
        return db;
      })();
    }
    try {
      return await this.opening;
    } finally {
      this.opening = null;
    }
  }

  async add(context: common.UIAbilityContext,
    title: string, body: string): Promise<number> {
    const cleanTitle: string = title.trim();
    const cleanBody: string = body.trim();
    if (cleanTitle.length === 0 || cleanBody.length === 0) {
      throw new Error('标题与内容不能为空');
    }
    const db = await this.ready(context);
    const values: relationalStore.ValuesBucket = {
      title: cleanTitle,
      body: cleanBody,
      updated_at: Date.now(),
      priority: 0
    };
    return await db.insert('inspection_draft', values);
  }

  async all(context: common.UIAbilityContext): Promise<DraftRecord[]> {
    const db = await this.ready(context);
    const predicate = new relationalStore.RdbPredicates('inspection_draft');
    const cursor = await db.query(predicate,
      ['id', 'title', 'body', 'updated_at', 'priority']);
    const records: DraftRecord[] = [];
    try {
      const idIndex = cursor.getColumnIndex('id');
      const titleIndex = cursor.getColumnIndex('title');
      const bodyIndex = cursor.getColumnIndex('body');
      const timeIndex = cursor.getColumnIndex('updated_at');
      const priorityIndex = cursor.getColumnIndex('priority');
      while (cursor.goToNextRow()) {
        records.push({
          id: cursor.getLong(idIndex),
          title: cursor.getString(titleIndex),
          body: cursor.getString(bodyIndex),
          updatedAt: cursor.getLong(timeIndex),
          priority: cursor.getLong(priorityIndex)
        });
      }
    } finally {
      cursor.close();
    }
    return records.sort((a: DraftRecord, b: DraftRecord) =>
      b.updatedAt - a.updatedAt);
  }
}

新旧页面接口也保持一致:原来的 repository.add(context, title, body) 与 repository.all(context) 不需要改调用方式。新增的 priority 由仓储统一写入默认值,历史记录因 SQL 默认值也能得到 0。如果首页要显示优先级,只需在现有列表中读取 item.priority,不必让页面直接执行 SQL。

有两个细节需要重视。第一,迁移发生在 ready() 的异步初始化期间,opening 保证同一个仓储实例的并发调用等待同一份初始化任务;若项目里实例化了多个仓储,还需要更高层的统一数据库管理器,避免多个实例争相进行 DDL。第二,打开数据库并不等于可写。只有 migrate 正常完成,store 才缓存给后续增删改查。迁移失败的页面应该保持原有输入,并提供重试入口,不要继续向用户显示“保存成功”。

失败回滚,不能只看有没有捕获异常

所谓回滚,不是把 catch 写上去就成立,而是让更改和版本推进成为同一个提交单元。在迁移前把旧表的几条巡检记录留作对照,观察升级后主键和正文内容是否保持不变;再加入一条新的 priority=0 记录,确认查询与插入共用新结构。发生异常时,检查 version 与列结构,而不能只盯着一条“异常已处理”的日志。

若 ALTER TABLE 因列已经存在而报错,不能直接吞掉错误后把版本设为2。它可能意味着该设备曾经历部分迁移、开发测试版本变化,或者数据库被其他路径修改。更稳妥的处理是停用写入,记录错误类别,核实当前列结构再决定修复方案。rollBack() 本身失败也不能被当成升级成功;此时应让仓储保持未就绪状态,并考虑在应用恢复流程中重新检查数据库完整性。

迁移提交与失败回滚的故障分支图

建议设计三组不同的检查样本。样本一是全新安装:在空数据库上完成初始化,检查五个字段及版本2。样本二是保留旧草稿的升级安装:使用第一篇代码预先生成四列表和历史数据,再换成新代码,检查数据条数、原ID及 updated_at 不变化。样本三是故障注入:在受控测试分支让迁移 SQL 报错,确认没有向页面开放半成品仓储;恢复正确 SQL 后可以再次启动迁移。这些是可复现的验证设计,实际结果需要在目标 SDK 的 DevEco Studio 和相应设备环境中采集,不应拿预期结果冒充已取得的测试记录。

另外,事务并不意味着应用能够跨所有意外断电和文件系统故障提供绝对保证。数据库事务解决的是一组 SQL 操作的原子性问题;生产环境仍需要考虑备份、磁盘空间、损坏恢复、数据安全级别及历史数据留存政策。特别是数据库版本高于应用代码时,不推荐自动降级并删除新字段;拒绝写入通常比静默破坏未来版本的数据更安全。

迁移验收还应设置一个容易遗漏的“空草稿库”样本:数据库文件已存在,但用户从未创建过巡检记录。它与全新安装不是同一个状态,因为建库动作可能已经发生,数据库版本与表是否存在也可能不同。升级代码不能根据“查询结果是零行”来决定是否创建表;应读取版本、执行明确的 DDL 路径,再核对新字段是否存在。

对需要长期保存的巡检信息,回归检查应该同时覆盖中文标题、长文本内容、相同更新时间以及具有非连续主键的历史记录。数据量大的场景还要关注迁移期间的页面加载提示,避免让用户以为应用卡死而反复点击保存。升级计划上线前可在测试副本中故意中断迁移、恢复后重新打开,从而检查事务失败路径和二次启动行为。这里的关键是保留现场与可追溯记录,而不是依靠“打开页面没报错”作出成功判断。

什么时候迁移任务可以算收尾

审核一段 Schema 迁移代码,可以从四个问题着手:旧用户的字段语义有没有变化;失败时版本是否被提前推进;重复启动是否会再次增列;迁移期间页面是否还能误写入。只要一个问题没有明确答案,迁移就不算真正完成。版本号是结构契约的签章,不是开发者想改就改的数字。

下一篇将使用这一版五列表结构研究多条巡检记录的批量事务写入。届时会继续沿用 inspection_draft.db、自增主键、priority 默认值及 DraftRepository 的边界,同时讨论批量更新中途失败、事务互斥和重复提交等新的问题,而不会再重复整套建表教程。

相关官方资料:华为开发者:关系型数据库持久化、relationalStore 接口参考、数据备份与恢复。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐