仓库盘点页平时看起来很轻:扫一下条码,列表数量加一,顶部显示最近结果。直到把扫码枪切到连续模式,300 个回调在不到两秒里涌进 ArkUI,页面开始一边跳数字一边掉帧。更怪的是返回上一页后,HiLog 还在打印“应用批次”,再次进入页面会一次扫描加两遍。这个 Demo 叫PulseShelf,页面是ScanPressurePage,任务号SCAN-1742。我没有先改动画,而是做了一个能重复制造回调风暴的小型压测入口,把事件进入、窗口聚合、UI 提交和生命周期退出分别计数。
一、300 个回调不等于 300 次界面更新
压测数据很直接:原始回调 300 个,实际只有 64 个不同条码,236 个是连续重复;采用 200 ms 时间窗后,业务只提交 12 次 UI。页面退出后又模拟 4 个迟到回调,它们应被页面代次拦截,不能混进 300 的业务统计。最终订阅数必须归零,状态机为READY → BURSTING → BUFFERING → APPLYING → BACKPRESSURE_STABLE。
原实现的问题不在扫码 SDK,而在桥接方式:原生回调里直接this.items = [...this.items, result],每条结果都会复制数组、触发响应式更新和列表测量。为了“防重复”又在页面里加了一个 Set,但 Set 只挡住业务插入,仍挡不住 300 次回调、日志和状态赋值。页面销毁时只调用扫码器的stop(),RxJS 订阅没有释放;第二次进入后,新旧订阅同时消费同一个全局 Subject。
RxJS 的价值不是把回调换成更花哨的写法,而是把吞吐规则写成一条可检查的管道。官方把操作符定义为可组合的数据流函数,bufferTime、takeUntil都列在 API 中;Subscription则代表可释放资源,unsubscribe()用来取消执行,父订阅还可以通过add()统一托管子订阅。RxJS Operators;RxJS API;Subscription 指南源码。
二、先做一个不会撒谎的事件封套
PulseShelf 没有把 SDK 的原始对象直接丢进 Subject。桥接层把每条结果转换成ScanEvent,字段只有code、source、receivedAt和epoch。epoch是页面本次启动的代次,重新进入就加一;无论 SDK 的 stop 是否能瞬间清空硬件缓冲,旧回调都会因为代次不一致被拒绝。
第一段代码解决原生回调与流的边界。start()先停止旧会话,保存稳定的 handler 引用,再启动新代次。计数器rawCount只统计当前代次的 300 个业务回调;页面退出后抵达的 4 个旧代次回调记到lateIgnored,两种数字绝不混在一起。
import{Subject}from'rxjs';exporttypeScanEvent={code:string;source:'CAMERA'|'SCANNER';receivedAt:number;epoch:number;};exportclassScanCallbackBridge{readonlyevents$=newSubject<ScanEvent>();rawCount=0;lateIgnored=0;privateactiveEpoch=0;privaterunning=false;privatereadonlyonResult=(code:string):void=>{constcallbackEpoch=ScannerNative.resultEpoch();if(!this.running||callbackEpoch!==this.activeEpoch){this.lateIgnored+=1;return;}this.rawCount+=1;this.events$.next({code,source:'SCANNER',receivedAt:Date.now(),epoch:callbackEpoch});};start():number{this.stop();this.activeEpoch+=1;this.rawCount=0;this.lateIgnored=0;this.running=true;ScannerNative.onResult(this.activeEpoch,this.onResult);returnthis.activeEpoch;}stop():void{this.running=false;ScannerNative.offResult(this.onResult);}}真实三方扫码库未必能返回resultEpoch(),做法也不复杂:注册回调时用局部常量捕获当前代次,并把它写进事件封套,但注销时仍要保存同一个包装函数引用。不要在每次start()中创建匿名函数后再用另一个匿名函数注销。桥接层的events$是进程级资源时不能随页面 complete,否则下一页无法复用;如果它属于单页,则应在最终销毁时 complete。所有权先说清楚,资源释放才不会互相打架。
三、200 ms 窗口做的不是延迟,而是提交整形
这里没有使用debounceTime。防抖会不断等待“安静”,连续扫码可能让一个合法条码迟迟不落地;throttleTime又会丢弃窗口内后续的不同条码。我们需要的是按时间切片,把窗口内相同 code 合并、不同 code 全保留,然后整批交给 ArkUI。
第二段代码就是核心管道。bufferTime(200)每 200 ms 给出一个数组;Map 以 code 为键,保留窗口内最后一条,既能反映最新时间,也不会让同码重复加库存。takeUntil(destroy$)把页面生命周期纳入流,额外的event.epoch === pageEpoch是对迟到数据的第二道门禁。两道门禁职责不同:前者终止订阅,后者拒绝跨代事件。
import{Subject,Subscription,bufferTime,filter,map,takeUntil}from'rxjs';typeScanBatch={events:ScanEvent[];raw:number;distinct:number};exportclassScanPressurePipeline{privatereadonlydestroy$=newSubject<void>();privatereadonlyroot=newSubscription();uiCommits=0;bind(source$:Subject<ScanEvent>,pageEpoch:number,apply:(batch:ScanBatch)=>void):void{conststream=source$.pipe(filter((event)=>event.epoch===pageEpoch),bufferTime(200),filter((events)=>events.length>0),map((events):ScanBatch=>{constlatest=newMap<string,ScanEvent>();events.forEach((event)=>latest.set(event.code,event));return{events:Array.from(latest.values()),raw:events.length,distinct:latest.size};}),takeUntil(this.destroy$)).subscribe((batch)=>{this.uiCommits+=1;apply(batch);});this.root.add(stream);}dispose():void{this.destroy$.next();this.destroy$.complete();this.root.unsubscribe();}}为什么既有takeUntil又显式root.unsubscribe()?前者表达数据流的生命周期,后者是最后的资源兜底,并让未来加入的计时器订阅、错误通道订阅一起挂到订阅树。重复调用unsubscribe()是安全的,但重复调用bind()不是;因此页面只在本代次初始化一次 pipeline,onPageShow不能无条件再绑一遍。若以后把mergeMap等高阶操作符加进管道,还要确认内层订阅是否同样被终止,不能只盯最外层。
四、ArkUI 只接收批次,不接收回调节奏
页面层维护InventoryRow[]、最近条码和五个诊断数字,但一次窗口只赋值一次新数组。64 个不同条码不等于 64 行:同一商品可能在不同窗口再次出现,因此仓库 Map 以商品码累计数量,最后再转成排序稳定的数组。状态从BUFFERING到APPLYING只在非空批次发生,完成压测且 300 个回调均已被接收后才进入BACKPRESSURE_STABLE。
第三段代码解决 UI 提交和退出顺序。先把批次落入内存 Map,再构造一次数组并更新诊断状态;退出时先让页面失去接收资格,再停硬件、销毁流。这个顺序保证硬件缓冲中迟到的 4 个回调只增加lateIgnored,不会进入 Subject。
@Entry@Componentstruct ScanPressurePage{@Staterows:InventoryRow[]=[];@Statestate:string='READY';@StaterawCallbacks:number=0;@StatedistinctCodes:number=0;@StateuiCommits:number=0;privatereadonlystock=newMap<string,number>();privatereadonlybridge=newScanCallbackBridge();privatepipeline?:ScanPressurePipeline;privatepageEpoch=0;aboutToAppear():void{this.pageEpoch=this.bridge.start();this.pipeline=newScanPressurePipeline();this.pipeline.bind(this.bridge.events$,this.pageEpoch,(batch)=>{this.state='APPLYING';batch.events.forEach((event)=>this.stock.set(event.code,(this.stock.get(event.code)??0)+1));this.rows=Array.from(this.stock.entries()).map(([code,count])=>({code,count}asInventoryRow));this.rawCallbacks+=batch.raw;this.distinctCodes=this.stock.size;this.uiCommits=this.pipeline?.uiCommits??0;if(this.rawCallbacks===300)this.state='BACKPRESSURE_STABLE';});}aboutToDisappear():void{this.pageEpoch+=1;this.bridge.stop();this.pipeline?.dispose();this.pipeline=undefined;}}这里没有在每个批次里对rows原地push,因为原地修改与 ArkUI 的观测边界很容易让“数据变了、列表没重绘”或“每项都重测”混在一起。一次窗口构造一次数组,更新频率就等于 UI 提交频率。业务量更大时,可以只更新受影响行的可观测模型,但那属于列表增量渲染,不应和扫码背压塞进同一个调试开关。
五、日志必须能证明三个守恒关系
我把压测完成条件写成三条:callbacks = distinct + duplicate,即300 = 64 + 236;窗口提交总数为 12,不能跟条码数混为一谈;页面退出后activeSubscriptions = 0,4 个迟到回调只能出现在 ignored 计数里。HiLog 最终固定输出:task=SCAN-1742 window=200ms、callbacks=300 distinct=64 collapsed=236、uiCommits=12、lateIgnored=4 subscriptions=0、state=BACKPRESSURE_STABLE。
DevEco Studio 截图里,左侧目录展示bridge/ScanCallbackBridge.ets、stream/ScanPressurePipeline.ets和pages/ScanPressurePage.ets;中间停在bufferTime(200)与takeUntil;右侧模拟器显示同一任务号与计数;底部 HiLog 给出上述五行。红色细圈只标窗口参数、代次过滤和订阅归零,目的不是强调“代码多”,而是让复现者知道应该盯哪三个门禁。
六、17:42 的最终屏幕,比流畅感更有说服力
在连续扫码模式下,任务SCAN-1742于 17:42 结束,页面状态BACKPRESSURE_STABLE。300 个回调中 64 个不同码、236 个重复被合并,200 ms 窗口产生 12 次 UI 提交;退出测试页后注入 4 个迟到回调,重新进入也没有库存增长,活动订阅为 0。相同脚本连续跑十轮,提交次数允许因调度边界在 11~13 之间波动,但本轮素材和截图固定记录 12,正文、日志与 UI 不混用范围值。
竖屏页把“开始 300 回调压测”和“停止并校验释放”分成两个按钮,避免停止动作藏在返回键里。状态栏时间、任务号、窗口参数、计数守恒、迟到回调和订阅数都能在单屏看到;没有用吞吐仪表盘或营销式大数字,因为这个问题的关键不是峰值多高,而是哪些事件被保留、何时提交、离开页面后是否真的停止。
七、背压方案的边界,取决于业务是否允许合并
商品入库若每次扫码都代表一件实物,同码在 200 ms 内也可能是两件,不能像 Demo 一样简单“同码留最后一条”。这时 Map 的值应是计数和,而不是最后事件;只有设备抖动会重复上报的协议,才适合按 code 去重。窗口大小同样不能凭感觉:200 ms 是本次扫码枪与 60 Hz UI 下的折中,人工单次扫码可以更短,工业流水线则应按最大批次和业务延迟预算推导。
错误通道也要单独处理。硬件断开不应让events$永久 error,否则重新连接只能重建整个总线;更合适的是把连接状态做成另一条流,事件流保持可恢复。应用进入后台时,如果产品不允许继续盘点,应立即停止硬件并销毁页面管道;如果允许后台采集,订阅所有权必须迁到服务层,页面只能订阅聚合后的库存状态,不能让页面组件假装自己还活着。
最后,bufferTime会持有当前窗口里的事件,事件对象不要携带大图、PixelMap 或原生句柄。扫码事件只保留小型不可变字段,大资源在桥接前转换或引用计数释放。RxJS 能组织生命周期,但不会替开发者猜测对象所有权。
八、稳定不是“少刷几次”,而是离开后绝不再刷
这次优化把一个笼统的“扫码卡顿”拆成四个可验证动作:原生桥接给事件贴代次,200 ms 窗口整形吞吐,ArkUI 按批次提交,订阅树在页面退出时归零。64 个有效条码没有被节流丢掉,236 个协议重复没有浪费重绘,4 个迟到回调也没有借旧订阅穿回新页面。只有当BACKPRESSURE_STABLE与subscriptions=0同时成立,我才认为这条事件流真正结束。