灯光模拟HarmonyOS应用实战-69-onBackup已返回,最后一次历史写入还在flush:用BackupWriteBarrier建立一致性点
灯光模拟HarmonyOS应用实战-69-onBackup已返回,最后一次历史写入还在flush:用BackupWriteBarrier建立一致性点
用户刚完成最后一道灯光题,结果页已经出现,系统紧接着触发应用备份。备份回调很快返回“完成”,而历史记录的 Preferences.flush() 仍在另一条 Promise 链上等待。恢复后少了最新一条记录,页面和日志却都没有明确失败。
这是生命周期与业务写入没有共同一致性点的问题。The_kemusan 当前历史写入会执行 put() 和 flush(),但页面采用 .then() 异步接收结果;EntryBackupAbility.onBackup() 只打印日志并等待一个已经完成的 Promise。两条链彼此不知道对方的进度。要让“备份开始前已接受的写入”有明确边界,需要用 BackupWriteBarrier 记录写入序号、耐久回执和备份截点。

这次设计关注的不是延长等待时间,而是回答三个可核对的问题:备份要等哪些写入、怎样知道它们已结束、超时或失败时如何报告而不假装成功。
一、当前onBackup没有连接任何业务存储任务
EntryBackupAbility.ets 的回调只有两步:写一条 hilog,然后 await Promise.resolve()。它没有取得 PracticeStore,没有读取 exam_history,也没有等待页面发起的写入。
export default class EntryBackupAbility extends BackupExtensionAbility {
async onBackup() {
hilog.info(DOMAIN, 'testTag', 'onBackup ok');
await Promise.resolve();
}
}
项目的 backup_config.json 把 allowToBackupRestore 设为 true,module.json5 也注册了类型为 backup 的 EntryBackupAbility。这些配置说明备份扩展入口存在,不说明业务写入已经和回调形成顺序关系。
| 层次 | 当前行为 | 尚缺的证据 |
|---|---|---|
| 页面结果 | 调用 addSimpleRecord() | 写入何时完成 |
PracticeStore | put() 后 flush() | 失败是否上抛 |
| 备份扩展 | 打日志后立即返回 | 截点前写入是否耐久 |
| 恢复扩展 | 记录 BundleVersion | 恢复内容是否包含末条历史 |
因此,看到 onBackup ok 只能说明回调走到该日志,不能说明最后一次历史已被持久化或已进入系统备份内容。
二、最后一次答题写入是一条未被备份观察的Promise链

页面的 addSimpleRecord() 构造 PracticeRecord 后调用:
this.store.addRecord(record).then((records: PracticeRecord[]) => {
this.records = records;
});
这个方法本身返回 void。调用它的 answerTheoryQuestion()、finishLightExam() 和 finishPracticalExam() 都不会等待历史写入。存储层内部确实有顺序:先读取旧记录,插入新记录,截取最多 100 条,再执行 putString();putString() 中依次等待 put() 与 flush()。但这条内部顺序没有暴露给页面或备份层。
还有一个更隐蔽的边界:putString() 捕获异常后直接返回,addRecord() 仍可能把 nextRecords 当作返回结果。于是 UI 可以更新出一条仅存在内存的历史,而调用方拿不到“flush 失败”的回执。
竞态可以按时间排列:
T1:页面接受最后一题结果并调用addRecord();T2:Preferences.put()已调用,flush()尚未结束;T3:系统调用onBackup();T4:onBackup()等待已完成 Promise 后返回;T5:业务写入才完成或失败。
固定睡眠 500 毫秒无法解决这个问题。设备负载和存储时延不是常量,而且睡眠结束时仍不知道写入结果。
三、先定义“备份截点前写入”的语义
屏障需要一个清楚的承诺:当备份回调取得截点序号 cutoffSequence 后,等待所有序号不大于该值的已接受写入进入终态。截点之后新产生的记录可以留给下一次备份,不能让不断到来的写入使本次等待永不结束。
export enum DurableWriteState {
ACCEPTED = 'accepted',
DURABLE = 'durable',
FAILED = 'failed'
}
export interface WriteReceipt {
sequence: number;
state: DurableWriteState;
errorCode: string;
}
export interface BackupCutoff {
sequence: number;
capturedAt: number;
}
sequence 由写入协调器单调分配。ACCEPTED 表示任务进入队列,不能等同于已经刷入存储;DURABLE 表示项目定义的持久化步骤已成功返回;FAILED 保留稳定错误码。究竟 Preferences.flush() 的完成与系统备份快照之间有哪些平台保证,还要结合目标 SDK 文档和故障实验确认,业务层不能自己扩大承诺。
四、BackupWriteBarrier集中跟踪未完成写入

屏障应该靠近唯一写入协调器,而不是让每个页面保存自己的 Promise。下面的示例展示核心状态:分配序号、登记任务、记录终态、按截点等待。
export class BackupWriteBarrier {
private nextSequence: number = 1;
private receipts: Map<number, WriteReceipt> =
new Map<number, WriteReceipt>();
private pending: Map<number, Promise<WriteReceipt>> =
new Map<number, Promise<WriteReceipt>>();
accept(task: () => Promise<void>): WriteReceipt {
const sequence: number = this.nextSequence++;
const accepted: WriteReceipt = {
sequence,
state: DurableWriteState.ACCEPTED,
errorCode: ''
};
this.receipts.set(sequence, accepted);
const completion: Promise<WriteReceipt> =
this.runTask(sequence, task);
this.pending.set(sequence, completion);
return accepted;
}
captureCutoff(): BackupCutoff {
return {
sequence: this.nextSequence - 1,
capturedAt: Date.now()
};
}
}
accept() 只表示排队成功。实际的 runTask() 应把异常转换为 FAILED 回执并清理 pending。如果多个答题事件允许并发写同一个 JSON 数组,还要在协调器里串行化“读—改—写”,否则两次写入可能都读取同一个旧数组,后完成的一次覆盖前一次。
五、写入端口必须返回耐久回执而不是吞掉异常
屏障无法从一个永远正常返回的 putString() 判断失败。存储端口需要把阶段和错误传给协调器,页面再决定如何提示或重试。
interface HistoryWritePort {
writeHistoryJson(value: string): Promise<void>;
}
class PreferencesHistoryWritePort implements HistoryWritePort {
constructor(private store: preferences.Preferences) {}
async writeHistoryJson(value: string): Promise<void> {
await this.store.put('exam_history', value);
await this.store.flush();
}
}
async function persistHistory(port: HistoryWritePort,
records: PracticeRecord[]): Promise<void> {
const payload: string = JSON.stringify(records);
await port.writeHistoryJson(payload);
}
这里刻意不捕获后静默返回。端口只负责原样报告失败,协调器再生成业务错误码。日志不应写完整历史 JSON;序号、载荷长度、摘要和错误码足够定位,多余的答题正文会扩大隐私暴露面。
如果页面需要乐观更新,也要保留 pending 标记。写入失败时展示“记录未保存”并允许重新提交,不能继续把它计入已持久化统计。
六、备份回调等待固定截点并返回明确结果
waitUntilSettled() 只等待截点内任务。全部进入 DURABLE 才返回成功;任一失败、超时或协调器不可达都产生不同状态。
export enum BarrierResultCode {
READY = 'ready',
WRITE_FAILED = 'write_failed',
TIMEOUT = 'timeout',
COORDINATOR_UNAVAILABLE = 'coordinator_unavailable'
}
export interface BarrierResult {
code: BarrierResultCode;
cutoffSequence: number;
failedSequences: number[];
}
async function prepareBusinessDataForBackup(
barrier: BackupWriteBarrier
): Promise<BarrierResult> {
const cutoff: BackupCutoff = barrier.captureCutoff();
return await barrier.waitUntilSettled(cutoff, 1500);
}
1500 毫秒只是示例策略,不是 HarmonyOS 固定要求。真实超时要根据数据量、回调约束和设备实验确定。超时后不能打印 onBackup ok;应记录截点与结果码,并按平台允许的方式让此次备份失败或延后。
备份扩展与页面是否位于同一进程、是否能访问同一个内存单例,必须按实际配置验证。如果不能共享,屏障状态需要落到一个双方都能读取的耐久协调介质,或把所有历史写入统一交给可达服务。仅在两个类里各自 new BackupWriteBarrier() 不会形成屏障。
七、进程重建时需要耐久序号和回读证明
纯内存 pending 能协调同进程并发,却无法解释进程在 put() 与 flush() 之间终止后的状态。更强的方案会为正式载荷保存提交序号,并在写入后回读确认序号与摘要。
interface DurableHistoryEnvelope {
schemaVersion: number;
commitSequence: number;
records: PracticeRecord[];
payloadDigest: string;
}
interface DurableMarker {
commitSequence: number;
payloadDigest: string;
}
业务流程可以是“生成信封 → 写入 → flush → 回读 → 比较序号与摘要 → 标记该序号为 DURABLE”。摘要用于一致性比对,不能自动等同于加密或隐私保护。是否采用单 key、候选 key 与正式 key,取决于 Preferences 的实际能力和故障模型;没有平台事务保证时,不应把多 key 顺序描述成原子提交。
恢复后还可以比较信封和耐久标记。如果二者不一致,页面进入“历史待恢复”状态,不把载荷直接当正常记录,也不自动清空。
八、并发写入必须先串行化再等待屏障
假设两道题很快连续写入:A 和 B 都先调用 listRecords(),都读取到旧数组 R0。A 写 A + R0,B 写 B + R0,最终哪个后完成就覆盖另一个。即使屏障等到两次 flush 都结束,数据仍可能缺一条。
class HistoryWriteQueue {
private tail: Promise<void> = Promise.resolve();
enqueue(operation: () => Promise<void>): Promise<void> {
const next: Promise<void> = this.tail.then(operation);
this.tail = next.catch(() => {
return;
});
return next;
}
}
每次“读—改—写—回读”作为一个队列任务,再交给屏障登记。tail 上的 catch 只用于让后续任务继续排队,原任务的 next 仍向调用方报告异常。否则第一次失败会让队列永久拒绝,也可能再次吞掉错误。
| 风险 | 仅等待所有 Promise | 串行写入 + 屏障 |
|---|---|---|
| 备份早于 flush 返回 | 可避免 | 可避免 |
| 两次读改写互相覆盖 | 不能避免 | 可以按序处理 |
| 写入失败被记录 | 取决于端口 | 明确回执 |
| 截点后新写入拖住本次备份 | 可能 | 固定截点后不会 |
九、故障用例要控制Promise完成顺序
正常设备上连续点击几次很难稳定复现竞态。测试端口应允许暂停 put、flush 和回读,主动构造不同顺序。
| 用例 | 安排 | 关键断言 |
|---|---|---|
| B01 | 无待写入时触发备份 | 截点 0,立即 READY |
| B02 | 截点内一条 flush 延迟 | 回调在该任务终态前不返回 READY |
| B03 | 截点后新增一条 | 本次只等待旧截点 |
| B04 | 一条写入抛错 | 返回 WRITE_FAILED 和对应序号 |
| B05 | flush 永不结束 | 到策略时间返回 TIMEOUT |
| B06 | 两次写入同时发起 | 队列后载荷包含两条记录 |
| B07 | 写后回读摘要不一致 | 不标记 DURABLE |
| B08 | 进程重建后标记不一致 | 恢复入口报告待处理状态 |
还要核对日志顺序:先出现写入接受序号,再出现备份截点,最后才是各序号终态与备份结果。只有日志文本而没有序号,无法证明两条异步链的先后关系。
十、验证清单、排障表与事实边界
- 所有历史写入都经过唯一协调器分配序号。
- “接受写入”和“耐久完成”使用不同状态。
- 存储异常不会在端口内部静默变成成功。
- 同一 JSON 数组的读改写被串行化。
- 备份开始时捕获固定截点。
- 截点之后的新任务不会拖住当前备份。
- 失败、超时和协调器不可达分别报告。
- 写入后回读并核对提交序号与摘要。
- 页面能区分内存乐观记录与已持久化记录。
- 日志不包含完整答题历史正文。
- 进程和扩展运行边界经过目标环境验证。
- 备份恢复后核对最后提交序号。
| 现象 | 先看哪里 | 常见原因 | 修复方向 |
|---|---|---|---|
| 恢复后少最后一条 | 截点与耐久序号 | 回调未等待业务写入 | 在 onBackup 前等固定截点 |
| UI有记录、重开后消失 | 写入回执 | 异常被 putString() 吞掉 | 返回失败并标记待保存 |
| 连续答题少中间一条 | 写队列 | 并发读改写覆盖 | 串行化完整操作 |
| 备份一直不结束 | 截点范围 | 不断等待新任务 | 截点后写入留待下次 |
| 打印成功但载荷不一致 | 成功日志位置 | 日志早于屏障结果 | 只在 READY 后记录完成 |
| 两个屏障互不相见 | 实例和进程 | 页面与扩展各建单例 | 建立可共享协调入口 |
| flush返回后回读不同 | 提交验证 | 只等待调用完成 | 比较序号与摘要 |
当前源码能够确认:PracticeStore.putString() 会等待 Preferences.put() 和 flush(),但捕获异常后直接返回;页面的 addSimpleRecord() 返回 void 并通过 .then() 更新内存列表;EntryBackupAbility.onBackup() 没有连接 PracticeStore,只记录日志并等待 Promise.resolve()。因此当前代码没有可见的业务写入屏障。
BackupWriteBarrier、写入序号、串行队列、耐久信封、超时策略和回读协议都是建议方案,尚未写入 The_kemusan。这次没有触发真实系统备份,没有注入 flush 故障,没有运行构建,没有生成新的 HAP,也没有在模拟器或真机完成备份恢复。flush() 与系统备份快照的具体保证、扩展进程边界及回调失败方式,都必须结合对应 SDK 文档、编译结果和设备实验再确认。
更多推荐



所有评论(0)