鸿蒙 PC Markdown 编辑器可靠性:原子快照与崩溃恢复
本文聚焦未保存正文的节流快照、原子提交、异步队列、启动恢复和大文档恢复边界。完整示例代码:https://gitcode.com/VON-/codex_md_oh。
为什么不能每次按键都写完整文档
编辑器需要把未保存内容带出 ArkWeb,才能在进程被系统终止后恢复。但如果每次输入都通过 Bridge 传输并写入全文,文档越大,输入延迟和写放大会越明显。
OhMarkdown 当前采用 1.5 秒节流快照:文档发生变化后只安排一个定时器,定时器触发时发送最新内容和 revision。
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);}代码来源:web-editor/src/main.ts
保存命令执行前还会立即刷新一次快照,避免用户输入后立刻保存时,最后一段内容尚未进入恢复区。
原生侧限制 Bridge 输入
Bridge 新增的onSnapshot不是无条件接收任意字符串。原生层会拒绝超过 5MiB 的恢复内容、负数 revision 和非整数 revision:
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:content,hasUtf8Bom:this.documentFormat.hasUtf8Bom,lineEnding:this.documentFormat.lineEnding,revision:revision,updatedAt:Date.now()};this.flushRecoveryQueue(context.filesDir);}代码来源:entry/src/main/ets/shared/ui/WorkspaceShell.ets
5MiB 及以上文档不会周期传输全文。大文档恢复需要后续改为增量日志,不能为了表面上的功能完整而让输入路径承担不可控开销。
用 AtomicFile 防止恢复记录自身损坏
恢复记录位于应用沙箱,并通过 HarmonyOSAtomicFile写入:
asyncfunctionwriteAtomicPayload(path:string,payload:string):Promise<void>{constexpectedBytes=buffer.from(payload,'utf-8').length;constatomicFile=newfileIo.AtomicFile(path);letwriteStarted:boolean=false;try{constwriteStream=atomicFile.startWrite();writeStarted=true;awaitnewPromise<void>((resolve,reject)=>{writeStream.write(payload,'utf-8',(error?:Error)=>{if(error){reject(error);}else{resolve();}});});atomicFile.finishWrite();writeStarted=false;constpersistedStat=awaitfileIo.stat(path);if(persistedStat.size!==expectedBytes){thrownewError('The complete atomic record could not be verified.');}}catch(error){if(writeStarted){try{atomicFile.failWrite();}catch(_){}}throwerrorinstanceofError?error:newError(String(error));}}代码来源:entry/src/main/ets/shared/services/RecoveryService.ets
startWrite、finishWrite和failWrite组成提交边界。写入中断时不会把半段 JSON 当成下一次启动的有效快照。
鸿蒙 PC 强杀恢复截图
在鸿蒙 MateBook Pro 2in1 模拟器中输入未保存内容,等待恢复快照生成后执行应用强制停止,再重新启动。应用检测到上次会话记录并显示Discard与Recover:
选择Recover后,恢复内容重新进入 CodeMirror,标签显示星号,状态栏保持Modified。只有保存成功、新建文档、撤销回基线或用户明确丢弃后,恢复记录才会被清除。
当前边界
本轮模拟器验证证明了“输入、快照、强杀、重启、提示、恢复”主链路可运行。后续仍需要对保存途中断电、恢复文件损坏、用户 URI 权限失效以及大文档增量恢复做故障注入。
恢复系统面对哪些失败
桌面编辑器中的“崩溃”并不只有代码异常。应用可能被用户强制结束、系统因内存压力终止、ArkWeb 单独重载、设备断电,或者保存对话框打开时进程退出。恢复设计要先区分哪些数据已经在磁盘,哪些只存在内存。
至少有三份状态:
- 持久化基线:最近一次确认写入用户文档的内容与格式。
- 当前编辑状态:CodeMirror 中用户正在编辑的最新正文。
- 恢复快照:应用沙箱中最近一次成功原子提交的未保存正文。
恢复快照不是新的文件版本系统,也不代表已经保存。它只是当内存状态丢失时的候选草稿。恢复后标签必须保持 Modified,用户仍需明确保存到原 URI或新文件。
快照频率是数据窗口和输入成本的折中
每次按键写全文可以把丢失窗口缩得很小,却会在大文档中造成跨 Bridge 复制、JSON 序列化和存储写放大。间隔过长则会丢失更多输入。1.5 秒节流表示连续输入期间最多维护一个等待任务,到点写入最新内容。
节流与防抖语义不同。纯防抖要等用户停止输入后才执行,长时间持续输入可能一直没有快照;当前策略在已有定时器时不重复安排,到达间隔就刷新,因此持续输入也会周期产生恢复点。
关键生命周期可以主动刷新,例如保存命令前、应用进入后台前或文档切换前。主动刷新也不能阻塞每次输入,且必须防止旧 revision 异步完成后覆盖新记录。
revision 用于约束异步顺序
快照包含递增 revision。它不是文档版本号,而是当前编辑会话中变化的顺序标识。假设 revision 10 的写入耗时较长,revision 11 随后到达;如果两个写任务并发,较旧任务可能最后完成,把新内容覆盖。
原生侧应串行化恢复写入,或在提交前比较 pending revision,只写最新记录。页面重载和文档切换还需要会话身份,避免 A 文档迟到的快照写进 B 文档记录。当前队列使用 pending record 和写入状态收敛最新值,为后续多标签扩展提供了基本模型。
参数边界同样重要:revision 必须是非负整数,正文长度不能超过恢复上限,格式枚举必须有效。任何异常输入都拒绝,不让损坏记录进入沙箱。
AtomicFile 保护的是快照文件本身
恢复记录通常是 JSON。如果直接覆盖recovery.json,进程在写到一半时终止,下一次启动会得到半段 JSON;此时恢复功能反而制造新的错误。AtomicFile先写临时内容,finishWrite提交,失败时failWrite回滚。
原子提交保证看到旧完整记录或新完整记录,不保证 JSON 内业务字段一定正确。因此读取时仍要验证版本、类型、长度、行尾枚举、revision 和更新时间。解析失败或记录超限时应隔离或清理,并向用户提供可理解状态,不能让启动流程反复崩溃。
快照写入后可以重新读取并验证字节长度或 JSON 结构,代价是额外 I/O。是否执行取决于可靠性目标;对恢复这类低频后台写入,验证成本通常可以接受。
启动恢复的决策顺序
应用启动时不能发现记录就自动覆盖当前文件。恢复记录可能比磁盘旧、URI 已失效,或者用户已经在其他应用修改文档。安全流程是:
- 读取并验证沙箱记录。
- 显示文档名和恢复时间,提供 Recover 与 Discard。
- 选择 Recover 时把正文载入 CodeMirror,并保持 Modified。
- 若原 URI 可读,记录当前磁盘版本作为后续冲突基线。
- 选择 Discard 时明确清除记录,进入普通空文档或磁盘文档。
- 对话框关闭、应用再次退出时不应意外删除用户尚未决定的草稿。
恢复提示必须出现在编辑器可操作之前或以明确阻塞状态呈现,避免用户先输入新内容,再被旧快照覆盖。
恢复快照与保存备份不是一回事
恢复快照保护“还没保存的用户输入”,保存备份保护“写目标 URI 前的旧磁盘版本”。二者触发和清理条件不同:
| 记录 | 内容来源 | 创建时机 | 清理时机 |
|---|---|---|---|
| 恢复快照 | 当前 CodeMirror 正文 | 编辑节流或生命周期刷新 | 保存成功、撤销回基线、明确丢弃 |
| 待处理保存备份 | 写入前的磁盘正文 | 每次覆盖用户 URI 前 | 保存成功或旧版本已确认恢复 |
应用启动时如果两者同时存在,应优先处理可能受损的磁盘保存,再处理未保存草稿,并避免把两个对话框的内容混淆。旧磁盘版本恢复成功不等于草稿已保存,草稿仍可继续恢复到编辑器中。
大文档为什么暂不做全文周期快照
5MiB 以上文档如果每 1.5 秒跨 Bridge 发送全文,会显著增加输入路径负担。当前在大文件模式关闭周期全文恢复,是明确的性能保护,但也意味着异常终止可能丢失更多未保存内容。
更完整的方案是增量恢复日志:记录 CodeMirror 事务的 change set、revision 和基线摘要,周期性压缩为检查点。启动时读取最近基线,再顺序应用已提交增量。设计时必须处理:
- 事务日志损坏或中间缺失。
- 基线文件被外部修改。
- 撤销、重做与复合事务语义。
- 日志无限增长和压缩时强杀。
- 多标签会话隔离。
- 版本升级后的日志兼容。
在这些问题解决前,不能用未经验证的增量方案替代简单可靠的全文快照。界面应对大文件恢复限制给出准确状态。
恢复数据的隐私和生命周期
沙箱快照包含用户未保存正文,敏感程度不低于原文件。它不应进入普通日志、崩溃上报、云同步或公共测试产物。文件权限依赖应用沙箱,记录名称不应包含完整用户路径。
用户明确丢弃、新建替换、保存成功或撤销回持久化基线后,应清理不再需要的记录。卸载应用自然清除沙箱,但应用内也可以提供“清除恢复数据”能力。若未来支持加密工作区,恢复快照是否加密必须纳入同一威胁模型,不能让明文草稿绕过加密文件。
更新时间只用于提示和清理策略,不应依赖它判断内容先后,revision 与会话身份更可靠。
强杀测试必须命中真实窗口
一次重启出现恢复提示,证明主链路连通。更系统的设备用例包括:
- 输入后不足 1.5 秒强杀,验证已声明的最大丢失窗口。
- 等待快照后继续输入再强杀,确认恢复到最近提交而非未来内容。
- 快照写入期间强杀,启动只能看到旧完整或新完整记录。
- Recover 后内容、文件名、BOM/行尾和 Modified 状态正确。
- Discard 后再次启动不再提示。
- 正常保存后强杀重启,不出现过期恢复提示。
- 恢复记录 JSON 损坏、版本未知或超限时,应用仍可启动。
- 原 URI 权限失效时,恢复正文仍可另存为新文件。
故障注入版本可以在startWrite、写入、finishWrite等阶段加入测试暂停点,从而稳定命中窗口。测试钩子要只存在于内部构建,并确保不改变正式提交顺序。
恢复系统验收清单
- 快照与当前文档 revision、URI、名称和格式绑定。
- 普通编辑使用有上限的周期快照,不在每次按键同步全文。
- 异步写入不会让旧 revision 覆盖新记录。
- AtomicFile 失败调用回滚,读取端仍做完整字段校验。
- Recover 保持 Modified,不把恢复误标为已保存。
- Discard、保存成功和撤销回基线能清理正确记录。
- 保存备份与编辑恢复快照职责、提示和清理顺序分开。
- 大文档限制明确,增量日志在验证完成前不冒充可靠方案。
- 沙箱正文不进入日志、遥测和公共证据。
- 模拟器强杀、真机后台回收和故障注入分别记录。
恢复能力的质量不在于“有一个 JSON 文件”,而在于快照频率、原子提交、revision 顺序、启动决策和清理条件形成闭环。任何一处含糊,都可能把本来用于保护用户的机制变成覆盖新内容或泄露草稿的来源。
用显式状态机描述恢复生命周期
恢复会话可以抽象为 clean、dirty-pending、snapshotting、snapshotted、recoverable、restored、discarded。状态机的价值是明确哪些事件清理记录:普通窗口缩放不会,撤销回持久化基线会,保存请求开始不会,保存实际成功才会。
进程结束后内存状态消失,沙箱中有效记录使下次启动进入 recoverable。用户 Recover 后进入 restored/dirty,不能直接 clean;Discard 后删除记录。删除失败也要处理,避免用户每次启动重复看到已丢弃内容。
多文档时每个会话有独立状态,不使用单个全局 dirty。应用级启动流程只负责枚举/排序待恢复会话,并逐个交给文档状态机。
快照队列如何合并高频更新
原生侧写 AtomicFile 期间,Web 可能送来新 revision。简单忽略会扩大丢失窗口,启动并发写又破坏顺序。单写者队列可维护pendingRecoveryRecord:若正在写,只用更新记录替换 pending;当前写完成后检查是否仍有更新,再写一次最新值。
这种“只保留最新待写”适合全文快照,因为中间 revision 不需要长期保存。失败时保留 pending 并更新操作状态,但重试要有退避,不能磁盘异常时无限紧循环。应用进入后台时可以尝试最后刷新,超过时间则依赖最近成功记录。
队列还应绑定 session generation。清理记录或切换文档会增加 generation,先前异步任务完成后发现代数不匹配,不再重新创建已清理快照。
JSON 记录的版本与迁移
恢复记录包含version,用于未来增加编码、工作区、选区或增量日志。新增可选字段可以向后兼容;改变正文语义或日志格式需要迁移函数。未知更高版本默认不解析,更不能按旧结构写回覆盖。
迁移在临时/原子文件中完成,原记录保留到新记录验证成功。迁移失败时允许用户导出原始正文,若旧结构仍能安全读取。不要在应用启动主线程执行复杂大记录迁移,设置大小与时间上限。
测试保留每个历史版本的最小夹具,升级 HAP 后验证恢复与清理。发布前还要测试从上一公开版本直接升级,不只测试当前源码生成的记录。
损坏记录的隔离与用户处理
JSON 解析失败、字段缺失、长度超限或哈希不符时,应用不应循环崩溃,也不应直接覆盖损坏文件。可以把记录移动到隔离区,启动普通编辑器并提示存在无法自动恢复的数据,提供删除或导出原始文件给高级用户。
原始文件可能包含部分敏感正文,导出必须经过用户明确选择目标,不自动上传。隔离文件设置数量和保留期限,避免损坏记录无限积累。诊断只记录验证失败类型和大小。
如果记录内容字段能够安全读取但其他元数据损坏,可以允许“恢复为未命名文档”,不尝试写原 URI。这比完全丢弃更有价值,但必须明确来源不完整。
恢复后的磁盘冲突
崩溃期间,用户可能在另一应用修改原文件。Recover 不能立即把快照写回磁盘。加载草稿时重新读取当前磁盘作为外部版本,保存前进行三方或至少基线冲突检查。
恢复记录最好包含创建快照时的持久化基线摘要和格式。若当前磁盘摘要不同,界面标记外部变化,优先建议另存或比较。快照中的 URI 权限失效时,同样恢复为可另存的 Modified 文档。
恢复正文、当前磁盘和旧保存备份可能同时存在。处理顺序先保护可能受损的磁盘,再让用户选择草稿;任何自动合并都不能清理原始记录,直到用户确认并保存成功。
选区、滚动与撤销是否恢复
最小可靠闭环只恢复正文、文档身份和格式。选区与滚动位置可以改善体验,但不是数据正确性的前提;撤销历史体积和序列化复杂度更高,不宜在没有验证时加入快照。
若记录选区,必须校验 from/to 在正文长度内,损坏时回到文档开头。滚动位置属于视图状态,可以按行号近似恢复。IME 组合中的临时文本不应被当作已提交正文,快照以 EditorState 稳定文档为准。
撤销历史若未来恢复,需要与 CodeMirror 版本和事务格式兼容。版本不匹配时宁可只恢复正文,也不要让损坏历史影响编辑。
多标签恢复的资源上限
多个脏标签都保存全文会快速占用沙箱空间。需要限制单文档大小、总恢复大小、会话数量和保留时间。达到总上限时不能默默删除最新草稿,可以优先清理已保存/过期记录,并向用户提示哪些大文档未启用完整恢复。
启动时不要一次把所有正文加载进多个 ArkWeb。先读取轻量元数据展示恢复列表,用户选择后再读取正文;活动文档优先,其他会话延迟恢复。记录索引本身也用原子提交并能从单文件扫描重建。
同一 URI 出现多个恢复会话时保留时间与窗口身份,不能只按文件名覆盖。用户可以逐个比较或另存。
恢复体验的文案原则
提示应回答文档、时间、状态和动作后果。例如 Recover 表示把草稿载入编辑器且仍未保存,Discard 表示删除这份沙箱草稿,不代表删除磁盘文件。避免使用“恢复文件成功”让用户误以为已经写入原文件。
待处理保存备份提示则说明检测到上次保存中断,Restore Previous会尝试把写入前磁盘版本恢复到目标,Keep Current保留当前目标内容。两类对话框使用不同标题和说明。
默认焦点不放在破坏性 Discard 上,关闭窗口等同于稍后决定并保留记录。时间显示本地化,超长文件名截断但可查看完整信息。
恢复可靠性的量化指标
可以统计不含正文的本地指标:快照请求次数、成功率、P95 写入耗时、被大小限制跳过次数、启动发现记录次数、用户选择恢复/丢弃、恢复后保存成功率。数据默认脱敏,文件名和 URI 不进入指标。
这些数字帮助发现某个系统版本 AtomicFile 失败率、快照间隔实际过长或大文档覆盖不足。但指标不能替代故障注入,低失败率不证明强杀窗口安全。
对发布门槛,更关键的是固定用例全部通过、损坏记录不阻塞启动,以及任何失败不清除最后有效快照。
系统生命周期事件只能作为加固
应用进入后台、窗口关闭和页面隐藏事件可以触发即时快照,但系统不保证强杀或断电前一定回调。可靠性基础仍是周期提交,生命周期刷新只是缩短正常退出窗口。不要把数据保护全部放在onBackground或销毁回调中。
回调还可能时间很短,刷新任务应优先提交已准备的最新快照,不在退出阶段执行昂贵预览、字数或压缩。超时后保留上一次有效记录,不能先删除旧文件再尝试写新文件。
时钟不能决定快照先后
updatedAt用于向用户显示大致时间,系统时间可能被手动调整或网络校准。快照覆盖顺序由 session 和 revision 判断,不按时间戳大小。跨进程重启后 revision 可以重新开始,但新的 sessionId 使记录不会与旧会话混淆。
显示时间采用本地化并说明“恢复草稿时间”,不把它当作磁盘文件修改时间。测试可以注入时间变化,确认队列和启动选择仍正确。
磁盘空间管理与恢复优先级
沙箱空间不足时,恢复写入可能失败。应用应先清理已确认保存、过期或已丢弃记录,再尝试最新脏文档;不能删除唯一有效快照为临时缓存让路。缓存、预览资源和日志的清理优先级低于用户草稿保护。
写入前估算 JSON 的 UTF-8 字节与总上限,错误状态提示恢复保护暂不可用,并建议立即保存或另存。空间恢复后允许重试,但使用退避避免每次按键重复失败。
恢复记录的完整性摘要
AtomicFile 防止半写,记录还可以包含正文与关键元数据的摘要,用于发现意外损坏。摘要不是防攻击签名,因为同一应用可重算,但能识别存储位翻转和不完整迁移。验证失败时隔离,不把正文自动写回用户文件。
摘要计算会扫描全文,应与快照写入合并而不是每个按键执行。大文档增量日志则对每段记录和检查点分别校验,启动时顺序验证到最后有效位置。
恢复功能的用户控制
设置中可以允许用户关闭周期恢复或调整隐私策略,但关闭前说明未保存内容在异常退出后无法找回。企业或高敏感场景可能要求退出即清除草稿,个人写作则更重视恢复。策略变化只影响后续记录,已有记录由用户明确选择清理。
提供查看和清除恢复数据时只显示文档名、时间和大小,正文需用户主动打开。清除操作二次确认且不删除原始磁盘文档。透明的控制比隐藏沙箱副本更能建立信任。