Demo:SplitMarkLedger / 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 映射需要按实际工程补齐。
@Entry@Componentstruct SplitMarkPage{privatestack:NavPathStack=newNavPathStack();@StateisWide:boolean=true;@StateselectedDocId:string='DOC-217';@StateselectedParagraphId:string='P-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。
exporttypeLedgerAction='ADD'|'EDIT'|'UNDO'|'REDO';exportinterfaceAnnotationEvent{opId:string;documentId:string;paragraphId:string;action:LedgerAction;baseRevision:number;textAfter:string;}exportclassAnnotationLedger{revision:number=12;text:string='';duplicateIgnored:number=0;staleIgnored:number=0;privateappliedIds:Set<string>=newSet<string>();apply(event:AnnotationEvent):boolean{if(this.appliedIds.has(event.opId)){this.duplicateIgnored+=1;returnfalse;}if(event.documentId!=='DOC-217'||event.paragraphId!=='P-07'||event.baseRevision!==this.revision){this.staleIgnored+=1;returnfalse;}this.text=event.textAfter;this.appliedIds.add(event.opId);this.revision+=1;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识别并计入duplicateIgnored=1;另一个是新的编辑动作,但baseRevision=13,当前版本已是16,因此进入staleIgnored=1。这两个事件不能让当前内容倒退,也不能推进修订号。
第三段代码描述本地序列的构造与校验,不借用任何尚未验证的云端 Undo SDK。注意撤销和重做本身使用各自新的事件 ID,不能继续用 OP-020,否则会被正确的去重规则挡住。
constledger=newAnnotationLedger();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:'来自旧修订的迟到操作'});// revision=16, duplicateIgnored=1, staleIgnored=1如果把“撤销”误当成普通删除,重做时将找不到被撤销对象;如果把“重做”误当成重放老回执,又会被幂等检查拒绝。正确办法是保存历史事件身份,另发一条新的逆向或正向事件引用同一原动作。这一点可以在业务层做得很清楚,不需要把 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到13,OP-020 从13到14,OP-021 代表 undo 从14到15,OP-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-24):https://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-08):https://developer.huawei.com/consumer/en/doc/harmonyos-guides/arkts-navigation-navdestination
- 华为开发者《平行视界方向社区资料》:https://developer.huawei.com/consumer/cn/forum/topic/0201221235973021541
本文的撤销账本、事务 ID、修订号码、拒绝计数以及测试夹具全部为项目自定义工程设计,不是 Navigation 或 EasyGo 自动提供的撤销接口。