鸿蒙 PC Markdown 编辑器多文档架构:DocumentSession 会话模型
桌面 Markdown 编辑器从单文档升级到多标签,表面上只是工具栏多出一排标签,真正困难的部分却是状态归属发生了变化。单文档程序可以把documentUri、正文、修改标记和撤销历史都放在页面级变量中;多标签程序必须回答更严格的问题:切走时保存什么,切回时恢复什么,后台标签是否还能被保存,两个内容相同但来源不同的文件如何区分,关闭活动标签后焦点落在哪里,最后一个标签关闭后工作台是否仍然可用。
本文基于鸿蒙 PC 原生 Markdown 编辑器 OhMarkdown 的实际实现,拆解 ArkUI 原生外壳与 ArkWeb CodeMirror 内核如何共同维护多文档会话。示例代码来自公开仓库:https://gitcode.com/VON-/codex_md_oh
多标签不是字符串数组
最容易实现、也最容易出问题的方案,是只在原生层保存每个标签的id、标题和正文字符串。切换标签时,把当前编辑器内容读出来,再把目标正文整段写进去。这个方案能让两个标签显示不同文字,却会丢失编辑器内部状态:选区、滚动位置、折叠区、组合输入状态、撤销栈和重做栈都不再属于文档。用户在标签甲输入内容,切到标签乙后按撤销,如果编辑内核仍使用同一条历史链,就可能撤销甲的内容,甚至退回上一份文件。
因此,多文档会话需要两层状态:原生层负责文件身份、持久化和业务状态,Web 编辑器层负责编辑器原生状态。两层通过稳定的sessionId对齐,而不是通过文件名或正文内容猜测身份。
原生层的会话模型定义在entry/src/main/ets/shared/services/DocumentSessionService.ets:
exportconstMAX_OPEN_DOCUMENT_SESSIONS:number=12;exportinterfaceDocumentSession{id:string;uri:string;name:string;content:string;persistedContent:string|undefined;format:DocumentFormat;revision:number;dirty:boolean;wordCount:number;largeDocumentMode:boolean;}这些字段不是为了让对象显得完整,而是分别承担不同的约束。
id是进程内稳定身份。一个未命名文档没有 URI,两个新建文档的名称又都可能是Untitled.md,所以名称不能作为键。uri是系统文件身份,用于打开、保存和判断同一文件是否已经打开。content是原生侧最近一次捕获的编辑内容,它让工作区在 ArkWeb 临时不可用时仍能保有业务状态。persistedContent表示最近一次确认写入外部文件的正文,用来区分编辑缓冲区和磁盘基线。format保存 UTF-8 BOM 与换行格式,避免多标签切换时把甲文档的 CRLF 策略错误应用到乙文档。revision、dirty和wordCount分别承担恢复快照排序、未保存提示和状态栏显示。largeDocumentMode则把大文档保护边界也变成会话属性,避免切换后错误开启预览或全文快照。
会话上限固定为十二个。上限不是随意的 UI 限制,而是当前内存模型下的保护措施。CodeMirror 的EditorState会保留文档树、选择区和历史扩展;标签越多、文档越大,常驻内存越高。Alpha 阶段先给出明确边界,比允许无限打开后被系统低内存终止更可靠。未来如果要提高上限,应先引入后台会话冻结、历史压缩和内存观测,而不是只修改常量。
新建会话必须从完整默认值开始
未命名文档由一个纯函数创建:
exportfunctioncreateUntitledSession(id:string):DocumentSession{return{id:id,uri:'',name:'Untitled.md',content:'',persistedContent:'',format:createDefaultDocumentFormat(),revision:0,dirty:false,wordCount:0,largeDocumentMode:false};}集中创建默认值可以阻止一种常见错误:页面在不同入口手工拼接会话对象,某个入口忘记初始化format或persistedContent,问题直到保存时才出现。纯函数也让“关闭最后一个标签后创建空白会话”与“点击加号新建会话”使用完全相同的基线。
ArkUI 的状态更新采用替换对象而不是原地修改:
exportfunctionreplaceDocumentSession(sessions:Array<DocumentSession>,updated:DocumentSession):Array<DocumentSession>{returnsessions.map((session:DocumentSession):DocumentSession=>session.id===updated.id?updated:session);}这是为了配合声明式 UI 的变更检测。对数组中的对象直接赋值,可能让视图层无法稳定观察到变化;返回新数组能让标签标题、星号和活动状态及时重绘。对于最多十二项的小数组,线性映射的成本远低于引入复杂可变状态后产生的一致性风险。
切换前先捕获,切换后再应用
多标签切换的核心顺序是“捕获当前会话、应用目标会话、激活目标编辑状态”。如果先切换activeSessionId再读取编辑器,读出的正文就会被错误记到目标会话。OhMarkdown 在WorkspaceShell.ets中把顺序写得很明确:
privateasyncactivateDocumentSession(sessionId:string):Promise<void>{if(sessionId===this.activeDocumentSessionId||this.operationInProgress){return;}consttargetSession=this.documentSessions.find((session:DocumentSession):boolean=>session.id===sessionId);if(!targetSession){return;}awaitthis.captureActiveDocumentSession();this.applyDocumentSession(targetSession);awaitthis.activateEditorSession(targetSession);}operationInProgress会阻止文件选择或保存过程中发生标签切换。原因是这类操作存在异步窗口:系统选择器打开后,用户看到的仍是原窗口;如果此时活动会话改变,选择器返回的 URI 可能被写入另一个标签。最稳妥的做法是把文件操作和会话切换做互斥,等操作闭环后再接受下一次切换。
捕获正文通过 ArkWeb 的runJavaScript完成:
privateasynccaptureActiveDocumentSession():Promise<void>{letcontent=this.documentContent;if(this.editorReady){try{constresult=awaitthis.editorController.runJavaScript('window.OhMarkdownEditor?.getDocument() ?? ""');content=this.decodeJavaScriptString(result);}catch(_){}}this.documentContent=content;this.syncActiveDocumentSession(content);}这里保留documentContent作为失败回退。如果 ArkWeb 正在重载或脚本调用失败,原生层不会把会话覆盖为空字符串。另一个容易忽略的细节是runJavaScript返回的字符串可能经过 JSON 编码,例如正文中的换行和引号会以转义形式出现。实现通过JSON.parse解码,在解析失败时才回退原结果:
privatedecodeJavaScriptString(result:string):string{try{returnJSON.parse(result)asstring;}catch(_){returnresult;}}如果省略这一步,普通单行文本看起来正常,包含换行、反斜杠或引号的 Markdown 在标签切换后却可能变成带引号的 JSON 字面量。这类问题非常适合通过真实多行文档验证,不能只测hello。
Web 层保存完整 EditorState
原生层会话对象不能替代 CodeMirror 状态。Web 内核为每个sessionId保存一个EditorSessionSnapshot:
interfaceEditorSessionSnapshot{state:EditorState;baselineDocument:Text;forcedDirty:boolean;pendingDirty:boolean;documentRevision:number;lastRecoveryRevision:number;}consteditorSessions=newMap<string,EditorSessionSnapshot>();functionpersistActiveSession():void{editorSessions.set(activeSessionId,{state:editor.state,baselineDocument,forcedDirty,pendingDirty,documentRevision,lastRecoveryRevision});}直接保存EditorState有三个重要效果。首先,正文和选区天然绑定,不需要自己计算光标偏移。其次,撤销历史是状态的一部分,标签之间不会串线。最后,CodeMirror 的文档是持久化数据结构,保存状态快照并不等于每次都复制整篇字符串,切换成本比手工序列化全部内部字段更可控。
切换时,当前状态先进入 Map,再查找目标快照:
functionactivateSession(sessionId:string,content:string,recovered:boolean=false):void{if(sessionId===activeSessionId){return;}persistActiveSession();window.clearTimeout(bridgeTimer);window.clearTimeout(recoveryTimer);recoveryTimer=undefined;pendingNativeChange=false;activeSessionId=sessionId;conststoredSession=editorSessions.get(sessionId);if(!storedSession){resetEditorDocument(content,recovered);return;}isApplyingNativeDocument=true;editor.setState(storedSession.state);isApplyingNativeDocument=false;baselineDocument=storedSession.baselineDocument;forcedDirty=storedSession.forcedDirty;pendingDirty=storedSession.pendingDirty;documentRevision=storedSession.documentRevision;lastRecoveryRevision=storedSession.lastRecoveryRevision;}切换前必须清除旧会话的 Bridge 定时器和恢复定时器。否则用户刚从甲切到乙,甲延迟发送的onChange或onSnapshot可能在乙已成为活动会话后到达原生层,造成乙被标记为修改,甚至把甲的正文写进乙的恢复记录。清除定时器后,目标会话如果本身为脏状态,再为它重新安排恢复快照,这样异步任务始终跟随当前身份。
isApplyingNativeDocument用来区分“用户编辑”与“程序切换状态”。设置EditorState可能触发更新监听,如果不做抑制,单纯切换标签也会被当成一次输入,revision 增加、标签出现星号、恢复快照被重新写入。任何双运行时编辑器都需要这类来源标记,否则状态回环迟早出现。
打开文件时先去重,再决定是否复用空白标签
桌面用户经常从文件树重复点击同一个文档。正确行为是激活已打开标签,而不是创建第二个指向同一 URI 的会话。OhMarkdown 先按 URI 查重:
constopenedSession=this.documentSessions.find((session:DocumentSession):boolean=>session.uri===openedDocument.uri);if(openedSession){awaitthis.captureActiveDocumentSession();this.applyDocumentSession(openedSession);awaitthis.activateEditorSession(openedSession);return;}若当前标签是未命名、未修改且内容为空,则复用它,避免用户启动应用后第一次打开文件就留下一个无意义的空标签。复用条件必须同时检查 URI、脏状态和正文;只检查正文为空会误伤一个“保存后为空”的真实文件,只检查未修改则可能把已经命名的空文件覆盖。
新文件会话保留读取阶段识别出的格式信息,并根据内容长度初始化大文档模式。文件打开完成不代表可以把所有字段设成默认值;BOM、换行、URI 和磁盘基线都必须从OpenedDocument一起迁移,否则第一次切换标签后再保存就可能破坏字节语义。
关闭活动标签是一个状态迁移
关闭非活动标签相对简单:从数组删除,并清理 Web 层快照。关闭活动标签需要选择后继、恢复其原生状态、恢复其 CodeMirror 状态,最后才删除旧快照。
privateasynccloseDocumentSession(sessionId:string):Promise<void>{constsessionIndex=this.documentSessions.findIndex((session:DocumentSession):boolean=>session.id===sessionId);if(sessionIndex<0){return;}constwasActive=sessionId===this.activeDocumentSessionId;letremainingSessions=this.documentSessions.filter((session:DocumentSession):boolean=>session.id!==sessionId);if(!wasActive){this.documentSessions=remainingSessions;this.closeEditorSession(sessionId);return;}if(remainingSessions.length===0){remainingSessions=[createUntitledSession(this.createDocumentSessionId())];}this.documentSessions=remainingSessions;constnextIndex=Math.min(sessionIndex,remainingSessions.length-1);constnextSession=remainingSessions[nextIndex];this.applyDocumentSession(nextSession);awaitthis.activateEditorSession(nextSession);this.closeEditorSession(sessionId);this.clearRecoveryDraft();}后继索引使用Math.min。关闭中间标签时,新数组相同索引正好是右侧邻居;关闭最后一个标签时,索引收缩到新数组末尾。这个规则比固定跳到第一个标签更符合桌面编辑器的空间记忆。
最后一个标签关闭后自动创建空白会话,是为了保持工作台结构稳定。编辑器区域不会消失,快捷键、新建和打开命令仍有明确目标,也避免 ArkWeb 进入“没有活动 sessionId”的额外状态。空工作台并不一定要等于没有会话,内部保留一个干净会话反而能减少大量分支。
未保存状态不能只看当前标签
标签上的星号来自每个DocumentSession.dirty,而不是全局documentDirty。活动标签编辑时,Bridge 回调更新页面状态,再同步回活动会话对象;切换后applyDocumentSession把目标会话的dirty、revision和格式恢复到页面字段。这样原生 UI 仍可以使用简洁的活动文档属性,同时标签栏能读取整个会话数组展示各自状态。
关闭脏标签时,程序给出取消、放弃和保存三个分支。保存分支不能在发出保存命令后立即关闭,因为系统文件选择器可能被取消,外部 URI 写入也可能失败。实现设置pendingCloseSessionId,等待保存成功并且当前会话已变为干净状态后才关闭。这个等待条件把“用户点击保存”与“数据已经持久化”分开,是防止丢文档的关键。
多标签恢复还有一个现阶段边界:当前崩溃恢复记录主要围绕活动会话建立。若要保证所有后台脏标签在进程终止后都恢复,需要把单记录扩展为按 sessionId 或 URI 索引的恢复集合,并设计容量上限、加密与淘汰策略。在完成这套存储模型前,不应该仅凭标签星号宣称多文档恢复已经完整。
鸿蒙 PC 模拟器中的实际状态
下图来自 MateBook Pro 2in1 模拟器。两个Untitled.md标签分别持有独立正文,当前标签显示Session-B,另一个标签仍保留未保存标记。切换回前一标签后正文和撤销历史均保持独立。
截图验证的重点不是“出现两个标签”,而是至少完成以下闭环:甲输入、乙输入、甲撤销、乙内容不变;两个标签独立显示脏状态;关闭脏标签选择取消后内容保留;选择放弃后只关闭目标;选择保存时只有成功写入才关闭;连续新建达到十二个后拒绝继续增长。
Web 自动化测试直接验证了会话隔离:
awaitpage.evaluate(()=>{consthost=windowasunknownasEditorTestWindow;host.OhMarkdownEditor.setSessionDocument('session-a','文档甲');});awaitpage.locator('.cm-content').click();awaitpage.keyboard.insertText('修改');awaitpage.evaluate(()=>{consthost=windowasunknownasEditorTestWindow;host.OhMarkdownEditor.activateSession('session-b','文档乙');});awaitpage.locator('.cm-content').click();awaitpage.keyboard.insertText('新增');测试随后切回甲并撤销,期望正文退回“文档甲”;再切到乙,期望仍为“文档乙新增”。这比只断言标签数量更接近真正风险,因为多标签最严重的缺陷不是 UI 少一个标签,而是用户编辑落入错误文档。
设计结论
鸿蒙 PC Markdown 编辑器的多文档能力,本质上是跨 ArkUI 与 ArkWeb 的分布式状态管理。原生层掌握文件、格式、持久化和标签生命周期,Web 层掌握 CodeMirror 的正文结构、选区和撤销历史,二者以稳定 sessionId 对齐。切换必须遵循先捕获再应用,异步定时器必须在身份变化时清理,关闭必须等持久化结果而不是等待按钮点击。
这套模型仍然保持了可控复杂度:十二个会话使用小数组,EditorState 使用 Map 保存,页面只暴露活动文档字段,没有引入通用状态管理框架。它解决的是实际的数据归属问题,而不是为标签栏制造抽象。后续要扩展固定标签、会话重启恢复、标签拖动或跨窗口编辑,都可以围绕同一个会话身份继续演进,而不需要推翻文件保存与编辑内核。