第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.length>0&&snapshot.version>0&&snapshot.savedAt>0}版本号和账本让重启恢复有依据。页面不能把解析失败的草稿直接当成空内容,否则用户会以为内容被正常清掉了。
这类模型最好放在models或common中,页面、Service 和仓储都使用同一份类型,避免各层用字符串互相猜。
写入走临时文件,再替换正式文件
exportclassDraftStore{constructor(privatereadonlyfileWriter:DraftFileWriter){}asyncsave(snapshot:DraftSnapshot):Promise<void>{consttempPath=draftTempPath(snapshot.draftId,snapshot.version)conststablePath=draftStablePath(snapshot.draftId)awaitthis.fileWriter.writeText(tempPath,JSON.stringify(snapshot))constloaded=JSON.parse(awaitthis.fileWriter.readText(tempPath))asDraftSnapshotif(!isValidDraft(loaded)){thrownewError('invalid draft snapshot')}awaitthis.fileWriter.replace(tempPath,stablePath)}}先写 temp,再读回校验,最后替换正式文件。即使写 temp 过程中崩溃,上一份 stable 草稿仍然存在。
Service 的目标不是把所有逻辑都塞进去,而是把跨页面、跨生命周期或需要持久化的判断集中起来。页面只表达用户动作。
页面用防抖保存完整快照
@Componentstruct ArticleEditorPage{@Statetitle:string=''@Statebody:string=''@Statesaving:boolean=falseprivateautosave:DraftAutosaveService=createDraftAutosaveService()onInputChanged():void{constsnapshot=this.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):Promise<DraftSnapshot|null>{conststable=awaitthis.store.loadStable(draftId).catch(()=>null)if(stable!==null&&isValidDraft(stable)){awaitthis.journal.markRecovered(draftId,stable.version)returnstable}awaitthis.journal.markCorrupted(draftId)returnnull}}恢复服务不把坏文件吞掉。能恢复就标记已恢复,不能恢复就标记损坏,让页面给出恢复失败说明,而不是直接显示空白编辑器。
实际项目里,很多问题不是第一次进入页面暴露,而是在冷启动、返回前台、页面重建或回调晚到时暴露。把这段生命周期补上,修复才算闭环。
恢复提示要给用户选择
| 情况 | 页面表现 | 工程处理 |
|---|---|---|
| 找到稳定草稿 | 提示恢复上次编辑 | 用户确认后进入编辑页 |
| 只有损坏草稿 | 隔离坏文件 | 提示无法恢复并保留日志 |
| 附件缺失 | 恢复正文并标记附件失败 | 不要丢正文 |
| 保存中崩溃 | 使用上一个 stable | temp 不覆盖正式草稿 |
兜底路径不是可有可无。用户遇到异常时,页面至少要给出当前状态、可执行动作和可排查线索。
排查时找直接覆盖草稿的代码
rg-n"draft.json|writeText|replace|autosave|DraftSnapshot"entry features common rg-n"JSON.parse|JSON.stringify|attachmentIds|DraftJournal"entry features common rg-n"onTextChange|onInput|schedule\(|debounce"entry features common第一组命令找旧路径,第二组命令找新边界,第三组命令看生命周期或回调位置。命中后不要只看字段名,要判断它是否仍在直接改页面状态。
验证清单
| 验证项 | 预期结果 |
|---|---|
| 输入后立刻杀进程 | 重启恢复到上一份完整草稿 |
| 写 temp 失败 | stable 草稿不损坏 |
| JSON 半截 | 进入损坏隔离,不显示空白假象 |
| 附件缺失 | 正文仍可恢复 |
| 连续输入 | 不会每字符同步写文件卡顿 |
这些验证最好在真机或模拟器里按顺序走一遍。只看源码很难发现冷启动、重启和回调晚到这类问题。
常见问题和处理方式
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| 草稿半截 | 直接覆盖正式文件 | temp 加替换 |
| 打开变空白 | 解析失败当空草稿 | 损坏隔离并提示 |
| 附件乱了 | 正文和附件分开保存 | 统一 DraftSnapshot |
| 输入卡顿 | 保存频率太高 | 防抖保存 |
如果现象没有出现在表里,仍然建议按“输入来源 -> 中间状态 -> 持久化或页面展示 -> 失败兜底”的顺序排查。
小结:草稿保存先保护上一份完整内容
草稿不是普通缓存,它承载用户输入。写入时先生成完整快照,落临时文件,校验后替换正式版本;重启时按恢复账本加载稳定版本,坏文件隔离。这样即使崩溃发生在写入中间,用户也不会丢掉上一份可用内容。
| 保存频率太高 | 防抖保存 |
如果现象没有出现在表里,仍然建议按“输入来源 -> 中间状态 -> 持久化或页面展示 -> 失败兜底”的顺序排查。
小结:草稿保存先保护上一份完整内容
草稿不是普通缓存,它承载用户输入。写入时先生成完整快照,落临时文件,校验后替换正式版本;重启时按恢复账本加载稳定版本,坏文件隔离。这样即使崩溃发生在写入中间,用户也不会丢掉上一份可用内容。