
鸿蒙 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 侧的命令集合使用联合类型限制typeNativeCommandnew|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.etsprivateonEditorCommand(command:string,content:string):void{if(commandsave||commandsaveAs||commandautoSave){if(this.documentDirty||this.documentUri.length0){this.documentContentcontent;}this.syncActiveDocumentSession(this.documentContent);this.saveDocument(this.documentRevision,commandautoSave,commandsaveAs);}elseif(commandopen){this.requestOpenDocument();}elseif(commandnew){this.requestCreateDocument();}elseif(commandfind){this.openSearchPanel(SearchPanelMode.DOCUMENT);}elseif(commandfindWorkspace){this.openSearchPanel(SearchPanelMode.WORKSPACE);}elseif(commandquickOpen){this.openSearchPanel(SearchPanelMode.QUICK_OPEN);}}保存命令携带正文是因为 CodeMirror 是当前缓冲区正文的事实来源打开命令不携带路径因为选择器与授权必须由原生层决定搜索命令只选择原生面板模式不让 Web 自行枚举工作区。不同命令的数据量与所有权不同协议不应该为了“统一格式”让每条消息都携带 URI、正文和配置。当前正文仅在保存类动作中采纳并与documentDirty、URI 和修订号结合。这样可以避免一个过期的非保存命令意外覆盖原生缓存。对于自动保存原生层还传递当前修订号给保存流程完成时复核结果是否仍对应当前编辑状态。视图命令需要请求和应用两个阶段源码、分栏、预览是双层状态。原生工作台需要知道当前模式以便更新按钮与大文档降级ArkWeb 需要真正改变 DOM 布局。原生路由先更新viewMode再调用受限脚本应用}elseif(commandviewSource||commandviewSplit||commandviewPreview){constmodecommandviewSource?source:commandviewSplit?split:preview;if(!this.largeDocumentMode||modesource){this.viewModemode;this.setEditorMode(mode);}}privatesetEditorMode(mode:stringthis.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 保持扫描不阻塞 UICodeMirror 提供精确选区。若为了“减少层数”把搜索全放进任意一层都要牺牲其中至少一个条件。错误需要回到用户可理解的层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捕获请求断言CtrlS产生save、CtrlShiftF产生findWorkspace、CtrlP产生quickOpen并检查命令面板执行后载荷。它验证的是 Web 端不会把不同快捷键混为一谈也不会绕过白名单。ArkTS 构建和 ohosTest 验证原生服务模拟器人工路径验证真实工作台。当前2ca99e9基线中 Playwright 为29/29ohosTest 为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 可以通过窄协议保持一致而不把文件权限交给脚本也不复制整套文档状态。这份可推理性将直接决定后续功能能否做大而不失控。