
仓库盘点页平时看起来很轻扫一下条码列表数量加一顶部显示最近结果。直到把扫码枪切到连续模式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 OperatorsRxJS APISubscription 指南源码。二、先做一个不会撒谎的事件封套PulseShelf 没有把 SDK 的原始对象直接丢进 Subject。桥接层把每条结果转换成ScanEvent字段只有code、source、receivedAt和epoch。epoch是页面本次启动的代次重新进入就加一无论 SDK 的 stop 是否能瞬间清空硬件缓冲旧回调都会因为代次不一致被拒绝。第一段代码解决原生回调与流的边界。start()先停止旧会话保存稳定的 handler 引用再启动新代次。计数器rawCount只统计当前代次的 300 个业务回调页面退出后抵达的 4 个旧代次回调记到lateIgnored两种数字绝不混在一起。import{Subject}fromrxjs;exporttypeScanEvent{code:string;source:CAMERA|SCANNER;receivedAt:number;epoch:number;};exportclassScanCallbackBridge{readonlyevents$newSubjectScanEvent();rawCount0;lateIgnored0;privateactiveEpoch0;privaterunningfalse;privatereadonlyonResult(code:string):void{constcallbackEpochScannerNative.resultEpoch();if(!this.running||callbackEpoch!this.activeEpoch){this.lateIgnored1;return;}this.rawCount1;this.events$.next({code,source:SCANNER,receivedAt:Date.now(),epoch:callbackEpoch});};start():number{this.stop();this.activeEpoch1;this.rawCount0;this.lateIgnored0;this.runningtrue;ScannerNative.onResult(this.activeEpoch,this.onResult);returnthis.activeEpoch;}stop():void{this.runningfalse;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}fromrxjs;typeScanBatch{events:ScanEvent[];raw:number;distinct:number};exportclassScanPressurePipeline{privatereadonlydestroy$newSubjectvoid();privatereadonlyrootnewSubscription();uiCommits0;bind(source$:SubjectScanEvent,pageEpoch:number,apply:(batch:ScanBatch)void):void{conststreamsource$.pipe(filter((event)event.epochpageEpoch),bufferTime(200),filter((events)events.length0),map((events):ScanBatch{constlatestnewMapstring,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.uiCommits1;apply(batch);});this.root.add(stream);}dispose():void{this.destroy$.next();this.destroy$.complete();this.root.unsubscribe();}}为什么既有takeUntil又显式root.unsubscribe()前者表达数据流的生命周期后者是最后的资源兜底并让未来加入的计时器订阅、错误通道订阅一起挂到订阅树。重复调用unsubscribe()是安全的但重复调用bind()不是因此页面只在本代次初始化一次 pipelineonPageShow不能无条件再绑一遍。若以后把mergeMap等高阶操作符加进管道还要确认内层订阅是否同样被终止不能只盯最外层。四、ArkUI 只接收批次不接收回调节奏页面层维护InventoryRow[]、最近条码和五个诊断数字但一次窗口只赋值一次新数组。64 个不同条码不等于 64 行同一商品可能在不同窗口再次出现因此仓库 Map 以商品码累计数量最后再转成排序稳定的数组。状态从BUFFERING到APPLYING只在非空批次发生完成压测且 300 个回调均已被接收后才进入BACKPRESSURE_STABLE。第三段代码解决 UI 提交和退出顺序。先把批次落入内存 Map再构造一次数组并更新诊断状态退出时先让页面失去接收资格再停硬件、销毁流。这个顺序保证硬件缓冲中迟到的 4 个回调只增加lateIgnored不会进入 Subject。EntryComponentstruct ScanPressurePage{Staterows:InventoryRow[][];Statestate:stringREADY;StaterawCallbacks:number0;StatedistinctCodes:number0;StateuiCommits:number0;privatereadonlystocknewMapstring,number();privatereadonlybridgenewScanCallbackBridge();privatepipeline?:ScanPressurePipeline;privatepageEpoch0;aboutToAppear():void{this.pageEpochthis.bridge.start();this.pipelinenewScanPressurePipeline();this.pipeline.bind(this.bridge.events$,this.pageEpoch,(batch){this.stateAPPLYING;batch.events.forEach((event)this.stock.set(event.code,(this.stock.get(event.code)??0)1));this.rowsArray.from(this.stock.entries()).map(([code,count])({code,count}asInventoryRow));this.rawCallbacksbatch.raw;this.distinctCodesthis.stock.size;this.uiCommitsthis.pipeline?.uiCommits??0;if(this.rawCallbacks300)this.stateBACKPRESSURE_STABLE;});}aboutToDisappear():void{this.pageEpoch1;this.bridge.stop();this.pipeline?.dispose();this.pipelineundefined;}}这里没有在每个批次里对rows原地push因为原地修改与 ArkUI 的观测边界很容易让“数据变了、列表没重绘”或“每项都重测”混在一起。一次窗口构造一次数组更新频率就等于 UI 提交频率。业务量更大时可以只更新受影响行的可观测模型但那属于列表增量渲染不应和扫码背压塞进同一个调试开关。五、日志必须能证明三个守恒关系我把压测完成条件写成三条callbacks distinct duplicate即300 64 236窗口提交总数为 12不能跟条码数混为一谈页面退出后activeSubscriptions 04 个迟到回调只能出现在 ignored 计数里。HiLog 最终固定输出taskSCAN-1742 window200ms、callbacks300 distinct64 collapsed236、uiCommits12、lateIgnored4 subscriptions0、stateBACKPRESSURE_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。相同脚本连续跑十轮提交次数允许因调度边界在 1113 之间波动但本轮素材和截图固定记录 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与subscriptions0同时成立我才认为这条事件流真正结束。