练习结束后历史多了一条,重启却消失了:底层写入失败,服务层仍返回拼好的数组,页面把候选数据误当作已保存数据。

历史写入失败与可恢复结果

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,失败重试继续使用同一个 idcreatedAt。不要点击重试就调用原 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 队列的保护范围。

正式历史、待存结果、写入队列与Preferences责任

六、结果页保留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失败存储边界拒绝putFAILED/put,待存记录仍在
flush失败put完成后拒绝flushFAILED/flush,不得显示已保存
失败后重试恢复存储,再用原ID提交重新加载并合并,只保留一条同ID记录
重复点击重试快速点击两次页面门闩只发出一次任务
连续两个不同结果并发调用服务追加A和B队列后续读取包含前次结果
已有100条保存更新的结果保留100条且顺序一致
保存失败后强杀待存仍在内存不承诺恢复,验证提示文案准确

接入后运行 hvigorw assembleHap --no-daemon,再核对服务返回、页面显示和重启回读。构建通过不能代替运行验收。

九、并发、页面退出和清空历史必须使用同一个所有者

若设置页仍调用旧 Store,清空与追加仍会互相覆盖。迁移时列出 exam_history 的所有入口,逐一切换。

操作必须共享的约束
增加记录同一服务实例、同一写入队列
清空记录排在已有写任务之后,并清理待存策略
页面刷新保存失败后不绕过服务读取可能未确认的缓存
应用退出不把内存待存状态描述成已落盘
多窗口或多进程需要额外协调,不能只复用本文队列

清空后自动重试旧待存,会让记录重新出现。应明确选择“清空同时放弃待存”或“处理待存后再清空”,不能由异步完成顺序决定。

页面销毁后,已经开始的持久化任务可能仍会完成。正式接入应让服务继续负责落盘,并用页面生命周期标记阻止过期回调写入已经失效的 UI;服务完成不等于一定要立刻弹出一个全局提示。

十、故障定位表与最小迁移顺序

现象优先检查修复方向
页面有记录,重启没有是否吞掉flush异常返回明确失败,不更新正式历史
重试出现两条相同内容是否每次重新创建ID首次固定记录,重试复用原ID
保存一条后旧历史全没了读取异常是否兜底成空数组严格读取失败即停止写入
A、B快速保存只剩B队列是否覆盖整个读改写串行处理完整事务边界
flush失败但读回有新值读回的是否仍是缓存重建实例后核对持久化文件
清空后记录重新出现是否还有排队保存或待存重试统一所有写入口和清空语义
保存失败导致考试变不合格把业务结果和存储状态混成一个布尔值分开成绩状态与保存状态

先改返回契约,再统一队列、同 ID 重试与缓存恢复,最后梳理清空和退出。保存接口必须明确:哪条记录确认保存、哪一步失败、是否保留重试机会。

Logo

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

更多推荐