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

发布时间:2026/7/18 21:45:19
鸿蒙 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_MILLISECONDS1500;functionscheduleRecoverySnapshot():void{if(!pendingDirty||largeDocumentMode){window.clearTimeout(recoveryTimer);recoveryTimerundefined;return;}if(recoveryTimer!undefined){return;}recoveryTimerwindow.setTimeout(flushRecoverySnapshot,RECOVERY_SNAPSHOT_INTERVAL_MILLISECONDS);}定时器已存在时不重复创建。连续输入期间第一次变更启动计时后续变更只更新当前 EditorState和 revision触发时发送最新全文。这是节流不是每次输入重新延后。它保证持续写作也会定期产生恢复点而普通防抖可能在用户长时间不停输入时一直不写。文档撤销回保存基线后pendingDirtyfalse函数清除定时器。大文档同样清除避免早先普通文档留下的定时任务在模式变化后误发全文。revision 防止重复快照真正发送前再次校验functionflushRecoverySnapshot():void{window.clearTimeout(recoveryTimer);recoveryTimerundefined;if(!pendingDirty||largeDocumentMode||documentRevisionlastRecoveryRevision){return;}window.ohMarkdownBridge?.onSnapshot(editor.state.sliceDoc(),documentRevision);lastRecoveryRevisiondocumentRevision;}dirty可能在计时期间改变所以触发时不能只相信安排时状态。revision与lastRecoveryRevision相等表示这一版本已发送不需要重复写。保存命令会立即调用flushRecoverySnapshot()确保用户输入后马上保存时最后版本先进入恢复区写入成功后再清理。revision是整数原生侧拒绝负数和非整数。它不是时间戳避免系统时钟变化影响顺序。每个 Web会话保存自己的 revision和 lastRecoveryRevision标签切换不会借用另一文档版本。原生边界限制正文大小JavaScript Proxy回调进入 ArkTS后先验证privateonEditorSnapshot(content:string,revision:number):void{if(content.lengthMAX_RECOVERY_CHARACTERS||revision0||revision%1!0){this.operationStatusRecovery snapshot rejected;return;}constcontextthis.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):Promisevoid{if(this.recoveryWriteInProgress){return;}this.recoveryWriteInProgresstrue;try{while(this.pendingRecoveryRecord!undefined){constrecordthis.pendingRecoveryRecord;constgenerationthis.recoveryGeneration;this.pendingRecoveryRecordundefined;awaitsaveRecoveryRecord(filesDir,record);if(generation!this.recoveryGeneration){awaitclearRecoveryRecord(filesDir);}}}finally{this.recoveryWriteInProgressfalse;}}写入进行时新快照只覆盖pendingRecoveryRecord中间版本自然合并。第一笔完成后 while读取最新记录。恢复需求关注最近状态不需要把每个按键版本都写到磁盘。异常时若没有更新版本会把失败记录放回 pending并显示错误当前不会无限自动重试避免故障存储形成忙循环。后续一次新快照会再次触发队列。generation 解决清理竞态用户保存成功或明确放弃时调用privateclearRecoveryDraft():void{constcontextthis.getHostContext();if(!context){return;}this.recoveryGeneration1;this.pendingRecoveryRecordundefined;clearRecoveryRecord(context.filesDir).catch((){this.operationStatusUnable to clear recovery record;});}只删除文件不够。假设旧快照正在写用户保存成功后删除记录旧写入随后完成恢复文件又出现。队列在写前捕获 generation完成后发现 generation变化就再次删除。这个代际标记把“清理”定义成逻辑边界而不只是一次文件操作。切换标签还会清理 Web定时器避免甲快照以乙身份进入原生层。恢复可靠性依赖定时器、队列和会话三者共同处理身份变化。记录写入前做完整校验RecoveryService验证版本、字段类型、长度、换行枚举、revision和时间functionisRecoveryRecordValid(record:RecoveryRecord):boolean{returnrecord.version1typeofrecord.documentUristringrecord.documentUri.length4096typeofrecord.documentNamestringrecord.documentName.length0record.documentName.length255typeofrecord.contentstringrecord.content.lengthMAX_RECOVERY_CHARACTERStypeofrecord.hasUtf8BombooleanisLineEndingValid(record.lineEnding)Number.isInteger(record.revision)record.revision0typeofrecord.updatedAtnumberrecord.updatedAt0;}写入前校验防止生成永远无法读取的记录。读取时再次校验因为沙箱文件可能被旧版本留下、写坏或手工修改。恢复逻辑不能因为一份损坏 JSON阻止应用启动。文件大小上限高于正文字符上限为 JSON转义和多字节 UTF-8预留空间。读取前先 stat空文件或超过上限直接忽略避免解析不可信大文件。AtomicFile 防止半截 JSON记录通过AtomicFile写入只有完整流结束后才提交constatomicFilenewfileIo.AtomicFile(path);letwriteStartedfalse;try{constwriteStreamatomicFile.startWrite();writeStartedtrue;awaitnewPromisevoid((resolve,reject){writeStream.end(payload,utf-8,(error?:Error){error?reject(error):resolve();});});atomicFile.finishWrite();writeStartedfalse;}catch(error){if(writeStarted){atomicFile.failWrite();}throwerror;}曾经使用writeStream.write后立即 finish流尚未结束可能造成提交时序不完整。改为end并等待回调后再 finish明确关闭写入流。提交后还比较磁盘字节大小与 UTF-8预期防止看似成功的短写。启动时先处理保存备份再处理草稿应用 ready后依次检查privateasynccheckStartupRecords():Promisevoid{awaitthis.checkPendingSaveBackup();awaitthis.checkRecoveryRecord();}保存中断备份优先因为它关系外部文件旧版本未保存草稿关系编辑缓冲区。二者含义不同不能用一个“恢复”按钮混合。加载恢复记录时 JSON解析或校验失败返回 undefined不阻塞启动。有效记录显示 Discard和 Recover。选择恢复后先尝试读取磁盘正文作为 persisted基线再把记录正文、URI、格式和 revision应用到会话documentDirtytrueWeb以 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 编辑器而言恢复能力不是附加功能而是用户敢于把真实长文档交给应用的最低信任基础。