简介:这是一份面向微信小程序开发者的学习型蓝牙通信实战Demo,聚焦BLE设备交互核心流程,解决初学者在小程序蓝牙API调用中常见的搜索失败、连接不稳定、特征值读写异常等典型问题。资源共17个文件,包含4个JS逻辑文件(实现蓝牙状态管理、设备发现与数据收发)、3个WXML/WXSS页面组件(搜索页与设备页UI)、3个JSON配置文件(页面路由与窗口设置)、2个文本说明文件(资源内容与标签说明)、1个README.md项目文档、1个.gitignore版本控制配置及1张蓝牙图标PNG,整体仅16KB,轻量易导入。已有100人学习下载。读者可直接运行并快速掌握小程序蓝牙模块的完整链路:从初始化、扫描、选择设备、连接服务、启用通知,到字符串级数据写入与实时接收;代码结构清晰,search页面开箱即用,device页面服务/特征已固化便于调试,utils中封装了通用蓝牙工具方法,是理解小程序硬件通信机制与工程化组织方式的优质入门参考。
1. 项目概述
提起微信小程序里的蓝牙功能,很多人的第一反应是:不就调一下蓝牙接口嘛,能有多难?结果一动手就发现,事情完全不是这么回事。设备搜索不到、配对失败、数据发出去没反应、iOS 和安卓表现还不一样……桩桩件件都能把人折磨到怀疑人生。我手头正好有一套整理完的“微信小程序 蓝牙Demo”,把低功耗蓝牙(BLE)从扫描到通信的完整流程串了起来,分享给正准备入坑或者已经在坑里挣扎的朋友。
这个Demo解决的核心问题很明确:小程序端如何稳定地完成蓝牙设备的发现、连接、服务发现、特征值读写和通知接收。它适合三类人看:一是刚接触小程序蓝牙开发、想快速跑通一个完整流程的初学者;二是已经在写蓝牙功能但经常被各种兼容性问题卡住的前端工程师;三是做硬件配套App、需要一份可参考的小程序端实现方案的开发者。Demo本身不大,但流程是完整的,能直接改改UUID和业务逻辑就用在真实项目里。
简单说结论:这套Demo走的是微信官方wx.openBluetoothAdapter到wx.writeBLECharacteristicValue的完整链路,核心逻辑集中在连接管理和数据收发两个模块,真机调试表现稳定,在 iOS 和大部分安卓机型上都能正常跑通。下面我把整个实现思路、关键代码和踩过的坑一条条拆开讲。
2. 整体设计与实现思路
2.1 为什么选择小程序原生蓝牙 API
在动手写代码之前,先回答一个很多人会问的问题:蓝牙功能是用小程序原生 API 做,还是用 uni-app、Taro 这类跨端框架封装好的蓝牙插件?
我的建议是:如果目标平台只有微信小程序,直接上原生 API,别绕弯子。原因有三个。
第一,原生 API 的调用链和微信官方文档完全对应。也就是说,你打开文档搜wx.openBluetoothAdapter,看到的说明和实际代码里的调用方式是一一对应的,排错的时候查资料最省心。跨端框架虽然封装了统一接口,但封装层一多,遇到问题反而要先去翻框架源码或者提 issue,定位问题成本高不少。
第二,蓝牙 API 的很多坑都跟平台底层实现有关,原生 API 能让你直接感知到这些差异。比如 iOS 上系统弹窗的时机、安卓上定位权限和蓝牙权限的联动关系,这些用原生 API 时更容易定位到底是小程序层的问题还是系统层的问题。
第三,原生 API 本身已经够简单了,跨端框架带来的“统一性”收益在蓝牙这个场景下并不明显。我在实际项目里试过用 uni-app 封装蓝牙,结果遇到一个诡异的问题:安卓上onBLECharacteristicValueChange回调不稳定,后来排查发现是框架层对原生事件的转发有 bug。这种坑在原生 API 下根本不存在。
2.2 蓝牙通信的基本链路与核心概念
要理解这个 Demo 的代码结构,得先清楚 BLE 通信的基本逻辑。BLE 设备不像经典蓝牙那样需要配对后建立串口连接,它的核心是GATT(通用属性协议),一句话概括就是:设备暴露出一组服务(Service),每个服务下面有几个特征值(Characteristic),这些特征值就是数据的出入口。
拿常见的蓝牙透传模块 HC-05/HC-06 来类比(虽然它们更偏经典蓝牙,但概念类似):模块上电后广播自己,手机扫描到之后发起连接,连接成功后找到服务的 UUID,然后往特征值里写数据,模块就能把数据转发到串口。反过来,模块从串口收到的数据会通过特征值的通知(Notify)机制传给手机。
整个流程对应到小程序的 API 调用链是:
wx.openBluetoothAdapter():初始化蓝牙适配器。这是第一步,适配器没打开,后面所有操作都没法做。wx.startBluetoothDevicesDiscovery():开始扫描附近的蓝牙设备。wx.onBluetoothDeviceFound():监听扫描结果,回调里能拿到设备列表。wx.createBLEConnection():连接目标设备。wx.getBLEDeviceServices():获取设备上所有的服务列表。wx.getBLEDeviceCharacteristics():获取指定服务下的特征值列表。wx.notifyBLECharacteristicValueChange():开启指定特征值的通知,这样才能收到设备主动发来的数据。wx.writeBLECharacteristicValue():向特征值写入数据。wx.onBLECharacteristicValueChange():监听特征值变化,也就是接收设备发来的数据。
这套链路就是整个 Demo 的骨架。后面所有代码都是围绕这九个步骤展开的。
2.3 Demo 的目录结构与模块划分
我把 Demo 的代码按功能拆成了独立模块,避免把所有逻辑堆在一个页面里。实际工程里的目录结构大致是:
├── pages │ └── index │ ├── index.js # 页面逻辑,负责 UI 交互和蓝牙状态管理 │ ├── index.wxml # 页面结构 │ └── index.wxss # 样式 ├── utils │ ├── bluetooth.js # 蓝牙核心逻辑封装(扫描、连接、收发) │ ├── buffer.js # 数据转换工具(ArrayBuffer 转字符串等) │ └── constant.js # 全局常量(UUID、指令定义等) └── app.js # 小程序入口其中bluetooth.js是核心,所有蓝牙操作都封装成了 Promise 风格的方法,页面层只需要关心调用和结果回调,不需要关心实现细节。为什么要用 Promise 而不是直接回调?因为蓝牙操作本身就是一连串有先后顺序的异步步骤,Promise 的链式调用能让代码从上到下读起来像同步逻辑,逻辑清晰且好维护。如果用回调嵌套,扫描、连接、发现服务、发现特征值这四层嵌套能把人看吐。
constant.js里放的是设备相关的关键参数。如果你的设备是 HC-05/HC-06 这类常见的蓝牙透传模块,默认的服务 UUID 通常是0000FFE0-0000-1000-8000-00805F9B34FB,特征值 UUID 是0000FFE1-0000-1000-8000-00805F9B34FB。但要注意,不同厂家的模块 UUID 可能不一样,有的用FFE0/FFE1,有的用FFF0/FFF1,甚至一些定制模块会直接用自定义的 UUID。拿到模块的第一件事就是查它的数据手册,确认服务和特征值 UUID,这个步骤错了,后面连上了也收发不了数据。
3. 核心代码实现与关键步骤解析
3.1 初始化蓝牙适配器:一切的前提
openBluetoothAdapter是整个蓝牙操作的第一道门槛,但我见过太多人在这上面翻车。最常见的错误是:用户没打开手机蓝牙就直接调这个 API,结果返回错误码10001(蓝牙未打开)。正确的做法是先检查系统蓝牙状态,再决定要不要打开。
这个 Demo 里,我封装了一个initBluetooth方法,逻辑如下:
// utils/bluetooth.js function initBluetooth() { return new Promise((resolve, reject) => { wx.openBluetoothAdapter({ success: (res) => { console.log('蓝牙适配器初始化成功', res); resolve(res); }, fail: (err) => { console.error('蓝牙适配器初始化失败', err); // 错误码 10001 表示蓝牙未打开,10002 表示不支持蓝牙 if (err.errCode === 10001) { wx.showModal({ title: '提示', content: '请先打开手机蓝牙', showCancel: false, }); } else { wx.showModal({ title: '提示', content: '当前设备不支持蓝牙或蓝牙初始化失败', showCancel: false, }); } reject(err); }, }); }); }这里有个细节:微信的蓝牙适配器在某些机型上需要用户授权定位权限才能扫描到设备。尤其是安卓 6.0 以上的系统,蓝牙扫描往往会关联定位权限,如果用户拒绝了定位授权,扫描会一直返回空列表。所以我在初始化之后,通常还会加一个校验逻辑,在小程序启动时尝试调用wx.getSetting检查权限状态,必要时引导用户去设置页打开授权。
注意:iOS 13 及以后版本对蓝牙权限管理更严格,首次调用蓝牙相关 API 时系统会弹出蓝牙权限询问框,用户必须选择“允许”,否则后续操作全部失败。这个弹窗在开发者工具里看不到,真机上才会出现,调试时要留意。
3.2 扫描设备:如何精准过滤目标设备
初始化完成之后就是扫描。扫描本身不复杂,但问题是:如果不加过滤,扫描结果会非常杂乱,周边所有在广播的 BLE 设备都会出现在列表里。这个 Demo 里我保留了两个过滤维度:设备名称(name)和信号强度(RSSI)。
具体代码:
// utils/bluetooth.js function startScan(filterName) { return new Promise((resolve, reject) => { wx.startBluetoothDevicesDiscovery({ allowDuplicatesKey: false, // 默认 false,不重复上报同一设备 success: (res) => { console.log('开始扫描成功', res); resolve(res); }, fail: (err) => { console.error('开始扫描失败', err); reject(err); }, }); // 监听新设备发现 wx.onBluetoothDeviceFound((res) => { const devices = res.devices; devices.forEach((device) => { const deviceName = device.name || device.localName || ''; // 如果设置了过滤名称,则只保留匹配的设备 if (filterName && deviceName.indexOf(filterName) === -1) { return; } // 用 deviceId 去重,避免重复添加 if (!this.deviceList.find((item) => item.deviceId === device.deviceId)) { this.deviceList.push({ deviceId: device.deviceId, name: deviceName, RSSI: device.RSSI, }); } }); }); }); }几个关键点:
allowDuplicatesKey这个参数很多人会忽略。如果设为true,同一个设备在广播期间会被重复上报,你需要自己在回调里做去重;设为false则只在新设备出现时上报一次。实测下来,在设备密集的场景下,设为false更省心,但代价是回调里拿不到设备的实时 RSSI 变化。- 有些设备广播时不会上报
name,只有空字符串。遇到这种情况,如果只按名称过滤,设备会直接被漏掉。所以我加了device.localName作为备选,再不行就干脆不过滤name,而是让用户从列表里手动选择。 - 扫描到设备之后,
deviceId是连接时唯一需要的标识,一定不能丢。它通常是一串类似XX:XX:XX:XX:XX:XX的 MAC 地址格式,在 iOS 上可能是 UUID 格式,但不要关心格式,直接用就行。
扫描过程需要注意一个体验问题:扫描不能一直开着。蓝牙扫描比较耗电,而且会持续占用系统资源。正确做法是:用户选了设备或者扫码成功后,立即调用wx.stopBluetoothDevicesDiscovery()停止扫描。同时建议在页面onUnload或onHide时也停掉扫描,避免切后台之后还在扫描。
3.3 连接设备:成功与失败的分水岭
扫描到设备之后,点击列表项就进入连接环节。连接的代码相对简单:
// utils/bluetooth.js function connectDevice(deviceId) { return new Promise((resolve, reject) => { // 先停止扫描,避免干扰连接 wx.stopBluetoothDevicesDiscovery({ complete: () => { wx.createBLEConnection({ deviceId: deviceId, timeout: 10000, // 连接超时时间,默认是 0,表示不超时 success: (res) => { console.log('连接成功', res); resolve(res); }, fail: (err) => { console.error('连接失败', err); reject(err); }, }); }, }); }); }这里必须强调timeout参数。微信文档里写的是“超时时间,单位为 ms,默认不超时”,但我在安卓上实测发现,如果不设置超时,某些异常设备会导致连接请求挂起很久都不返回,用户只能干等。所以我一般会设为10000,10 秒内连不上就提示失败。
连接成功后,并不意味着可以立即读写数据。接下来需要做两件事:获取服务列表和获取特征值。这两步是连在一起的,缺一不可。
async function getServiceAndCharacteristic(deviceId) { try { // 1. 获取所有服务 const servicesRes = await getBLEDeviceServices(deviceId); const services = servicesRes.services; console.log('服务列表', services); // 2. 遍历服务,找到我们需要的服务 UUID let targetService = null; let targetCharacteristic = null; for (const service of services) { if (service.uuid.toUpperCase() === TARGET_SERVICE_UUID.toUpperCase()) { targetService = service; break; } } if (!targetService) { throw new Error('未找到目标服务 UUID'); } // 3. 从目标服务下获取特征值 const charRes = await getBLEDeviceCharacteristics(deviceId, targetService.uuid); const characteristics = charRes.characteristics; console.log('特征值列表', characteristics); for (const char of characteristics) { if (char.uuid.toUpperCase() === TARGET_CHARACTERISTIC_UUID.toUpperCase()) { targetCharacteristic = char; break; } } if (!targetCharacteristic) { throw new Error('未找到目标特征值 UUID'); } // 4. 保存服务和特征值,供后续读写使用 this.serviceId = targetService.uuid; this.characteristicId = targetCharacteristic.uuid; this.deviceId = deviceId; this.isConnected = true; // 5. 开启通知 await this.enableNotify(targetCharacteristic.uuid); return { serviceId: this.serviceId, characteristicId: this.characteristicId }; } catch (err) { console.error('获取服务或特征值失败', err); throw err; } }我习惯把服务发现和特征值发现合成一个步骤,因为实际开发中这两步必然连续发生。而且这里有个容易被忽视的点:服务列表里可能有好几个服务,特征值列表里也可能有好几个特征值,不能想当然地认为第一个就是你要的。一定要用 UUID 精确匹配,宁可多写几行判断,也不要赌顺序。我之前接过一个厂家定制的蓝牙模块,服务列表里有三个服务,特征值全在一个不起眼的服务下面,如果只看第一个服务,数据根本收不到。
3.4 开启通知:设备主动上报数据的开关
连接成功后,如果要接收设备主动发来的数据(比如传感器读数、串口透传数据),必须调用wx.notifyBLECharacteristicValueChange开启通知。这一步是新手最容易漏掉的,漏掉之后的表现是:手机给设备发数据没问题,但设备发给手机的数据怎么也收不到。
// utils/bluetooth.js function enableNotify(characteristicId) { return new Promise((resolve, reject) => { wx.notifyBLECharacteristicValueChange({ deviceId: this.deviceId, serviceId: this.serviceId, characteristicId: characteristicId, state: true, // 开启通知 success: (res) => { console.log('开启通知成功', res); // 监听特征值变化,设备上报数据会触发这个回调 wx.onBLECharacteristicValueChange((changeRes) => { const { value } = changeRes; const data = ab2hex(value); // ArrayBuffer 转十六进制字符串 this.onDataReceived && this.onDataReceived(data); }); resolve(res); }, fail: (err) => { console.error('开启通知失败', err); reject(err); }, }); }); }这里注册的wx.onBLECharacteristicValueChange回调是整个数据接收链路的核心。注意它的位置:一定要在开启通知成功之后注册,不能在页面初始化时就注册。因为回调注册是一种全局监听,如果在连接前就注册,一旦收到其他设备的数据,逻辑会混乱。
还有一点要注意:wx.onBLECharacteristicValueChange注册的是全局回调,同一个页面多次调用不会覆盖,而是叠加。如果页面逻辑里开了多个连接,回调会被触发多次。这个 Demo 简化了场景,只维护单连接,如果你要做多设备连接,需要自己维护回调队列,不然数据会串。
3.5 数据发送与接收:核心的字节处理
BLE 的数据收发全部基于ArrayBuffer,这对很多只写过 JavaScript 字符串操作的前端开发者来说是个坎。简单说,ArrayBuffer是一块固定长度的二进制内存区域,不能直接读写,需要通过DataView或Uint8Array这类视图来操作。
发送数据时,我需要把字符串转成ArrayBuffer:
// utils/buffer.js function string2ArrayBuffer(str) { const bytes = []; for (let i = 0; i < str.length; i++) { bytes.push(str.charCodeAt(i) & 0xff); } const buffer = new ArrayBuffer(bytes.length); const dataView = new DataView(buffer); for (let i = 0; i < bytes.length; i++) { dataView.setUint8(i, bytes[i]); } return buffer; }接收数据时,把ArrayBuffer转回字符串:
function arrayBuffer2String(buffer) { const uint8Array = new Uint8Array(buffer); let str = ''; for (let i = 0; i < uint8Array.length; i++) { str += String.fromCharCode(uint8Array[i]); } return str; }实际使用中,string2ArrayBuffer存在一个隐患:当字符编码大于0xff时(比如中文、Emoji),按位与操作会丢失高位,导致数据错误。如果你要传输的数据涉及中文,不能简单用charCodeAt(0) & 0xff,得用TextEncoder先把字符串编码成 UTF-8 字节流。不过标准小程序基础库可能不支持TextEncoder,这时可以引入一份 UTF-8 编码转换工具,或者约定传输内容只用 ASCII 字符集。
写入数据时还有一个非常关键的限制:单次写入的字节数不能超过 20 字节。这是 BLE 协议层的限制,不同设备可能略有差异,但大多数标准 BLE 模块一次只能接收 20 字节的写请求。如果你的数据超过 20 字节,需要自己拆包,分多次写入,每次写入之间加适当的延时(一般 20-50ms),否则数据包会丢失。
Demo 里我写了一个拆包发送工具:
// utils/bluetooth.js function sendData(str) { const buffer = string2ArrayBuffer(str); const chunkSize = 20; // 每次最多 20 字节 const totalChunks = Math.ceil(buffer.byteLength / chunkSize); for (let i = 0; i < totalChunks; i++) { const start = i * chunkSize; const end = Math.min(start + chunkSize, buffer.byteLength); const chunk = buffer.slice(start, end); // 每个分包延迟 30ms 发送,避免粘包 setTimeout(() => { wx.writeBLECharacteristicValue({ deviceId: this.deviceId, serviceId: this.serviceId, characteristicId: this.characteristicId, value: chunk, success: (res) => { console.log(`第 ${i + 1}/${totalChunks} 包发送成功`, res); }, fail: (err) => { console.error(`第 ${i + 1}/${totalChunks} 包发送失败`, err); }, }); }, i * 30); } }注意:用setTimeout做分包延时只是最基础的做法。真实项目中,更稳妥的方案是等上一包写入成功后再发下一包,即在success回调里递归发送下一包,而不是盲目地定时发送。因为某些蓝牙模块对接收缓冲区的处理能力有限,如果写入速度太快,即使单包不超过 20 字节,也会出现丢包。
3.6 设备断开与资源清理
蓝牙连接不是一次性的,用户可能随时切换设备、退出页面、或者微信切后台。这些场景下如果不做清理,会出现连接泄漏,之后重新连接会失败。
清理的要点有三个:
// utils/bluetooth.js function closeConnection() { // 1. 关闭通知 if (this.deviceId && this.characteristicId) { wx.notifyBLECharacteristicValueChange({ deviceId: this.deviceId, serviceId: this.serviceId, characteristicId: this.characteristicId, state: false, complete: () => {}, }); } // 2. 断开蓝牙连接 if (this.deviceId) { wx.closeBLEConnection({ deviceId: this.deviceId, complete: () => { this.deviceId = null; this.serviceId = null; this.characteristicId = null; this.isConnected = false; }, }); } // 3. 关闭蓝牙适配器(可选) wx.closeBluetoothAdapter({ complete: () => {}, }); }这里有个细节:断开连接时,我把this.deviceId等状态置空,是为了防止下次连接时残留旧状态。有些同学不清理状态,结果连接新设备时,代码里还拿着旧设备的deviceId去读数据,白白踩坑。
另外,wx.onBLEConnectionStateChange这个监听方法要注意注册时机和注销时机。当连接意外断开时(比如设备超出范围、设备关电),微信会回调给这个监听器。Demo 里我在初始化蓝牙时注册了一个全局的onBLEConnectionStateChange,回调里判断connected字段是否为false,如果是,就更新 UI 并清理状态。
4. 常见问题与排查技巧实录
4.1 搜索不到设备
搜索不到设备的排查链路,我总结成一张表:
| 排查点 | 检查方法 | 解决方案 |
|---|---|---|
| 手机蓝牙是否打开 | 系统设置查看蓝牙状态 | 打开蓝牙后重试 |
| 定位权限是否授权 | 小程序设置页查看权限 | 引导用户授权定位权限 |
| 设备是否在广播 | 用第三方蓝牙调试工具(如 nRF Connect)扫描确认 | 设备端重新上电,确认广播开启 |
| 设备是否被过滤 | 看代码里是否设置了filterName | 临时去掉过滤条件,查看所有设备 |
| iOS 蓝牙权限是否开启 | 系统设置-隐私-蓝牙 | 授权后重启小程序 |
其中最常见的就是定位权限问题。安卓上第一次调用startBluetoothDevicesDiscovery时,系统可能会弹出定位权限请求,如果用户点了拒绝,扫描会静默失败,界面没有任何报错,设备也扫不到。这种问题特别坑,因为错误提示非常不明显。我后来在代码里加了一个权限预检查,在进入蓝牙页面时先用wx.getSetting查一下scope.userLocation的授权状态,如果被拒绝就弹窗引导用户去设置页打开。
4.2 扫描到设备但连不上
这个问题分两种场景:
第一种是扫描到了设备,但createBLEConnection直接报错。常见错误码有-1(未知错误)和10004(连接失败)。-1在安卓上出现概率较高,通常是因为设备协议栈有问题或者系统蓝牙服务异常,最简单的处理方式是提示用户关闭并重新打开蓝牙,再重新扫描连接。
第二种是连接回调显示成功,但之后getBLEDeviceServices拿到的服务列表是空的。这种情况多见于一些低成本的 BLE 模块,它们在连接后需要几十到几百毫秒准备 GATT 服务表。解决方法是:连接成功后加一个短暂延时(await sleep(300)),再获取服务列表。不要觉得延时是土办法,在蓝牙开发里,适当的延时是规避竞态条件的常见手法。
4.3 数据发送成功但设备没反应
这个问题的本质是:你往一个“可写”的特征值写数据,但那个特征值并不是设备真正监听的那个。排查步骤:
- 确认你写入的特征值 UUID 是正确的。用 nRF Connect 这类工具连接设备,查看服务和特征值,确认数据手册里写的 UUID 和实际设备一致。
- 确认特征值具备“Write”属性。每个特征值有
properties字段,里面有read、write、notify等属性。如果特征值不具备 write 属性,写入会失败或无效。在小程序里可以通过getBLEDeviceCharacteristics返回的properties字段判断。 - 确认数据格式符合设备要求。有些设备要求特定帧头帧尾,比如
AA 55开头、校验和结尾。直接发纯文本可能被设备视为无效数据丢弃。
4.4 设备数据收不到
“收不到数据”的分支比较多,我挨个说明:
没有开启通知:刚说了,notifyBLECharacteristicValueChange必须调用,state 必须为true,且必须在连接成功后、读数据之前完成。
注册监听时机不对:wx.onBLECharacteristicValueChange要在通知开启成功后再调用wx.onBLECharacteristicValueChange去监听。有个坑是:先调用notify再注册监听,中间设备恰好发来一条数据,这条数据就会因为监听未就绪而丢失。稳妥的做法是把监听注册放在notifyBLECharacteristicValueChange的success回调里,保证时序。
特征值属性不对:接收数据依赖的是 Notify 属性,不是 Read 属性。如果设备端的特征值不具备 Notify 属性,小程序的notifyBLECharacteristicValueChange调用会失败,或者成功了也收不到数据。用工具确认特征值的 properties 里是否有notify: true。
安卓 6.0 以上设备名偶发为空:这个问题比较隐蔽。某些安卓设备在扫描到 BLE 设备后,回调里的name字段会为空,但设备的localName字段可能有值。如果你在列表里显示设备名,要注意兼容device.name || device.localName。
4.5 蓝牙在 iOS 上比安卓上更难调通
这是很多开发者的共识。iOS 对 BLE 的管控比安卓严格得多,主要体现在:
- iOS 上,蓝牙的
deviceId不是 MAC 地址,而是系统生成的 UUID,这意味着你无法跨会话记住某个设备(重启小程序后 deviceId 会变化)。所以 iOS 上“记住上次连接设备并自动重连”的需求实现起来比较麻烦,需要在应用层用其他方式识别设备,比如连接后读取设备的广播数据或特定特征值来确认身份。 - iOS 上
wx.openBluetoothAdapter之后,如果系统蓝牙权限弹窗被用户拒绝,之后所有蓝牙 API 都会失败,而且失败的错误信息比较模糊。必须引导用户去系统设置里打开权限。 - iOS 上
onBLECharacteristicValueChange的回调频率比安卓低,如果设备高频上报数据,iOS 可能会有一定丢包。这个属于系统层面限制,只能通过调整设备端上报频率或增加分包重传机制来缓解。
4.6 用开发者工具调试蓝牙的正确姿势
微信开发者工具里有个“模拟蓝牙”的功能,但说实话,它只能用来验证流程,不能用来验证真实设备交互。因为模拟接口不涉及真实的 BLE 协议栈,很多错误码都不一致。我的经验是:
- 开发者工具只用来调试 UI 和逻辑,比如扫描列表显示、按钮状态切换、数据收发界面的布局。
- 真机调试务必使用
wx.getSystemInfoSync()确认基础库版本,尽量使用2.12.0以上的版本,蓝牙相关 API 在这个版本之后稳定了很多。 - 真机调试时,建议打开调试器的“网络”和“Console”,把
wx.onBluetoothDeviceFound回调里的原始数据打出来看,能发现很多 UI 层看不到的问题。
5. 工程化封装与项目实战扩展
5.1 把蓝牙逻辑抽象成单例 Store
这个 Demo 为了降低理解门槛,把蓝牙逻辑直接写在utils/bluetooth.js里。但真实项目中,如果数据收发状态要跨页面共享(比如设备列表页选完设备,跳到控制页面发指令),直接用全局变量或者getApp()挂载会变得难以维护。
我的建议是:把蓝牙模块封装成单例,或者用一个简单的全局 EventBus 来分发数据事件。Demo 里我用了最朴素的方案:
// app.js const bluetoothManager = require('./utils/bluetooth.js'); App({ onLaunch() { this.globalData.bluetoothManager = bluetoothManager; }, });这样任意页面都能通过getApp().globalData.bluetoothManager访问同一个蓝牙实例,连接状态、已选设备、收到的数据都能共享。如果你的项目用了 MobX 或 Vuex 类似的状态管理,也可以把蓝牙状态放进去,但核心思路不变:蓝牙连接实例全局唯一,页面只负责展示和触发。
5.2 自动重连机制的设计
实际项目里,用户的蓝牙设备经常因为断电、超远距离等原因断开连接。一个体验好的小程序应该有自动重连机制。但自动重连不能无脑做,不然设备不在范围内时会陷入死循环。
我采用的是“有限重试策略”:监听onBLEConnectionStateChange,当检测到断线时,先弹出提示“蓝牙已断开,正在重连...”,然后启动一个重连计数器。最多重试 3 次,每次间隔 1 秒、2 秒、4 秒(指数退避)。如果 3 次都失败,就提示用户手动重新连接。这个策略避免了设备不在附近时无限重试耗尽电量。
// 伪代码示例 wx.onBLEConnectionStateChange((res) => { if (!res.connected && this.isConnected) { this.retryCount = 0; this.reconnect(); } }); function reconnect() { if (this.retryCount >= 3) { this.showReconnectFail(); return; } this.retryCount++; const delay = Math.pow(2, this.retryCount) * 1000; // 2s, 4s setTimeout(async () => { try { await this.connectDevice(this.lastDeviceId); await this.getServiceAndCharacteristic(this.lastDeviceId); console.log('重连成功'); } catch (err) { this.reconnect(); } }, delay); }5.3 蓝牙透传之外的常见业务扩展
这个 Demo 的基础能力是 BLE 读写,实际项目中你还会遇到这些扩展需求:
蓝牙打印:小程序蓝牙打印机的原理,其实就是向打印机模块的特征值写入排版好的打印指令(通常是 ESC/POS 指令)。核心还是writeBLECharacteristicValue,区别在于数据必须按指令格式组装。最常见的坑是打印指令里包含大量二进制数据,不能用字符串拼接,必须用ArrayBuffer按字节组装。
OTA 固件升级:这个相对复杂,需要分包发送固件文件,每包都有序号和校验,设备端收到后回 ACK。小程序的writeBLECharacteristicValue单次 20 字节限制在这里非常致命,所以必须实现一个可靠的流式传输协议。好消息是,只要你能把控好分包和 ACK 机制,小程序完全可以胜任 OTA 功能。
多设备连接:微信小程序支持同时连接多个 BLE 设备(数量取决于系统限制),核心是在事件处理时区分deviceId。我处理过多设备场景,一个页面同时控制两块蓝牙板卡,用Map<deviceId, ConnectionState>管理状态,比用单个字段优雅得多。
MQTT 桥接:如果你需要把小程序的蓝牙数据转发到服务器,一般做法是小程序通过wx.request把收到的数据 POST 到后端,后端再通过 MQTT 发给设备管理平台。这里要注意频率控制,蓝牙设备高频上报时,不能每条数据都直接发 HTTP 请求,不然请求量大到后端扛不住。合理做法是在小程序端做节流、聚合,比如每 1 秒批量上传一次。
5.4 兼容性测试清单
最后整理一份我在上线前必跑的兼容性测试清单,照着测一遍能避开大部分线上问题:
| 测试项 | 测试场景 | 预期结果 |
|---|---|---|
| 基础流程 | 首次打开小程序,授权蓝牙,扫描设备,连接,收发数据 | 全流程无异常 |
| 蓝牙关闭状态 | 系统蓝牙关闭时进入蓝牙页 | 提示打开蓝牙,无白屏或死循环 |
| 定位权限拒绝 | 拒绝定位授权后扫描 | 提示授权引导,不静默失败 |
| 设备断电 | 连接过程中拔掉设备电源 | 提示连接失败或者断线重连 |
| 页面切后台 | 连接成功后切后台再切回来 | 蓝牙连接状态正常,无异常断开 |
| 页面销毁 | 连接状态下退出页面 | 连接关闭,无泄漏 |
| 多次连接 | 连续连接、断开不同设备 10 次 | 无累积错误,状态正确 |
| 大数据传输 | 发送超过 300 字节数据 | 分包发送成功,无丢包 |
| 低电量模式 | 手机开启低电量模式 | 蓝牙功能正常(部分安卓机型会限制扫描) |
这 9 项是我每次改完蓝牙代码必跑的回归项。尤其是“多次连接”这一项,最容易暴露状态清理不干净的问题。
6. 写在最后的几个实战心得
做小程序蓝牙开发这两三年,我最大的体会是:蓝牙功能本身不难,难的是把各种异常情况考虑周全。微信的蓝牙 API 表面上只有几个方法,但每个方法在不同机型、不同系统版本上的表现都有细微差别。写这套 Demo 的时候,我把常见的问题都提前处理了,但真到上线前,还是建议你拿真机多测几个品牌,尤其是华为、小米、iPhone 这三个主流阵营,覆盖低端机和旗舰机。
另外一个很实用的经验是:调试蓝牙问题,手机上的第三方工具比微信开发者工具靠谱得多。像 nRF Connect、LightBlue 这些工具可以让你绕过微信小程序,直接查看设备上的服务和特征值,快速判断是设备问题还是小程序代码问题。我遇到过几次设备连上但拿不到服务列表的诡异问题,就是用 nRF Connect 一看才发现是设备端 GATT 服务没准备好,跟小程序没关系。
如果你决定把这套 Demo 用到自己的项目里,建议先从最简单的场景跑通:手机连上一个蓝牙透传模块,用微信小程序发一串 ASCII 字符串,设备端能通过串口收到;设备端通过串口发数据,小程序能收到并显示。把这条路跑通之后,再逐步加业务逻辑,比如自定义协议、分包重传、OTA、多设备管理。不要一上来就想着做大而全,蓝牙的调试链路长,问题定位成本高,小步快跑才是正道。
这套 Demo 的代码结构、逻辑设计和踩坑记录基本都在上面了。如果你正在做类似的功能,希望这份内容能帮你少走点弯路。有具体问题也欢迎留言交流,我在不忙的时候会尽量回复。
本文还有配套的精品资源,点击获取