灯光模拟HarmonyOS应用实战-65-onRestore收到BundleVersion却只记日志-用版本握手与回读回执对账exam_history
灯光模拟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 中它的具体字段契约,因此示例不会假设 versionCode、versionName 或其他成员一定存在。正确做法是先查官方文档,再用一个适配器把已确认字段转换成业务版本对象。
interface SourceVersion {
appRevision: string;
dataSchemaHint: number | undefined;
}
interface BundleVersionAdapter {
fromPlatform(value: BundleVersion): SourceVersion;
}
BundleVersionAdapter 是方案接口,不是 HarmonyOS API。它隔离了平台字段读取:如果不同 API 级别的字段名或含义变化,只改适配器;版本决策、迁移表和测试仍使用稳定的 SourceVersion。
恢复握手至少要回答四个问题:
- 来源应用版本是否在支持范围内?
- 来源数据结构版本能否由当前版本直接读取?
- 是否存在一条连续、可重复执行的迁移路径?
- 无法识别时,是安全拒绝、只读隔离,还是延后到应用启动处理?
把 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 时还要考虑 put 与 flush 的失败传播,不能沿用当前 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 应由稳定的恢复输入派生或由协调器生成并持久化,重复收到同一任务时先查回执。幂等规则可以是:
- 同一
ticketId已经RECONCILED,只重新回读核对,不重复迁移。 - 同一任务停在
PLANNED,从数据可用检查继续。 - 同一任务停在
MIGRATED,先验证暂存摘要,再决定提交。 - 来源版本或输入摘要变化,创建新任务,不覆盖旧回执。
- 失败任务保留原因,人工或新版本明确选择后再重试。
日志只输出 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 |
| 日志暴露过多对象字段 | 日志参数 | 直接序列化整对象 | 只记归一版本、任务号和原因码 |
当前源码能够确认的是:模块声明了 EntryBackupAbility;backup_config.json 允许进入备份恢复流程;onRestore(bundleVersion) 当前只记录参数并结束;PracticeStore 使用 kemusan_exam_store/exam_history;恢复扩展没有调用该仓储,也没有业务回读或对账代码。本文没有据此判断平台实际包含范围或恢复顺序。
BundleVersionAdapter、RestorePlan、迁移注册表、候选区、历史指纹和 RestoreReceipt 都属于改进方案,尚未写入 The_kemusan。本次没有查证目标 SDK 中 BundleVersion 的具体字段,没有运行项目构建,没有生成新的 HAP,没有触发系统备份,也没有在模拟器或真机执行卸载、重装、换机或恢复后回读。平台行为必须以华为官方文档、当前 SDK 编译和目标设备实验为准。
更多推荐



所有评论(0)