鸿蒙 PC Markdown 编辑器命令路由:ArkUI 与 ArkWeb 如何保持同一状态
鸿蒙 PC Markdown 编辑器命令路由:ArkUI 与 ArkWeb 如何保持同一状态
一个混合架构 Markdown 编辑器最容易出现的并不是“按钮没有响应”,而是同一个动作在不同层留下了不同事实:ArkUI 认为已经切到分栏,ArkWeb 仍停在源码模式;Web 编辑器认为文档已保存,原生层的 URI 实际写入失败;快捷键调用了一条路径,工具栏又走另一条路径。短期看这些问题像零散缺陷,长期看却会让整个产品失去可推理性。
鸿蒙 PC 版 OhMarkdown 使用 ArkUI 承担桌面工作台、文件权限与窗口级交互,ArkWeb 内承载 CodeMirror 编辑内核和 Markdown 预览。命令路由的目标不是把两层伪装成同一运行时,而是明确谁拥有事实、消息能携带什么、结果如何回写。本文基于公开仓库 https://gitcode.com/VON-/codex_md_oh 的提交ad1e31a,并纳入2ca99e9增加的工作区搜索命令。所有代码和测试均来自已经进入主分支的实现。
两个运行时必须先承认边界
ArkUI 能使用系统文件选择器、持久 URI 授权、CoreFileKit、首选项和原生窗口事件;ArkWeb 更适合运行 CodeMirror、GFM 渲染、DOMPurify 和编辑器内部键盘逻辑。试图让其中任意一层包办全部能力,都会产生明显代价。全部放在 ArkUI 中意味着重新实现成熟编辑内核;全部放进 Web 中则会把本地文件权限和系统能力暴露给更宽的脚本环境。
因此命令被分成三类。第一类是 Web 内部命令,如撤销和重做,它们直接操作 CodeMirror 状态。第二类是原生能力命令,如打开、保存、选择文件夹、导出和打印,Web 只发送白名单消息。第三类是双层状态命令,如切换源码、分栏和预览,入口可以来自 Web,但原生状态负责工作台按钮与策略,随后再调用 Web 应用最终模式。
这种分类避免了“所有命令都过 Bridge”或“所有命令都在 Web 执行”的机械设计。路由的价值恰恰在于保留所有权:编辑器事务留在编辑器,文件事务留在原生,跨层状态使用明确的请求与应用阶段。
Bridge 不是远程过程调用万能口
Web 侧的命令集合使用联合类型限制:
typeNativeCommand='new'|'open'|'openWorkspace'|'save'|'saveAs'|'autoSave'|'find'|'findWorkspace'|'quickOpen'|'viewSource'|'viewSplit'|'viewPreview'|'exportHtml'|'print';functionrequestNativeCommand(command:NativeCommand):void{window.OhMarkdownEditor?.requestCommand(command);}联合类型首先在编译期阻止拼错名称,也让审查者能一眼看到 Web 可以请求哪些原生动作。它没有execute、eval或任意方法名,也没有路径参数。打开和保存使用的是原生层当前文档会话,不允许脚本指定/system或其他未授权位置。
类型当然不是安全边界的全部,因为运行时 JavaScript 仍可能构造字符串。原生层因此不做动态方法反射,而是用显式分支解析。只有白名单命令会进入对应处理函数,未知字符串被自然忽略。Bridge 注册也只暴露onReady、onState、onChange、onSnapshot、资源导入读取和onCommand等有限方法,没有把整个WorkspaceShell对象交给 Web。
消息结构保持窄而可验证
命令回调由 ArkWeb Bridge 接收命令名和当前正文。原生路由的真实代码位于entry/src/main/ets/shared/ui/WorkspaceShell.ets:
privateonEditorCommand(command:string,content:string):void{if(command==='save'||command==='saveAs'||command==='autoSave'){if(this.documentDirty||this.documentUri.length===0){this.documentContent=content;}this.syncActiveDocumentSession(this.documentContent);this.saveDocument(this.documentRevision,command==='autoSave',command==='saveAs');}elseif(command==='open'){this.requestOpenDocument();}elseif(command==='new'){this.requestCreateDocument();}elseif(command==='find'){this.openSearchPanel(SearchPanelMode.DOCUMENT);}elseif(command==='findWorkspace'){this.openSearchPanel(SearchPanelMode.WORKSPACE);}elseif(command==='quickOpen'){this.openSearchPanel(SearchPanelMode.QUICK_OPEN);}}保存命令携带正文,是因为 CodeMirror 是当前缓冲区正文的事实来源;打开命令不携带路径,因为选择器与授权必须由原生层决定;搜索命令只选择原生面板模式,不让 Web 自行枚举工作区。不同命令的数据量与所有权不同,协议不应该为了“统一格式”让每条消息都携带 URI、正文和配置。
当前正文仅在保存类动作中采纳,并与documentDirty、URI 和修订号结合。这样可以避免一个过期的非保存命令意外覆盖原生缓存。对于自动保存,原生层还传递当前修订号给保存流程,完成时复核结果是否仍对应当前编辑状态。
视图命令需要请求和应用两个阶段
源码、分栏、预览是双层状态。原生工作台需要知道当前模式,以便更新按钮与大文档降级;ArkWeb 需要真正改变 DOM 布局。原生路由先更新viewMode,再调用受限脚本应用:
}elseif(command==='viewSource'||command==='viewSplit'||command==='viewPreview'){constmode=command==='viewSource'?'source':command==='viewSplit'?'split':'preview';if(!this.largeDocumentMode||mode==='source'){this.viewMode=mode;this.setEditorMode(mode);}}privatesetEditorMode(mode:string=this.viewMode):void{this.runEditorScript(`window.OhMarkdownEditor?.setMode(${JSON.stringify(mode)})`);}JSON.stringify用于编码字符串参数,避免把用户数据或状态直接拼成可执行片段。模式值又来自有限分支,不是任意输入。大文档模式只允许源码视图,这条规则同时存在于命令启用条件和原生应用层,前者给用户正确反馈,后者守住最终状态。
当 Web 编辑器尚未完成onReady时,原生层保存期望状态;准备完成后onEditorReady依次设置文档、模式、同步滚动和主题。也就是说,路由不是假设两个运行时总在同一时刻可用,而是允许原生状态在 Web 重载后重新投影。
文档正文与界面状态不能混成一个对象
OhMarkdown 的文档会话包含 URI、名称、正文、持久化基线、格式、指纹、修订号和脏状态。视图模式、侧栏、搜索面板和主题属于工作台状态。命令路由只更新与动作相关的字段,避免一个“打开文档”对象顺便覆盖整个窗口设置。
这种拆分在多标签场景尤其关键。切换标签前,原生层从 Web 捕获活动会话;切换后将目标会话状态注入 Web。命令面板发出的保存命令只作用于当前activeDocumentSessionId。如果异步保存期间用户切到其他标签,完成回调要复核会话标识和修订号,不能把“已保存”状态写到新标签。
命令协议没有直接传sessionId,是因为 Bridge 回调发生在当前活动 Web 会话中,原生接收时读取自身活动标识。长期如果支持后台标签任务,则应显式携带不可伪造的会话令牌并做代际检查,而不是继续依赖活动状态。当前范围下,保持协议窄比预先引入分布式事务模型更合理。
保存命令是一项文件事务
保存不是把正文传给 ArkTS 后就结束。原生层需要检查外部修改、编码与换行格式、目标 URI、原子写入结果和当前修订。自动保存与手动保存还具有不同反馈:自动保存不能弹出打断输入的系统选择器,未命名文档也不能悄悄决定路径。
Web 层在请求保存时会锁定待保存正文,保存成功后由原生回调确认基线。失败时仍保留 dirty 状态和恢复快照。路由通过command === 'autoSave'告诉保存流程使用安静反馈,但没有跳过冲突检查。这个设计把“入口不同”和“数据安全规则相同”分开:自动保存可以安静,绝不能比手动保存更随意。
原生层拥有文件事务,是因为它能使用已授权 URI 和AtomicFile。Web 只提供缓冲区事实,不判断写入是否成功。只有收到原生成功结果后,CodeMirror 的保存基线才能推进。这防止了典型的假保存:界面显示未修改,实际文件因权限、空间或外部变化并未落盘。
查找命令展示了路由的分层价值
当前文档查找、工作区全文搜索和快速打开都从 Web 快捷键或命令面板进入,但最终面板由 ArkUI 构建。三者仅通过命令名选择模式:find、findWorkspace、quickOpen。原生层根据模式决定查询字段、可用选项和是否调用SearchService。
工作区搜索没有把根 URI 传入 ArkWeb。原生层从已授权的workspaceRootUri开始枚举,跳过符号链接和资源目录,正文匹配进入 TaskPool。结果被点击后,原生读取文档并解析有效偏移,再让 Web 的jumpToOffset完成选区和滚动。路由形成的是能力接力,而不是 URI 往返搬运。
这条路径体现了混合架构的优势:ArkUI 保持文件安全,TaskPool 保持扫描不阻塞 UI,CodeMirror 提供精确选区。若为了“减少层数”把搜索全放进任意一层,都要牺牲其中至少一个条件。
错误需要回到用户可理解的层
Bridge 调用可能失败、文件选择可能取消、保存可能冲突、Web 页面可能尚未准备好。命令路由不应该让异常变成控制台日志后消失。原生层使用operationStatus显示如Unable to save、Workspace search canceled或File unavailable; auto save paused,需要决策时显示冲突栏和确认对话框。
错误也不应该无差别弹窗。取消文件选择器是正常用户路径,通常只恢复状态;自动保存失败应保留脏状态并给出非模态提示;覆盖磁盘版本是破坏性动作,需要二次确认;搜索中的单个不可读文件只计入跳过数量,不使整轮任务失败。命令名称相同并不意味着错误策略相同,路由需要把动作交给拥有领域知识的服务。
Web 脚本调用使用可选链,页面未就绪时不会抛出未定义异常。关键初始化由onReady重新同步。但保存这类不可丢动作不能只靠可选链吞掉,原生会检查editorReady和操作状态,并保留恢复记录作为第二道保障。
焦点和模态层级也是状态
命令面板位于 Web,搜索侧栏和设置位于 ArkUI,文件选择器属于系统。每次路由都会改变焦点所有者。打开搜索命令时,原生层展开侧栏并延迟聚焦search-query-input;快速打开输入后方向键由 ArkUI 控件处理;结果打开后 Web 编辑器获得焦点和选区。
三方差异视图显示时,Web 的全局快捷键会优先将 Escape 交给冲突比较,而不会打开命令面板。原生冲突栏也保持文档操作的决策入口。模态优先级必须在两层都表达,否则用户可能在冲突比较上再叠加系统选择器,最终不知道哪一层接收键盘。
因此焦点不能被视为“渲染完调用一下 focus”。它与命令生命周期同样重要:请求前谁拥有焦点,系统窗口是否接管,完成后应返回编辑器还是搜索框,取消时是否恢复原任务。当前实现对已经覆盖的命令给出确定路径,尚未支持的右键和快捷键配置会在 G3 后续步骤单独验证。
安全边界从注册到执行逐层收紧
第一层是 TypeScript 联合类型,减少开发阶段误用;第二层是 Web 暴露对象只提供固定requestCommand;第三层是 ArkWeb Bridge 的methodList白名单;第四层是onEditorCommand显式分支;第五层是每个服务自己的 URI、格式和文件状态验证。任意一层都不是单独的万能防线,但组合后能防止命令入口意外变成任意系统调用。
Bridge 载荷中的正文可能很大,也可能包含引号、脚本标签和双向文本。它作为字符串参数传输,不被当成代码。原生反向调用 Web 时用JSON.stringify编码。预览 HTML 另经 Markdown 渲染和 DOMPurify 净化,命令路由不会因为“内容来自本地文件”而跳过处理。
应用没有申请网络权限,命令也没有远程服务分支。这个事实使状态链更容易推理:同一文档的事实来自用户文件、当前缓冲区和本地恢复记录,不存在云端副本悄然改变命令结果。未来若加入同步,必须新增冲突模型,而不能在当前save命令后偷偷上传。
真实界面证明的是跨层闭环
下图是 MateBook Pro 2in1 模拟器中的实际命令面板。用户在 ArkWeb 内按快捷键打开入口,筛选并执行视图命令:
命令完成后,ArkUI 工作台与 ArkWeb 编辑区显示一致的分栏状态:
截图不用于证明所有系统能力都完成,而用于固定一个真实事实:命令从 Web 入口经过 Bridge 到原生路由,再回到 Web 应用状态,链路在模拟器上成立。对应测试报告在docs/test/ohmarkdown/2026-07-18-g3-02-command-palette/,后续搜索路由证据在docs/test/ohmarkdown/2026-07-19-g3-05-workspace-search/。
自动化要分别观察两端
Web Playwright 测试通过 mockohMarkdownBridge捕获请求,断言Ctrl+S产生save、Ctrl+Shift+F产生findWorkspace、Ctrl+P产生quickOpen,并检查命令面板执行后载荷。它验证的是 Web 端不会把不同快捷键混为一谈,也不会绕过白名单。
ArkTS 构建和 ohosTest 验证原生服务,模拟器人工路径验证真实工作台。当前2ca99e9基线中 Playwright 为29/29,ohosTest 为7/7。这些数字不等于远程 CI 或真机结论,项目仍明确记录正式签名、鸿蒙 PC 真机和远程 Runner 待补。
测试设计应避免只断言“状态栏有文字”。更关键的是请求类型、正文是否在正确时机采纳、模式禁用是否生效、旧异步结果是否被代际令牌拒绝、文件失败后 dirty 是否保留。命令路由的回归常常发生在时序而不是视觉层。
为什么没有引入通用事件总线
通用事件总线看似能减少分支,但它会把字符串主题、载荷类型和生命周期隐藏在订阅关系中。当前命令数量有限、跨层边界明确,显式联合类型和分支反而更易审查。开发者搜索quickOpen就能找到注册、Bridge、原生路由、服务和测试,而不必追踪运行时事件表。
也没有让每个 ArkUI 控件直接执行一段 Web JavaScript。所有视图动作通过集中辅助函数编码参数,文件动作通过领域方法。散落的脚本字符串会让 CSP、安全审查和页面重载恢复变得困难。集中路由稍显重复,却把系统行为保持在可见范围内。
未来功能超过当前复杂度时,可以把分支重构为类型化映射,但前提是仍保留每个命令的权限和载荷契约。重构目标应是减少真实重复,而不是追求“零 if”。对本地文档编辑器,显式往往比抽象漂亮更重要。
性能与并发考虑
普通命令路由是常数级分支,不构成性能热点。风险来自命令触发的工作:大正文跨 Bridge、工作区扫描、导出渲染和文件写入。解决方式不是把路由异步化后不管,而是让领域服务定义预算。搜索正文进入低优先级 TaskPool;恢复快照有节流和大小限制;大文档禁用实时预览;文件保存使用原子提交并复核指纹。
原生层有operationInProgress防止互斥文件操作重入,但工作区搜索不占用全局文件操作锁,以免扫描期间无法继续编辑。搜索自己使用请求序号和服务代际取消。这说明并发策略不能由命令路由一刀切:保存需要串行保护,搜索需要可取消后台执行,编辑输入必须始终可用。
反向脚本调用也要限制数量。状态频繁变化时不应每次击键都跨层设置所有控件。Web 用onChange回传必要字数和 dirty,恢复快照按节流发送;原生只在模式、主题、会话等边界变化时下发配置。这种粗粒度同步降低了两个运行时相互抖动的风险。
可维护性检查表
新增命令前需要回答:事实由哪一层拥有;是否涉及文件或系统权限;载荷最小需要什么;是否允许在大文档或冲突状态执行;操作能否取消;失败如何反馈;是否改变文档 dirty、修订号或基线;焦点最终落在哪里;Web 与原生分别需要哪些测试;应用重载后状态如何恢复。
实现后还要检查:命令名进入联合类型和原生白名单;没有任意路径或脚本载荷;反向参数用结构化编码;异步完成复核会话和代际;工具栏、命令面板和快捷键读取同一状态;未知命令没有副作用;模拟器真实执行与 Playwright mock 结果一致。
这些问题比“要不要使用消息总线”更接近产品质量。命令路由是架构边界的日常执行点,每个小动作都可能扩大权限、复制状态或引入竞态。保持检查表可以让功能增长仍然在原有规则内发生。
结论与已知边界
OhMarkdown 当前命令路由已经覆盖文件、新建、保存、搜索、快速打开、视图、导出和打印的基础入口,形成 Web 请求、原生决策、服务执行和状态回写的完整路径。它的优势不是跨层调用次数少,而是每次调用的所有权和数据都能解释。
当前仍未完成用户快捷键配置、右键命令复用、插件命令隔离、远程同步冲突和真机无障碍全量验证。文章不会把这些未来项描述为已实现。现有基线证明的是:在鸿蒙 PC 混合编辑器里,ArkUI 与 ArkWeb 可以通过窄协议保持一致,而不把文件权限交给脚本,也不复制整套文档状态。这份可推理性将直接决定后续功能能否做大而不失控。