鸿蒙 PC Markdown 编辑器外部修改检测:从文件指纹到冲突决策
桌面 Markdown 文件很少只被一个程序触碰。用户可能在终端执行格式化脚本、在 Git 客户端切换分支、让生成器更新文档,或者同时打开另一款编辑器。应用如果假设“打开之后磁盘永远不变”,下一次保存就可能无提示覆盖外部内容;反过来,如果任何时间戳变化都弹出冲突,又会把自己的保存、元数据更新和无内容变化误判成风险。
OhMarkdown 在鸿蒙 PC 版本中建立了活动文档外部修改检测。公开仓库是 https://gitcode.com/VON-/codex_md_oh,基础实现进入提交57aea97,完整冲突比较进入5cb4aed。本文讨论文件指纹、轮询、正文复核、干净重载和脏缓冲区保护,不把尚未完成的全工作区文件监听或云端同步描述成现有能力。
外部修改检测首先是数据所有权问题
打开文件后,系统同时存在至少三份相关事实:磁盘当前版本、编辑器当前缓冲区、应用上次成功保存或打开时的持久化基线。只比较磁盘与缓冲区无法判断是谁改变了什么;只记录时间戳又无法解释内容是否真的变化。三方模型是可靠冲突处理的基础。
持久化基线代表应用最后确认的磁盘内容。缓冲区可能在基线上产生本地修改,磁盘也可能被外部程序改写。若只有磁盘变化而缓冲区干净,应用可以自动重载;若两边都变化,就必须暂停自动保存并让用户决策;若指纹变化但正文与格式仍等于基线,只需更新指纹,不应打扰用户。
这个模型也说明检测不是保存逻辑的附加弹窗。它直接影响 dirty、自动保存、标签关闭和恢复快照。外部变化判断必须属于文档可靠性状态机,而不是一个每隔几秒显示 Toast 的独立功能。
指纹用于筛选而不是证明内容相同
文档服务读取文件后记录DocumentFingerprint,主要包含字节长度与纳秒级修改时间。它的作用是廉价判断“是否值得重新读取”,而不是充当内容哈希。真实接口类似:
exportinterfaceDocumentFingerprint{size:number;mtimeNs:number;}exportfunctionisSameDocumentFingerprint(left:DocumentFingerprint|undefined,right:DocumentFingerprint):boolean{returnleft!==undefined&&left.size===right.size&&left.mtimeNs===right.mtimeNs;}文件大小相同不代表正文相同,修改时间也可能因复制、恢复或文件系统粒度产生碰撞。因此指纹相同时可以跳过大部分轮询读取,指纹不同时则重读正文、BOM 和换行格式。最终用户数据决策基于实际内容与格式,而不是只看元数据。
没有在每次轮询计算整文件 SHA-256,是性能和用途上的取舍。哈希仍需读取全部文件,与直接读取并解析内容的 I/O 成本相近,而且应用最终还要获得磁盘正文用于重载或比较。指纹做第一层过滤,正文做第二层确认,更符合活动文档场景。
轮询只覆盖活动文档
当前实现通过组件生命周期启动定时器,只检查活动文档:
privateinitializeDocumentReliability():void{if(this.externalChangeTimerId<0){this.externalChangeTimerId=setInterval(()=>{this.checkActiveDocumentForExternalChanges();},EXTERNAL_CHANGE_POLL_MILLISECONDS);}}aboutToDisappear():void{this.cancelScheduledAutoSave();if(this.externalChangeTimerId>=0){clearInterval(this.externalChangeTimerId);this.externalChangeTimerId=-1;}}只看活动文档并非完整文件监听,但它有明确预算。用户当前正在编辑的文件风险最高;所有后台标签同时轮询会随着标签数量线性增加 I/O。切换标签时应用读取目标会话和指纹,后续轮询转到新活动文档。
组件消失时必须清理定时器,否则页面重建后会出现多个轮询并发,旧实例还可能更新已经失效的状态。生命周期清理与启动条件同等重要。定时器只触发检查,检查函数自己还有重入保护,避免上一次慢 I/O 未结束时启动下一轮。
检查入口先拒绝不合适的时机
核心方法的第一段不是读取,而是条件过滤:
privateasynccheckActiveDocumentForExternalChanges():Promise<void>{if(this.externalChangeCheckInProgress||this.operationInProgress||this.documentUri.length===0||this.externalConflictVisible){return;}constsessionId=this.activeDocumentSessionId;constdocumentUri=this.documentUri;this.externalChangeCheckInProgress=true;try{constfingerprint=awaitreadDocumentFingerprint(documentUri);if(sessionId!==this.activeDocumentSessionId||documentUri!==this.documentUri||isSameDocumentFingerprint(this.documentFingerprint,fingerprint)){return;}// 指纹变化后再读取正文。}finally{this.externalChangeCheckInProgress=false;}}文件操作进行中时跳过,是为了避免把应用自己的原子保存中间状态识别为外部变化。未命名文档没有磁盘目标,不需要检查。冲突已经显示时暂停继续轮询,防止磁盘再次变化不断覆盖待比较版本。externalChangeCheckInProgress阻止定时器重入。
更关键的是捕获sessionId和documentUri。异步stat等待期间用户可能切换标签;恢复后必须确认仍在同一会话和 URI。只依赖组件当前字段会把旧文件结果应用到新标签,这是多文档编辑器里非常隐蔽的竞态。
指纹变化后读取正文与格式
当元数据发生变化,服务调用readUtf8Document。该函数不是普通文本读取,它同时检测 UTF-8 BOM、换行类型、内容和新指纹。检查逻辑随后区分三种情况:
constdiskDocument=awaitreadUtf8Document(documentUri);if(sessionId!==this.activeDocumentSessionId||documentUri!==this.documentUri){return;}if(this.persistedDocumentContent===diskDocument.content&&this.isSameDocumentFormat(this.documentFormat,diskDocument.format)){this.documentFingerprint=diskDocument.fingerprint;this.syncActiveDocumentSession();}elseif(this.documentDirty){this.registerExternalConflict(diskDocument);}else{this.applyExternalDiskDocument(diskDocument);}第一种情况说明正文和格式没有变化,只是元数据不同。应用更新指纹,不提示。第二种情况是本地 dirty,磁盘也变化,进入冲突。第三种是本地干净,安全应用磁盘版本。
格式必须参与比较。磁盘可能只从 LF 改成 CRLF,或增加 UTF-8 BOM,JavaScript 字符串正文看起来相同,但字节事实已经改变。若忽略格式,下一次保存会静默恢复旧格式。无损编辑器不能把换行和 BOM 当成无关元数据。
干净缓冲区可以自动重载
当本地没有修改,外部变化可直接应用。实现会更新 URI、名称、正文、持久化基线、格式、指纹、修订、dirty 和状态,再调用 Web 编辑器设置文档:
privateapplyExternalDiskDocument(diskDocument:OpenedDocument):void{this.clearExternalConflict();this.documentUri=diskDocument.uri;this.documentName=diskDocument.name;this.documentContent=diskDocument.content;this.persistedDocumentContent=diskDocument.content;this.documentFormat=diskDocument.format;this.documentFingerprint=diskDocument.fingerprint;this.documentRevision=0;this.documentDirty=false;this.operationStatus='Reloaded after external change';this.setEditorDocument(diskDocument.content);this.clearRecoveryDraft();this.syncActiveDocumentSession(diskDocument.content);}重置修订和 dirty 很重要,因为外部磁盘版本此刻成为新的基线。恢复草稿也应清理,否则下次启动可能把旧干净内容当作未保存恢复。所有字段一起更新,避免标签标题、状态栏和 Web 正文各自处于不同版本。
自动重载仍有体验取舍:光标和滚动位置可能变化。当前实现优先保证正文事实正确,后续可在内容差异较小且偏移有效时尝试恢复选区。不能为了保留光标而继续展示过期内容,更不能在没有提示的情况下把旧缓冲区重新写回磁盘。
脏缓冲区必须进入显式冲突
本地 dirty 表示缓冲区包含尚未写入的用户工作。外部磁盘版本也可能包含其他工具的有效修改。实现将磁盘文档按活动会话存入externalConflicts,显示冲突栏,取消待执行自动保存:
privateregisterExternalConflict(diskDocument:OpenedDocument):void{this.externalConflicts.set(this.activeDocumentSessionId,diskDocument);this.externalConflictVisible=true;this.cancelScheduledAutoSave();this.operationStatus='External changes need attention';}冲突按会话存储,而不是单个全局对象。用户可能在一个标签处理冲突时切换另一个标签,后者的磁盘版本不能覆盖前者。活动会话切换时,原生层根据映射恢复对应冲突可见状态。
自动保存被立即暂停,因为它最可能造成无提示覆盖。手动保存也会在写入前检查指纹,不能借按钮绕过。冲突栏提供 Compare、Keep Local、Use Disk 和 Save As,用户可以看到选择后果,而不是只收到“文件已变化”的死路提示。
文件暂时不可用时宁可暂停
文件可能被移动、删除、卸载或权限失效。检查捕获异常后,如果会话仍匹配,会取消定时自动保存并显示File unavailable; auto save paused。应用不会把读取失败解释为“文件不存在,可以新建覆盖”,也不会无限重试写入。
这种错误是可恢复的。用户可以恢复挂载、重新授权或 Save As 到新位置,编辑缓冲区与恢复快照仍保留。真正危险的是错误降级为普通保存:如果 URI 指向被重新创建的不同文件,自动写入可能覆盖不相关内容。
根目录权限和单文件 URI 的生命周期需要在鸿蒙 PC 真机上继续验证。模拟器能覆盖文件删除和修改,但无法代表所有外接存储、网络盘或企业策略。当前报告因此只确认本地授权文件路径。
原子保存与检测必须协同
应用自己的保存会改变 mtime 和大小。如果轮询恰好在 AtomicFile 提交前后运行,可能看到临时状态。operationInProgress让检查避开显式文件事务,保存完成后更新新指纹,使下一轮不误报。
不过操作锁不能成为唯一依据。系统调度可能让检查先开始,随后用户触发保存。捕获会话和 URI、正文复核以及保存路径自身的冲突检查共同降低竞态风险。未来若采用文件观察 API,也仍需处理自写事件和外部事件去重。
保存前检查与后台轮询解决不同窗口。轮询尽早提示,保存前复核守住最终写入时刻。只做轮询会在两个周期之间漏掉变化;只在保存时检查则让用户长时间编辑过期基线。两者结合才符合桌面编辑器预期。
三方比较建立在真实基线上
进入 Compare 后,应用向 Web 传递持久化基线、本地缓冲区和磁盘正文。三列并不是“旧、新、另一个旧”的装饰,而是分别回答:共同起点是什么、本地写了什么、磁盘被改了什么。格式信息也随元数据传递。
小中型文档显示完整三方逐行差异;总字符数超过上限时降级为有界摘要,避免构造巨大 DOM 冻结界面。外部检测本身不承担差异算法,但必须保存正确磁盘快照,否则用户打开比较时磁盘再次变化会让决策对象漂移。
Keep Local 使用冲突时捕获的磁盘版本作为新基线,再允许用户保存;Use Disk 应用捕获版本前需要二次确认;Save As 将本地缓冲区写到新 URI。每个动作都围绕三份事实更新,而不是简单切换一个布尔值。
真实冲突界面
下图来自 MateBook Pro 2in1 模拟器。文档本地已有修改,同时外部程序改写文件后,应用没有自动覆盖,而是显示显式冲突栏:
选择比较后,可以查看基线、本地和磁盘三方内容:
截图对应仓库中的设备报告docs/test/ohmarkdown/2026-07-18-g3-03-document-reliability/。它证明本地模拟器路径与界面状态成立,不代表远程文件系统和所有第三方编辑器已经兼容。
自动化测试应该制造真实磁盘变化
仅调用registerExternalConflict不能证明检测有效。设备测试需要先通过应用打开文件,再从测试进程改写字节,等待轮询,确认干净文档重载或脏文档出现冲突。格式测试还应只改变 BOM 或换行,验证正文相同时仍识别字节变化。
纯函数测试适合覆盖指纹比较、格式解析和三方摘要。Playwright 适合覆盖三方视图和关闭行为。ohosTest 适合验证 CoreFileKit 读取和字节保存。模拟器手工路径验证工作台、系统文件和焦点。当前统一基线的 Playwright 为29/29、ohosTest 为7/7,外部检测主体在57aea97与5cb4aed完成。
时间相关测试应使用可控等待或轮询条件,避免硬编码“睡眠两秒必然触发”。真机 I/O 调度可能有抖动。更稳的断言是等待状态达到目标并设置总体超时,同时记录实际耗时。
性能与扩展边界
活动文档指纹检查通常只做一次stat,开销较低;只有变化时才读取正文。对单个活动文件,这一策略能在响应性与资源之间取得平衡。对于数百标签或整个工作区,它不适用,因此当前没有扩展到全量轮询。
大文件变化时重读会占用内存和 I/O。检查函数不会在 UI 线程进行正文比较之外的复杂渲染,三方视图还有字符上限。G3-10 需要在鸿蒙 PC 真机测量 10 MiB 级文档的轮询、重读、冲突显示和输入响应,不能用小文件模拟器截图推断性能领先。
如果平台提供可靠的文件观察 API,可以用事件唤醒替代固定轮询,但事件仍需指纹去重和正文复核。观察 API 可能丢事件、合并事件或只提供目录级通知,不能删除当前一致性模型。实现替换的是触发方式,不是三方事实。
安全与隐私边界
检测只访问用户通过系统选择器授权的活动文档 URI,不扫描任意目录,不上传哈希或正文,也不申请网络。磁盘快照保存在内存中的活动会话冲突映射,恢复记录位于应用沙箱。冲突界面通过textContent或受控编辑器展示,不把 Markdown 当可执行 HTML。
错误消息不应泄漏完整系统路径到不必要的遥测。当前项目没有远程遥测,状态栏使用通用描述。未来若加入诊断日志,应默认脱敏 URI,仅在用户主动导出诊断包时提供可审查信息。
符号链接和工作区搜索有额外NOFOLLOW规则;单文件授权 URI 的外部修改检测遵循系统授权结果。应用不尝试绕过权限读取原路径,这使“无法访问”成为正常错误状态,而不是使用更高权限解决。
没有采用的方案
没有仅靠 mtime 自动重载,因为同大小修改、时间戳复制和格式变化会产生误判。没有每轮计算哈希,因为仍需读全文,且哈希不能替代格式和正文快照。没有默认自动合并,因为 Markdown 行级合并可能把语义冲突伪装成成功,当前没有成熟合并引擎和用户验收。
没有为所有打开标签创建独立定时器。那会让资源消耗随标签增长,并制造多个并发 Bridge 状态。当前只保证活动文档,后台标签在激活时复核。这个边界明确记录在项目状态中。
没有在文件消失时立即创建新文件。删除可能是用户或版本控制的意图,自动重建会对抗外部操作。应用保留缓冲区并暂停保存,把 Save As 作为显式恢复路径。
验收清单
回归外部修改检测时,需要覆盖:元数据未变不重读;只改变 mtime 不弹冲突;干净正文变化自动重载;dirty 与磁盘同时变化显示冲突;只改变 BOM 或换行也被识别;应用自己的保存不误报;切换标签后旧检查结果不落到新会话;文件删除暂停自动保存;冲突期间不继续轮询覆盖快照;Keep Local、Use Disk、Save As 更新正确基线。
设备矩阵还应包括:MateBook 模拟器、鸿蒙 PC 真机 Debug 与 Release、本地文档目录、外接存储、版本控制切换、输入法激活、多窗口切换和大文件。每项都需要记录文件字节、UI 状态和操作耗时,而不是只保留一张提示截图。
结论
外部修改检测的核心不是轮询,而是三方事实与不可逆操作的约束。文件指纹负责低成本发现变化,正文和格式负责确认,活动会话令牌阻止异步串线,自动保存暂停保护本地与磁盘两份工作,三方比较帮助用户做最终决策。
OhMarkdown 当前已完成本地活动文档纵切并通过 MateBook Pro 2in1 模拟器验证。全工作区事件监听、自动合并、远程存储和真机长期稳定性仍是后续项。明确这些边界并不会削弱产品,反而确保每一次“已完成”都能对应真实代码、文件字节和设备证据。