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

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

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

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

恢复目标先于实现

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

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

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

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

编辑变更后安排快照:

ts 复制代码
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 防止重复快照

真正发送前再次校验:

ts 复制代码
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后先验证:

ts 复制代码
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记录:

ts 复制代码
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 解决清理竞态

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

ts 复制代码
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和时间:

ts 复制代码
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 写入,只有完整流结束后才提交:

ts 复制代码
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后依次检查:

ts 复制代码
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 编辑器而言,恢复能力不是附加功能,而是用户敢于把真实长文档交给应用的最低信任基础。

相关推荐
梦想不只是梦与想12 小时前
HarmonyOS应用分层架构设计
harmonyos·分层架构·一次开发,多端部署
MardaWang14 小时前
当滚动逃离了框架 ——HarmonyOS Web 与原生混排滚动的冲突本质与解法
harmonyos·arkts·鸿蒙·deveco studio
tsqtsqtsq030915 小时前
DevEco Studio 介绍
harmonyos
HwJack2018 小时前
【共创稿事节】HarmonyOS 7文旅展陈展厅大空间 3DGS 重建的分块策略与拼接踩坑
3d·华为·harmonyos
golang学习记19 小时前
VSCode AI完成任务的仪式感:撒礼花
ide·vscode·编辑器
熊猫钓鱼>_>20 小时前
Kotlin Multiplatform for OpenHarmony 实战:为 kotlin-inject 实现依赖注入适配
开发语言·华为·kotlin·ai编程·inject·鸿蒙·openharmony
m0_7381858220 小时前
Flutter 鸿蒙化实战:media_info 适配 OpenHarmony,媒体信息与缩略图
flutter·华为·harmonyos·鸿蒙·媒体
m0_7381858221 小时前
Flutter 鸿蒙化实战:just_audio 适配 OpenHarmony,功能强大的播放器
flutter·华为·harmonyos·鸿蒙
翼辉cto21 小时前
Kotlin Multiplatform 三方库 SQLDelight 的 OpenHarmony 鸿蒙化适配实战
开发语言·kotlin·harmonyos