灯光模拟HarmonyOS应用实战-26-历史写入失败别返回成功数组:用明确结果保留重试机会
练习结束后历史多了一条,重启却消失了:底层写入失败,服务层仍返回拼好的数组,页面把候选数据误当作已保存数据。

The_kemusan 已有 100 条上限和旧字段兼容。本篇解决明确返回失败、保留待存结果、同 ID 重试,以及失败后的缓存恢复。
下面先核对当前源码,再给出建议新增的写入核心和页面接入片段。新增实现没有替换原工程,没有执行新增代码的 HAP 构建或真机故障注入。本文的“可恢复”指当前进程仍在时保留重试机会,不代表内存中的待存结果能够跨强杀或设备重启恢复。
一、当前addRecord返回的是候选数组,未必是保存结果
原工程 entry/src/main/ets/services/PracticeStore.ets 的写入分为两层:
private async putString(key: string, value: string): Promise<void> {
if (!this.store) {
return;
}
try {
await this.store.put(key, value);
await this.store.flush();
} catch (err) {
return;
}
}
源码中 addRecord() 追加并裁剪数组,等待上述方法后无条件返回 nextRecords。未初始化或写入异常仍会正常返回,页面便误认为保存成功。
读取链路也有相似问题:读取失败和解析失败都可能变成 []。追加记录时若把“读取失败”当成“真的没有历史”,就可能生成只有一条记录的新数组。保存接口要诚实,首先要保证读旧数据失败时停止写入,不能覆盖式兜底。
| 阶段 | 当前表象 | 应有语义 |
|---|---|---|
| 尚未取得Preferences实例 | 返回新数组 | 存储未就绪,没有确认写入 |
| 读取历史失败 | 当成空数组继续追加 | 保留原状态,停止本次写入 |
| put失败 | 返回新数组 | 本次提交失败,可展示重试 |
| flush失败 | 返回新数组 | 持久化未确认,缓存可能已变化 |
| flush完成 | 返回新数组 | 可以发布新的正式历史快照 |

二、结果类型表达状态,记录ID在首次创建时固定
建议将以下类型放入 models/HistorySaveResult.ets。返回值不再让页面猜测是否成功;失败时 records 为空,明确禁止把候选数组冒充正式快照。
import { PracticeRecord } from './DrivingLightModels';
export enum HistorySaveState {
SAVED,
FAILED
}
export interface HistorySaveResult {
state: HistorySaveState;
recordId: string;
records: PracticeRecord[];
stage: string;
message: string;
}
stage 区分打开、读取、编码、写入、持久化和恢复阶段。页面显示简短文案,诊断日志只记录脱敏信息。
一次练习只创建一次 PracticeRecord,失败重试继续使用同一个 id 和 createdAt。不要点击重试就调用原 addSimpleRecord() 重新生成 ID,否则服务层无法判断这是同一次业务提交,最终可能出现两条内容相同的历史。
三、读取必须失败可见,兼容只补允许缺失的字段
以下函数放入建议新增的 services/ReliablePracticeStore.ets,仅处理本应用本地记录。必要字段异常就停止;可选字段允许缺失,但存在时类型必须正确。
import { common } from '@kit.AbilityKit';
import { preferences } from '@kit.ArkData';
import { PracticeRecord, SUBJECT_THREE } from '../models/DrivingLightModels';
import { HistorySaveResult, HistorySaveState } from '../models/HistorySaveResult';
const STORE_NAME: string = 'kemusan_exam_store';
const HISTORY_KEY: string = 'exam_history';
const MAX_HISTORY_COUNT: number = 100;
function invalidOptionalText(value: string | undefined): boolean {
return value !== undefined && typeof value !== 'string';
}
function decodeHistory(raw: string): PracticeRecord[] {
const source = JSON.parse(raw) as PracticeRecord[];
if (!Array.isArray(source)) {
throw new Error('History root is not an array');
}
const records: PracticeRecord[] = [];
for (let index = 0; index < source.length; index++) {
const item: PracticeRecord = source[index];
if (item === null || typeof item !== 'object' ||
typeof item.id !== 'string' || item.id.length === 0 ||
typeof item.passed !== 'boolean' ||
typeof item.score !== 'number' || !Number.isFinite(item.score) ||
typeof item.total !== 'number' || !Number.isFinite(item.total) ||
typeof item.createdAt !== 'number' || !Number.isFinite(item.createdAt) ||
typeof item.durationSeconds !== 'number' || !Number.isFinite(item.durationSeconds) ||
typeof item.reason !== 'string') {
throw new Error(`Invalid history item at ${index}`);
}
if (invalidOptionalText(item.subject) || invalidOptionalText(item.mode) ||
invalidOptionalText(item.wrongQuestionId) || invalidOptionalText(item.correctOption) ||
invalidOptionalText(item.selectedOption) ||
(item.mastered !== undefined && typeof item.mastered !== 'boolean')) {
throw new Error(`Invalid optional field at ${index}`);
}
const record: PracticeRecord = {
id: item.id,
subject: typeof item.subject === 'string' && item.subject.length > 0 ? item.subject : SUBJECT_THREE,
mode: typeof item.mode === 'string' && item.mode.length > 0 ? item.mode : '科三灯光模拟',
passed: item.passed, score: item.score, total: item.total,
reason: item.reason,
wrongQuestionId: typeof item.wrongQuestionId === 'string' ? item.wrongQuestionId : '',
correctOption: typeof item.correctOption === 'string' ? item.correctOption : '',
selectedOption: typeof item.selectedOption === 'string' ? item.selectedOption : '',
createdAt: item.createdAt, durationSeconds: item.durationSeconds,
mastered: item.mastered === true
};
records.push(record);
}
return records;
}
类型断言不验证 JSON,所以仍需运行时校验。科目、模式为空字符串时沿用旧版默认值,其他可选文本允许空;成绩只验证有限数值,范围规则另行处理。下一节用 getAll() 区分历史键不存在与值类型损坏。
四、单进程串行写入,失败后重新读取持久化文件
以下完整核心与上一节同文件。它只实现追加;迁移时清空等其他写入口也必须经过同一个实例和队列。
export class ReliablePracticeStore {
private context: common.UIAbilityContext;
private store?: preferences.Preferences;
private tail: Promise<void> = Promise.resolve();
private mustReload: boolean = false;
constructor(context: common.UIAbilityContext) {
this.context = context;
}
addRecord(record: PracticeRecord): Promise<HistorySaveResult> {
// 当前模型字段均为基本类型,提前序列化固定本次提交内容。
const frozen: string = JSON.stringify(record);
const task: Promise<HistorySaveResult> = this.tail.then(() => this.commit(frozen));
this.tail = task.then(() => {}, () => {});
return task;
}
private async commit(frozen: string): Promise<HistorySaveResult> {
let stage: string = 'encode';
let recordId: string = '';
try {
const record: PracticeRecord = decodeHistory(`[${frozen}]`)[0];
recordId = record.id;
if (this.mustReload) {
stage = 'recover';
this.store = undefined;
await preferences.removePreferencesFromCache(this.context, STORE_NAME);
this.mustReload = false;
}
stage = 'open';
let store: preferences.Preferences | undefined = this.store;
if (!store) {
store = await preferences.getPreferences(this.context, STORE_NAME);
this.store = store;
}
stage = 'read';
const all: Object = await store.getAll();
const values = all as Record<string, preferences.ValueType>;
const raw = values[HISTORY_KEY];
if (raw !== undefined && typeof raw !== 'string') {
throw new Error('History value is not a string');
}
const current: PracticeRecord[] = decodeHistory(typeof raw === 'string' ? raw : '[]');
const merged: PracticeRecord[] = [];
for (let index = 0; index < current.length; index++) {
if (current[index].id !== record.id) {
merged.push(current[index]);
}
}
merged.push(record);
merged.sort((left: PracticeRecord, right: PracticeRecord) => right.createdAt - left.createdAt);
const next: PracticeRecord[] = merged.slice(0, MAX_HISTORY_COUNT);
stage = 'encode';
const encoded: string = JSON.stringify(next);
stage = 'put';
await store.put(HISTORY_KEY, encoded);
stage = 'flush';
await store.flush();
const saved: HistorySaveResult = {
state: HistorySaveState.SAVED, recordId, records: next,
stage: 'done', message: '已保存到本机历史'
};
return saved;
} catch (error) {
if (stage === 'put' || stage === 'flush' || stage === 'recover') {
this.mustReload = true;
}
const failed: HistorySaveResult = {
state: HistorySaveState.FAILED, recordId, records: [],
stage, message: '本次结果尚未确认保存,请保留页面后重试'
};
return failed;
}
}
}
队列覆盖整个读改写过程;只排队 put() 仍会读到旧数组。失败后 tail 恢复可执行状态,不堵住后续任务。
合并先移除同 ID,再插入固定记录,重试不增加重复行。前提是同 ID 代表同一次结果,ID 唯一性仍由创建层保证。
此例保留原来的 100 条策略,按原始 createdAt 排序后裁剪。长时间积压的旧待存记录重试时可能位于保留窗口外,SAVED 表示本次合并与保留策略已持久化,不承诺所有输入永久留在最近历史中。若产品需要无损保留所有待存记录,就应调整容量策略或更换存储模型。
五、为什么flush失败后不能再用get读回冒充确认
XML 模式下 put() 修改缓存,flush() 提交持久化。失败后 get() 读到新值,或再次取得同一缓存实例,都不能证明文件已保存。
维护方 API 文档说明,removePreferencesFromCache() 移除实例后,再次获取才会重新读取持久化文件,旧引用应停止使用。示例仅在提交异常后安排这条恢复路径,不删除实际历史文件,也不在每次正常追加时强制清缓存。参见 Preferences API:缓存、持久化与实例移除。
| 观察到的结果 | 能否宣布持久化成功 | 原因 |
|---|---|---|
| 页面数组多了一项 | 不能 | 只是页面内存 |
| put返回完成 | 不能单独证明XML文件更新 | 更新可能仍在缓存 |
| 同实例get读到新值 | 不能 | 可能读回刚写的缓存 |
| flush成功完成 | 可以按接口契约发布成功结果 | 完成持久化提交 |
| 进程重启后读取相同记录 | 更进一步的运行验收 | 跨进程生命周期验证 |
示例针对当前工程的普通 Preferences 路径。较新 SDK 若选择其他存储模式,写入与缓存语义可能不同,应以对应模式官方说明为准,不能把这里的 XML 恢复策略机械套用到所有实现。多进程也不属于这个 Promise 队列的保护范围。

六、结果页保留pendingRecord,成功后才替换正式历史
页面需要区分考试结果与保存状态。考试合格已经发生,不应因为磁盘写入失败而改成考试不合格;可以继续展示成绩,但历史列表和统计不能提前增加。下面片段放在 Index 的普通字段和方法区,reliableStore 在取得有效 UIAbilityContext 后创建一次。
@State historySaving: boolean = false;
@State historySaveMessage: string = '';
private pendingRecord: PracticeRecord | null = null;
private reliableStore: ReliablePracticeStore | null = null;
private async savePracticeResult(record: PracticeRecord): Promise<void> {
if (this.historySaving || this.pendingRecord !== null) {
return;
}
this.pendingRecord = record;
await this.retryPendingRecord();
}
private async retryPendingRecord(): Promise<void> {
const record: PracticeRecord | null = this.pendingRecord;
const store: ReliablePracticeStore | null = this.reliableStore;
if (this.historySaving || record === null || store === null) {
return;
}
this.historySaving = true;
this.historySaveMessage = '正在保存本次结果';
try {
const result: HistorySaveResult = await store.addRecord(record);
if (result.state === HistorySaveState.SAVED) {
this.records = result.records;
this.pendingRecord = null;
}
this.historySaveMessage = result.message;
} finally {
this.historySaving = false;
}
}
这段片段采用“结果未处理完,不开始下一轮”的交互前提,因此只保留一个待存记录。下一轮按钮必须同时受 historySaving 和待存状态约束;不能一边保留一个字段,一边允许用户连续完成十轮,最后覆盖掉前九轮待存结果。
pendingRecord 是普通字段,控制界面需另设状态变量。失败后展示“成绩已生成,历史尚未保存”和重试入口;离开前说明可能失去当前待存机会。
七、失败不等于自动重试,重试也不能无限循环
一次异常不能证明空间不足,不应自动清空历史。按阶段给出克制的提示:
| 阶段 | 提示方向 |
|---|---|
| read | 历史暂时无法读取,未覆盖旧记录 |
| put或flush | 保存未确认,请保留结果后重试 |
| recover或open | 本地存储暂不可用 |
| encode | 结果内容异常,请反馈 |
自动重试应限次并退避,退出页面时停止调度。跨重启恢复需要已成功持久化的待存账本;把数据再写回同一失败介质,不能保证永久不丢失。
八、验证矩阵要包含写失败和再次启动,不只看一次正常保存
使用独立测试库,在存储边界注入失败,不破坏用户历史;正式设备补充正常保存、重启回读和连续提交验收。
| 场景 | 注入或操作 | 预期结果 |
|---|---|---|
| 首次使用 | 没有历史键 | 第一条保存成功,重启后存在 |
| 打开失败 | 获取实例失败 | FAILED/open,正式历史不变 |
| 历史值类型错误 | 用测试库写入非字符串 | FAILED/read,不覆盖原值 |
| JSON损坏 | 用测试库写入截断文本 | FAILED/read,不降级为空数组 |
| put失败 | 存储边界拒绝put | FAILED/put,待存记录仍在 |
| flush失败 | put完成后拒绝flush | FAILED/flush,不得显示已保存 |
| 失败后重试 | 恢复存储,再用原ID提交 | 重新加载并合并,只保留一条同ID记录 |
| 重复点击重试 | 快速点击两次 | 页面门闩只发出一次任务 |
| 连续两个不同结果 | 并发调用服务追加A和B | 队列后续读取包含前次结果 |
| 已有100条 | 保存更新的结果 | 保留100条且顺序一致 |
| 保存失败后强杀 | 待存仍在内存 | 不承诺恢复,验证提示文案准确 |
接入后运行 hvigorw assembleHap --no-daemon,再核对服务返回、页面显示和重启回读。构建通过不能代替运行验收。
九、并发、页面退出和清空历史必须使用同一个所有者
若设置页仍调用旧 Store,清空与追加仍会互相覆盖。迁移时列出 exam_history 的所有入口,逐一切换。
| 操作 | 必须共享的约束 |
|---|---|
| 增加记录 | 同一服务实例、同一写入队列 |
| 清空记录 | 排在已有写任务之后,并清理待存策略 |
| 页面刷新 | 保存失败后不绕过服务读取可能未确认的缓存 |
| 应用退出 | 不把内存待存状态描述成已落盘 |
| 多窗口或多进程 | 需要额外协调,不能只复用本文队列 |
清空后自动重试旧待存,会让记录重新出现。应明确选择“清空同时放弃待存”或“处理待存后再清空”,不能由异步完成顺序决定。
页面销毁后,已经开始的持久化任务可能仍会完成。正式接入应让服务继续负责落盘,并用页面生命周期标记阻止过期回调写入已经失效的 UI;服务完成不等于一定要立刻弹出一个全局提示。
十、故障定位表与最小迁移顺序
| 现象 | 优先检查 | 修复方向 |
|---|---|---|
| 页面有记录,重启没有 | 是否吞掉flush异常 | 返回明确失败,不更新正式历史 |
| 重试出现两条相同内容 | 是否每次重新创建ID | 首次固定记录,重试复用原ID |
| 保存一条后旧历史全没了 | 读取异常是否兜底成空数组 | 严格读取失败即停止写入 |
| A、B快速保存只剩B | 队列是否覆盖整个读改写 | 串行处理完整事务边界 |
| flush失败但读回有新值 | 读回的是否仍是缓存 | 重建实例后核对持久化文件 |
| 清空后记录重新出现 | 是否还有排队保存或待存重试 | 统一所有写入口和清空语义 |
| 保存失败导致考试变不合格 | 把业务结果和存储状态混成一个布尔值 | 分开成绩状态与保存状态 |
先改返回契约,再统一队列、同 ID 重试与缓存恢复,最后梳理清空和退出。保存接口必须明确:哪条记录确认保存、哪一步失败、是否保留重试机会。
更多推荐

所有评论(0)