
1. 从一个真实需求说起为什么要死磕连续扫码去年接了一个仓储盘点的小程序项目需求方开口第一句话就是“我要能一直扫扫完一个自动跳下一个别让我点来点去。”听起来简单得不行对吧微信小程序不是自带wx.scanCode吗调一下不就完了结果真上手才发现事情远没有想象中那么顺滑。wx.scanCode是单次调用接口每次扫码都会拉起一个全屏的原生扫码界面扫完返回结果界面关闭。用户想连续扫十个条码就得经历“点击按钮→扫码界面弹出→对准→识别→界面关闭→再点按钮”这个循环十次。在仓库那种光线一般、条码磨损、工人戴着手套的环境里这个体验简直是灾难。后来我把目光转向了camera组件。它允许你在页面内嵌一个相机预览区域配合wx.createCameraContext()和其中的onCameraFrame回调理论上可以实现“页面不跳转、持续识别”的效果。但真正动手之后我踩了一连串的坑帧回调频率怎么控制、识别逻辑放主线程还是 Worker、iOS 和 Android 表现不一致、连续识别同一个码怎么去重、页面切后台相机怎么释放……这些问题官方文档要么一笔带过要么根本没提。这篇文章就是把这些东西全部摊开讲清楚。如果你也在做需要连续扫码的小程序或者对camera组件的底层机制感兴趣那接下来的内容应该能帮你省下不少试错时间。我会从整体设计思路讲起然后拆解核心细节再给出可直接参考的实现方案最后把那些年我踩过的坑整理成一份速查表。2. 整体设计思路为什么不能只用 scanCode2.1 scanCode 的能力边界在哪里先把wx.scanCode的定位说清楚。它是微信提供的一个原生扫码能力封装调用后会拉起一个独立的原生页面由微信客户端自己完成图像采集和识别开发者拿到的是一个已经解析好的结果对象。这个接口的优点是稳定、兼容性好、识别率高毕竟底层用的是微信自己的识别引擎。但它的局限也很明显无法自定义界面扫码界面完全由微信控制你改不了按钮位置、加不了提示文字、没法在取景框上叠加自己的 UI。单次触发每次调用只能扫一个码扫完就结束没有“连续模式”这个选项。无法获取原始帧你拿不到相机预览画面也就没法做自定义的图像处理比如批量识别多个条码、识别特定格式的图形码等。页面跳转感强原生扫码页拉起时用户能明显感知到“换了个界面”在需要高频连续操作的场景里这种割裂感很影响效率。所以scanCode适合“偶尔扫一次”的场景比如扫个付款码、扫个链接。但一旦需求变成“连续扫几十个”它就不够用了。2.2 camera 组件能补上哪些短板camera组件是微信小程序提供的一个原生组件它把相机预览直接嵌入到你的页面里。你可以像摆一个普通 view 一样摆它设置它的位置、大小、层级。配合wx.createCameraContext()创建的上下文对象你能拿到onCameraFrame回调每一帧画面都会以ArrayBuffer的形式传给你。这就打开了自定义的大门界面完全可控取景框、扫描线、提示文字、已扫列表全部由你自己画。持续识别只要相机开着帧回调就在跑你可以在回调里做识别实现“扫完一个接着扫下一个”。可做图像预处理拿到原始帧数据后你可以做灰度化、二值化、区域裁剪等操作提升特定场景下的识别率。多码识别潜力理论上可以在一帧画面里同时定位多个条码区域实现批量识别。但代价也很直接识别逻辑要自己写。微信不会帮你解析条码内容你得自己引入识别库或者把帧数据传给后端做识别。这就引出了下一个关键决策。2.3 识别方案选型前端识别还是后端识别这是整个项目里最重要的一个岔路口。两条路各有优劣选错了后面全是返工。对比维度前端识别JS/WASM 库后端识别帧上传服务端解析响应速度快本地计算无网络延迟慢依赖网络往返通常 200ms 起网络依赖无离线可用强依赖弱网环境基本不可用识别率取决于库的质量和调参可用成熟 OCR/条码引擎识别率高包体积引入 WASM 库会增加几十到几百 KB小程序包体积几乎不增加服务器成本无需要处理高并发帧上传成本高隐私合规图像不出设备合规压力小图像上传需考虑隐私政策开发复杂度需要处理 Worker、内存、兼容性接口简单但需处理网络异常和重试我当时的项目是仓储盘点仓库里网络信号时好时坏而且工人操作节奏很快等不起网络往返。所以我选了前端识别方案。具体来说是把识别逻辑放在Worker里跑主线程只负责拿帧、丢给 Worker、接收结果。为什么用 Worker因为onCameraFrame的回调频率很高如果直接在回调里跑识别算法主线程会被占满页面直接卡死。Worker 是独立线程识别再慢也不会阻塞 UI。这个决策后面会详细展开。3. 核心细节解析帧回调、Worker 与去重逻辑3.1 onCameraFrame 的触发机制与频率控制onCameraFrame是CameraContext上的一个回调注册方法。你调用cameraContext.onCameraFrame(callback)之后相机每采集一帧画面就会触发一次 callback把帧数据传进来。帧数据的结构大概是这样{ width: 640, // 帧宽度 height: 480, // 帧高度 data: ArrayBuffer // RGBA 格式的像素数据 }注意data是RGBA格式每个像素占 4 个字节。一帧 640x480 的画面数据量就是 640 * 480 * 4 1,228,800 字节差不多 1.2MB。如果相机每秒采集 30 帧那就是每秒 36MB 的数据流过。这个量级如果不加控制内存和 CPU 都扛不住。所以第一件事就是降频。我不需要每帧都识别每秒识别 3 到 5 次足够了。做法很简单在回调里加一个时间戳判断let lastFrameTime 0; const FRAME_INTERVAL 200; // 200ms 识别一次即每秒 5 次 cameraContext.onCameraFrame((frame) { const now Date.now(); if (now - lastFrameTime FRAME_INTERVAL) { return; // 跳过这一帧 } lastFrameTime now; // 把 frame 丢给 Worker 处理 worker.postMessage({ width: frame.width, height: frame.height, data: frame.data }, [frame.data]); // 注意转移所有权避免拷贝 });这里有个关键点postMessage的第二个参数是转移列表。把frame.data加进去之后这个 ArrayBuffer 的所有权就从主线程转移到了 Worker不会发生内存拷贝。如果不加这个每帧 1.2MB 的数据拷贝会带来明显的性能开销。注意转移所有权之后主线程就不能再访问这个 ArrayBuffer 了。如果你后续还需要用原始帧做别的事情就不能转移只能拷贝。但在连续扫码场景里帧数据用完即弃转移是最优解。3.2 Worker 里的识别逻辑怎么组织Worker 收到帧数据后要做几件事把 RGBA 转成灰度、做二值化、定位条码区域、解码。这一整套流程如果全部自己写工作量不小。实际项目中我建议直接引入成熟的条码识别库比如zxing-wasm或者quagga2的 WASM 版本。以zxing-wasm为例Worker 里的代码大概长这样import { readBarcodes } from zxing-wasm; self.onmessage async (e) { const { width, height, data } e.data; // 把 RGBA 转成 zxing 需要的格式 const imageData { data: new Uint8ClampedArray(data), width: width, height: height }; try { const results await readBarcodes(imageData, { formats: [QRCode, Code128, EAN13], // 按需指定格式 tryHarder: true, maxNumberOfSymbols: 1 }); if (results.length 0) { self.postMessage({ success: true, text: results[0].text, format: results[0].format }); } else { self.postMessage({ success: false }); } } catch (err) { self.postMessage({ success: false, error: err.message }); } };这里有几个实操要点格式限定formats一定要按需指定。如果你只扫 QR 码就只写[QRCode]。格式越多识别越慢。tryHarder 的取舍tryHarder: true会提升识别率但也会增加单帧处理时间。在光线好、条码清晰的场景里可以关掉在恶劣环境下再打开。maxNumberOfSymbols连续扫码场景通常一次只扫一个设成 1 可以避免不必要的计算。3.3 连续扫码的去重与节流策略连续扫码最怕什么怕同一个码被反复识别。相机对着一个条码每秒识别 5 次如果不去重同一个码会连续触发 5 次“扫码成功”业务逻辑直接乱掉。去重策略我试过三种第一种基于内容去重。维护一个最近识别结果的集合如果新结果和集合里的某个值相同就忽略。集合可以设一个过期时间比如 3 秒。这种方案简单但有个问题如果两个不同的商品条码内容恰好相同比如同一款商品的多个包装会被误判为重复。第二种基于时间窗口去重。识别到一个码之后强制冷却 1.5 秒期间所有识别结果都忽略。这种方案能解决“同一个码连续触发”的问题但如果用户手速快1.5 秒内扫了下一个码就会被漏掉。第三种基于内容时间双重去重。如果新结果和上一次结果相同且距离上次识别时间小于 2 秒就忽略如果内容不同立即放行。这是我最终采用的方案兼顾了准确性和流畅度。let lastResult ; let lastResultTime 0; const DEDUP_INTERVAL 2000; function shouldAccept(newResult) { const now Date.now(); if (newResult lastResult now - lastResultTime DEDUP_INTERVAL) { return false; } lastResult newResult; lastResultTime now; return true; }实操心得去重窗口不要设太长。我一开始设了 5 秒结果工人扫完一个码之后马上扫下一个如果两个码内容碰巧一样比如同一批次的产品第二个就被吞了。后来改成 2 秒配合界面上的“已扫描”提示用户能清楚看到当前码已经被记录不会重复扫。4. 完整实操流程从零搭建连续扫码页面4.1 页面结构与 camera 组件配置先看页面的 WXML 结构。核心就是一个全屏的 camera 组件上面叠加自定义的 UI 层。view classscan-container camera device-positionback flashoff frame-sizemedium resolutionmedium bindinitdoneonCameraInit binderroronCameraError classcamera-view / view classoverlay view classscan-frame view classscan-line / /view view classhint{{ hintText }}/view /view view classresult-panel view classresult-title已扫描 {{ scannedList.length }} 项/view scroll-view scroll-y classresult-list view wx:for{{ scannedList }} wx:keyindex classresult-item text classresult-code{{ item.code }}/text text classresult-time{{ item.time }}/text /view /scroll-view /view /view几个关键属性说明frame-size可选small、medium、large。这个属性直接影响onCameraFrame返回的帧尺寸。small大约是 320x240medium大约是 640x480large大约是 1280x720。尺寸越大识别率越高但数据量也越大。我实测下来medium是性价比最高的选择640x480 的分辨率足够识别大多数条码数据量也还能接受。resolution相机采集分辨率和frame-size是两回事。resolution影响预览画面的清晰度frame-size影响帧回调的数据尺寸。可以分开设置。device-position连续扫码通常用后置摄像头设成back。flash默认关掉。如果环境光线暗可以提供一个手电筒按钮让用户手动开启但不要默认开费电。4.2 相机初始化与权限处理相机初始化是异步的bindinitdone触发后才算真正就绪。在这之前调用onCameraFrame可能会报错。所以正确的顺序是Page({ data: { cameraReady: false, scannedList: [], hintText: 正在启动相机... }, onCameraInit() { this.setData({ cameraReady: true, hintText: 请将条码对准取景框 }); this.startScan(); }, onCameraError(e) { console.error(相机错误, e.detail); this.setData({ hintText: 相机启动失败请检查权限 }); // 引导用户去设置页开启权限 wx.showModal({ title: 需要相机权限, content: 请在设置中允许使用摄像头, confirmText: 去设置, success: (res) { if (res.confirm) { wx.openSetting(); } } }); }, startScan() { if (!this.data.cameraReady) return; const context wx.createCameraContext(); this.cameraContext context; this.frameListener context.onCameraFrame((frame) { this.handleFrame(frame); }); this.frameListener.start(); }, onUnload() { // 页面卸载时务必停止帧监听并释放相机 if (this.frameListener) { this.frameListener.stop(); } } });注意onCameraFrame返回的是一个CameraFrameListener对象必须调用它的start()方法才会开始接收帧调用stop()才会停止。很多人在这一步踩坑注册了回调但忘了 start结果一直收不到帧还以为是相机没启动。4.3 Worker 的创建与通信小程序的 Worker 使用方式和 Web Worker 类似但有一些限制。首先Worker 文件必须放在特定目录下通常是workers/目录。其次Worker 里不能直接使用小程序的 API只能跑纯 JS 逻辑。创建 Worker 的代码// 在主线程中 const worker wx.createWorker(workers/scanWorker.js); worker.onMessage((res) { if (res.success this.shouldAccept(res.text)) { this.addScanResult(res.text, res.format); } }); worker.onError((err) { console.error(Worker 错误, err); });Worker 文件workers/scanWorker.js里引入识别库。这里有个坑Worker 里不能直接用import语法需要用require而且引入的库必须是纯 JS 或 WASM不能依赖 DOM 或小程序 API。// workers/scanWorker.js const { readBarcodes } require(./zxing-wasm.js); self.onmessage async (e) { const { width, height, data } e.data; // ... 识别逻辑 };如果识别库体积较大建议做按需加载或者分包。我用的zxing-wasm压缩后大概 300KB 左右放在 Worker 里不影响主包体积但首次加载会有一定耗时。可以在页面onLoad时就创建 Worker提前预热。4.4 识别结果的处理与界面反馈识别到结果之后要做三件事去重判断、加入列表、给出反馈。addScanResult(code, format) { const now new Date(); const timeStr ${now.getHours()}:${String(now.getMinutes()).padStart(2, 0)}:${String(now.getSeconds()).padStart(2, 0)}; const newList [{ code: code, format: format, time: timeStr }, ...this.data.scannedList]; this.setData({ scannedList: newList, hintText: 已扫描${code} }); // 震动反馈 wx.vibrateShort({ type: medium }); // 播放提示音可选 // this.playBeep(); // 2 秒后恢复提示文字 setTimeout(() { this.setData({ hintText: 请将条码对准取景框 }); }, 2000); }震动反馈在连续扫码场景里非常重要。工人往往不会一直盯着屏幕看扫到没扫到全靠手感和声音。wx.vibrateShort在 iOS 和 Android 上表现有差异Android 通常更明显iOS 的震动偏弱。如果环境嘈杂建议加上提示音。提示音可以用wx.createInnerAudioContext()播放一个短促的 beep 音频。注意 iOS 静音键的问题如果用户开了静音innerAudioContext默认是不出声的。需要设置obeyMuteSwitch false才能强制播放但这个属性在部分基础库版本上表现不一致需要做好降级处理。5. 常见问题与排查技巧实录5.1 帧回调不触发或触发频率异常这是最常见的问题表现是onCameraFrame注册了但一直不回调或者回调频率远低于预期。排查顺序确认 camera 组件已经初始化完成。在bindinitdone之前调用onCameraFrame是无效的。确认调用了frameListener.start()。只注册不 start等于没注册。检查frame-size设置。某些基础库版本下frame-size设成large会导致帧回调不稳定降成medium或small试试。检查页面是否在前台。小程序切到后台后相机和帧回调都会被暂停这是系统行为无法绕过。检查是否有多个 camera 组件。同一页面同时存在多个 camera 组件时只有第一个能正常工作。实操心得我在 Android 上遇到过帧回调频率忽高忽低的情况后来发现是相机自动对焦在频繁调整。把focus-mode设成fixed或者手动触发对焦帧率就稳定了。不过focus-mode属性在部分机型上不支持需要做兼容判断。5.2 识别率低、识别慢的优化方向识别率低通常和图像质量有关。可以从这几个方面优化提高帧分辨率把frame-size从small升到medium识别率会有明显提升。限制识别区域不要对整帧做识别只对取景框内的区域做识别。可以在 Worker 里先裁剪出中心区域再送给识别库。这样既提升速度又减少干扰。调整二值化阈值如果条码对比度低可以在识别前做一次自适应二值化。zxing-wasm内部有相关处理但你可以通过预处理进一步提升。开启 tryHarder在识别率优先的场景下打开代价是单帧处理时间增加 30% 到 50%。识别慢的话首先要确认是不是 Worker 里的识别库在重复初始化。识别库应该只初始化一次而不是每帧都初始化。其次检查帧数据拷贝次数确保用了转移所有权的方式传递 ArrayBuffer。5.3 iOS 与 Android 的兼容性差异这是最让人头疼的部分。同样的代码两个平台表现可能完全不同。问题现象iOS 表现Android 表现处理方式帧回调频率较稳定约 15-20fps波动大10-30fps用时间戳降频不依赖固定帧率震动反馈偏弱短促明显可调强度重要操作加提示音兜底静音键影响提示音默认被静音通常不受影响设置 obeyMuteSwitchfalse相机启动速度较快部分机型较慢加 loading 状态避免白屏后台恢复需重新初始化部分机型可自动恢复统一在 onShow 里检查相机状态内存占用较高易触发回收相对宽松及时 stop 帧监听释放 Worker注意iOS 上如果小程序切到后台再回来相机经常需要重新初始化。我的做法是在onShow里判断cameraReady状态如果相机已经失效就重新走一遍初始化流程。不要假设相机一直可用。5.4 连续扫码的性能与内存管理连续扫码跑久了小程序会变卡甚至闪退基本都是内存问题。几个关键控制点及时释放帧数据用了转移所有权之后主线程不再持有帧数据Worker 处理完也要及时置空引用让 GC 回收。控制识别频率不要每帧都识别200ms 一次足够了。识别频率翻倍CPU 占用也差不多翻倍。限制已扫列表长度界面上展示的已扫列表不要无限增长超过 100 条就截断或者分页。setData的数据量过大会导致通信开销剧增。页面隐藏时停止扫描在onHide里调用frameListener.stop()在onShow里重新start()。不要让相机在后台空跑。onHide() { if (this.frameListener) { this.frameListener.stop(); } }, onShow() { if (this.data.cameraReady this.frameListener) { this.frameListener.start(); } }5.5 常见问题速查表问题可能原因解决方案帧回调不触发未调用 start / 相机未初始化确认 initdone 后调用 start识别结果重复未做去重加内容时间双重去重页面卡顿主线程跑识别识别逻辑移入 Worker内存暴涨帧数据未释放用转移所有权传递 ArrayBufferiOS 无提示音静音键开启设置 obeyMuteSwitchfalseAndroid 帧率不稳自动对焦干扰尝试固定对焦模式切后台后相机失效系统回收相机资源onShow 里重新初始化Worker 报错引入了不兼容的库确保库是纯 JS/WASM无 DOM 依赖识别率低分辨率不足/光线差提升 frame-size增加补光扫码后界面无反馈setData 未触发渲染检查数据路径和 setData 调用6. 一些进阶玩法与扩展思路连续扫码做稳之后可以往上叠一些更有意思的能力。批量识别多码zxing-wasm支持maxNumberOfSymbols大于 1可以在一帧里同时识别多个条码。这在盘点场景里很有用一次对准货架同时扫多个商品。但要注意多码识别对图像质量要求更高而且结果排序不稳定需要自己做位置排序。扫码结果实时校验扫到的码可以立即和本地缓存或后端接口做校验判断是否属于当前盘点任务。如果不属于界面标红提示避免误扫。这个逻辑放在 Worker 里做不了需要在主线程收到结果后异步请求。离线模式把商品信息缓存在本地扫码后直接匹配不依赖网络。等有网了再批量同步。这对仓库、地下车库等弱网场景非常实用。自定义识别区域在取景框上画一个矩形只识别这个矩形内的条码。实现方式是在 Worker 里根据坐标裁剪帧数据。这样可以避免取景框外的条码干扰也能提升识别速度。扫码历史导出把已扫列表导出成 CSV 或 Excel方便后续对账。小程序端可以用wx.getFileSystemManager()写文件然后调wx.shareFileMessage分享出去。这些扩展我在不同项目里都做过核心的连续扫码框架不变只是在结果处理层做加法。先把基础跑通再按需叠加不要一上来就全都要那样调试成本会成倍增加。我个人在实际操作中的体会是连续扫码这件事难点从来不在“识别”本身而在“稳定”和“流畅”。识别库选对了识别率都不会太差但帧回调的管理、Worker 的通信、内存的控制、双端的兼容这些才是真正吃时间的地方。把降频、去重、释放这三件事做扎实基本就能覆盖 80% 的坑。剩下的 20%靠真机实测慢慢磨。