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

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


所有评论(0)