45-崩溃后草稿损坏-用临时文件和恢复账本保护输入
第45篇崩溃后草稿损坏用临时文件和恢复账本保护输入摘要编辑页草稿最怕“写了一半崩溃再打开内容坏了”。如果每次输入都直接覆盖正式草稿崩溃、磁盘异常或序列化失败都会把可恢复内容一起毁掉。更稳的做法是写临时文件和恢复账本完整写入后再原子替换重启时按账本恢复或隔离损坏草稿。有个长文编辑页用户写到一半切后台应用被系统回收。重进后草稿还在但正文少了后半段图片列表也乱了。原代码在每次输入后直接覆盖draft.json中途写入失败就留下了半截 JSON。草稿保存不能只追求实时要保证任何一次崩溃都不会破坏上一份完整内容。这篇文章解决四个实际问题草稿写入先落临时文件成功后再替换正式文件。恢复账本记录最后一次完整版本和待恢复版本。重启时先校验 JSON 和版本不直接打开损坏草稿。保存频率用防抖和显式保存结合避免每个字符都落盘。先确认损坏的是内容还是索引草稿损坏不一定是文件消失。可能是 JSON 半截、图片附件缺失、版本不匹配或者页面读取了旧索引。位置现象风险处理方向正文少半截直接覆盖正式文件崩溃留下半写入临时文件加替换图片错位附件和正文不同步索引先更新了快照统一保存重启空白解析失败被当成空草稿损坏隔离频繁卡顿每字符写文件防抖保存这一步的价值是先把问题放回运行链路。只要能确认问题停在哪个位置后面就不用靠猜测改页面。编辑状态和持久草稿要隔一层快照页面里的输入状态变化很频繁持久化层需要的是完整快照。不要让每个输入框都直接写草稿文件。模块应该负责不应该负责EditorPage当前输入、保存中提示文件路径DraftSnapshotBuilder把页面状态转成快照磁盘写入DraftStore临时文件和替换UI 布局DraftJournal版本、路径、恢复状态输入事件RecoveryPage恢复或丢弃选择序列化细节边界定清后代码就不会在页面、服务和回调之间来回复制同一段判断。后续新增场景也能先判断应该落在哪一层。草稿快照要有版本和校验字段exportinterfaceDraftSnapshot{draftId:stringversion:numbertitle:stringbody:stringattachmentIds:string[]savedAt:number}exportinterfaceDraftJournalEntry{draftId:stringstableVersion:numberpendingVersion:numberrecovering:booleanupdatedAt:number}exportfunctionisValidDraft(snapshot:DraftSnapshot):boolean{returnsnapshot.draftId.length0snapshot.version0snapshot.savedAt0}版本号和账本让重启恢复有依据。页面不能把解析失败的草稿直接当成空内容否则用户会以为内容被正常清掉了。这类模型最好放在models或common中页面、Service 和仓储都使用同一份类型避免各层用字符串互相猜。写入走临时文件再替换正式文件exportclassDraftStore{constructor(privatereadonlyfileWriter:DraftFileWriter){}asyncsave(snapshot:DraftSnapshot):Promisevoid{consttempPathdraftTempPath(snapshot.draftId,snapshot.version)conststablePathdraftStablePath(snapshot.draftId)awaitthis.fileWriter.writeText(tempPath,JSON.stringify(snapshot))constloadedJSON.parse(awaitthis.fileWriter.readText(tempPath))asDraftSnapshotif(!isValidDraft(loaded)){thrownewError(invalid draft snapshot)}awaitthis.fileWriter.replace(tempPath,stablePath)}}先写 temp再读回校验最后替换正式文件。即使写 temp 过程中崩溃上一份 stable 草稿仍然存在。Service 的目标不是把所有逻辑都塞进去而是把跨页面、跨生命周期或需要持久化的判断集中起来。页面只表达用户动作。页面用防抖保存完整快照Componentstruct ArticleEditorPage{Statetitle:stringStatebody:stringStatesaving:booleanfalseprivateautosave:DraftAutosaveServicecreateDraftAutosaveService()onInputChanged():void{constsnapshotthis.buildSnapshot()this.autosave.schedule(snapshot)}privatebuildSnapshot():DraftSnapshot{return{draftId:currentDraftId(),version:Date.now(),title:this.title,body:this.body,attachmentIds:readAttachmentIds(),savedAt:Date.now()}}}页面只负责生成完整快照不直接写文件。附件 id 和正文一起进入快照避免正文恢复了、附件索引没恢复。页面层要控制展示和交互节奏但不应该拥有底层事实。这样页面重建、横竖屏变化或返回前台时都能重新从 Service 拿到可信结果。恢复时先隔离损坏版本exportclassDraftRecoveryService{constructor(privatereadonlystore:DraftStore,privatereadonlyjournal:DraftJournal){}asyncrecover(draftId:string):PromiseDraftSnapshot|null{conststableawaitthis.store.loadStable(draftId).catch(()null)if(stable!nullisValidDraft(stable)){awaitthis.journal.markRecovered(draftId,stable.version)returnstable}awaitthis.journal.markCorrupted(draftId)returnnull}}恢复服务不把坏文件吞掉。能恢复就标记已恢复不能恢复就标记损坏让页面给出恢复失败说明而不是直接显示空白编辑器。实际项目里很多问题不是第一次进入页面暴露而是在冷启动、返回前台、页面重建或回调晚到时暴露。把这段生命周期补上修复才算闭环。恢复提示要给用户选择情况页面表现工程处理找到稳定草稿提示恢复上次编辑用户确认后进入编辑页只有损坏草稿隔离坏文件提示无法恢复并保留日志附件缺失恢复正文并标记附件失败不要丢正文保存中崩溃使用上一个 stabletemp 不覆盖正式草稿兜底路径不是可有可无。用户遇到异常时页面至少要给出当前状态、可执行动作和可排查线索。排查时找直接覆盖草稿的代码rg-ndraft.json|writeText|replace|autosave|DraftSnapshotentry features common rg-nJSON.parse|JSON.stringify|attachmentIds|DraftJournalentry features common rg-nonTextChange|onInput|schedule\(|debounceentry features common第一组命令找旧路径第二组命令找新边界第三组命令看生命周期或回调位置。命中后不要只看字段名要判断它是否仍在直接改页面状态。验证清单验证项预期结果输入后立刻杀进程重启恢复到上一份完整草稿写 temp 失败stable 草稿不损坏JSON 半截进入损坏隔离不显示空白假象附件缺失正文仍可恢复连续输入不会每字符同步写文件卡顿这些验证最好在真机或模拟器里按顺序走一遍。只看源码很难发现冷启动、重启和回调晚到这类问题。常见问题和处理方式现象常见原因处理方式草稿半截直接覆盖正式文件temp 加替换打开变空白解析失败当空草稿损坏隔离并提示附件乱了正文和附件分开保存统一 DraftSnapshot输入卡顿保存频率太高防抖保存如果现象没有出现在表里仍然建议按“输入来源 - 中间状态 - 持久化或页面展示 - 失败兜底”的顺序排查。小结草稿保存先保护上一份完整内容草稿不是普通缓存它承载用户输入。写入时先生成完整快照落临时文件校验后替换正式版本重启时按恢复账本加载稳定版本坏文件隔离。这样即使崩溃发生在写入中间用户也不会丢掉上一份可用内容。| 保存频率太高 | 防抖保存 |如果现象没有出现在表里仍然建议按“输入来源 - 中间状态 - 持久化或页面展示 - 失败兜底”的顺序排查。小结草稿保存先保护上一份完整内容草稿不是普通缓存它承载用户输入。写入时先生成完整快照落临时文件校验后替换正式版本重启时按恢复账本加载稳定版本坏文件隔离。这样即使崩溃发生在写入中间用户也不会丢掉上一份可用内容。