1. 连续扫码的需求拆解与方案选型
1.1 单次扫码和连续扫码的体验差在哪
微信小程序里做扫码,很多人第一反应就是wx.scanCode。这个 API 确实简单,一行代码就能拉起原生扫码界面,扫完拿到结果,然后写业务逻辑。问题是,它本质上是“一次调用换一次结果”。你扫完一个码,原生扫码界面关闭,回到小程序页面;想再扫下一个,就得再点一次按钮,再调一次wx.scanCode。这种模式放在低频场景里没问题,比如扫一张门票、扫一个付款码、扫一个设备编号,用户等一两秒完全能接受。
但连续扫码的需求完全不同。仓库盘点时,操作员可能要在半小时内扫几百个货架标签;门店核销时,店员要连续扫几十张券码;快递入库时,快递员要一边走一边扫,手不能停。这个时候如果每扫一次都弹一次原生界面、等一次加载、点一次按钮,效率直接砍半,用户会边用边骂。连续扫码要的是“页面不跳、相机不关、结果一条接一条进列表”,中间最好只有震动和短提示音,而不是反复弹窗。
所以判断能不能用wx.scanCode,关键看两个指标:扫码频率和操作连续性。如果一分钟只扫一两次,wx.scanCode足够稳;如果一分钟要扫十次以上,或者操作员需要单手连续操作,那就必须换成页面内持续识别的方案。这也是微信小程序里连续扫码和单次扫码最大的分水岭。
1.2 wx.scanCode 的边界:能用,但不适合高频
wx.scanCode的优势是兼容性好、调用简单、不需要自己处理相机权限和画面。它支持二维码、一维码、Data Matrix、PDF417 等类型,返回结果也直接。很多小程序项目一开始就是用它顶上的。但它的缺点在高频场景里会被放大。
第一,每次调用都会拉起原生扫码界面,这个界面会覆盖当前页面。用户扫完一个码,界面关闭,再调一次,界面又打开。视觉上闪来闪去,操作节奏被打断。第二,原生扫码界面通常有自己的提示和交互,你很难在里面加“已扫 23 条”“上一码是什么”这类业务信息。第三,连续调用需要递归封装,如果用户点了取消,递归要停下来,否则会反复弹。第四,部分 Android 机型在频繁调用相机时会出现启动慢、黑屏、对焦慢的问题。
我试过在盘点类项目里用wx.scanCode做连续扫码,做法是在success回调里再调一次wx.scanCode,形成递归。代码能跑,但体验只能算“能用”。扫到第十几个的时候,操作员会明显感觉每次都要等界面切换。后来换成camera组件之后,扫码速度才真正提上来。所以我的经验是:wx.scanCode适合低频、单次、对体验要求不高的场景;高频连续扫码,把它当降级方案就好。
1.3 camera 组件 + mode=scanCode 为什么是主线方案
微信小程序提供了camera组件,它可以直接在页面里显示相机画面。从基础库 2.7.0 开始,camera组件支持mode="scanCode",开启扫码模式后,相机画面会持续识别二维码和条形码,识别到内容就触发bindscancode事件。这个事件不是只触发一次,而是可以连续触发,这才是连续扫码的核心。
它的工作方式可以这样理解:页面里放一个camera组件,用户把相机对准码,组件在后台不断取帧、识别,一旦识别成功就把结果抛给你的 JS 逻辑。你拿到结果后,可以做震动、录音效、写入列表、调用接口,然后继续等下一个码。整个过程都在当前页面内完成,没有原生扫码界面的覆盖和关闭,操作员可以一直拿着手机扫,节奏非常顺。
当然,camera组件也不是没有代价。它是原生组件,层级较高,普通view盖不上去,叠加 UI 要用cover-view;开发者工具对它的模拟有限,必须真机调试;权限被拒绝后处理起来比wx.scanCode麻烦;长时间开启相机还会发热、耗电。但这些代价换来的是连续扫码体验的质变。对于盘点、核销、入库这类场景,我觉得这笔账非常划算。
1.4 几个典型场景:盘点、核销、入库、巡检
连续扫码不是一个孤立功能,它背后对应的是几类很具体的业务。第一类是仓库盘点:操作员拿着手机或 PDA,沿着货架逐个扫商品条码,扫完一批后统一提交。这个场景要求扫码快、去重准、能看已扫数量,最好还能导出结果。第二类是门店核销:顾客出示券码,店员连续扫码核销,要求即时反馈、防止同一张券重复核销。第三类是快递入库:快递员扫运单号,连续扫几十上百个,要求列表不卡、网络断了也能先存本地。第四类是设备巡检:巡检人员扫设备二维码,记录巡检时间和设备编号,要求能识别重复设备并提示。
这些场景有一个共同点:扫码只是入口,真正的重头戏是结果管理。你不能扫完就完了,还要去重、清洗、校验、记录时间、批量提交、处理失败。所以连续扫码的实现不能只盯着camera组件,还要把后面的数据流一起设计好。否则扫码很快,列表一长就卡死,或者同一条码扫了五次全进列表,业务方照样不满意。
2. 开发前的准备:基础库、权限、页面布局
2.1 基础库版本和真机调试的最低要求
camera组件的mode="scanCode"和bindscancode事件依赖基础库版本。我一般会把最低基础库设到 2.7.0 以上,实际项目里建议更高一些,因为一些机型兼容性和同层渲染优化在高版本更好。你可以在微信开发者工具的“详情”里设置调试基础库,也可以在app.json里通过requiredBackgroundModes之类的配置影响运行环境,但更关键的是在发布时选择合适的最低基础库。
这里有一个很容易踩的坑:开发者工具里 camera 组件能显示画面,但扫码回调不一定和真机一致。我遇到过在工具里怎么扫都不触发bindscancode,换到真机立刻正常;也遇到过工具里能触发,但 Android 真机上手电筒不生效。所以我的习惯是:页面骨架可以在工具里写,但扫码回调、权限弹窗、震动、音效、手电筒、相机切换这些,全部用真机调试。iOS 和 Android 各准备一台,至少各测一轮。
另外,camera组件在部分旧机型上启动较慢。你可以加一个 loading 状态,相机binderror触发时给出提示,不要让用户对着黑屏发呆。如果项目必须覆盖很低的基础库,那就准备降级方案:检测wx.canIUse或基础库版本,不支持camera扫码模式时,退回wx.scanCode递归调用。
2.2 摄像头权限、隐私声明和审核口径
摄像头权限是连续扫码的第一道门槛。camera组件需要scope.camera权限。用户第一次进入页面时,小程序会弹出授权窗口;如果用户拒绝,后续再调wx.authorize通常不会再次弹出,必须引导用户去设置页开启。我的处理方式是:先用wx.getSetting查权限状态,如果是undefined,调wx.authorize;如果是false,弹一个说明弹窗,用户点确认后调wx.openSetting。不要一上来就反复弹授权,用户会很烦。
在app.json里建议声明摄像头用途:
{ "permission": { "scope.camera": { "desc": "用于连续扫描二维码和条形码,完成盘点、核销等操作" } } }这个描述要写得具体,不要只写“需要使用摄像头”。审核时,小程序后台的用户隐私保护指引里也要声明摄像头用途,说明收集的是扫码画面,用于识别二维码/条形码。页面里最好也有明确提示,比如“请将二维码放入框内,扫描结果仅用于本次业务”。不要隐藏相机用途,也不要在用户不知情的情况下采集画面。合规这件事,前期多写几行说明,后期能省掉很多审核沟通。
注意:如果用户拒绝摄像头权限,页面不能只留一个黑屏。要给出“去设置开启权限”的按钮,并且提供
wx.scanCode降级入口,让业务还能继续。
2.3 页面布局:相机区、状态区、结果区怎么放
连续扫码页面的布局,核心原则是:相机区域要够大,操作按钮要顺手,结果列表不能挤占扫码区。我一般会把页面分成三块:上方是相机区,占屏幕高度的 45% 到 60%;中间是状态和工具栏,显示“已扫多少条”“当前是否暂停”“手电筒开关”;下方是结果列表,可以滚动查看最近扫到的码。
相机区直接放camera组件,设置device-position="back"、mode="scanCode"、flash="{{flash}}"。如果需要叠加扫码框或提示文字,要用cover-view,因为camera是原生组件,普通view盖不上去。但cover-view的样式能力有限,圆角、阴影、复杂动画支持不好,所以我的建议是:扫码框尽量简单,用四个角或者一条横线就够,别在相机上堆太多 UI。结果列表放在相机下方,用普通scroll-view就行,不受原生组件层级影响。
按钮方面,暂停/继续、手电筒、清空、批量提交这几个最常用。暂停按钮很重要:连续扫码时用户可能需要停下来整理货物,如果相机一直识别,容易误扫。手电筒在仓库暗处非常实用,camera的flash设为torch就能常亮,但部分 Android 机型不支持,需要做兼容提示。批量提交按钮放在底部固定,显示已扫数量,防止用户扫了一堆忘了提交。
3. 核心实现:连续扫码回调、节流去重、数据入库
3.1 camera 组件的关键属性与 bindscancode 回调
camera组件有几个关键属性必须搞清楚。mode设为scanCode才会开启扫码模式;device-position控制前/后置摄像头,连续扫码一般用back;flash控制闪光灯,取值有auto、on、off、torch,手电筒常亮用torch;bindscancode是扫码识别回调,识别成功后会触发,事件对象里包含detail.result、detail.scanType、detail.charSet、detail.path、detail.rawData等字段。
最常用的是detail.result,也就是扫码内容的字符串。detail.scanType可以告诉你扫到的是二维码还是条形码,如果业务需要区分码类型,可以用这个字段做判断。detail.rawData是原始数据,一般用不到。回调触发频率和相机帧率、码的清晰度有关,同一个码在画面里停留时,可能会连续触发多次。如果你不处理,列表里会瞬间多出好几条重复记录。所以拿到bindscancode之后,第一件事不是急着入库,而是节流和去重。
提示:
camera组件在页面隐藏时通常会停止相机,但不同机型表现有差异。保险起见,在onHide里把扫码状态置为暂停,回到页面再恢复。
3.2 防重复触发:全局节流与同一码去重
连续扫码最核心的细节就是防重复。我一般会做两层过滤。第一层是全局节流:两次扫码回调之间至少间隔 200 到 300 毫秒。因为相机识别是连续取帧,同一个码可能在极短时间内触发多次回调,加上一个全局锁,可以避免同一帧里连续处理。第二层是同一码去重:如果本次扫到的内容和上一次相同,并且距离上次时间小于 1500 毫秒,就直接忽略。这个时间窗可以根据业务调整,仓库盘点通常 1.5 秒够用;如果用户可能故意连续扫同一个码,比如同一商品多件,那就不能按内容去重,而要改成计数模式。
实现时不要把这些状态放在data里频繁setData,因为setData是异步的,高频调用会卡,而且可能拿到旧值。我习惯把_lastScan、_scanLock挂在页面实例上,作为普通属性使用。比如:
this._lastScan = { code: '', time: 0 }; this._scanLock = false;在回调里判断:
if (this._scanLock) return; if (now - this._lastScan.time < 260) return; if (code === this._lastScan.code && now - this._lastScan.time < 1500) return;然后立刻设置锁和时间,再处理业务。处理完 260 毫秒后释放锁。这样既不会漏掉不同码,也不会让同一个码疯狂入列表。实测下来,这个参数在大部分 Android 和 iOS 真机上都比较稳。
3.3 结果清洗、业务校验和重复码处理
扫码结果拿到后,不能直接信任。有些条码会带换行、空格、不可见字符,有些二维码内容前后有空白,直接入库可能导致后续查询对不上。我一般会先做清洗:code.trim(),再把中间的空格和不可见字符去掉,比如code.replace(/[\s\u0000-\u001f]+/g, '')。如果业务条码有固定前缀,比如P-、SKU-,可以用正则去掉前缀,但一定要确认所有码都有这个规则,否则会误伤。
业务校验也很重要。比如核销场景,扫码结果可能是一个券码,你需要判断它是否符合长度和格式,是否已经核销过。盘点场景,扫码结果可能要在本地商品库里匹配,匹配不到的要单独标记。我的做法是:扫码回调里只做轻量校验,比如非空、长度范围、正则格式,重业务校验放到批量提交时由后端处理。这样扫码过程不会被网络请求打断,体验更顺。
重复码的处理要看业务。如果业务要求“同一条码只能出现一次”,那就用Set维护已扫集合,重复时震动提示“已扫过”,但不写入列表。如果业务要求“同一条码可以扫多次,但记录次数”,那就用Map统计,列表里显示“数量 +1”。我见过一个项目,仓库盘点时同一箱货有多个相同条码,操作员需要扫多次,结果开发按去重做了,导致数量永远不对。所以去重策略一定要和业务方确认清楚,不能想当然。
3.4 数据列表与批量提交的性能控制
连续扫码扫到几十条以后,列表渲染压力会明显上升。如果你每扫一条就this.setData({ records: 新数组 }),列表越长,setData传输的数据量越大,页面会越来越卡。我的优化习惯是:内存里维护一个完整数组this._records,页面只渲染最近 100 条,每次setData只更新这 100 条。如果业务需要查看全部,就分页加载或者跳转到独立的结果页。
批量提交也有讲究。不要每扫一条就调一次接口,那样网络请求太多,也容易因为弱网导致失败。正确的做法是本地先攒着,用户点“批量提交”时一次性发送。接口设计上建议加一个batchNo和timestamp,后端做幂等处理,避免用户重复点击提交导致重复入库。提交成功后清空本地列表,提交失败要保留数据,并给出重试入口。
注意:如果扫码结果要用来换取业务凭证,比如登录 token、核销 token,务必通过你自己的后端接口完成,小程序端只拿短期票据,不要在小程序代码里硬编码任何密钥。这是安全底线。
4. 从零写一个连续扫码页面:完整实操
4.1 app.json 权限与页面配置
先在app.json里声明页面和摄像头权限。页面路径按你的项目结构来,权限描述写清楚用途:
{ "pages": [ "pages/scan/scan" ], "permission": { "scope.camera": { "desc": "用于连续扫描二维码和条形码,完成盘点、核销等操作" } }, "window": { "navigationBarTitleText": "连续扫码" } }如果项目使用了较新的隐私协议流程,还需要在小程序后台配置用户隐私保护指引,声明摄像头用途。代码里处理权限时,先查wx.getSetting,再决定是调wx.authorize还是wx.openSetting。不要在onLoad里直接调wx.authorize,因为用户可能已经拒绝过,直接调不会弹窗,还会进fail。
4.2 WXML 与 WXSS 骨架
WXML 结构尽量简单:相机区、工具栏、结果列表、底部提交栏。相机上的提示用cover-view,按钮放在相机外面,避免原生组件层级问题。
<view class="scan-page"> <view class="camera-wrap"> <camera class="camera" device-position="back" flash="{{flash}}" mode="scanCode" bindscancode="handleScanCode" binderror="handleCameraError" ></camera> <cover-view class="scan-tip">{{tipText}}</cover-view> </view> <view class="toolbar"> <button size="mini" bindtap="toggleFlash">{{flash === 'off' ? '开灯' : '关灯'}}</button> <button size="mini" bindtap="toggleScan">{{scanning ? '暂停' : '继续'}}</button> <button size="mini" bindtap="clearList">清空</button> </view> <scroll-view scroll-y class="result-list"> <view wx:for="{{records}}" wx:key="id" class="record-item"> <text class="code">{{item.code}}</text> <text class="time">{{item.timeText}}</text> </view> </scroll-view> <view class="footer"> <text>已扫 {{recordCount}} 条</text> <button size="mini" type="primary" bindtap="submitBatch" loading="{{submitting}}">批量提交</button> </view> </view>WXSS 方面,camera宽度给 100%,高度可以用50vh或固定像素。结果列表设置flex: 1,让页面撑满屏幕。cover-view的文字要设置颜色和背景,保证在相机画面上看得清。按钮区域用flex横向排列,底部提交栏固定在底部,避免被列表顶下去。
.scan-page { display: flex; flex-direction: column; height: 100vh; background: #f5f5f5; } .camera-wrap { position: relative; width: 100%; height: 50vh; background: #000; } .camera { width: 100%; height: 100%; } .scan-tip { position: absolute; left: 0; right: 0; bottom: 20rpx; text-align: center; color: #fff; font-size: 28rpx; background: rgba(0, 0, 0, 0.45); padding: 12rpx 0; } .toolbar { display: flex; justify-content: space-around; padding: 16rpx 0; background: #fff; } .result-list { flex: 1; background: #fff; } .record-item { display: flex; justify-content: space-between; padding: 20rpx 24rpx; border-bottom: 1rpx solid #eee; } .code { font-size: 28rpx; color: #333; word-break: break-all; } .time { font-size: 24rpx; color: #999; margin-left: 16rpx; } .footer { display: flex; align-items: center; justify-content: space-between; padding: 16rpx 24rpx; background: #fff; border-top: 1rpx solid #eee; }4.3 JS 逻辑:扫码、反馈、列表、提交
JS 部分我把关键逻辑整理成可直接参考的版本。重点看_lastScan、_scanLock、handleScanCode和submitBatch。
Page({ data: { scanning: true, flash: 'off', tipText: '对准二维码/条形码,连续扫描中', records: [], recordCount: 0, submitting: false }, onLoad() { this._lastScan = { code: '', time: 0 }; this._scanLock = false; this._records = []; this._audio = wx.createInnerAudioContext(); this._audio.src = '/assets/beep.mp3'; this._audio.volume = 0.8; wx.setKeepScreenOn({ keepScreenOn: true }); this.checkCameraAuth(); }, onHide() { this.setData({ scanning: false, tipText: '已暂停' }); }, onShow() { if (this._needRecheckAuth) { this._needRecheckAuth = false; this.checkCameraAuth(); } }, onUnload() { wx.setKeepScreenOn({ keepScreenOn: false }); if (this._audio) { this._audio.destroy(); } }, checkCameraAuth() { wx.getSetting({ success: (res) => { const auth = res.authSetting['scope.camera']; if (auth === false) { wx.showModal({ title: '需要摄像头权限', content: '连续扫码需要访问摄像头,请在设置中开启', confirmText: '去设置', success: (modalRes) => { if (modalRes.confirm) { this._needRecheckAuth = true; wx.openSetting(); } } }); } else if (auth === undefined) { wx.authorize({ scope: 'scope.camera', fail: () => { wx.showToast({ title: '未授权摄像头', icon: 'none' }); } }); } } }); }, handleScanCode(e) { if (!this.data.scanning) return; const now = Date.now(); let code = (e.detail.result || '').trim(); if (!code) return; if (this._scanLock) return; if (now - this._lastScan.time < 260) return; if (code === this._lastScan.code && now - this._lastScan.time < 1500) { return; } this._scanLock = true; this._lastScan = { code, time: now }; this.handleValidCode(code); setTimeout(() => { this._scanLock = false; }, 260); }, handleValidCode(code) { wx.vibrateShort({ type: 'medium' }); if (this._audio) { this._audio.stop(); this._audio.play(); } const record = { id: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, code, timeText: this.formatTime(new Date()) }; this._records.push(record); const records = this._records.slice(-100); this.setData({ records, recordCount: this._records.length, tipText: `已扫 ${this._records.length} 条` }); }, formatTime(date) { const pad = (n) => String(n).padStart(2, '0'); return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; }, toggleFlash() { this.setData({ flash: this.data.flash === 'off' ? 'torch' : 'off' }); }, toggleScan() { const scanning = !this.data.scanning; this.setData({ scanning, tipText: scanning ? '对准二维码/条形码,连续扫描中' : '已暂停' }); }, clearList() { wx.showModal({ title: '确认清空', content: '清空后无法恢复,是否继续?', success: (res) => { if (res.confirm) { this._records = []; this._lastScan = { code: '', time: 0 }; this.setData({ records: [], recordCount: 0, tipText: '对准二维码/条形码' }); } } }); }, submitBatch() { if (this.data.submitting) return; if (!this._records.length) { wx.showToast({ title: '暂无数据', icon: 'none' }); return; } this.setData({ submitting: true }); const codes = this._records.map((item) => item.code); wx.request({ url: 'https://your-domain.com/api/scan/batch', method: 'POST', data: { codes, deviceId: 'device-demo', timestamp: Date.now() }, success: (res) => { if (res.data && res.data.success) { wx.showToast({ title: '提交成功', icon: 'success' }); this._records = []; this.setData({ records: [], recordCount: 0 }); } else { wx.showToast({ title: '提交失败', icon: 'none' }); } }, fail: () => { wx.showToast({ title: '网络异常', icon: 'none' }); }, complete: () => { this.setData({ submitting: false }); } }); }, handleCameraError(e) { console.error('camera error', e.detail); wx.showToast({ title: '相机启动失败', icon: 'none' }); } });这段代码里,_records保存完整数据,页面只渲染最近 100 条,避免长列表卡顿。震动和音效放在handleValidCode里,扫到有效码立刻反馈。暂停/继续只控制scanning变量,不销毁camera组件,避免相机反复重启。批量提交时把 codes 一次性发给后端,后端做幂等。
4.4 降级方案:wx.scanCode 递归调用的封装
如果用户基础库太低,或者摄像头权限一直拿不到,可以降级到wx.scanCode。做法是封装一个递归函数,每次扫码成功后继续调用,直到用户取消或主动暂停。注意fail里判断用户取消,不要继续递归。
Page({ data: { scanning: true, records: [] }, startContinuousScan() { if (!this.data.scanning) return; wx.scanCode({ scanType: ['qrCode', 'barCode', 'datamatrix', 'pdf417'], success: (res) => { this.handleValidCode(res.result); setTimeout(() => { this.startContinuousScan(); }, 300); }, fail: (err) => { if (err.errMsg && err.errMsg.indexOf('cancel') > -1) { this.setData({ scanning: false }); wx.showToast({ title: '已停止连续扫码', icon: 'none' }); } else { wx.showToast({ title: '扫码失败', icon: 'none' }); } } }); }, handleValidCode(code) { const records = this.data.records.concat({ code, timeText: new Date().toLocaleTimeString() }); this.setData({ records }); wx.vibrateShort({ type: 'medium' }); } });这个方案的体验不如camera顺滑,但兼容性最好,适合做兜底。你可以根据wx.getSystemInfoSync().SDKVersion或者wx.canIUse('camera')来判断是否走降级。实际项目里,我一般优先用camera,失败或权限拒绝时提示用户切换到单次扫码模式。
5. 常见问题与排查技巧实录
5.1 相机黑屏、无回调、重复触发速查表
连续扫码出问题时,排查顺序很重要。下面这张表是我在实际项目中整理出来的,基本覆盖了八成常见现象。
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
| 相机黑屏 | 未授权摄像头、隐私协议未确认、基础库过低 | 检查scope.camera,配置隐私声明,升级基础库,真机调试 |
bindscancode不触发 | mode没设scanCode、事件名拼写错误、基础库低于 2.7.0 | 设置mode="scanCode",检查bindscancode,升级基础库 |
| 同一个码连续入列表 | 没有节流和去重 | 加全局锁和同一码时间窗去重 |
| 不同码漏记 | 全局节流时间太长 | 把全局节流降到 200 到 300 毫秒,同一码去重保持 1000 到 1500 毫秒 |
| 列表越来越卡 | setData数据量太大 | 只渲染最近 100 条,完整数据放内存或本地存储 |
| 手电筒不生效 | 部分 Android 机型不支持torch | 捕获错误,提示用户手动补光 |
| 扫码对焦慢 | 镜头脏、光线暗、码太小、反光 | 提示用户调整距离,开手电筒,擦拭镜头 |
| 审核被拒 | 摄像头用途不明确 | 在隐私协议和页面提示中写清楚扫码用途 |
排查时我建议先看真机日志,不要只信开发者工具。camera组件的binderror会返回错误信息,console.error打出来。权限问题用wx.getSetting确认状态。回调不触发先检查mode和基础库。重复触发先检查去重逻辑是不是写在了setData之后,因为setData异步可能导致判断失效。
5.2 性能、发热、耗电和列表卡顿
连续扫码最容易被忽略的问题是发热和耗电。相机持续开启,加上连续识别,手机会很快热起来,尤其是 Android 中低端机。我的优化手段有几个:第一,提供暂停按钮,用户不扫的时候手动暂停;第二,扫到码后如果业务允许,短暂关闭识别 200 毫秒,但不要频繁销毁camera组件,否则重启更慢;第三,页面隐藏时立刻暂停,回到页面再恢复;第四,设置屏幕常亮,但页面卸载时取消常亮,避免用户忘了关。
列表卡顿通常是因为setData数据太大。前面代码里用了this._records.slice(-100),只把最近 100 条传给视图。如果业务必须展示全部,可以用分页加载,或者把结果页做成独立页面,主扫码页只显示最近几条和总数。另外,列表项里的时间字符串可以提前格式化,不要在 WXML 里做复杂计算。wx:key一定要用唯一 id,不要用 index,否则列表更新时可能错位。
网络提交也要控制频率。不要每扫一条就请求一次,批量提交是更合理的做法。如果担心数据丢失,可以在本地wx.setStorageSync存一份草稿,提交成功后再清除。弱网环境下,先存本地再异步提交,用户体验会好很多。
5.3 我踩过的坑与独家经验
第一个坑是去重时间窗太短。我最早设了 500 毫秒,结果同一个码在相机画面里停留时,还是会偶尔触发两次。后来改成 1500 毫秒,并且加了全局锁,才稳定下来。但时间窗也不能太长,如果用户故意连续扫同一个码,比如同一商品多件,1500 毫秒可能会漏记。所以这个参数一定要跟业务确认:是“唯一码”还是“可重复码”。
第二个坑是音效文件太长。我一开始用了一个 2 秒的提示音,连续扫码时音效重叠,听起来很乱。后来换成 100 毫秒以内的短“嘀”声,并且用InnerAudioContext提前创建,每次stop再play,体验就干净了。震动反馈也要用wx.vibrateShort,不要用长震动,否则连续扫几十次手会麻。
第三个坑是开发者工具和真机差异。我在工具里调通了bindscancode,真机上却因为基础库版本低不触发。后来把最低基础库设到 2.7.0 以上,并且在代码里加了版本判断,才解决问题。所以我的建议是:所有扫码相关功能,必须在真机上验证,iOS 和 Android 各一台,低端机和高端机各一台,别偷懒。
第四个坑是相机上的 UI 被覆盖。我一开始用普通view在相机上画扫码框,结果完全看不到。后来换成cover-view才显示出来。但cover-view不支持复杂的 CSS,动画和阴影基本别想。所以现在的做法是:相机上只放最简单的提示文字和四个角,其他信息全部放到相机下方。这样既不影响识别,也方便布局。
最后一个经验是批量提交的幂等。用户可能因为网络慢,连续点了两次提交,如果后端没做幂等,数据就重复了。我会在提交时生成一个batchNo,后端根据batchNo去重。提交成功后清空本地列表,失败则保留,并提示“网络异常,请重试”。这样即使弱网,也不会丢数据或重复入库。
提示:连续扫码页面最好加一个“最近扫到”的大字提示,让用户知道自己刚才扫了什么。很多人扫太快,根本不看列表,如果扫错了,没有即时提示就很难发现。