鸿蒙 PC Markdown 编辑器可靠性设计:未保存内容的崩溃恢复闭环
鸿蒙 PC Markdown 编辑器可靠性设计:未保存内容的崩溃恢复闭环
桌面编辑器的崩溃恢复不能只在启动时弹出一句“发现草稿”。可靠闭环必须回答:输入多久落一次恢复记录、连续输入是否造成高频全文写入、记录写到哪里、写一半进程退出会怎样、恢复后为什么仍是未保存、用户保存或放弃后何时清理、旧异步写入会不会在清理后重新出现。
本文基于鸿蒙 PC Markdown 编辑器 OhMarkdown,拆解 CodeMirror 节流快照、ArkTS写入队列、沙箱原子记录、启动校验和用户确认组成的恢复链路。完整代码位于 https://gitcode.com/VON-/codex_md_oh
恢复目标先于实现
当前 Alpha目标是常规文档未保存输入的恢复点目标不超过两秒。Web内核使用 1.5秒定时器,为 Bridge、文件写入和调度留出余量。五兆字符以上文档不周期传输全文,因为一次按键后跨运行时复制数兆字符串会伤害输入延迟和内存;大文档恢复需要未来增量日志。
恢复记录只位于应用沙箱,不直接覆盖用户文件。自动恢复内容和显式保存是两种权限语义:前者保护编辑缓冲区,后者才修改用户选择的 URI。应用重启后必须询问恢复或丢弃,不能静默写回外部文件。
当前恢复重点是活动会话。多标签后台脏文档尚未形成完整恢复集合,因此不能把“多标签内存隔离”误写成“所有标签崩溃后都恢复”。
Web 侧用节流而不是每键写入
编辑变更后安排快照:
constRECOVERY_SNAPSHOT_INTERVAL_MILLISECONDS=1500;functionscheduleRecoverySnapshot():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 防止重复快照
真正发送前再次校验:
functionflushRecoverySnapshot():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后先验证:
privateonEditorSnapshot(content:string,revision:number):void{if(content.length>MAX_RECOVERY_CHARACTERS||revision<0||revision%1!==0){this.operationStatus='Recovery snapshot rejected';return;}constcontext=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记录:
privateasyncflushRecoveryQueue(filesDir:string):Promise<void>{if(this.recoveryWriteInProgress){return;}this.recoveryWriteInProgress=true;try{while(this.pendingRecoveryRecord!==undefined){constrecord=this.pendingRecoveryRecord;constgeneration=this.recoveryGeneration;this.pendingRecoveryRecord=undefined;awaitsaveRecoveryRecord(filesDir,record);if(generation!==this.recoveryGeneration){awaitclearRecoveryRecord(filesDir);}}}finally{this.recoveryWriteInProgress=false;}}写入进行时新快照只覆盖pendingRecoveryRecord,中间版本自然合并。第一笔完成后 while读取最新记录。恢复需求关注最近状态,不需要把每个按键版本都写到磁盘。
异常时若没有更新版本,会把失败记录放回 pending并显示错误;当前不会无限自动重试,避免故障存储形成忙循环。后续一次新快照会再次触发队列。
generation 解决清理竞态
用户保存成功或明确放弃时调用:
privateclearRecoveryDraft():void{constcontext=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和时间:
functionisRecoveryRecordValid(record:RecoveryRecord):boolean{returnrecord.version===1&&typeofrecord.documentUri==='string'&&record.documentUri.length<=4096&&typeofrecord.documentName==='string'&&record.documentName.length>0&&record.documentName.length<=255&&typeofrecord.content==='string'&&record.content.length<=MAX_RECOVERY_CHARACTERS&&typeofrecord.hasUtf8Bom==='boolean'&&isLineEndingValid(record.lineEnding)&&Number.isInteger(record.revision)&&record.revision>=0&&typeofrecord.updatedAt==='number'&&record.updatedAt>0;}写入前校验防止生成永远无法读取的记录。读取时再次校验,因为沙箱文件可能被旧版本留下、写坏或手工修改。恢复逻辑不能因为一份损坏 JSON阻止应用启动。
文件大小上限高于正文字符上限,为 JSON转义和多字节 UTF-8预留空间。读取前先 stat,空文件或超过上限直接忽略,避免解析不可信大文件。
AtomicFile 防止半截 JSON
记录通过AtomicFile写入,只有完整流结束后才提交:
constatomicFile=newfileIo.AtomicFile(path);letwriteStarted=false;try{constwriteStream=atomicFile.startWrite();writeStarted=true;awaitnewPromise<void>((resolve,reject)=>{writeStream.end(payload,'utf-8',(error?:Error)=>{error?reject(error):resolve();});});atomicFile.finishWrite();writeStarted=false;}catch(error){if(writeStarted){atomicFile.failWrite();}throwerror;}曾经使用writeStream.write后立即 finish,流尚未结束可能造成提交时序不完整。改为end并等待回调后再 finish,明确关闭写入流。提交后还比较磁盘字节大小与 UTF-8预期,防止看似成功的短写。
启动时先处理保存备份,再处理草稿
应用 ready后依次检查:
privateasynccheckStartupRecords():Promise<void>{awaitthis.checkPendingSaveBackup();awaitthis.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 编辑器而言,恢复能力不是附加功能,而是用户敢于把真实长文档交给应用的最低信任基础。