免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

uniapp蓝牙扫描失控?四种方案彻底解决onBluetoothDeviceFound重复触发

uniapp蓝牙扫描失控?四种方案彻底解决onBluetoothDeviceFound重复触发 1. 问题现场监听回调像“打了鸡血”一样反复触发做uniapp蓝牙开发尤其是需要扫描周边低功耗蓝牙设备的场景十有八九会碰上这个鬼问题明明只调用了一次uni.startBluetoothDevicesDiscovery结果uni.onBluetoothDeviceFound这个监听回调像吃了兴奋剂一样不是触发几次而是疯狂触发几十次上百次。如果你在回调里做了setData、查数据库、发请求这类操作页面直接卡成幻灯片控制台刷屏刷得亲妈都不认识。最要命的是你满脑子想着“先取消监听再重新监听”跑到uniapp官方文档一翻uni.offBluetoothDeviceFound没有。uni.offBluetoothDeviceStateChange也没有。连uni.offBLEConnectionStateChange都是处于“不支持”的状态。当时我第一反应是“这玩意儿是不是压根没封装”又跑去微信小程序官方文档确认人家微信原生API里明明白白写着wx.offBluetoothDeviceFound(function listener)是有的。也就是说uniapp做跨端API封装的时候把“取消监听”这一块给漏了或者直接选择不实现Android、iOS、H5、各家小程序平台都没有统一暴露。这里先给第一次做蓝牙开发的同学说清楚背景onBluetoothDeviceFound是全局监听不是一次性回调。你每次扫到新设备都会触发它而且不同平台触发频率不一样。微信小程序在allowDuplicatesKey为false时可以做到设备去重但App端尤其是Android的uni-app底层实现会频繁上报压根不管你去不去重。于是就有了大量“同一台设备在列表里出现N遍”“回调里请求发了N次”的诡异现象。如果你也遇到了“想取消监听但找不到API”“设备列表疯狂重复”“回调里逻辑被重复执行”这几个痛点这篇文章就是写给你的。我会从问题根因到项目实践中真正能落地的四种解法全部展开讲一遍最后再分享几个不翻文档根本发现不了的坑。2. 根因拆解为什么uniapp没有offBluetoothDeviceFound要解决问题不能止步于“没这个API”你得搞清楚底层到底发生了什么否则换个平台又会踩同样的雷。2.1 uniapp API封装策略的“盲区”uniapp本质上是一层多端适配层它对外暴露的JavaScript API每个端对应不同的原生实现。拿蓝牙扫描来说微信小程序端调的是微信的wx.startBluetoothDevicesDiscoveryApp端调的是plus.android或plus.ios底层能力。问题在于uniapp这层封装在设计之初就没有把“取消监听”系列API完整纳入统一规范。我翻了uni-app的源码和官方GitHub上的issue有人早在几年前就提过“希望支持offBluetoothDeviceFound”维护者的回复大意是各平台底层实现差异太大暂时无法在框架层统一建议开发者使用条件编译在各个平台自行处理。说白了就是官方知道这个坑但没打算填。这意味着什么意味着你不能在uniapp公共代码里写uni.offBluetoothDeviceFound然后指望它在所有端都生效——这个方法在运行时压根不存在会直接报uni.offBluetoothDeviceFound is not a function。但如果只是做微信小程序端你完全可以通过条件编译调用wx.offBluetoothDeviceFound。2.2 设备“重复上报”的真正来源搞清楚为什么没有off方法之后还要理解第二个问题为什么监听回调会反复触发这块不同平台的机制不太一样。在微信小程序里wx.onBluetoothDeviceFound的回调频率与wx.startBluetoothDevicesDiscovery的参数allowDuplicatesKey直接挂钩。如果你传了allowDuplicatesKey: true那系统会重复上报同一台设备回调自然疯狂触发这个参数本来是为了实现RSSI测距这类需要连续采样场景准备的。如果你传false微信端会做一定程度的去重但注意微信端还是会在不同时机多次上报同一设备只是频次低了很多。在App端情况更复杂。Android系统本身的BLE扫描回调就是多次触发底层onLeScan或ScanCallback的onScanResult方法理论上每个广播包都会回调一次。而小米、华为、OPPO等厂商对蓝牙广播的处理策略又各不相同有的厂商会连续回调好几轮。uniapp把这一层挪到JS层之后没有任何去重处理你看到的就是设备像“分身”一样重复出现。所以重复监听问题的本质是框架层没有提供取消监听的统一API 平台底层的扫描回调本身就重复触发 开发者在回调里写了不该写的重复逻辑。这三层问题叠在一起才呈现出“无法取消监听也没法避免设备重复”的最终现象。3. 方案一标志位开关用一个布尔值卡住重复逻辑既然没法取消监听那就换个思路监听该挂着就挂着但我不让它随便执行里面的核心逻辑。用一个标志位来控制回调触发时先检查这个标志位如果已经处于“处理中”状态直接return掉。3.1 一个布尔值解决80%的重复问题当时我在做蓝牙搜索页时最直接的处理方式就是加一个isScanningHandled的锁。核心逻辑放在一个方法里回调触发时先判锁处理完以后再释放。// 页面data中定义 data() { return { isHandling: false, scanLockTimer: null } } // startDiscovery的success回调里注册监听 startScan() { uni.startBluetoothDevicesDiscovery({ allowDuplicatesKey: false, success: (res) { console.log(开始扫描, res); uni.onBluetoothDeviceFound(this.onDeviceFoundHandler); } }); }, // 监听回调带标志位控制 onDeviceFoundHandler(res) { if (this.isHandling) { return; } this.isHandling true; try { // 这里是核心业务逻辑比如解析设备列表 this.processDevices(res.devices || []); } finally { // 不管处理成功失败都释放锁 // 但注意不能立即释放否则同一批高频回调还是会连续进来 this.scanLockTimer setTimeout(() { this.isHandling false; }, 300); } }为什么释放锁要加setTimeout而不是立即释放因为Android端蓝牙广播回调是连续多次快速触发的如果你try-finally里立即释放锁第二次回调会在几毫秒后就进来锁等于白加了。我实测下来加一个300ms的延时释放基本能把同一批连续广播合并成一次逻辑处理页面卡顿问题立刻缓解。3.2 别忘了在页面卸载时清理定时器这个方案有一个容易忽略的地方scanLockTimer这个定时器必须在onUnload或onHide时清理否则页面都销毁了定时器还在跑甚至可能在销毁后又给一个已经不存在的页面实例触发setData直接报错。onUnload() { if (this.scanLockTimer) { clearTimeout(this.scanLockTimer); this.scanLockTimer null; } // 同时关闭蓝牙扫描停止回调触发 uni.stopBluetoothDevicesDiscovery({ complete: () {} }); }这种方式的优点是通用性极强——不管哪个平台不管底层回调多么高频它都能把“重复执行”限制在可控范围内。缺点则更明显它治标不治本。监听还挂着回调还在疯狂触发只是不再重复执行业务逻辑罢了。如果你在processDevices里做了uni.getBluetoothDevices这种耗时操作锁住以后虽然只跑一次了但整体性能依然不如“压根不回调”。4. 方案二节流与防抖让回调不再“连发”标志位适合“同一瞬间高频重复”的场景但如果回调是在一个时间段内持续触发比如持续10秒的扫描过程中设备反复上报标志位就不够优雅了。这时候需要上节流或防抖。4.1 节流固定时间窗口内只执行一次节流的核心思想是无论回调触发多少次在指定的时间间隔内只执行一次。我封装了一个通用的节流函数专门用于蓝牙回调场景。function throttle(fn, interval 500) { let lastTime 0; return function(...args) { const now Date.now(); if (now - lastTime interval) { lastTime now; fn.apply(this, args); } } } // 使用 created() { this.throttledHandler throttle(this.onDeviceFoundHandler, 500); }, startScan() { uni.startBluetoothDevicesDiscovery({ allowDuplicatesKey: false, success: (res) { uni.onBluetoothDeviceFound(this.throttledHandler); } }); }这样设置之后即使底层每秒回调十几次throttledHandler最多每500ms执行一次。对于展示设备列表这种业务来说500ms的更新频率完全够用肉眼看起来还更流畅不会出现列表“刷屏式”跳动。4.2 防抖等回调静默后再执行防抖的思路不一样它不管回调触发多少次只在“最后一次回调结束后的等待时间内没有新回调”时才执行。对于蓝牙扫描这种“一阵一阵上报”的场景防抖能让你的页面在设备上报暂停的空隙去更新一次UI而不是在广播风暴的正中间反复做无意义的更新。function debounce(fn, delay 1000) { let timer null; return function(...args) { if (timer) clearTimeout(timer); timer setTimeout(() { fn.apply(this, args); timer null; }, delay); } }用防抖处理蓝牙回调有一个明显好处能极大减少setData或页面渲染的次数。但代价是实时性变差比如设备刚出现在范围内可能要等1秒之后才在界面上显示出来。如果你做的是“扫描发现设备并展示”这种低频操作防抖更合适如果要做RSSI实时测距、设备靠近提醒那必须用节流。4.3 什么情况下节流防抖也扛不住节流防抖不是万能的。我遇到过一个极端场景Android端某款手机上蓝牙扫描回调持续高频触发即使加了500ms节流processDevices里的数组排序、去重逻辑还是让页面掉帧。后来排查发现问题不在回调频率而在于每个回调传入的res.devices数组里包含了大量重复设备Array.push之后数组越积越长每次排序都是对大数组进行全量排序。这种情况单纯靠节流已经解决不了性能问题必须配合下一节要讲的数据层去重方案双重夹击才行。5. 方案三条件编译直接调微信原生API杀掉监听如果你只做微信小程序端或者主要战场就是微信小程序那有一个更彻底的解法直接用wx.offBluetoothDeviceFound把监听取消掉。uniapp不提供不等于不能用条件编译就是干这个的。5.1 wx.offBluetoothDeviceFound的正确用法微信官方文档对wx.offBluetoothDeviceFound的描述是移除蓝牙设备发现事件的监听函数。它接收一个可选参数listener如果你传了具体的函数引用就只移除这个函数如果不传就等于移除所有该事件的监听。// 在页面data或methods里定义一个具名处理函数 methods: { onDeviceFoundHandler(res) { // 处理设备列表 }, stopListening() { // #ifdef MP-WEIXIN if (wx.offBluetoothDeviceFound) { wx.offBluetoothDeviceFound(this.onDeviceFoundHandler); } // #endif } }注意一个关键点必须传具名函数不能传匿名函数。很多开发者写uni.onBluetoothDeviceFound(function(res){...})然后想用wx.offBluetoothDeviceFound去移除这个监听发现怎么都移不掉。原因是off方法通过函数引用去匹配要移除的监听器匿名函数每次都是新引用根本匹配不上。正确做法是先定义一个具名方法on和off都引用同一个方法。5.2 条件编译的补充处理微信端的条件编译标记是MP-WEIXIN但如果你同时还要兼容App端代码结构要处理好。条件编译不是运行时判断而是编译期剔除所以写的时候要保证语法在两边都能解析通过。methods: { initBluetoothListener() { // #ifdef MP-WEIXIN if (wx.onBluetoothDeviceFound) { wx.onBluetoothDeviceFound(this.onDeviceFoundHandler); } // #endif // #ifndef MP-WEIXIN uni.onBluetoothDeviceFound(this.onDeviceFoundHandler); // #endif }, removeBluetoothListener() { // #ifdef MP-WEIXIN if (wx.offBluetoothDeviceFound) { wx.offBluetoothDeviceFound(this.onDeviceFoundHandler); } // #endif // #ifndef MP-WEIXIN // App端没有off方法这里可以有三种选择 // 1. 不处理靠标志位控制 // 2. 调用uni.stopBluetoothDevicesDiscovery停止扫描 // 3. 两者都做 uni.stopBluetoothDevicesDiscovery({ complete: () {} }); // #endif } }不过说实话这种方式只能解决微信端的“取消监听”需求。如果你做的是多端应用依然需要配合标志位或节流方案做兜底。条件编译写得多了代码会被各种#ifdef搞得很乱可维护性直线下降这一点要有心理准备。6. 方案四用Map按deviceId去重彻底告别重复设备前面三个方案都在控制“监听回调的执行频率”但还有个更底层的问题一直没解决——就算回调只执行一次res.devices数组里可能本身就存在重复设备。如果你把重复数据渲染到页面上用户看到的依然是“重复设备”。所以第四种方案从数据层面入手用Map按deviceId做唯一性缓存无论回调里来多少重复数据最终展示到界面上的必然是一份去重后的列表。6.1 Map去重的核心实现低功耗蓝牙设备有一个deviceId字段在一台手机上这个ID是识别设备的唯一标识底层是MAC地址iOS上则可能是系统生成的UUID。用Map缓存以deviceId为key每次进来新数据先查Map存在就更新RSSI不存在才新增条目。data() { return { deviceMap: new Map(), deviceList: [] } }, methods: { processDevices(devices) { if (!devices || devices.length 0) return; devices.forEach((device) { const key device.deviceId; const existing this.deviceMap.get(key); if (existing) { // 设备已存在更新信号强度即可 existing.RSSI device.RSSI; // 也可以更新name等其他字段 if (device.name) { existing.name device.name; } } else { // 新设备加入Map和列表 this.deviceMap.set(key, { deviceId: device.deviceId, name: device.name || 未知设备, RSSI: device.RSSI, isConnectable: device.isConnectable }); } }); // 将Map中所有设备刷新到展示列表 this.deviceList Array.from(this.deviceMap.values()); } }用Map的好处不仅仅是去重它还天然解决了一个衍生问题你不需要频繁对整个list做排序或过滤。Array.from(this.deviceMap.values())拿到的列表顺序就是设备被发现顺序的稳定映射配合Vue的响应式系统页面上的设备列表会保持稳定的排序不会因为重复回调而上下跳动。6.2 allowDuplicatesKey参数对数据层的连带影响之前提到allowDuplicatesKey这个参数它其实和数据去重方案强相关。如果你设置的是allowDuplicatesKey: true系统会高频重复上报同一设备Map缓存可以有效保证deviceList不膨胀、不重复。如果你设置为false微信端会自动去重但App端不一定遵守所以Map方案在App端尤其重要。这里有一个细节设备的name字段在Android上经常是空的。因为很多BLE设备为了省电广播包里只放了MAC地址和信号强度设备名需要主动去连接之后才能读出来。所以你在界面上看到一堆“未知设备”时不要怪代码写得有问题这是BLE协议本身的限制。Map去重时建议以deviceId为准千万别用name作为key否则两个同名的不同设备会被当成同一个反之同一个设备空名字时也会被疯狂合并。6.3 数据量过大的性能优化如果你所在场景扫描到的设备特别多比如商场里几百个蓝牙信标同时广播Map方案会在短时间内积攒大量设备。此时界面上一次性渲染几百个列表项脉动加载会很吃紧。我的经验是加一个阈值比如只展示信号最强的前50个设备// 在 processDevices 最后加上排序截断 const sorted Array.from(this.deviceMap.values()) .sort((a, b) (b.RSSI || -100) - (a.RSSI || -100)); this.deviceList sorted.slice(0, 50);这样既保证了设备列表不重复又控制了渲染规模界面滑动起来也流畅得多。7. 组合拳实战一次完整的蓝牙搜索功能最佳实践单独用标志位、节流、条件编译或Map去重的效果都有限真正线上跑得稳的蓝牙搜索页通常是几种方案配合使用。这一节我给出一个经过线上验证的完整设计你可以直接“抄作业”。7.1 页面生命周期里的蓝牙开关export default { data() { return { isScaning: false, deviceList: [], deviceMap: new Map(), isHandling: false, scanLockTimer: null } }, onLoad() { // 先初始化蓝牙适配器 uni.openBluetoothAdapter({ success: () { console.log(蓝牙适配器初始化成功); }, fail: (err) { console.error(蓝牙适配器初始化失败, err); uni.showToast({ title: 请先打开蓝牙, icon: none }); } }); }, onUnload() { this.closeScan(); }, methods: { // 开始扫描 startScan() { if (this.isScaning) return; // 每次开始扫描前重置去重Map this.deviceMap new Map(); this.deviceList []; this.isHandling false; uni.startBluetoothDevicesDiscovery({ allowDuplicatesKey: false, // 注意这里配合Map去重建议为false减少重复上报 success: (res) { console.log(开始扫描成功, res); this.isScaning true; // 注册监听注意用具名函数 uni.onBluetoothDeviceFound(this.onDeviceFoundHandler); }, fail: (err) { console.error(开始扫描失败, err); } }); }, // 监听回调标志位 节流 Map去重三重保障 onDeviceFoundHandler(res) { // 第一道门槛标志位控制核心逻辑执行频率 if (this.isHandling) { return; } this.isHandling true; // 第二道门槛数据层Map去重 const devices res.devices || []; devices.forEach((device) { const key device.deviceId; if (!this.deviceMap.has(key)) { this.deviceMap.set(key, { deviceId: device.deviceId, name: device.name || 未知设备, RSSI: device.RSSI }); } else { const existed this.deviceMap.get(key); existed.RSSI device.RSSI; if (device.name) existed.name device.name; } }); // 更新展示列表 this.deviceList Array.from(this.deviceMap.values()); // 释放锁300ms内不再执行 if (this.scanLockTimer) { clearTimeout(this.scanLockTimer); } this.scanLockTimer setTimeout(() { this.isHandling false; }, 300); }, // 停止扫描 stopScan() { uni.stopBluetoothDevicesDiscovery({ success: () { console.log(停止扫描成功); }, complete: () { this.isScaning false; } }); // 无法off监听时至少确保IsHandling复位 this.isHandling false; }, // 页面消失或退出时的清理 closeScan() { if (this.scanLockTimer) { clearTimeout(this.scanLockTimer); this.scanLockTimer null; } // #ifdef MP-WEIXIN if (wx.offBluetoothDeviceFound) { wx.offBluetoothDeviceFound(this.onDeviceFoundHandler); } // #endif this.stopScan(); } } }这个组合拳的整体逻辑是startScan开始扫描时重置状态和Map保证上次扫描的残留数据不干扰监听回调内部先用isHandling拦一道防止同一个广播风暴内多次执行复杂逻辑再用Map按deviceId去重保证数据不重复最后用300ms定时器释放锁保证后续扫描到的真新设备也能正常入库。页面onUnload时主动清理监听微信端并停止扫描杜绝退出页面后蓝牙还在后台偷偷扫描的电量消耗。7.2 一个容易踩的坑页面前后台切换还有一个场景必须提醒用户在使用蓝牙搜索功能时中途切到后台比如接了个电话再切回来页面的onShow会触发但蓝牙扫描可能已经被系统暂停甚至结束了。如果不重新调用startBluetoothDevicesDiscovery设备列表就会一直停留在切出去之前的状态。我的处理方案是在onShow里做一次“扫描状态自愈”onShow() { // 从后台回前台如果之前正在扫描需要重新开启 if (this.isScaning) { uni.startBluetoothDevicesDiscovery({ allowDuplicatesKey: false, success: (res) { console.log(重新开启扫描, res); }, fail: (err) { console.warn(重新开启扫描失败, err); } }); } }注意重新开启扫描时不需要重新uni.onBluetoothDeviceFound注册监听因为监听注册一次后就一直挂在那里前提是页面还在且没有执行off操作。如果你在closeScan里已经调用了wx.offBluetoothDeviceFound那回到前台就需要同时重新on和重新startDiscovery否则检测不到设备。8. 微信端之外的隐藏方案原生插件与UTS扩展如果你开发的是App端又不满足于标志位Map这种“曲线救国”的方式还可以考虑直接编写原生插件或uts插件在原生层实现真正的offBluetoothDeviceFound。这个方案门槛高但能从根本上解决问题。8.1 uts插件的大致思路uni-app从HBuilderX 3.6开始支持utsuni TypeScript开发原生插件可以用TypeScript语法编写直接调用Android/iOS原生API的代码。在uts插件里你可以把Android的BluetoothAdapter.LeScanCallback或ScanCallback的注册和反注册都封装出来暴露给JS层调用。// uts插件中的Android端代码逻辑示意 import { BluetoothAdapter } from android.bluetooth; export function registerLeScan(callback: (device: any) void): void { // 注册扫描回调 } export function unregisterLeScan(): void { // 反注册扫描回调 }这样JS侧就能直接调用插件提供的方法完成监听取消。但说实话uts插件要做好原生层的生命周期管理并不容易还要处理Android 12及以上版本的权限声明、Android 13附近的蓝牙扫描新变化对大多数业务团队来说投入产出比不高。除非你团队里有人熟悉Android原生开发否则我更推荐用前文的组合拳方案。8.2 什么时候才需要上原生方案我个人的判断标准很简单如果你的项目只需要“扫描设备-展示列表-连接设备”这个基础流程组合拳完全够用没必要上原生插件。但如果你的核心业务高度依赖蓝牙比如“持续扫描多个设备并实时计算距离”“同时维持多台设备的连接和取消连接”这类场景下监听生命周期必须做到精确可控那原生方案是值得投入的。另外如果你的蓝牙功能涉及后台保活、系统级通知交互那uniapp这层封装已经远远不够了直接用Android原生代码开发一个模块集成进uniapp工程或者干脆用原生开发整体模块再接进主工程才是正路。9. 那些年我们踩过的蓝牙扫描坑经验总结文章最后把几年uniapp蓝牙开发中踩过的、见过别人踩的坑集中梳理一遍。每一项都是我交过学费换来的。9.1 监听器叠加问题如果你在同一个页面多次调用uni.onBluetoothDeviceFound而没有做off每次调用都会新增一个监听器微信端会重复触发回调App端更离谱——每注册一次底层就多一个回调接收者如果你不小心在按钮点击事件里多次注册回调触发的次数会成倍增长。规避方法在startScan里注册监听之前先尝试用uni.offBluetoothDeviceFound若存在移除旧的监听或者用wx.offBluetoothDeviceFound条件编译处理微信端。没有off能力的平台用一个布尔量标记listenerRegistered保证同一个页面实例只注册一次。9.2 stopBluetoothDevicesDiscovery的时机很多新手只在页面卸载时才调用stopBluetoothDevicesDiscovery这是不对的。蓝牙扫描是非常耗电的操作系统对扫描频率也有隐藏限制如果长时间高频扫描而不主动停止Android系统可能会在底层直接断开你的扫描会话之后所有API调用都会失败。正确做法是扫描到目标设备后立即停止扫描页面隐藏时停止扫描超时比如10秒还没找到设备也主动停止并提示用户。9.3 回调里的异常必须兜底onBluetoothDeviceFound回调里如果抛出任何JS异常在微信小程序端会直接导致后续所有回调中断在App端则可能导致当前扫描会话崩溃。所以回调里的try-catch不是可选项是必选项。前文的代码示例里我用try-finally释放标志位锁正是这个原因——就算执行业务逻辑时报错锁也要释放否则后面所有回调全部被拦截扫描功能彻底瘫痪。9.4 Android权限申请的顺序Android 12及以上版本蓝牙扫描不仅需要ACCESS_FINE_LOCATION还需要BLUETOOTH_SCAN权限。uniapp的uni.authorize不一定能处理所有蓝牙权限申请建议在openBluetoothAdapter之前先主动申请定位权限和蓝牙权限并且在用户拒绝后给出明确指引——否则在Android部分机型上startBluetoothDevicesDiscovery会一直返回fail你查半天代码也找不出问题。9.5 写在最后的一点个人体会回头再看“uniapp蓝牙无法取消监听”这个问题我认为与其纠结官方补齐API不如先接受现实把能控制的逻辑控制起来。标志位、节流、Map去重这三板斧在任何平台、任何小程序框架下都适用条件编译调原生API只适合特定端原生插件方案是最终手段但成本高。我目前线上跑的项目依然用的是“标志位Map去重主动stopDiscovery”这套组合几个月下来没有出现设备重复或页面卡死的反馈。最后分享一个小技巧蓝牙开发永远要在真机上调试模拟器上跑不通的。Android和iOS的真机行为差异很大同一套代码在一台手机上顺滑如丝换一台手机可能就问题百出。准备几台不同厂商的Android测试机比看一百篇文档都管用。
返回列表