灯光模拟HarmonyOS应用实战-65-onRestore收到BundleVersion却只记日志-用版本握手与回读回执对账exam_history

EntryBackupAbility.onRestore(bundleVersion) 已经拿到了恢复来源的版本对象,但当前实现只把它序列化后写入日志,随后等待一个已完成的 Promise。与此同时,业务历史由 PracticeStore 使用 Preferences 中的 kemusan_exam_store/exam_history 读取。两个位置之间没有版本决策、迁移安排或恢复后的业务对账。

这篇文章不讨论系统究竟会不会自动包含某个 Preferences 文件,也不推断云端、换机、重装或用户开关行为。那些结论必须查目标 SDK 文档并在设备上执行完整恢复实验。本文只处理源码中能够确定的缺口:恢复回调收到 BundleVersion 后没有形成业务版本握手;回调结束后,也没有证据说明 exam_history 已按预期恢复且可被当前版本解释。

改进思路是把回调变成一个窄入口:先把平台版本对象适配为业务可理解的来源版本,再生成 RestorePlan;数据可读后对 exam_history 做字段、数量和摘要回读;最后产出可持久化的 RestoreReceipt。任何一步不一致都应留下明确状态,而不是只打印“onRestore ok”。

备份恢复版本握手

一、当前恢复回调只记录参数

entry/src/main/ets/entrybackupability/EntryBackupAbility.ets 的实现很短,能够直接看到 BundleVersion 进入函数后没有参与条件判断,也没有调用 PracticeStore

async onRestore(bundleVersion: BundleVersion) {
  hilog.info(
    DOMAIN,
    'testTag',
    'onRestore ok %{public}s',
    JSON.stringify(bundleVersion)
  );
  await Promise.resolve();
}

module.json5 声明了该备份扩展,backup_config.json 中有 allowToBackupRestore: true。这些内容只能证明源码配置了一个入口。它们不能证明系统实际包含了哪个文件,也不能证明恢复完成后历史记录与原设备一致。

业务历史的锚点在 PracticeStore.ets

const STORE_NAME: string = 'kemusan_exam_store';
const HISTORY_KEY: string = 'exam_history';

async listRecords(): Promise<PracticeRecord[]> {
  const raw = await this.getString(HISTORY_KEY, '[]');
  const source = JSON.parse(raw) as LegacyRecord[];
  const records: PracticeRecord[] = [];
  for (let index = 0; index < source.length; index++) {
    records.push(this.normalizeRecord(source[index]));
  }
  return records.sort(
    (left: PracticeRecord, right: PracticeRecord) =>
      right.createdAt - left.createdAt
  );
}

当前 EntryBackupAbility 没有导入 PracticeStore,也没有读取 exam_history。因此可以准确陈述“源码没有业务层恢复后对账”,但不能反向推出“平台没有恢复 Preferences”。平台文件恢复与应用业务验收是两个不同问题。

二、BundleVersion 应进入决策而不是只进日志

BundleVersion 是平台提供的类型。本文没有查到并验证当前项目目标 SDK 中它的具体字段契约,因此示例不会假设 versionCodeversionName 或其他成员一定存在。正确做法是先查官方文档,再用一个适配器把已确认字段转换成业务版本对象。

interface SourceVersion {
  appRevision: string;
  dataSchemaHint: number | undefined;
}

interface BundleVersionAdapter {
  fromPlatform(value: BundleVersion): SourceVersion;
}

BundleVersionAdapter 是方案接口,不是 HarmonyOS API。它隔离了平台字段读取:如果不同 API 级别的字段名或含义变化,只改适配器;版本决策、迁移表和测试仍使用稳定的 SourceVersion

恢复握手至少要回答四个问题:

  1. 来源应用版本是否在支持范围内?
  2. 来源数据结构版本能否由当前版本直接读取?
  3. 是否存在一条连续、可重复执行的迁移路径?
  4. 无法识别时,是安全拒绝、只读隔离,还是延后到应用启动处理?

JSON.stringify(bundleVersion) 写进日志不等于回答了这些问题。日志也不适合作为后续页面读取的业务状态,因为它没有稳定结构、没有完成状态,且可能受日志保留策略影响。

三、握手输入需要平台版本和数据版本两条线

应用版本与数据结构版本不能互相替代。一个新应用版本可能没有修改 exam_history;反过来,一个热修复或迁移任务也可能改变数据结构却维持相同展示版本。建议把两条线分别建模。

interface RestoreHandshakeInput {
  sourceApp: SourceVersion;
  targetAppRevision: string;
  sourceSchema: number | undefined;
  targetSchema: number;
  backupRevision: string | undefined;
}

enum RestoreDecision {
  READ_DIRECTLY = 'read_directly',
  MIGRATE = 'migrate',
  HOLD_FOR_APP_START = 'hold_for_app_start',
  REJECT = 'reject'
}

interface RestorePlan {
  decision: RestoreDecision;
  fromSchema: number | undefined;
  toSchema: number;
  reasonCode: string;
}

sourceSchema 应来自业务数据封套或同一快照中的元信息,而不是凭 BundleVersion 猜。当前工程的 exam_history 是一个裸 JSON 数组,没有这类封套,所以旧恢复数据可能只能得到“结构版本未知”。未知并不等于版本 0;把它们混为一谈会让迁移函数处理并不符合预期的对象。

建议决策矩阵如下:

来源结构当前结构迁移链计划说明
与当前相同当前不需要READ_DIRECTLY仍需回读对账
低于当前当前连续且已验证MIGRATE逐版本迁移
低于当前当前中间步骤缺失REJECT不跨级猜转换
高于当前当前无降级读取器REJECT防止旧应用覆盖新结构
未知当前可做旧格式安全解码HOLD_FOR_APP_START 或受限迁移保留原因
未知当前根本无法解释REJECT不写回空数组

最后两行对当前裸数组尤其重要。旧历史没有显式结构版本时,应通过受限解码获得事实,不应仅根据应用版本宣称数据必然兼容。

四、版本决策用连续迁移表而不是大分支

迁移逻辑应一版一版前进。这样每一步输入和输出都可单独验证,也能检查是否缺了一段。下面的注册表是业务层方案,不是当前源码。

interface HistoryMigration {
  from: number;
  to: number;
  migrate(raw: string): string;
}

class MigrationRegistry {
  constructor(private readonly steps: HistoryMigration[]) {}

  resolve(from: number, to: number): HistoryMigration[] | undefined {
    const result: HistoryMigration[] = [];
    let cursor = from;
    while (cursor < to) {
      const step = this.steps.find((item) => item.from === cursor);
      if (step === undefined || step.to !== cursor + 1) {
        return undefined;
      }
      result.push(step);
      cursor = step.to;
    }
    return result;
  }
}

迁移函数应保持确定性:相同输入和相同迁移版本产生相同输出;失败不修改正式 exam_history;再次执行同一恢复任务不增加重复记录。实际写 Preferences 时还要考虑 putflush 的失败传播,不能沿用当前 putString() 吞掉异常的方式去生成“成功回执”。

迁移注册表只解决业务结构演进,不能决定平台恢复时序。onRestore 被调用时目标文件是否已经可读、回调结束前是否允许执行 Preferences 操作、是否应把较重工作延后,都必须根据目标 SDK 文档确认。若数据在回调时尚不可读,就只保存待处理的握手计划,在应用下一次初始化时完成业务对账。

五、onRestore 只编排,不直接堆迁移细节

恢复事务六步流程

建议让恢复扩展依赖一个很薄的协调器。平台参数适配、版本决策和任务登记在回调内完成;真正读取 exam_history 的时机由经过文档确认的执行点决定。

export default class EntryBackupAbility extends BackupExtensionAbility {
  private readonly coordinator: RestoreCoordinator =
    RestoreServices.createCoordinator();

  async onRestore(bundleVersion: BundleVersion): Promise<void> {
    const source = this.coordinator.normalizeSource(bundleVersion);
    const plan = this.coordinator.createPlan(source);
    const ticket = await this.coordinator.register(plan);

    if (ticket.runNow) {
      await this.coordinator.executeAndReconcile(ticket.id);
    }
  }
}

这段示例没有读取 BundleVersion 的假定字段,所有平台细节都放在 normalizeSource()register() 先写入一个恢复任务,使回调中断后应用仍知道任务处于哪一步。executeAndReconcile() 只有在明确可以访问业务数据时才执行。

协调器的状态可以保持有限集合:

  • RECEIVED:已收到平台回调并保存来源版本。
  • PLANNED:已形成直接读取、迁移、延后或拒绝计划。
  • DATA_AVAILABLE:在经过确认的时机读到了原始历史。
  • MIGRATED:需要时已在暂存区完成迁移。
  • RECONCILED:回读与预期一致。
  • FAILED:保留原因码,正式历史未被覆盖。

状态越清楚,越不需要依赖一句“onRestore ok”判断全链路是否完成。

六、exam_history 对账应比较可解释事实

恢复后的业务对账不应只检查 key 是否存在。一个值可能存在,却是空数组、字段不兼容、条数变化或重复记录。也不应把完整答题内容写入日志。可以计算一个最小指纹,比较预期与回读结果。

interface HistoryFingerprint {
  rawPresent: boolean;
  decodedCount: number;
  rejectedCount: number;
  newestCreatedAt: number | undefined;
  idDigest: string;
}

interface HistoryReconcileResult {
  matched: boolean;
  reasonCode: string;
  expected: HistoryFingerprint | undefined;
  actual: HistoryFingerprint;
}

idDigest 表示按稳定顺序对记录 ID 求摘要,具体算法应通过项目的摘要端口实现,本文不指定平台库。摘要用于一致性比对,不等于安全加密承诺。若备份侧当前没有预期指纹,恢复侧仍可以产生 actual,但只能报告“已读到多少条、拒绝多少条”,不能宣称和来源完全一致。

对账函数应明确几种结果:

function reconcileHistory(
  expected: HistoryFingerprint | undefined,
  actual: HistoryFingerprint
): HistoryReconcileResult {
  if (!actual.rawPresent) {
    return {
      matched: false,
      reasonCode: 'HISTORY_KEY_ABSENT',
      expected,
      actual
    };
  }
  if (actual.rejectedCount > 0) {
    return {
      matched: false,
      reasonCode: 'HISTORY_HAS_REJECTED_RECORDS',
      expected,
      actual
    };
  }
  if (expected === undefined) {
    return {
      matched: false,
      reasonCode: 'NO_SOURCE_FINGERPRINT',
      expected,
      actual
    };
  }
  const same = expected.decodedCount === actual.decodedCount &&
    expected.idDigest === actual.idDigest;
  return {
    matched: same,
    reasonCode: same ? 'HISTORY_MATCHED' : 'HISTORY_MISMATCH',
    expected,
    actual
  };
}

没有来源指纹时返回 matched=false,并不代表数据一定错误,而是证据不足。页面可以把它呈现为“已恢复,等待核对”或只在诊断区保留;不能把“不知道”改写为成功。

七、恢复回执要可重放且不泄露历史内容

备份恢复职责结构

一次恢复任务最终应生成 RestoreReceipt,记录来源版本归一结果、计划、执行阶段和对账摘要。回执不是历史数据副本,不应保存题目、答案或失败原因正文。

interface RestoreReceipt {
  ticketId: string;
  sourceAppRevision: string;
  sourceSchema: number | undefined;
  targetSchema: number;
  decision: RestoreDecision;
  stage: string;
  historyCount: number;
  rejectedCount: number;
  reconcileCode: string;
  completedAt: number | undefined;
}

ticketId 应由稳定的恢复输入派生或由协调器生成并持久化,重复收到同一任务时先查回执。幂等规则可以是:

  1. 同一 ticketId 已经 RECONCILED,只重新回读核对,不重复迁移。
  2. 同一任务停在 PLANNED,从数据可用检查继续。
  3. 同一任务停在 MIGRATED,先验证暂存摘要,再决定提交。
  4. 来源版本或输入摘要变化,创建新任务,不覆盖旧回执。
  5. 失败任务保留原因,人工或新版本明确选择后再重试。

日志只输出 ticketId、阶段和原因码。即使日志标记为公开参数,也不要序列化完整 BundleVersion 或历史内容,除非已审阅其数据分类和必要性。当前源码把整个 bundleVersion 放进日志,这是否包含敏感或冗余字段需要结合类型定义判断。

八、正式历史提交前后都要回读

迁移应在内存或独立暂存 key 中完成,校验通过后才提交到正式 exam_history。Preferences 的多 key 写入是否具备事务语义不能凭经验假设;因此“暂存、提交、回读”的耐久边界要按 ArkData 文档和故障实验确认。

一个业务层执行骨架可以这样写:

async function executeRestore(ticket: RestoreTicket): Promise<RestoreReceipt> {
  const originalRaw = await historyPort.readRaw();
  const candidateRaw = await buildCandidate(ticket, originalRaw);
  const candidateReport = historyDecoder.decode(candidateRaw);

  if (candidateReport.rejected.length > 0) {
    return receiptForFailure(ticket, 'CANDIDATE_REJECTED');
  }

  await historyPort.writeCandidate(ticket.id, candidateRaw);
  const candidateReadback = await historyPort.readCandidate(ticket.id);
  if (candidateReadback !== candidateRaw) {
    return receiptForFailure(ticket, 'CANDIDATE_READBACK_MISMATCH');
  }

  await historyPort.commit(candidateRaw);
  const actual = await historyPort.readFingerprint();
  return receiptFromReconcile(ticket, actual);
}

这里的 writeCandidate/readCandidate/commit 都是方案端口,不是现有 PracticeStore 方法。它们必须让失败向上返回,而不是吞掉异常。候选值不合格时保留当前正式历史;正式提交后仍要回读,因为“调用完成”与“业务内容可解释”不是同一层证据。

如果系统在回调前已经覆盖正式 Preferences,originalRaw 可能就是恢复后的值。此时“失败不覆盖现有数据”应理解为不再用空数组或半迁移值二次覆盖,并将读取到的原始载荷保存在受控恢复区。具体可回滚能力取决于平台时序和存储方案,必须经过设备实验。

九、版本与对账用例要成对覆盖

版本测试和历史对账测试不能分开只测成功路径。一个版本可迁移,但数据中有非法记录;一个版本完全相同,key 却缺失;这两种情况都不能给出完整成功回执。

用例来源版本/结构exam_history 回读预期回执
B01同版本/同结构指纹一致RECONCILED/HISTORY_MATCHED
B02旧结构/迁移链完整迁移后指纹一致记录迁移步骤并完成
B03旧结构/迁移链缺失任意FAILED/MIGRATION_PATH_MISSING
B04新于当前/无降级读取任意FAILED/SOURCE_TOO_NEW
B05结构未知可安全解码延后或受限计划,不伪造结构号
B06同结构key 不存在FAILED/HISTORY_KEY_ABSENT
B07同结构部分记录拒绝FAILED/HISTORY_HAS_REJECTED_RECORDS
B08同结构条数相同但 ID 摘要不同FAILED/HISTORY_MISMATCH
B09无来源指纹数据可读NO_SOURCE_FINGERPRINT,不宣称一致
B10同任务重复回调已有完成回执不重复迁移,仅复核

参数化用例可以先验证决策器:

const plans: Array<[number | undefined, number, RestoreDecision]> = [
  [3, 3, RestoreDecision.READ_DIRECTLY],
  [2, 3, RestoreDecision.MIGRATE],
  [4, 3, RestoreDecision.REJECT],
  [undefined, 3, RestoreDecision.HOLD_FOR_APP_START]
];

for (const item of plans) {
  const plan = planner.create({
    sourceSchema: item[0],
    targetSchema: item[1]
  });
  expect(plan.decision).toBe(item[2]);
}

这些是实现后的验证设计,本文没有执行。集成层还要覆盖进程中断、flush 失败、候选回读不一致、正式提交后重启,以及同一恢复任务多次进入回调。

十、验证清单、排障表与事实边界

实现后建议逐项核对:

  • BundleVersion 经过目标 SDK 文档确认后由单一适配器读取。
  • 平台应用版本与业务数据结构版本分开建模。
  • 未知结构版本不会被默认成某个数字。
  • 迁移链逐版本连续,缺一步就拒绝。
  • onRestore 只做编排和任务登记。
  • 数据可读时机符合目标平台回调契约。
  • exam_history 回读区分 key 缺失、空历史、部分拒绝和完全一致。
  • 对账至少比较可解码条数与稳定 ID 摘要。
  • 没有来源指纹时不宣称来源与目标一致。
  • 候选校验失败不写空数组覆盖正式历史。
  • 写入异常能够传到回执,不被静默吞掉。
  • 相同恢复任务重复执行不会重复迁移或重复记录。
  • 日志只保留任务号、阶段和原因码。
  • 应用初始化能继续处理回调中登记的待办任务。
  • 模拟器和真机分别完成备份、恢复、重启与回读。
症状优先核对常见原因修复方向
日志有“onRestore ok”但历史为空回执和 key 存在性把回调进入当成业务完成增加 exam_history 回读与状态码
旧版本数据恢复后页面异常来源结构与迁移链只看应用版本引入独立结构版本和连续迁移
每次启动都重复迁移ticketId 与完成回执任务没有幂等身份先查回执,再从稳定阶段继续
条数相同却内容不同ID 摘要只比较数组长度比较稳定顺序摘要并记录不一致
迁移失败后历史变空候选提交顺序先覆盖正式 key 再校验暂存、校验、提交、回读
回调内读不到 Preferences平台恢复时序假定文件已可用查文档,登记任务后在应用启动续办
写入失败仍显示成功存储端口返回值复用吞异常的 putString()让失败进入 RestoreReceipt
日志暴露过多对象字段日志参数直接序列化整对象只记归一版本、任务号和原因码

当前源码能够确认的是:模块声明了 EntryBackupAbilitybackup_config.json 允许进入备份恢复流程;onRestore(bundleVersion) 当前只记录参数并结束;PracticeStore 使用 kemusan_exam_store/exam_history;恢复扩展没有调用该仓储,也没有业务回读或对账代码。本文没有据此判断平台实际包含范围或恢复顺序。

BundleVersionAdapterRestorePlan、迁移注册表、候选区、历史指纹和 RestoreReceipt 都属于改进方案,尚未写入 The_kemusan。本次没有查证目标 SDK 中 BundleVersion 的具体字段,没有运行项目构建,没有生成新的 HAP,没有触发系统备份,也没有在模拟器或真机执行卸载、重装、换机或恢复后回读。平台行为必须以华为官方文档、当前 SDK 编译和目标设备实验为准。

Logo

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

更多推荐