HarmonyOS 7 Navigation:平行视界批注撤销账本与回放屏障【鸿蒙心迹】 DemoSplitMarkLedger / SplitMarkPage / LedgerAuditPage边界以下批注与撤销数据来自人为设计的事件夹具开发界面、手机页面是演示示意图没有在本次任务中完成编译、实机多窗口或系统级 EasyGo 验收。文档工作台做成双栏之后最容易被忽略的未必是布局而是“撤销”这个看似普通的按钮。左侧目录刚切换文档右侧用户正在改批注如果撤销动作只盯着当前组件状态它可能撤销另一个文档的文字如果只按服务器回执顺序排队重复回执还可能把已经撤销的批注再次贴回页面。这篇从一个刻意构造的异常案例入手用户在DOC-217的P-07段落添加一条“标记为风险”批注改写内容又做一次撤销、一次重做。画面上最终还是那段已改写的文字但真正需要验收的是一份操作账本每个动作属于哪个文档、针对哪个历史修订、能否重复应用、怎么逆向回放以及两栏在回放过程中是否曾短暂呈现不同版本。一、不要让左右两栏分别拥有一份“真相”本例项目名SplitMarkLedger主页面SplitMarkPage详情诊断页LedgerAuditPage。演示任务UNDO-1010-11文档DOC-217段落P-07。左栏固定显示目录和段落索引右栏展示当前批注内容、撤销/重做按钮以及版本号。我们的目标不是重建一个通用富文本编辑器只验证操作在双栏之间的归属与回放协议。假设当前文档业务修订为12。输入动作依次是新增OP-019提交到修订13编辑OP-020提交到修订14撤销OP-020提交到修订15重做OP-020提交到修订16。另有一个重复的OP-020回执和一个基于修订13的迟到操作都被拒绝。最后账本统计为四个有效提交、撤销一次、重做一次、重复忽略一次、迟到忽略一次状态LEDGER_CLEAN。修订号不能直接当作页面构建次数。右栏组件可能在折叠屏收拢后重新构建左栏列表还可能因滚动而复用元素但这些 UI 事件不应该无缘无故把业务修订16推进到17。同样修订从13到14不等于路由栈 push 了一页。我们必须把“窗口可见结构”“Navigation 页面栈”和“批注数据修订”分开记账。双栏页面有两个需要保持的身份文档身份DOC-217和段落身份P-07。如果左栏选择已经换成其他文档右栏仍在等待旧文档提交回执那条回执只能落回自己所属的数据账本不能因为当前右栏刚好显示一个编辑框就覆盖它。一个请求能成功返回不代表它仍有权限改写当前用户正在看的对象。二、先给操作定义稳定身份再考虑 UI 动画“撤销最后一次改动”这句话的关键是“最后一次”属于哪条序列。没有稳定操作 ID、文档 ID 和基准修订事件到了两个页面谁先消费就有可能成为事实。最开始的设计不急着做乐观动画先把操作表示成不可变记录opId、documentId、paragraphId、kind、baseRevision、forwardPatch、inversePatch。本例不使用下标区分批注因为左栏过滤、目录折叠或文章排序都会改变可见位置。P-07是稳定业务标识OP-019和OP-020是示例事务标识。撤销操作并不是删除OP-020的存在记录而是追加一条“针对 OP-020 的逆向变更”让审核人员仍能追溯完整历史。重做也是追加正向变更而非修改旧事件的时间戳伪装成第一次提交。操作种类应该有限至少区分新增、编辑、撤销和重做。如果将所有动作压成一个setText回放虽然简单却失去了撤销关系难以判断重复回执是否已经应用。为避免引入未验证的第三方编辑器接口本文的AnnotationLedger完全由 ArkTS 编写只管理字符串和修订真实富文本范围、选择锚点及冲突合并属于后续扩展。三、Navigation 只组织视图撤销合同放在应用层华为官方 ArkUI 文档支持Navigation、NavPathStack、NavDestination及NavigationMode.Split等页面导航机制。这些能力适合把目录与详情组织成双栏或紧凑堆栈它们不会天然把一条批注操作变成跨页原子事务也不会替业务自动生成可审计的撤销历史。我们只把已验证的导航结构用于容器层而不是编造所谓 EasyGo 系统原子撤销接口。SplitMarkPage负责根据窗口条件选择 Split 或 Stack 模式业务账本作为稳定的状态所有者在两栏中通过同一个数据快照渲染。折叠屏切换时布局可以重建操作账本不需要重新初始化。这一段显示 Navigation 层应如何与业务模型保持边界。isWide是应用决定的当前窗口布局状态不应把固定宽度阈值说成 HarmonyOS 强制规则。代码只示意容器接线和本地回调路径页面注册、详细 NavDestination 映射需要按实际工程补齐。EntryComponentstruct SplitMarkPage{privatestack:NavPathStacknewNavPathStack();StateisWide:booleantrue;StateselectedDocId:stringDOC-217;StateselectedParagraphId:stringP-07;build(){Navigation(this.stack){Column({space:12}){Text(批注工作台).fontSize(22);Text(文档${this.selectedDocId}/ 段落${this.selectedParagraphId});Button(打开账本诊断).onClick(()this.stack.pushPathByName(LedgerAuditPage,{taskId:UNDO-1010-11,docId:this.selectedDocId}));}}.mode(this.isWide?NavigationMode.Split:NavigationMode.Stack).title(SplitMarkLedger);}}这里没有把isWide改动当作undo()触发器。版式从 Split 收到 Stack 时既不会自动执行一次撤销也不应生成新的业务修订。如果目标 NavDestination 还没有注册真实工程会遇到路由错误示意代码只是说明调用形态必须在项目中配置对应的navDestination构造器后验证。双栏共享状态还涉及可见性的误区隐藏左栏不等于删除左栏曾选中的文档而恢复左栏时也不意味着可以用它的旧快照覆盖最新右栏修订。选择身份保存为独立字段页面根据当前账本快照获取数据能减少布局切换时的“旧视图当数据库”问题。四、最难的不是撤销按钮而是撤销哪一条历史现在回到DOC-217的四个有效动作。新增 OP-019 后文本从空批注变为“标记为风险”编辑 OP-020 后文本更新为“标记为风险需复查”撤销 OP-020 时回到“标记为风险”重做 OP-020 时又恢复“标记为风险需复查”。每一步都追加账本事件最终修订16当前可见文本与事件史一致。撤销的逆向补丁应在 OP-020 被接受时冻结下来而不是在用户点击撤销那一刻临时读取某个正在变化的输入框。因为这时左栏可能切换右栏可能正在重新布局输入框文本也可能经过输入法组合。保存原补丁和逆补丁可以让撤销的语义与 UI 暂存状态解耦。下方代码是一个有界演示账本只允许同一文档按预期修订提交并通过appliedIds去重。undo、redo在这里都是有意义的提交操作因此修订分别进入15、16而不是简单地把revision从14减回13。exporttypeLedgerActionADD|EDIT|UNDO|REDO;exportinterfaceAnnotationEvent{opId:string;documentId:string;paragraphId:string;action:LedgerAction;baseRevision:number;textAfter:string;}exportclassAnnotationLedger{revision:number12;text:string;duplicateIgnored:number0;staleIgnored:number0;privateappliedIds:SetstringnewSetstring();apply(event:AnnotationEvent):boolean{if(this.appliedIds.has(event.opId)){this.duplicateIgnored1;returnfalse;}if(event.documentId!DOC-217||event.paragraphId!P-07||event.baseRevision!this.revision){this.staleIgnored1;returnfalse;}this.textevent.textAfter;this.appliedIds.add(event.opId);this.revision1;returntrue;}}注意apply这个演示模型不会自动合并冲突也没有网络存储事务。它保证的是本地单序列在提交点的明确性。真实应用跨进程或多设备同步时必须让同一文档的修订更新在持久化层具备原子条件否则两个页面同时读取12、各自算出13单靠 UI 中的 Set 没有办法防止并发覆盖。业务还需要区分两种拒绝duplicateIgnored是同一个opId再来一次不能重复提交staleIgnored是不同操作携带过期的baseRevision或错误文档归属。把两者合并成“失败2次”会丢掉真正的恢复建议。重复回执通常可直接确认已有结果版本冲突则可能需要刷新基线、重新生成用户操作。五、双栏回放应看见同一个修订而不是左右一边先改假设右栏先消费撤销事件显示“标记为风险”左栏仍持有修订14的批注预览“标记为风险需复查”两个视图在一帧内看似都合理合在一起却自相矛盾。这个问题不能靠给左栏加一个旋转动画解决应该让 UI 两边都订阅同一份以revision标识的不可变快照。组件接收的应该是(documentId, revision, annotationSummary)这样的完整对象而不是分别更新一个文字字段和一个数字字段。更新时先构造新快照再一次性替换应用侧可观察引用。如此右栏的文本和左栏的计数都来自修订15或修订16不会出现一部分用了旧修订、一部分用了新修订的拼接态。这也是与此前“详情请求迟到”的不同之处。请求代次隔离只问返回值还属于当前页面吗本篇的操作账本还必须回答这次操作基于什么修订、有没有逆向补丁、是否可重做以及回放后两栏有没有采用同一个可见快照。即使没有网络请求撤销本身也会制造跨视图一致性问题。图2为依据业务事件模型设计的 DevEco Studio 风格示意图不是真实运行日志截图。诊断页里我会明确显示四条成功提交不只显示“已保存”每条提交有自己的 action、目标段落、提交前后修订。这样在一次切换窄屏后可以靠状态表判断账本有没有被重建而不是仅凭右栏文字还在就认为恢复成功。六、把重复回执与迟到编辑摆到台面上在规定的四次有效提交之外夹具还投递两个不应影响结果的事件。一个是与已接受的OP-020完全相同的重复回执它被appliedIds识别并计入duplicateIgnored1另一个是新的编辑动作但baseRevision13当前版本已是16因此进入staleIgnored1。这两个事件不能让当前内容倒退也不能推进修订号。第三段代码描述本地序列的构造与校验不借用任何尚未验证的云端 Undo SDK。注意撤销和重做本身使用各自新的事件 ID不能继续用 OP-020否则会被正确的去重规则挡住。constledgernewAnnotationLedger();ledger.apply({opId:OP-019,documentId:DOC-217,paragraphId:P-07,action:ADD,baseRevision:12,textAfter:标记为风险});ledger.apply({opId:OP-020,documentId:DOC-217,paragraphId:P-07,action:EDIT,baseRevision:13,textAfter:标记为风险需复查});ledger.apply({opId:OP-021,documentId:DOC-217,paragraphId:P-07,action:UNDO,baseRevision:14,textAfter:标记为风险});ledger.apply({opId:OP-022,documentId:DOC-217,paragraphId:P-07,action:REDO,baseRevision:15,textAfter:标记为风险需复查});ledger.apply({opId:OP-020,documentId:DOC-217,paragraphId:P-07,action:EDIT,baseRevision:13,textAfter:重复回执});ledger.apply({opId:OP-023,documentId:DOC-217,paragraphId:P-07,action:EDIT,baseRevision:13,textAfter:来自旧修订的迟到操作});// revision16, duplicateIgnored1, staleIgnored1如果把“撤销”误当成普通删除重做时将找不到被撤销对象如果把“重做”误当成重放老回执又会被幂等检查拒绝。正确办法是保存历史事件身份另发一条新的逆向或正向事件引用同一原动作。这一点可以在业务层做得很清楚不需要把 UI 渲染树或 NavPathStack 当成操作账本。不建议只把 Undo 栈保存在某个 NavDestination 的局部变量中。页面被系统重建或者用户从紧凑栈返回双栏后本地对象可能已经消失这时“右栏文本存在、撤销栈丢了”的状态比直接报错更危险。正式工程可以选择持久化操作事件和当前快照但要明确定义持久化写入失败时的恢复策略并在文档级别保证修订单调递增。七、操作账本的持久化边界与异常恢复演示使用纯内存类故意不宣称已经具备断电恢复。要进入生产至少需要一份当前修订快照和一份可重放的有序事件记录。它们可以存入 ArkData 关系型数据库等官方支持的持久化方案但如何组织事务、如何处理并发写入取决于所选存储 API 的实际约束不能把setState当作数据库提交。比较稳妥的做法是先校验动作归属和基准修订再在持久化事务内写入新事件与新修订事务成功后发布新 UI 快照事务失败则保留当前画面并提供重试或撤销提示。不要先让两栏都显示“已保存”然后把真正的写入失败藏到日志里。在用户编辑文档的语境下错误的成功提示本身就是数据风险。恢复时要先检查账本版本历史记录若缺少 inversePatch不能假装可以无损撤销事件 ID 重复需要幂等折叠事件修订存在间隙则应进入诊断而不是直接跳过缺口。只有当重放得到的文本与保存的快照一致才把该文档恢复为LEDGER_CLEAN。否则应给出LEDGER_REPLAY_CONFLICT之类应用自定义状态等待人工处理。这条原则也适用于切换文档。右栏离开DOC-217时不应销毁还未确认的操作证据但过期动作不该借新页面的生命周期重获提交机会。把数据生命周期交给业务 Store把 UI 生命周期交给 Navigation可以减少“刚好重建页面就漏账”的偶发性问题。八、什么才算双栏撤销已经验收通过演示数据最后的可见值是DOC-217 / P-07 / 修订16 / 标记为风险需复查计数为 commit4、undo1、redo1、duplicateIgnored1、staleIgnored1。它们描述的是一组确定性应用层序列并未证明真实数据库写入成功也没有衡量双栏真实渲染耗时。本文的诊断日志均应被视为可重复构造的预期输出。图3为双栏批注工作台的演示主页面修订与文字为固定夹具结果。验收时我会先做一条没有竞争事件的基线按照新增、编辑、撤销、重做的顺序执行检查四次业务修订与文本一致。然后分别投递重复 OP-020、带旧修订的 OP-023确保两者都不推进版本。接着在第二次编辑与撤销之间切换宽窄窗口再在撤销后收起右栏并恢复确认导航形式改变不生成业务动作。再加一组跨文档测试左栏从 DOC-217 切到 DOC-308右栏稍后才收到 DOC-217 的旧回执。这份回执要回到自己的文档账本或被拒绝不能改写 DOC-308。只有这些反例都可解释时才有资格讨论如何优化动画和响应速度。否则用户越喜欢快速操作越容易触发隐藏的数据竞争。预期日志不需要完整复制用户批注文本。记录taskId、opId、docId、baseRevision、nextRevision、动作类别、状态及脱敏错误码已经足够定位大多数问题。日志中的文档标题、用户输入、附件路径可能涉及私人内容不应为了证明“调试很详细”而长期写入生产 HiLog。九、让诊断页成为操作语义的说明书LedgerAuditPage不应只是多一个“成功”图标。它要展示 OP-019 从12到13OP-020 从13到14OP-021 代表 undo 从14到15OP-022 代表 redo 从15到16。还要在拒绝区域标出重复 OP-020 和过期 OP-023并告诉阅读者为什么版本仍是16。对真正的开发者而言这比一串没有字段的时间戳更有帮助。图4为操作账本诊断示意。拒绝动作不会推进DOC-217当前修订。如果后续要处理多人实时批注就必须从单文档严格顺序走向更强的冲突模型。版本号门禁只能安全拒绝不符合预期基线的动作不能自动推导用户希望如何合并两条不同编辑这时需要明确的合并协议和冲突呈现。把LEDGER_CLEAN叫作“本地夹具回放一致”比叫作“多人协作已实现”准确得多。另一个限制在于真正的撤销粒度本篇按整个批注字符串记录版本。真实富文本编辑器需要考虑光标位置、文本区间、输入法组合文本、粘贴和附件对象。它们不该在没有实测证据时被简化成“把旧字符串放回去就完成了所有编辑撤销”。这个 Demo 讲的是协议骨架为后续细粒度变更留下接口而不是提供一个声称生产可用的通用编辑器。十、真正需要坚持的边界可以在这份模型里明确的是四次有效业务修订、两次拒绝、最终文本与左右栏统一的同一修订身份。系统Navigation负责页面组织应用的 AnnotationLedger 负责事件身份、基准修订与重放决策持久化系统再负责真正的原子提交。这三个层级不能互相冒名顶替。还不能声称的是实际设备在折叠、多窗口以及外接键盘组合下绝不会丢焦点、实际存储恢复绝不会失败或者第三方编辑器的批注模型可以无需改造就套用这套账本。需要在真实 DevEco 工程中补齐页面注册、统一状态订阅、数据库事务和手势/输入测试才具备产品意义上的验收条件。把撤销看成“操作记录的逆向提交”后很多曾经模糊的问题自然露出来重复消息为何应忽略旧修订为何不能覆盖新修订UI 的左栏与右栏为何必须绑定同一快照。这比把所有状态塞进页面组件更费一点设计时间却给调试、恢复与长期维护留出了可信的边界。把这套账本放到审阅产品里还需要考虑操作的可撤销期限。有些批注只影响当前草稿允许用户长期回退另一些动作已经触发外部通知或审批撤销内容不等于撤销外部副作用。正式系统应为每类动作声明undoable条件与不可逆副作用点击撤销前先判断业务状态而不是仅看历史栈里还有一个按钮可以点。回放时也不要随意重新触发原操作产生的通知、埋点或附件上传。应用可以把操作事件拆成“改变本地文档状态的纯转换”与“需要向外部系统发消息的副作用”前者在恢复时可按序重建后者需要幂等键及独立的投递账本。否则一次应用重启就可能把历史批注重新推送给同事。这类边界往往在单页演示中看不出来但文档工作台长期运行后影响会很明显。最后用户可见的“撤销成功”应以哪一层为准也需要明确是暂时更新了本地文本是修订已写入本地库还是远端协作已确认本文选择本地规则夹具因此只显示LEDGER_CLEAN不显示网络同步成功。未来接入远端协作需要把状态拆成LOCAL_COMMITTED与REMOTE_ACKED等不同证据而不是用一个绿色徽标掩盖提交层级。十一、官方参考华为开发者《Navigation》API参考2026-09-24https://developer.huawei.com/consumer/cn/doc/doccenter-references/api/ts-basic-components-navigation华为开发者《Navigation 页面跳转》https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/arkts-navigation-navigation-V5华为开发者《NavDestination》指南2026-09-08https://developer.huawei.com/consumer/en/doc/harmonyos-guides/arkts-navigation-navdestination华为开发者《平行视界方向社区资料》https://developer.huawei.com/consumer/cn/forum/topic/0201221235973021541本文的撤销账本、事务 ID、修订号码、拒绝计数以及测试夹具全部为项目自定义工程设计不是 Navigation 或 EasyGo 自动提供的撤销接口。