HarmonyOS 应用实战 51:崩溃后别让上次操作消失,用最小账本恢复入口而不重复写入

用户在“答案之书”里导入题库、编辑内容或进入抽取动画时,系统可能因为内存回收、闪退或强制关闭而中断。最危险的处理方式不是没有恢复,而是把整份页面状态保存下来,下一次启动后又把已经写入的数据再写一遍。

恢复能力要保存的不是页面,而是可判断的操作意图:它做什么、作用于谁、走到哪个阶段。只有这样,启动后才能决定继续进入某个页面、提示用户重新操作,还是直接丢弃已失效的临时状态。

在这里插入图片描述

先看现有启动链:页面不是最早的恢复入口

当前工程的 EntryAbility.onWindowStageCreate 明确要求在 loadContent('pages/Index') 前依次完成 PreferencesStore.init(ctx)SeedLoader.run(ctx)。代码注释给出的原因很具体:如果 UI 已挂载而偏好仓储和默认题库还没准备好,DeckPicker.aboutToAppear 会读到空数据,AppStorage 的 watch 也不会替它补上那次初始化。

这意味着恢复账本若要落地,读取时机应在仓储初始化之后、页面恢复决策之前;不能让 Index 或某个 Sheet 自己猜“上次用户在做什么”。

阶段 当前工程 owner 恢复账本应做什么
启动初始化 EntryAbility + PreferencesStore 确保账本可读取
默认数据准备 SeedLoader 先恢复可用题库与 currentDeckId
页面导航 Navigation + NavPathStack 只接收已校验的恢复意图
题库写入 DeckService 以真实持久化结果决定是否已提交

在这里插入图片描述
在这里插入图片描述

不要保存组件快照,只保存恢复所需的最小事实

@State、动画进度、输入框光标、Sheet 展开状态都属于当前 ArkUI 组件;它们在重启后没有可靠语义。真正值得跨启动保存的是操作类型、对象 id、阶段和时间。问题、答案全文和整份题库不应写进账本。

type RecoverableActionKind = 'draw' | 'editDeck' | 'importDeck';
type RecoverableActionPhase = 'started' | 'committed';

interface LastActionRecord {
  kind: RecoverableActionKind;
  phase: RecoverableActionPhase;
  subjectId?: string;
  createdAt: number;
}

这是一项建议新增的模型,并非当前工程已有实现。它的边界很窄:subjectId 可以是题库 id,不能是整份 SaveDeckPayloadphase 只表达是否已经完成不可逆提交,不能冒充页面动画是否结束。

把 started 写在意图建立后,把 committed 写在仓储成功后

以编辑题库为例,DeckService.save 的真实写路径是:清洗名称和答案、构造完整 Deck、调用 DeckRepository.saveDeck、最后更新 LastDeckUpdateAt。因此账本的提交标记必须晚于 Repository 成功,不能在点击“保存”时立刻写成 committed。

class LastActionLedger {
  async markStarted(kind: RecoverableActionKind, subjectId?: string): Promise<void> {
    const record: LastActionRecord = {
      kind,
      phase: 'started',
      subjectId,
      createdAt: Date.now()
    };
    await PreferencesStore.setJson(PrefStoreName.App, 'last_action', record);
  }

  async markCommitted(record: LastActionRecord): Promise<void> {
    await PreferencesStore.setJson(PrefStoreName.App, 'last_action', {
      ...record,
      phase: 'committed'
    });
  }

  async clear(): Promise<void> {
    await PreferencesStore.delete(PrefStoreName.App, 'last_action');
  }
}

started 的意义是“可以向用户解释中断发生在哪类操作中”,而非允许自动重放。committed 的意义是“数据已经由仓储确认写入”,恢复时绝不再调用一次保存。这样可以避免导入或新建题库在重启后被复制两次。

保存动作如何接入:不改变 DeckService 的责任

不要把账本逻辑塞进 DeckService.save 的每一个内部细节。DeckService 仍然负责数据校验、持久化和刷新;协调器只负责在调用前后记录恢复语义。

class DeckEditRecoveryCoordinator {
  constructor(private readonly ledger: LastActionLedger) {}

  async save(payload: SaveDeckPayload): Promise<DeckSummary> {
    await this.ledger.markStarted('editDeck', payload.id);
    const summary: DeckSummary = await DeckService.save(payload);
    await this.ledger.markCommitted({
      kind: 'editDeck',
      phase: 'started',
      subjectId: summary.id,
      createdAt: Date.now()
    });
    return summary;
  }
}

这段协调代码的输入仍是现有 SaveDeckPayload,输出仍是 DeckSummary。它不复制 DeckService 的名称长度、最小答案数等校验规则,也不直接写 AppStorage;后者应继续由 DeckService 在成功持久化后处理。

恢复时先判断对象是否还存在

账本记录并不保证对象还可用:用户可能在另一入口删除了题库,种子升级可能已恢复默认题库,或者 started 阶段根本还没有生成 id。恢复时应先让现有服务验证对象,再决定导航或清理。

type RecoveryDecision =
  | { kind: 'resume-edit'; deckId: string }
  | { kind: 'show-retry-import' }
  | { kind: 'discard'; reason: string };

async function resolveRecovery(record: LastActionRecord): Promise<RecoveryDecision> {
  if (record.phase === 'committed') {
    return { kind: 'discard', reason: '数据已经提交,不应重复执行' };
  }
  if (record.kind === 'editDeck' && record.subjectId) {
    const deck: Deck | null = await DeckService.get(record.subjectId);
    return deck
      ? { kind: 'resume-edit', deckId: deck.id }
      : { kind: 'discard', reason: '题库已不存在' };
  }
  if (record.kind === 'importDeck') {
    return { kind: 'show-retry-import' };
  }
  return { kind: 'discard', reason: '抽取页不恢复未稳定动画' };
}

这里故意没有“自动重新导入”或“自动重新抽取”。导入原文本可能没有保存,抽取动画的中间状态没有业务价值;恢复入口应让用户看见明确选择,而不是偷偷再次执行动作。

Navigation 只接收校验后的恢复参数

项目当前路由参数已经区分 DrawingParamsAnswerParamsDeckEditParams。账本恢复应复用这些已有契约,而不是在 NavPathStack 里塞一份未知对象。

async function applyRecovery(stack: NavPathStack, decision: RecoveryDecision): Promise<void> {
  if (decision.kind === 'resume-edit') {
    stack.pushPath({ name: RouteName.DeckEdit, param: { deckId: decision.deckId } });
    return;
  }
  if (decision.kind === 'show-retry-import') {
    // 当前工程没有导入恢复页;这是建议新增入口,不能伪称现有路由。
    return;
  }
}

resume-edit 只在 DeckService.get 确认题库存在后发生。抽取与答案页则依赖更完整的参数和稳定结果,不适合作为“崩溃后继续动画”的目标。

四种中断分别怎样处理

中断点 账本状态 恢复策略 禁止做的事
保存前退出 started 确认题库仍存在后可回编辑页 自动调用 save
Repository 保存后退出 committed 清理账本,读取现有题库 再次创建同名题库
导入预览中退出 started/importDeck 提示重新选择导入内容 假装原始文本仍在内存
抽取动画中退出 started/draw 丢弃中间动画,回到首页 恢复未知随机中间态

表格的判断依据是“是否已经形成稳定业务事实”,不是用户感觉操作走到了第几步。只要没有可靠的提交证据,就不能做自动重放。

验证必须覆盖重复写入与冷启动

建议把用例分成服务层和真机路径两部分:

  1. 模拟 DeckService.save 前抛错,断言账本保留 started,题库数量不变。
  2. 模拟 Repository 成功后、markCommitted 前中断,下一次启动先读取题库;即便账本仍是 started,也不能盲目重放保存。
  3. 删除账本指向的自定义题库,确认恢复决策为 discard,不构造失效 DeckEditParams
  4. 对 import 与 draw 两种 started 记录,确认应用只提供重试入口或丢弃,不保存输入正文与动画状态。
  5. 真机强制结束应用后重启,确认 EntryAbility 仍先完成 PreferencesStore.initSeedLoader.run,再进入恢复决策。
验收记录应至少包含:
- 账本读写是否发生在仓储初始化之后;
- committed 是否只在 Repository 成功后出现;
- 恢复路径是否再次调用了写入服务;
- 日志中是否只出现操作类型、对象 id 摘要与阶段;
- 冷启动后当前题库与题库列表是否仍一致。

常见误判与修复

现象 根因 修复
重启后出现两本相同题库 started 被当成可自动重放 只恢复入口,不重放写入
恢复页跳转后白屏 账本 id 没有经过 DeckService.get 校验 校验存在性后再构造路由参数
页面显示“已保存”,数据却不存在 点击时提前标 committed Repository 成功后才标记提交
诊断记录泄露问题文本 将完整 payload 直接写入账本 账本只保留 kind、phase、id、时间

小结

恢复账本不是“页面存档”。它是启动期在仓储已就绪后读取的一小段业务证据:什么操作中断了、是否已经提交、对象还能不能找到。把 startedcommitted 分开,把恢复决策交给现有服务和路由契约,就能避免应用在崩溃后既丢入口又重复写入。

Logo

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

更多推荐