ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

uniapp蓝牙开发:没有offBluetoothDeviceFound,如何彻底解决设备重复监听?

uniapp蓝牙开发:没有offBluetoothDeviceFound,如何彻底解决设备重复监听? 接到这个需求的时候我心里第一个反应就是“这事我熟。”做uniapp蓝牙开发碰到“设备重复出现”“回调越积越多”“怎么都取消不掉监听”这类问题的人不在少数尤其是当你在多个页面之间来回跳转、反复执行扫描的时候那种“扫描一次设备列表多三遍”的窒息感我相信不少人都体会过。先说结论uniapp的蓝牙API确实没有提供offBluetoothDeviceFound这个取消监听方法这是平台限制不是你的代码写得不对。但这不代表问题没法解决。换个思路用全局状态管理、回调去重和生命周期兜底完全可以把这个“监听魔咒”拆掉。这篇文章我会把这个问题的来龙去脉讲透然后给你一套可以直接抄进项目里的解决方案。1. 先从根源说起为什么uniapp没有offBluetoothDeviceFound1.1 不是不想做而是API设计上的“半截工程”我们先看一眼uniapp蓝牙模块的API全家福openBluetoothAdapter、startBluetoothDevicesDiscovery、onBluetoothDeviceFound、stopBluetoothDevicesDiscovery、getBluetoothDevices……这一套东西看着挺齐全但细心的朋友会发现几乎所有onXXX开头的监听方法都少了一个对应的offXXX。onBluetoothDeviceFound是注册设备发现回调的官方文档里清清楚楚写着“监听寻找到新设备的事件”但愣是没给你一个offBluetoothDeviceFound来取消监听。同样的情况还出现在onBluetoothAdapterStateChange、onBLEConnectionStateChange这些监听接口上。为什么会出现这种“半截工程”我个人的理解是uniapp作为跨端框架底层要兼容微信小程序、App、H5多套平台。微信小程序那边有完整的wx.offBluetoothDeviceFound但App端的原生蓝牙库uni-app的App端走的是原生插件在接口封装上做了简化结果就是“监听注册了取消没跟上”。你说这算不算坑算。但它是个已知的坑我们作为开发者要么绕开它要么填平它。1.2 重复监听到底是怎么发生的要解决问题先得看清楚问题是怎么产生的。举个最常见的场景你写了一个蓝牙扫描页面onLoad里调startBluetoothDevicesDiscovery同时用uni.onBluetoothDeviceFound(callback)注册监听。然后用户点了“返回”你以为onUnload里调用uni.stopBluetoothDevicesDiscovery()就万事大吉了。但实际上stopBluetoothDevicesDiscovery只是告诉蓝牙模块“停止搜索”它并不会把onBluetoothDeviceFound注册的那个callback从全局监听队列里移除。也就是说这个callback还在系统里挂着一直在等你下次扫描时继续上报。等你第二次进入这个页面onLoad里又注册了一个新的callback这时候系统里就有两个callback了。蓝牙检测到一个新设备这两个callback都会被调用你的设备列表里自然就出现了重复项。第3次进入重复3倍。第10次进入你自己品。更隐蔽的情况是如果多个页面同时监听了onBluetoothDeviceFound或者你在一个页面里注册了多个callback那全场的设备事件都会像广播一样每个页面、每个callback都收到一遍。1.3 为什么用uni.stopBluetoothDevicesDiscovery还不够很多人在排查这个问题的第一步就是想确认“我是不是忘了调用stop”。但我要说的是即使你每次退出页面都调用了stopBluetoothDevicesDiscovery也只能保证“不再发现新设备”无法保证“已注册的callback被移除”。你可以把startBluetoothDevicesDiscovery理解为一个“收音机开关”onBluetoothDeviceFound则是“你放在收音机旁边的喇叭”。关闭收音机喇叭不再发声但喇叭还在那里下次打开收音机它照样会响。你真正想要的是把喇叭移走但uniapp没给你“把喇叭拔掉”的接口。这就是为什么很多人会陷入“我明明调了stop为什么设备还是重复”的困惑中——因为解决的方向压根就偏了。2. 动手之前先搞清楚你遇到的到底属于哪种“重复”2.1 三种容易混淆的“重复”现象不要急着改代码先花一分钟做定位。同样是“设备重复”成因可能完全不一样解决方案也截然不同。我在实际项目里总结了以下三种情况现象触发时机根本原因同一设备在列表中出现多次轮询更新时数量持续上涨在同一注册周期内设备事件多次上报未对设备列表做去重每次onBluetoothDeviceFound触发都执行push进入扫码页第N次后列表一次性出现N倍重复设备多次进入页面每次注册新回调旧callback未被取消新旧callback同时响应设备列表在退出页面并重新进入后非但没有清空反而叠加页面生命周期内反复执行扫描全局状态未重置上一轮的设备数据残留第一种是去重逻辑缺失的问题第二种是回调重复注册的问题第三种是全局状态污染的问题。很多时候是三种叠加才让你觉得“这个bug邪了门”。2.2 在代码里快速定位问题属于哪一类我给你一个特别好用的定位方法在onBluetoothDeviceFound的回调函数里打印一行日志专门输出deviceId和一个随机标识。// 定位用代码临时加在回调函数中 const tag Math.random().toString(36).substr(2, 5); uni.onBluetoothDeviceFound((res) { const devices res.devices || []; devices.forEach(device { console.log([${tag}] deviceId , device.deviceId); }); });如果你在日志里看到同一个deviceId在同一秒内出现了两三次而且每次的随机标识tag相同说明是同一次回调的重复上报问题出在去重。如果你看到不同tag在处理同一个deviceId说明是历史回调残留问题出在重复注册。这个区分非常重要因为它直接决定了你接下来要采用的方案。3. 绕开平台限制一套可直接落地的“监听去重”方案3.1 核心思路全局唯一回调 全局设备缓存既然没有offBluetoothDeviceFound就别指望“取消监听”这条路了。我们反过来把“监听”这件事做成全局限一的不管页面上调用多少次uni.onBluetoothDeviceFound最终执行的回调永远只有同一个函数。怎么做用一个模块级的变量把这个回调保存起来// utils/bluetoothManager.js let foundHandler null; // 全局唯一回调 function registerFoundHandler(handler) { foundHandler handler; uni.onBluetoothDeviceFound(handler); } function unregisterFoundHandler() { // 注意uniapp没有offBluetoothDeviceFound所以我们不真正取消 // 而是把内部引用置空保证后续事件被“丢弃” foundHandler null; }这是最核心的心法你要管的不是uniapp的回调队列而是你自己的业务逻辑回调。uniapp系统里挂着的callback可以有一百个但真正“干活”的只有一个其余的都是空转。3.2 设备列表去重用deviceId做唯一标识onBluetoothDeviceFound每次返回的res.devices可能包含一个或多个设备。这一步必须做去重否则列表必乱。我建议用deviceId或者deviceIdname的组合作为唯一键function mergeDevices(deviceList, newDevices) { const map {}; // 先把旧列表转成map deviceList.forEach(d { if (d.deviceId) map[d.deviceId] d; }); // 用新设备覆盖/更新 newDevices.forEach(nd { if (!nd.deviceId) return; map[nd.deviceId] nd; }); return Object.values(map); }每次收到设备事件都走一遍mergeDevices保证同一个deviceId只存在于列表的一份位置上。有的读者可能会问“我需要保留重复设备的出现次数吗”我的建议是绝大多数场景不需要。你关心的是“附近有哪些设备、每个设备的信号强度”而不是“这个设备出现几次”。3.3 生命周期兜底离开页面时停止扫描清理业务回调虽然我们没法从uniapp层面移除系统回调但我们可以保证离开页面时业务逻辑不再响应后续事件。onUnload() { // 第一步停止扫描 uni.stopBluetoothDevicesDiscovery({ success: () console.log(扫描已停止), fail: (err) console.error(停止扫描失败, err) }); // 第二步清空业务回调让系统回调空转 bluetoothManager.unregisterFoundHandler(); // 第三步清空当前页面的设备列表 this.deviceList []; }这里有一个非常重要的细节unregisterFoundHandler之后系统里其实还挂着那个callback它依然会被调用但因为我们的foundHandler引用已经被置空了所以回调内部什么都不会执行。这就等于“软取消”效果和offBluetoothDeviceFound几乎一样。3.4 一个封装好的实例ScanManager全局单例理论讲了半天我们来看一个能直接用的完整模块。我习惯把蓝牙扫描逻辑抽成一个全局单例所有页面共用同一套逻辑避免“你扫描你的我监听我的”这种状态分裂。// utils/bluetoothManager.js class BluetoothManager { constructor() { this.adaptersOpened false; this.deviceMap new Map(); // deviceId - device this.deviceListeners []; // 业务回调列表 this.scanning false; this._boundFoundHandler this._onDeviceFound.bind(this); } init() { return new Promise((resolve, reject) { uni.openBluetoothAdapter({ success: (res) { this.adaptersOpened true; // 核心整个项目只注册一次监听 uni.onBluetoothDeviceFound(this._boundFoundHandler); resolve(res); }, fail: (err) { console.error(初始化蓝牙适配器失败, err); reject(err); } }); }); } // 内部处理设备事件 _onDeviceFound(res) { if (!this.scanning) return; // 没在扫描中直接忽略 const devices res.devices || []; devices.forEach(device { if (!device.deviceId) return; // 去重合并 const existed this.deviceMap.get(device.deviceId); if (existed) { this.deviceMap.set(device.deviceId, Object.assign(existed, device)); } else { this.deviceMap.set(device.deviceId, device); } }); // 通知所有业务监听者 const list Array.from(this.deviceMap.values()); this.deviceListeners.forEach(listener listener(list)); } // 业务方可注册自己的回调 addDeviceListener(listener) { if (typeof listener function) { this.deviceListeners.push(listener); } } // 页面卸载时移除业务回调 removeDeviceListener(listener) { const idx this.deviceListeners.indexOf(listener); if (idx -1) this.deviceListeners.splice(idx, 1); } // 开始扫描 startScan() { this.deviceMap.clear(); // 可选清空旧数据 this.scanning true; uni.startBluetoothDevicesDiscovery({ allowDuplicatesKey: false, success: (res) console.log(开始扫描, res), fail: (err) { console.error(启动扫描失败, err); this.scanning false; } }); } // 停止扫描 stopScan() { this.scanning false; uni.stopBluetoothDevicesDiscovery({ complete: () console.log(停止扫描流程结束) }); } // 重置所有状态慎用通常在退出蓝牙模块时调用 reset() { this.stopScan(); this.deviceListeners []; this.deviceMap.clear(); this.scanning false; } } export default new BluetoothManager();这个单例有几个关键设计我在实际项目中验证过非常有用只注册一次系统监听init()方法是幂等的不管哪个页面调用系统级uni.onBluetoothDeviceFound只会注册一次。即使你在多个页面反复执行init()也只需要增加一个防重入的判断即可。业务回调与系统回调解耦页面通过addDeviceListener接收设备列表卸载时通过removeDeviceListener移除。系统回调即使继续触发没有业务监听者接收也不会产生任何UI副作用。去重集中在数据层Map天然按deviceId去重并用新值覆盖旧值信息只增不重。3.5allowDuplicatesKey这个参数也值得留意startBluetoothDevicesDiscovery有一个allowDuplicatesKey参数iOS上比较敏感。它表示是否允许系统重复上报同一设备。默认是false也就是说系统层面已经帮你做了一个去重——但这是“系统认为的去重”不代表不会出现同一个设备在多个回调里上报的情况。我见过有人把allowDuplicatesKey设为true用来做信号强度RSSI快速刷新结果忘了自己还要维护列表去重导致设备列表疯狂重复。如果你确实需要高频RSSI更新建议设备列表的更新频率限制在每秒1次以内否则列表会闪得没法看。4. 实战如何在Vue页面里使用这套方案4.1 页面加载时初始化并开启扫描下面这段代码是一个标准蓝牙扫描页面的核心流程我用Vue3的写法展示Vue2也类似只是选项式API换成对应的生命周期template view classcontainer view v-foritem in deviceList :keyitem.deviceId classdevice-item text{{ item.name || 未知设备 }}/text text{{ item.deviceId }}/text text{{ item.RSSI }} dBm/text /view /view /template script setup import { ref, onMounted, onUnmounted } from vue; import BluetoothManager from /utils/bluetoothManager; const deviceList ref([]); // 设备列表更新回调 function handleDeviceUpdate(devices) { deviceList.value devices; } onMounted(async () { try { await BluetoothManager.init(); // 注册业务回调 BluetoothManager.addDeviceListener(handleDeviceUpdate); // 开始扫描 BluetoothManager.startScan(); // 可选定时检查蓝牙适配器状态 checkAdapterState(); } catch (err) { console.error(蓝牙启动失败, err); } }); onUnmounted(() { // 关键移除业务回调 BluetoothManager.removeDeviceListener(handleDeviceUpdate); // 关键停止扫描 BluetoothManager.stopScan(); }); function checkAdapterState() { uni.getBluetoothAdapterState({ success: (res) { console.log(适配器状态, res); if (!res.available) { // 提示用户开启蓝牙 } } }); } /script这里面的生命周期顺序很重要onUnmounted里先移除业务回调再停止扫描。因为业务回调一旦移除即使系统回调还在触发也不会更新到当前页面的deviceList上。然后再停扫描从源头减少触发频率。两个动作的组合能最大程度降低“退出页面后列表还跳一下”的诡异现象。4.2 在多个页面间共享设备状态这个场景我踩过坑A页面在扫描用户点进B页面B页面也要显示同一份设备列表。如果你在B页面重新调一次startBluetoothDevicesDiscovery就会和A页面产生两个扫描进程系统会报错或覆盖设备回调也会叠加。正确做法是A页面已经扫描了B页面只读取BluetoothManager.deviceMap并通过addDeviceListener订阅更新。这样两个页面看到的是同一份实时数据而且不会重复调用底层扫描。// B页面只订阅不扫描 onMounted(() { BluetoothManager.addDeviceListener(handleDeviceUpdate); // 立即拉取一次当前快照 handleDeviceUpdate(BluetoothManager.getDeviceList()); // 你需要给类加一个 getter 方法 });我再补充一点BluetoothManager里最好加一个getDeviceList()方法返回Array.from(this.deviceMap.values())这样新页面打开时能立刻拿到已有的设备列表不用等下一个设备事件触发用户体验会好很多。5. 避坑全记录真正跑项目时才发现的那些细节5.1 不要依赖uni.closeBluetoothAdapter来清理监听如果你在网上搜索历史方案会发现有说法是“调用uni.closeBluetoothAdapter就能重置一切”。实测下来这个方法虽然能关闭蓝牙适配器但它同时会断开已有的BLE连接并且它的回调是异步的也不能保证在所有平台上同步清除系统监听。更麻烦的是重新openBluetoothAdapter后如果你之前注册的callback还在它通常还在所有问题会原封不动地回来。我的建议是closeBluetoothAdapter只在真正退出整个蓝牙模块、不再使用时才调用千万不要用来做“重置监听”这样的轻量操作。5.2 小程序平台上的一个特殊坑如果你在微信小程序里跑同一套代码要注意微信小程序的wx.offBluetoothDeviceFound是可以用的但uniapp没有把这个接口透传出来。所以即使是小程序端编译产物你也只能调用uni.onBluetoothDeviceFound没法通过uni.offBluetoothDeviceFound来取消。这种情况下的最优解依然是我前面说的“单例业务回调解耦”方案。只要你用的全局单例底层就算注册了多个系统回调业务层也不会重复。5.3 设备列表还是偶尔重复检查一下deviceId是不是真的唯一别笑真有人在这上面栽过。部分低功耗蓝牙设备尤其是那些山寨模块广播里的MAC地址是会随机变化的也就是所谓的“随机地址”。这种情况下同一个物理设备每次广播可能带有不同的deviceId你的去重逻辑对它是无效的。如果项目对这类设备有硬性需求可以考虑用device.name外加RSSI范围做二次合并但这属于特殊策略通用性不强。绝大多数场景deviceId去重已经够用。5.4 内存泄漏不要以为uniapp会帮你回收javascript是垃圾回收的但uniapp底层桥接层没有。如果页面卸载时没有removeDeviceListener那个页面组件的回调函数会被BluetoothManager.deviceListeners数组一直引用着导致Vue组件实例无法被回收。页面跳转多了内存就上去了。我建议在开发阶段专门写一个测试页面反复进入退出扫描页50次然后观察内存变化小程序开发者工具的性能面板或App端的DevTools。如果你的deviceListeners数组长度持续增长那一定是有页面没有正确调用removeDeviceListener。6. 顺带解决一个隐藏问题设备信息重复渲染导致的性能下降6.1 频繁setData/更新响应的代价当你解决了监听重复问题后会发现自己面临一个新问题即使每个设备只出现一次onBluetoothDeviceFound回调的频率依然高得吓人。如果A设备-RSSI更新、B设备-RSSI更新、A设备再更新……每次都触发一次设备列表的更新页面就被频繁重渲染扫描页会明显掉帧。解决方案是做节流throttle处理。比如无论回调触发多少次只在每500毫秒内统一更新一次UI。// 在 BluetoothManager 中加入节流逻辑 class BluetoothManager { constructor() { // ... existing this._throttleTimer null; this._pendingDevices []; } _onDeviceFound(res) { if (!this.scanning) return; const devices res.devices || []; devices.forEach(device { if (device.deviceId) this.deviceMap.set(device.deviceId, device); }); // 节流合并短时间内的多次事件 if (!this._throttleTimer) { this._throttleTimer setTimeout(() { this._throttleTimer null; this._emit(); }, 500); } } _emit() { const list Array.from(this.deviceMap.values()); this.deviceListeners.forEach(listener listener(list)); } }实测下来500毫秒的节流对扫描体验几乎没有影响人眼的感知速度根本分辨不出500毫秒的延迟反而列表更新更平滑了。6.2 小程序端使用v-for时别忘绑定:key在uniapp小程序平台v-for如果不绑定:key框架会使用index作为默认key这会导致设备列表项位移时组件状态错乱。你要用deviceId作为keyview v-foritem in deviceList :keyitem.deviceId !-- 设备信息 -- /view7. 一点个人总结unniapp蓝牙开发的底层思维做了这么多uniapp蓝牙项目我最大的感受是在这个框架里做蓝牙开发你不能像写原生代码一样想当然地认为“有开就有关有监听就能取消”。uniapp对蓝牙能力的封装还停留在“能用”阶段离“好用”还差一大截。所以与其反复纠结没有offBluetoothDeviceFound不如从一开始就建立一套“单入口、单出口、数据驱动“的架构。单入口是指全局只注册一次系统回调单出口是指页面销毁时统一走removeDeviceListener stopScan数据驱动是指所有页面只与设备列表数据交互不直接跟系统API对接。按这个思路写完这套方案后我再也没有遇到设备重复扫描的问题。如果你的项目还在被这个bug折磨希望这篇记录能帮你节省一两天排查时间。最后再啰嗦一句动手改代码前先把”那是重复注册“还是”那是重复上报“分清楚方向错了改再多代码也是白搭。
返回列表