一、一个小角标,暴露出三套不一致的计数
多端协同的复核平台,最容易被忽略的不是上传,也不是同步,而是右上角那个醒目的数字。列表里明明只有三件待办,角标却显示四;用户点开其中一件后,列表已经移除,角标又重新跳回原来的数。看上去只是一个样式问题,实际排查时往往横跨事件合并、状态派生和异步回执三个环节。
我把问题缩到一个名为BadgeLedger的演示工程,页面叫BadgeLedgerPage,任务号固定为BL-1640。本例不连接真实协同服务,而是给页面提供版本号为v8的五条模拟事件:CF-201、CF-202、CF-203三个待处理冲突,重复发送一次的CF-201,以及已经结束的CF-199。期望结果很清楚:原始事件五条,去重后待处理三件,页面角标恰好为3,状态显示“待处理 3 项”。
这里使用的是 ArkUI 的Badge信息标记组件,不是桌面图标通知角标。Badge 只负责渲染已经计算好的数值,并不知道服务器是否重复发送,也不会自动理解“冲突解决”和“冲突撤销”。因此,工程要先建立唯一事实来源,再谈组件如何显示。
配图01:本篇主题的独立渐变封面。所有项目编号与样例数字均用于说明设计,不代表真实线上系统。
二、把计数定义为集合的投影,不要累加消息数
很多业务代码写着count++,收到一个冲突推送就加一;同步完成再减一。这种做法隐含一个危险前提:每条推送只到一次,而且一定按顺序抵达。网络重试、重复回执、离线恢复和多端同步都能打破这个前提。尤其是“某个冲突已处理”并非另一个需要展示的冲突,不能把它与新增事件一视同仁。
我的做法是按稳定的业务 ID 去重,再过滤掉不待处理的记录。角标数字只从筛选后的数组长度获得,不单独维护badgeCount。这样即使外部重复投递,同一份数据也不会把数量推高。示例数据都来自可控构造,不涉及真实账号、设备识别信息或后台接口。
为了先解决“重复与已结束事件混在一起”的问题,可以把下面的纯业务函数放进model/ConflictReducer.ets。它不依赖 UI,也不承担消息传输职责,便于做输入输出断言。
exportinterfaceConflictItem{id:string;title:string;pending:boolean;}exportfunctionuniquePending(input:ConflictItem[]):ConflictItem[]{constids:Set<string>=newSet<string>();constoutput:ConflictItem[]=[];for(constitemofinput){if(!item.pending||item.id.length===0||ids.has(item.id)){continue;}ids.add(item.id);output.push(item);}returnoutput;}函数刻意不修改input,而是返回新数组,以方便 ArkUI 观察到整体状态的变化。真实业务里还需要确定“同一个 ID 发生状态变更”采用哪一条记录为准:这里假设传入的是某一版本的权威完整快照,并且重复项的业务状态一致。如果拿到的是增量事件流,就要先按单条事件序号合并成快照,不能直接照搬首次出现即获胜的规则。
此外,稳定 ID 不能用当前数组索引代替。列表重新排序后,索引会改变,容易把某一行的已处理态错误复用到另一行。对多端冲突这类带回执的实体,CF-201一类服务端约定的稳定键才是跨版本的定位基准。示例中的CF-199被标记为已结束,因此被过滤;重复的CF-201只保留一次,输出三项。
三、把版本门禁放在 @State 赋值之前
仅有去重还不够。假设页面先收到版本v8的完整快照,随后因为某条慢链路重传又收到v7。如果每次都直接赋值,页面会从正确的三项退回旧列表,再把用户已经处理的项“复活”。所以处理入口必须验证版本,确认比当前页更新才允许替换待办数组。
下面这一段解决的是跨版本结果倒灌。示例把网关返回的数据通过onSnapshot注入,页面不预设存在系统级去重能力。Badge 从pending.length直接读取数据,版本文案与列表也使用同一份页面状态。代码作为核心结构片段,放进pages/BadgeLedgerPage.ets,其中导入上一段定义的模型和函数。
import{ConflictItem,uniquePending}from'../model/ConflictReducer';@Entry@Componentstruct BadgeLedgerPage{@Stateprivatepending:ConflictItem[]=[];@Stateprivateepoch:number=0;privatealive:boolean=true;aboutToAppear():void{this.alive=true;}aboutToDisappear():void{this.alive=false;}onSnapshot(version:number,events:ConflictItem[]):void{if(!this.alive||version<=this.epoch){return;}this.pending=uniquePending(events);this.epoch=version;}build(){Column({space:18}){Text('BadgeLedger · 冲突回执台').fontSize(23)Row({space:28}){Badge({count:this.pending.length,position:BadgePosition.RightTop,style:{badgeColor:'#D94F65',color:'#FFFFFF',fontSize:12}}){Text('待处理冲突').fontSize(18)}Text(`v${this.epoch}`).fontSize(16)}Text(`待处理${this.pending.length}项`)ForEach(this.pending,(item:ConflictItem)=>{Row(){Text(item.id);Text(item.title)}.width('100%').justifyContent(FlexAlign.SpaceBetween)},(item:ConflictItem)=>item.id)}.padding(22).width('100%')}}这里的aboutToDisappear只是阻止已经离开页面的异步结果继续修改这份实例状态,不负责取消外部网络连接。若真实项目通过长连接监听事件,还要在对应的页面管理层执行退订;否则回调闭包仍可能占用资源。epoch是业务快照版本号,不是系统生命周期序号。断网重连或刷新时若使用另一套版本空间,必须与服务端先谈清楚比较规则。
值得注意的是,Badge 的count参数只是展示上限前的数量;即使配置了maxCount,也不能让展示截断后的字符串变成业务判断依据。业务决策始终用完整数组。在本例只有三项,既不存在99+的显示边界,也不需要特殊的数字格式化逻辑。
配图02:DevEco Studio 白色主题布局示意图,项目树、核心代码、右侧模拟器和底部 HiLog 均为绘制的概念演示,日志不是真实运行记录。
四、确认回执别先清零,再等异步成功
角标常见的第二次闪烁,发生在用户点“已处理”之后。有人为了交互轻快先从列表删掉项目,向服务器提交失败后再把项目加回来。这样用户看到的是一个先减一再加一的数字跳变。对于协作复核页面,错误显示“已经处理”可能比慢一点更糟,因为其他人会据此继续下一步操作。
在这个 Demo 中,操作采用确认成功再更新的保守方案:点击按钮只登记提交中的 ID,等待服务端返回下一版完整快照,再让onSnapshot统一变更。另一个容易忽视的情况是,页面已经刷新至v9,旧请求针对v8返回了成功。旧响应的“成功”可以用于记录通信结果,却不能直接决定当前列表应该少哪一项。
下面的片段只负责提交时的代际门禁,sendAck是应用层自行提供的异步适配器,不是华为系统接口。它返回“服务端是否接受这次处理请求”,而实际列表变化仍然要以新的权威快照为准。
typeAckSender=(id:string,version:number)=>Promise<boolean>;classAckGate{privatesending:Set<string>=newSet<string>();asyncconfirm(id:string,version:number,getCurrentVersion:()=>number,sendAck:AckSender):Promise<boolean>{if(this.sending.has(id)||version!==getCurrentVersion()){returnfalse;}this.sending.add(id);try{constok=awaitsendAck(id,version);returnok&&version===getCurrentVersion();}catch(_){returnfalse;}finally{this.sending.delete(id);}}}这里的true只代表可以继续等待或请求更新快照,并不代表已经完成界面删除。异常分支会保留当前三项,而finally无论成功还是失败都清理本地提交占用标记,防止按钮永久无法再次操作。工程落地时要补超时、重试幂等键以及用户离开页面后的回调取消策略;没有后端契约,不能仅凭这段示例推断分布式一致性已经得到保证。
五、用一张屏幕示意图核对字段,而不是凭感觉验收
为了让文图可以逐项复核,我只定义一个固定的展示快照:BadgeLedgerPage,任务号BL-1640,版本v8,原始事件5条,去重待处理3项,三个 ID 分别是CF-201、CF-202、CF-203,最终状态为“待处理 3 项”。其中一条重复事件没有独立卡片,已结束的CF-199也不出现在待处理列表。屏幕上的徽标数字是3,没有假装执行任何确认操作。
配图03:9:16 纯屏幕示意图。状态栏仅为图形素材;ID、计数、版本均为受控演示数据,不是设备测试截图。
构造的日志应当围绕业务决策输出,例如“DEMO BL-1640 v8 输入5条”“DEMO BL-1640 去重后待处理3项”“DEMO BL-1640 忽略旧快照v7”。不要把用户名称、设备标识、原始冲突正文或请求凭证直接放入 HiLog。示意图里出现的每一行都是排版用样例,不证明系统日志接口已经在某台设备上真正产生过相同输出。
验收时可按输入、结果和边界三类情况独立验证:重复CF-201不增加角标;已结束的CF-199不参与展示;旧版v7不得覆盖v8;离开页面后的响应也不得重新赋值。最后再检查pending.length与实际展示行数一致。这里选择的是由完整快照派生角标的设计,若将来换为流式增量协议,应先调整归并规则,再谈性能优化。
六、工程结论:角标是投影,不是数据库
这次整理下来,我更愿意把 Badge 理解为数据状态的一个投影:业务记录存放在有稳定 ID 的集合中,版本负责拒绝旧结果,组件负责展示数量。这个职责切分不华丽,却能解释绝大多数“角标莫名增减”的问题。它也让开发者在排障时不用对着 UI 猜,而能直接复现给定版本、五条事件与三项输出之间的关系。
当然,这仍是一篇工程方案复盘,不是已上线系统的运行报告。本文代码中的 Badge、ForEach 和组件生命周期属于 ArkUI 能力;ConflictItem、版本号以及sendAck是业务层设计,需要与真实服务端的快照语义对齐。开发者扩展到跨设备合并、离线冲突和批量确认时,应优先确定数据契约,而不是先让右上角数字动起来。
参考资料:华为开发者联盟 ArkUI / Badge 与 TabBar 角标 FAQ(官方文档,具体 API 版本以工程依赖为准)。