☰
HarmonyOS 7 ArkUI:平行视界跨栏回写来源标签与环路抑制
2026/10/8 7:14:49 网站建设 项目流程

一、三次相同的选择变更,暴露的不是 List 性能问题

TwinSelect 是一个素材挑选 Demo:左栏是 240 条商品素材,右栏显示已选集合和大图预览。需求看起来很普通——宽屏以 ArkUINavigationMode.Split呈现,窄屏退化为单栏;左边勾选,右边立即更新;右边移除,左边同步取消。真正把问题暴露出来的是批量选择:我在左栏一次勾了 18 条,HiLog 却连续收到三次同内容事件,右栏又把“应用外部状态”当成用户操作回写。最终没有死循环,但 revision 从 39 快速涨到 47,埋点重复、动画重启,偶尔还会把刚取消的第 18 条重新选中。

这不是 List 刷新慢,也不是@State不可靠。根因是两个栏位都同时扮演“状态拥有者”和“事件生产者”,而同步消息里只有selectedIds,没有来源、基线版本和事件身份。只要视图重建、订阅重连或右栏做一次排序,内容相同的数组也能被当成新意图。

我把这轮修复编号定为PV-0712。验收场景固定为宽度1136vp、Split 模式、240 条素材、最终选中 18 条。目标不是让 revision 不增长,而是让一次用户意图只产生一个可追踪提交:最终revision=42、事件evt_0042、来源catalog-pane,三条回声全部丢弃,回滚次数为 0,快照摘要为d13e7a9c。

二、先收回两边各自维护真相的权力

项目没有引入复杂状态框架,目录也很克制:pages/BatchSelectionPage.ets负责布局,components/CatalogPane.ets和SelectionPane.ets只产生用户意图,model/SelectionCoordinator.ets保存唯一快照,model/SelectionEnvelope.ets定义同步协议。页面可以有两套渲染,但只能有一份已提交选择集合。

第一段代码解决“相同内容为什么被重复提交”。同步载荷增加origin、单调递增 revision、事件 ID 和稳定 fingerprint;Coordinator 先判事件身份,再判基线版本,最后才提交新快照。

// model/SelectionCoordinator.etsexportinterfaceSelectionEnvelope{eventId:stringorigin:'catalog-pane'|'selection-pane'baseRevision:numberselectedIds:string[]fingerprint:string}@ObservedV2exportclassSelectionCoordinator{@TraceselectedIds:string[]=[]@Tracerevision:number=39@TraceduplicateDrops:number=0privateseen:Set<string>=newSet()apply(envelope:SelectionEnvelope):boolean{if(this.seen.has(envelope.eventId)||envelope.fingerprint===this.fingerprint(this.selectedIds)){this.duplicateDrops++returnfalse}if(envelope.baseRevision!==this.revision){thrownewError(`STALE_BASE:${envelope.baseRevision}->${this.revision}`)}this.seen.add(envelope.eventId)this.selectedIds=[...envelope.selectedIds].sort()this.revision++hilog.info(0x1200,'TwinSelect',`commit${envelope.eventId}rev=${this.revision}origin=${envelope.origin}`)returntrue}privatefingerprint(ids:string[]):string{returnids.slice().sort().join('|')}}

这里故意没有用数组引用判断。跨栏传输、持久化恢复和组件重建都会创建新数组,引用不同不代表业务内容不同。baseRevision也不能省:它把“右栏基于 39 号快照删除一条,但左栏已经提交到 41”变成明确的冲突,而不是静默覆盖。Demo 中fingerprint用排序字符串便于读懂,产品工程应换成稳定哈希,并给seen设置容量或时间窗,否则长会话会把去重集合变成内存泄漏点。

apply()只在 Coordinator 内改变 revision。异常分支不修改任何可观察字段,因此 UI 不会先闪成错误状态再回退。重复调用是允许的,但幂等;真正的陈旧基线则抛出错误,让上层刷新快照后重新生成意图,不能拿旧 envelope 原样重试。

三、外部状态落地时,组件必须短暂失去“发言权”

第二段代码处理最隐蔽的回写环路。右栏收到快照后要更新 Checkbox 与计数,但这次变化不是用户点击,不应该再次发布。简单的布尔锁容易在异步动画中提前释放,所以我用一次性的applyEpoch标记本轮外部落地,并把用户事件与渲染赋值拆开。

// components/SelectionPane.ets@ComponentV2exportstruct SelectionPane{@Paramcoordinator:SelectionCoordinator=newSelectionCoordinator()@LocalprivaterenderedIds:string[]=[]privateapplyEpoch:number=0@Monitor('coordinator.revision')onSnapshotChanged():void{constepoch=this.coordinator.revisionif(epoch<=this.applyEpoch)returnthis.applyEpoch=epochthis.renderedIds=[...this.coordinator.selectedIds]}privateremoveByUser(id:string):void{constnext=this.renderedIds.filter(item=>item!==id)constenvelope=SelectionEnvelopeFactory.create('selection-pane',this.coordinator.revision,next)this.coordinator.apply(envelope)}build(){List(){ForEach(this.renderedIds,(id:string)=>{ListItem(){Row(){Text(id).layoutWeight(1)Button('移除').onClick(()=>this.removeByUser(id))}}},(id:string)=>id)}}}

关键点不是@Monitor本身,而是它只负责“投影快照”,绝不调用apply()。所有写入路径必须带明确的用户动作入口。ForEach的 key 直接使用素材 ID,避免数组整体替换后把 18 个 ListItem 全部当成新节点。页面退出时组件随树销毁,没有额外监听器;如果 Coordinator 接到 EventHub 或分布式订阅,则必须在aboutToDisappear成对注销,不能仅依赖 JS 对象回收。

另一个边界是快速连点。removeByUser()读取当前 revision 创建 envelope,连续点击可能让第二个 envelope 基线过期。项目策略不是吞掉第二次操作,而是捕获STALE_BASE,从最新快照重新计算next,只重建一次。最多重试一次,仍冲突就提示“选择已在另一栏更新”,避免无限重试制造新的环路。

四、Split 只决定摆放方式,不能决定状态寿命

第三段代码把布局变化和状态提交分开。宽度切到396vp时页面从 Split 回落到 Stack,但 Coordinator 保持 42 号快照;再次展开也不能重放 0042 事件。布局 epoch 只用于调试,不参与业务 revision。

// pages/BatchSelectionPage.ets@Entry@ComponentV2struct BatchSelectionPage{@Localcoordinator:SelectionCoordinator=newSelectionCoordinator()@Localmode:NavigationMode=NavigationMode.Split@LocalwidthVp:number=1136@LocallayoutEpoch:number=7privateonAreaChanged(width:number):void{constnext=width>=840?NavigationMode.Split:NavigationMode.Stackif(next===this.mode)returnthis.widthVp=widththis.mode=nextthis.layoutEpoch++hilog.info(0x1200,'TwinSelect',`layout epoch=${this.layoutEpoch}mode=${this.mode}width=${width}vp`)}build(){Navigation(){CatalogPane({coordinator:this.coordinator})}.mode(this.mode).navDestination({builder:()=>{SelectionPane({coordinator:this.coordinator})}}).onAreaChange((_,area)=>this.onAreaChanged(Number(area.width)))}}

我把阈值判断做成单一函数,并在模式未变化时立即返回。真实项目还应加入 24vp 左右的迟滞带,防止自由窗口停在阈值附近时 Split/Stack 抖动。layoutEpoch与 revision 分离尤其重要:布局可以反复变化,业务选择不应随之“伪更新”。

调试时我只盯四类日志:用户意图、Coordinator 提交、重复丢弃、布局变化。修复前一次批量选择出现 1 次 commit 加 3 次 echo;修复后仍能观察到三条到达,但被 fingerprint/eventId 门禁丢弃。这样能证明问题被控制,而不是日志被删掉。

图中的工程、代码和运行态都对应同一验收:BatchSelectionPage.ets在中间编辑,右侧模拟器为 1136vp Split 布局,底部日志显示evt_0042从catalog-pane提交到 revision 42,随后echo drop=3,摘要d13e7a9c。这组数据也被写进自动化测试,避免截图好看而协议已经变了。

五、窄屏回落不是另一个 Demo,而是同一状态机的另一投影

把窗口收窄到396×824vp后,页面变成单栏。选择结果仍是 18/240,revision 仍为 42,任务仍是PV-0712;变化的只有mode=STACK和layoutEpoch=8。这一步抓到了旧实现的第二个问题:页面曾在 Stack 模式初始化时用本地缓存覆盖 Coordinator,导致 revision 倒退。现在初始化只读快照,不再写回。

手机态页面特意保留“同步诊断”卡片,而不是只展示商品列表。它让测试人员能直接确认来源标签、事件 ID、重复丢弃数和摘要。真正上线时这些字段可以受 debug 开关控制,但日志与状态机必须继续存在,否则跨栏偶发问题会重新变成“用户说丢了一个勾选”。

六、最终保留的工程边界

这次改动没有追求一个万能 Store,而是明确了四条边界。第一,栏位只产生意图,Coordinator 才能提交真相;第二,外部快照落地与用户动作是两条不同路径;第三,业务 revision 与布局 epoch 永不混用;第四,去重是有界资源,订阅与缓存都要在生命周期结束时释放。

验收结果是:宽屏1136vp / SPLIT与窄屏396×824vp / STACK之间切换 20 次,18 条选择无丢失;evt_0042只提交一次;3 条回声全部丢弃;回滚 0;最终摘要始终为d13e7a9c。更重要的是,日志能解释每一次状态变化,而不是仅仅“现在不复现了”。

参考资料:华为 ArkUI Navigation 分栏开发文档(https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/arkts-navigation-split-mode)。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询