在这里插入图片描述

鸿蒙 PC Markdown 编辑器可靠性设计:未保存内容的崩溃恢复闭环

桌面编辑器的崩溃恢复不能只在启动时弹出一句“发现草稿”。可靠闭环必须回答:输入多久落一次恢复记录、连续输入是否造成高频全文写入、记录写到哪里、写一半进程退出会怎样、恢复后为什么仍是未保存、用户保存或放弃后何时清理、旧异步写入会不会在清理后重新出现。

本文基于鸿蒙 PC Markdown 编辑器 OhMarkdown,拆解 CodeMirror 节流快照、ArkTS写入队列、沙箱原子记录、启动校验和用户确认组成的恢复链路。完整代码位于 https://gitcode.com/VON-/codex_md_oh

恢复目标先于实现

当前 Alpha目标是常规文档未保存输入的恢复点目标不超过两秒。Web内核使用 1.5秒定时器,为 Bridge、文件写入和调度留出余量。五兆字符以上文档不周期传输全文,因为一次按键后跨运行时复制数兆字符串会伤害输入延迟和内存;大文档恢复需要未来增量日志。

恢复记录只位于应用沙箱,不直接覆盖用户文件。自动恢复内容和显式保存是两种权限语义:前者保护编辑缓冲区,后者才修改用户选择的 URI。应用重启后必须询问恢复或丢弃,不能静默写回外部文件。

当前恢复重点是活动会话。多标签后台脏文档尚未形成完整恢复集合,因此不能把“多标签内存隔离”误写成“所有标签崩溃后都恢复”。

Web 侧用节流而不是每键写入

编辑变更后安排快照:

const RECOVERY_SNAPSHOT_INTERVAL_MILLISECONDS = 1500;

function scheduleRecoverySnapshot(): void {
  if (!pendingDirty || largeDocumentMode) {
    window.clearTimeout(recoveryTimer);
    recoveryTimer = undefined;
    return;
  }
  if (recoveryTimer !== undefined) {
    return;
  }

  recoveryTimer = window.setTimeout(
    flushRecoverySnapshot,
    RECOVERY_SNAPSHOT_INTERVAL_MILLISECONDS
  );
}

定时器已存在时不重复创建。连续输入期间,第一次变更启动计时,后续变更只更新当前 EditorState和 revision;触发时发送最新全文。这是节流,不是每次输入重新延后。它保证持续写作也会定期产生恢复点,而普通防抖可能在用户长时间不停输入时一直不写。

文档撤销回保存基线后 pendingDirty=false,函数清除定时器。大文档同样清除,避免早先普通文档留下的定时任务在模式变化后误发全文。

revision 防止重复快照

真正发送前再次校验:

function flushRecoverySnapshot(): void {
  window.clearTimeout(recoveryTimer);
  recoveryTimer = undefined;
  if (!pendingDirty ||
    largeDocumentMode ||
    documentRevision === lastRecoveryRevision) {
    return;
  }

  window.ohMarkdownBridge?.onSnapshot(
    editor.state.sliceDoc(),
    documentRevision
  );
  lastRecoveryRevision = documentRevision;
}

dirty可能在计时期间改变,所以触发时不能只相信安排时状态。revision与 lastRecoveryRevision 相等表示这一版本已发送,不需要重复写。保存命令会立即调用 flushRecoverySnapshot(),确保用户输入后马上保存时最后版本先进入恢复区;写入成功后再清理。

revision是整数,原生侧拒绝负数和非整数。它不是时间戳,避免系统时钟变化影响顺序。每个 Web会话保存自己的 revision和 lastRecoveryRevision,标签切换不会借用另一文档版本。

原生边界限制正文大小

JavaScript Proxy回调进入 ArkTS后先验证:

private onEditorSnapshot(
  content: string,
  revision: number
): void {
  if (content.length > MAX_RECOVERY_CHARACTERS ||
    revision < 0 ||
    revision % 1 !== 0) {
    this.operationStatus = 'Recovery snapshot rejected';
    return;
  }

  const context = this.getHostContext();
  if (!context) {
    return;
  }
  this.pendingRecoveryRecord = {
    version: 1,
    documentUri: this.documentUri,
    documentName: this.documentName,
    content,
    hasUtf8Bom: this.documentFormat.hasUtf8Bom,
    lineEnding: this.documentFormat.lineEnding,
    revision,
    updatedAt: Date.now()
  };
  this.flushRecoveryQueue(context.filesDir);
}

记录不只保存正文,还保存 URI、名称、BOM和换行格式。恢复后再次保存必须保留原文档字节语义。version: 1 为未来格式迁移提供判断,updatedAt 用于显示和清理策略,不替代 revision。

Bridge内容不可信,即使来自应用自己的 Web页也要限制。页面版本不一致、脚本缺陷或异常输入不能让沙箱无限增长。

单写入者队列合并高频版本

文件写入可能慢于快照到达。ArkTS不为每个回调并行写,而是保留最新 pending记录:

private async flushRecoveryQueue(
  filesDir: string
): Promise<void> {
  if (this.recoveryWriteInProgress) {
    return;
  }

  this.recoveryWriteInProgress = true;
  try {
    while (this.pendingRecoveryRecord !== undefined) {
      const record = this.pendingRecoveryRecord;
      const generation = this.recoveryGeneration;
      this.pendingRecoveryRecord = undefined;
      await saveRecoveryRecord(filesDir, record);
      if (generation !== this.recoveryGeneration) {
        await clearRecoveryRecord(filesDir);
      }
    }
  } finally {
    this.recoveryWriteInProgress = false;
  }
}

写入进行时新快照只覆盖 pendingRecoveryRecord,中间版本自然合并。第一笔完成后 while读取最新记录。恢复需求关注最近状态,不需要把每个按键版本都写到磁盘。

异常时若没有更新版本,会把失败记录放回 pending并显示错误;当前不会无限自动重试,避免故障存储形成忙循环。后续一次新快照会再次触发队列。

generation 解决清理竞态

用户保存成功或明确放弃时调用:

private clearRecoveryDraft(): void {
  const context = this.getHostContext();
  if (!context) {
    return;
  }

  this.recoveryGeneration += 1;
  this.pendingRecoveryRecord = undefined;
  clearRecoveryRecord(context.filesDir).catch(() => {
    this.operationStatus =
      'Unable to clear recovery record';
  });
}

只删除文件不够。假设旧快照正在写,用户保存成功后删除记录,旧写入随后完成,恢复文件又出现。队列在写前捕获 generation,完成后发现 generation变化,就再次删除。这个代际标记把“清理”定义成逻辑边界,而不只是一次文件操作。

切换标签还会清理 Web定时器,避免甲快照以乙身份进入原生层。恢复可靠性依赖定时器、队列和会话三者共同处理身份变化。

记录写入前做完整校验

RecoveryService验证版本、字段类型、长度、换行枚举、revision和时间:

function isRecoveryRecordValid(
  record: RecoveryRecord
): boolean {
  return record.version === 1 &&
    typeof record.documentUri === 'string' &&
    record.documentUri.length <= 4096 &&
    typeof record.documentName === 'string' &&
    record.documentName.length > 0 &&
    record.documentName.length <= 255 &&
    typeof record.content === 'string' &&
    record.content.length <= MAX_RECOVERY_CHARACTERS &&
    typeof record.hasUtf8Bom === 'boolean' &&
    isLineEndingValid(record.lineEnding) &&
    Number.isInteger(record.revision) &&
    record.revision >= 0 &&
    typeof record.updatedAt === 'number' &&
    record.updatedAt > 0;
}

写入前校验防止生成永远无法读取的记录。读取时再次校验,因为沙箱文件可能被旧版本留下、写坏或手工修改。恢复逻辑不能因为一份损坏 JSON阻止应用启动。

文件大小上限高于正文字符上限,为 JSON转义和多字节 UTF-8预留空间。读取前先 stat,空文件或超过上限直接忽略,避免解析不可信大文件。

AtomicFile 防止半截 JSON

记录通过 AtomicFile 写入,只有完整流结束后才提交:

const atomicFile = new fileIo.AtomicFile(path);
let writeStarted = false;
try {
  const writeStream = atomicFile.startWrite();
  writeStarted = true;
  await new Promise<void>((resolve, reject) => {
    writeStream.end(payload, 'utf-8', (error?: Error) => {
      error ? reject(error) : resolve();
    });
  });
  atomicFile.finishWrite();
  writeStarted = false;
} catch (error) {
  if (writeStarted) {
    atomicFile.failWrite();
  }
  throw error;
}

曾经使用 writeStream.write 后立即 finish,流尚未结束可能造成提交时序不完整。改为 end并等待回调后再 finish,明确关闭写入流。提交后还比较磁盘字节大小与 UTF-8预期,防止看似成功的短写。

启动时先处理保存备份,再处理草稿

应用 ready后依次检查:

private async checkStartupRecords(): Promise<void> {
  await this.checkPendingSaveBackup();
  await this.checkRecoveryRecord();
}

保存中断备份优先,因为它关系外部文件旧版本;未保存草稿关系编辑缓冲区。二者含义不同,不能用一个“恢复”按钮混合。

加载恢复记录时 JSON解析或校验失败返回 undefined,不阻塞启动。有效记录显示 Discard和 Recover。选择恢复后先尝试读取磁盘正文作为 persisted基线,再把记录正文、URI、格式和 revision应用到会话,documentDirty=true,Web以 recovered模式加载。

恢复后不能立即清除记录。只有保存成功、撤销回可靠基线或用户明确丢弃后清理。否则应用恢复后再次崩溃,会失去唯一副本。

鸿蒙 PC 模拟器验证

下图来自 MateBook Pro 2in1模拟器。应用输入未保存内容,等待快照后被强制停止,再次启动显示恢复确认;选择恢复后正文回到 CodeMirror且标签保持未保存状态。

在这里插入图片描述

验证不能用正常退出代替强杀。正常生命周期可能有额外清理机会,只有系统强制停止才能覆盖进程突然终止。还应在快照写入前、写入中、写入后分别终止,并重复多轮检查记录不损坏。

Playwright验证输入后200毫秒没有快照、累计约1.6秒出现 revision为1的快照;恢复文档加载后 dirty仍为 true。模拟器验证系统生命周期和对话框,两层证据互补。

当前边界

五兆以上文档没有周期全文恢复;完整多标签恢复未实现;恢复记录未加密,依赖应用沙箱隔离;记录只有当前一份,没有历史版本;重复强杀和长期压力仍需内部试用。恢复内容若对应外部已修改文件,保存时会进入冲突保护,不能静默覆盖。

下一步应采用增量操作日志或分块快照支持大文档,为每个脏 session维护独立记录,设置总容量和淘汰策略,并在设备上进行大量随机终止测试。任何扩展都要保留原子提交和字段校验。

结语

崩溃恢复闭环由多个小约束组成:Web按1.5秒节流最新版本,revision去重,原生限制内容并用单写入者队列合并,generation阻止旧写入复活,AtomicFile提交完整 JSON,启动时校验并让用户决定,恢复后继续保持 dirty。

只有保存成功或明确丢弃才清理恢复记录,这条规则贯穿所有路径。对鸿蒙 PC Markdown 编辑器而言,恢复能力不是附加功能,而是用户敢于把真实长文档交给应用的最低信任基础。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐