鸿蒙编辑器数据安全实战:自动保存/崩溃恢复/大文件门禁三重保障(防内容截断设计)
鸿蒙编辑器数据安全实战:自动保存/崩溃恢复/大文件门禁三重保障(防内容截断设计)
编辑器产品的底线只有一条:用户的内容不能丢。MarkPin 在这条线上打过三场仗:会话恢复链路上的"死命令"(前端根本不认识的命令被静默丢弃)、大文件"保存即截断"(数据丢失级缺陷)、以及围绕这一切搭起来的保障体系——自动保存、未命名草稿恢复、崩溃快照、大文件门禁。这篇按"缺陷 → 体系"的顺序讲,其中大文件那个坑,值得每个做编辑器的人自查一遍。
摘要:本文复盘 MarkPin 编辑器在鸿蒙端的三重数据安全保障——自动保存、崩溃恢复与大文件门禁。文章先枚举内容丢失的四类缝隙(未命名草稿不恢复、崩溃丢内存、冷启动命令被丢弃、大文件保存截断),再以契约审计与证据链定位数据丢失级缺陷,给出两阶段加载 + UTF-8 安全切分、保存门禁单点拦截的完整实现,最后说明三重保障如何互相补位,并附验证结果与能力边界表。
一、问题定义:内容会在哪些缝隙里丢
把"内容丢失"的路径枚举出来,会发现缝隙比想象多:
- 正常退出的缝隙:未命名标签(草稿)重启不恢复——历史设计显式跳过无 fileUri 的标签,用户写了半篇没保存就关了应用,内容没了;
- 异常退出的缝隙:崩溃/被杀进程,内存里的内容全丢——需要崩溃快照兜底;
- 链路时序的缝隙:冷启动恢复路径上,原生侧向 WebView 下发加载命令时 WebView 还没就绪——命令被静默丢弃,用户看到空白编辑器,开始编辑后标脏机制还被计数器卡死吞掉(关标签不弹提醒,再次丢内容);
- 最阴险的一条:大文件保存截断。>1MB 的文件只加载前 100KB,而任何保存路径(Ctrl+S/自动保存/后台保存/关标签保存)都会把"当前内存里的 partial 内容"全文覆盖写回源文件——文档尾部静默丢失,用户完全无感知。叠加伤害:首屏 100KB 是字节硬切,切中多字节 UTF-8 序列时产出乱码替换符,还会随保存写回磁盘。
第 4 条是数据丢失级缺陷,也是这篇的主角。
二、分析方法:契约审计 + 证据链
大文件缺陷是在设计勘察(给新功能做规格时审计现有代码)中发现的,不是用户报的——这点值得说明:数据安全类缺陷等用户报告就太晚了。定位证据链很干净:
- 文件读取服务的契约就是"只返回首屏 N 字节"(大文件读取方法是个未实现的 TODO,走的是普通路径的截断实现);
- 会话对象没有大文件状态字段——上层保存逻辑无从知道内容不完整;
- 保存函数直接拿内存内容覆盖写源文件;
- 全工程检索无任何"已完整加载"判定。
四个事实拼起来,缺陷成立且影响范围明确:全部大文件读取与保存路径。修复设计在规格阶段就定了原则:门禁不是提示用户"文件太大",而是保证"没加载完就不许写"——用户无感,安全无价。
三、解决代码(一):两阶段加载与 UTF-8 安全切分
首屏读取的关键是 UTF-8 边界对齐——多读 4 字节观察序列完整性,回扫到安全切点:
// entry/src/main/ets/service/FileService.ets(真实代码,节选)
// 大文件首屏读取:仅读前 INITIAL_LOAD_CHARS 字节并在 UTF-8 安全边界截断。
//(字节硬切可能劈开多字节序列,TextDecoder 产出 U+FFFD 乱码并会随保存写回。)
private async readLargeHead(uriOrPath: string, totalBytes: number): Promise<LargeFileChunk> {
const file: fs.File = fs.openSync(uriOrPath, fs.OpenMode.READ_ONLY);
try {
const buf: ArrayBuffer = new ArrayBuffer(INITIAL_LOAD_CHARS + 4); // 多读 4 字节观察边界
const bytesRead: number = await fs.read(file.fd, buf, { offset: 0 });
const bytes: Uint8Array = new Uint8Array(buf, 0, bytesRead);
const cut: number = this.utf8SafeCut(bytes, INITIAL_LOAD_CHARS); // 回扫至安全边界
const content: string = util.TextDecoder.create('utf-8')
.decodeToString(new Uint8Array(buf, 0, cut));
// 返回 {content, nextOffset: cut, complete}——余量由后台分块追加
...
} finally { fs.closeSync(file); }
}
// UTF-8 安全截断点:从名义点回扫至多 3 字节,落在多字节序列内部时
// 截到序列起点之前;序列完整包含在名义点内则维持名义点。
private utf8SafeCut(bytes: Uint8Array, nominal: number): number {
if (nominal >= bytes.length) { return bytes.length; }
let i: number = nominal - 1;
let back: number = 0;
while (i >= 0 && back < 3 && (bytes[i] & 0xC0) === 0x80) { i--; back++; } // 0x80 前缀=续字节
...
}
余量加载:2MB 分块后台追加(首块延迟 400ms 让首屏先渲染),逐块同样 UTF-8 对齐,完成时非阻塞 toast「已加载完成」。前端侧追加有个容易忽略的细节——程序化插入不能进撤销历史(Ctrl+Z 不该把加载的内容"撤掉"),同时要抑制回声上报(原生先于下发已持有同内容,回声会被当作用户编辑双应用):
// editor-build/src/main.ts(真实代码,节选)
registerCommandHandler('appendContent', (payload: Record<string, string>) => {
const content: string = payload['content'] ?? '';
appendContentDepth++; // 追加期间跳过 web→native 回声上报
try {
view.dispatch({
changes: { from: view.state.doc.length, insert: content },
annotations: Transaction.addToHistory.of(false) // 不进撤销历史
});
} finally { appendContentDepth--; }
});
四、解决代码(二):保存门禁——单点拦截全部路径
门禁放在保存函数单点,一处拦截覆盖所有调用方:
// entry/src/main/ets/service/FileService.ets saveFile(真实代码,节选)
// 大文件门禁:余量未加载完时落盘 = 静默截断尾部。暂停所有保存
//(含自动保存/后台保存/LRU 预保存/关标签保存),加载完成(通常亚秒级)自动放行。
if (session.isLargeFile && !session.largeLoaded) {
result.success = false;
result.errorMessage = 'Large file not fully loaded — save paused';
hilog.warn(LOG_DOMAIN, LOG_TAG,
'saveFile: blocked, large file incomplete offset=%{public}d/%{public}d uri=%{public}s',
session.largeNextOffset, session.largeTotalBytes, session.fileUri);
return result;
}
设计要点:拒绝而非排队——余量加载通常亚秒级,加载完成自动放行,用户几乎感知不到门禁存在;自动保存服务直连保存函数,同样被拦,不需要每条路径各自判断。
五、体系拼图:三重保障怎么互相补位
大文件门禁只是体系的一角,完整拼图是三重保障互相覆盖时间窗:
- 自动保存 + 会话恢复:编辑 2s 防抖自动保存落盘;重启恢复工作区三件套(上次文件夹/侧栏面板/视图模式),与标签恢复、文件关联打开、崩溃恢复对话框三条启动链路正交互不阻塞;
- 未命名草稿:草稿内容直接嵌入标签列表持久化(≤1MB,2s 防抖),恢复的草稿带脏标记、关闭弹确认框——补齐"正常退出"路径的缝隙;
- 崩溃快照:异常退出兜底,快照带大文件续传字段(防恢复后再保存截断);LRU 卸载的大文件恢复走全文读取(partial 恢复会截尾且不可自愈)。
链路时序缝隙(冷启动命令被 WebView 丢弃)的修复模式也沉淀了下来:未就绪就暂存 + 就绪后重放(统一加载入口 + 页面加载完成事件重放,仿既有先例)。后来文件关联打开的"死命令"缺陷(前端根本没有该命令的处理器,分发器静默 return)修复时直接复用了这个模式——同一入口三处收敛。
六、验证与效果
Chrome 四断言:首屏+多块追加后文档长度精确等于全文;追加期间 web→native 上报 0 次(桥 spy 断言);Ctrl+Z 不撤销追加内容、用户后续输入撤销正常;jsdom 回归全绿。ArkTS:语法检查 0 Error、构建通过、装机冒烟通过(versionCode 递增)。真机 >1MB 端到端暂缓——用户暂无测试文件(模拟器非 root 无法落位外部大文件,环境限制如实登记)。会话/草稿恢复:模拟器自动化验证(强制停止真进程死亡 → 重启草稿还原、脏标记、关闭确认框全链通过)。
七、能力边界表
| 事项 | AI 表现 | 我的结论 |
|---|---|---|
| 缺陷发现时机 | 设计勘察阶段主动发现,非用户报告 | 数据丢失类缺陷不能等用户报——新功能规格化时顺手审计关联路径 |
| 门禁设计 | 单点拦截 + 自动放行,零用户负担 | 拒绝比提示优雅:加载亚秒级完成,门禁对用户近乎不存在 |
| UTF-8 边界处理 | 回扫切分一次写对(多读 4 字节观察) | 字节级切分必须显式处理多字节序列,TextDecoder 的替换符会污染数据 |
| 撤销历史隔离 | addToHistory=false + 回声抑制成对出现 | 程序化内容变更的两个伴生问题(撤销/回声)要一起想 |
| 真机端到端 | 暂缓(无测试文件),如实登记 | 验证不了就说验证不了,冒烟通过 ≠ 端到端闭环 |
八、三条心得
- "保存"是数据安全的最危险动词:任何"把内存内容写回文件"的路径,都要先问一句"内存里的内容完整吗"——大文件截断缺陷的本质是加载契约与保存契约不匹配,两个契约单独看都没错;
- 保障体系 = 互相补位的时间窗覆盖:自动保存管编辑间隙、草稿恢复管正常退出、崩溃快照管异常退出、门禁管加载未完——单看每个都简单,合起来才叫"不丢内容";
- 底层入口要收敛:加载统一入口 + 就绪重放模式,让后来者(文件关联打开)的同类缺陷修一处即修全部。
如果你也在试 AI 开发鸿蒙,或者想看后续,关注专栏,所有踩坑都会持续更新。
更多推荐



所有评论(0)