简介:这是一份面向微信小程序开发者的基础蓝牙通信实践Demo,聚焦BLE设备搜索、连接、字符串写入与通知读取等核心功能,适用于智能硬件联动、串口透传模块调试等IoT开发场景,尤其适合刚接触小程序蓝牙API、需规避常见踩坑点的中初级开发者。压缩包共17个文件(16KB),涵盖4个JS逻辑文件(含app.js与页面业务逻辑)、3个WXML/WXSS界面文件、3个JSON配置文件(页面与全局配置)、2个说明类TXT文档、1个README.md项目指南及.gitignore等工程规范文件,目录结构清晰,pages分层明确,utils封装可复用逻辑,便于快速理解小程序蓝牙模块组织方式。已有100人学习下载,读者可直接运行搜索页实现零配置设备发现,参考device页中已固化的服务与特征UUID完成数据收发,并基于字符串协议按需扩展指令格式;同时获得完整的小程序标准目录骨架、蓝牙状态管理思路及BLE串口模块实测验证经验。 微信小程序做蓝牙,市面上能搜到的Demo不少,但大多数只给你一个扫描列表和几个接口调用,真正涉及业务联调时,Android和iOS的差异、数据包分包、服务发现流程、权限处理这些坑,一个比一个深。我自己从零搭过几套完整的蓝牙小程序方案,包括后面还会展开的ESP32控制类应用,所以对这个标题下的内容特别有共鸣。这个Demo到底能解决什么问题,适合谁参考,我先说清楚:它本质上是一套“微信小程序连接BLE低功耗蓝牙设备”的完整链路,覆盖了打开蓝牙适配器、扫描设备、建立连接、发现服务和特征值、写入指令、接收硬件主动上报数据,以及常见异常处理。无论你要做的是智能灯、遥控小车、温湿度传感器,还是工程巡检类的信息采集系统,这套链路都是绕不开的地基。这篇文章我会把我实际踩过的坑、改过的代码、总结出的排查思路全部写出来,保证不是那种只贴接口文档的伪教程。
1. 项目整体设计与思路拆解
1.1 为什么小程序侧只能走BLE通道
很多第一次接触小程序蓝牙开发的人,第一个疑问就是:为什么我买了一个HC-05蓝牙模块,却死活连不上?答案在小程序的能力边界上。微信小程序开放给开发者的蓝牙API只覆盖低功耗蓝牙(BLE,Bluetooth Low Energy)协议栈,而HC-05、HC-06这类的经典蓝牙串口透传模块走的是SPP协议,二者根本不在一个技术体系里。BLE的通信模型是GATT,也就是服务和特征值组成的树状结构,设备通过广播包宣告自己存在,连接后客户端通过读写特征值和订阅通知来交换数据;而SPP就是纯粹的串口透传,没有这些分层模型。
所以,如果你手上的硬件模块是HC-05、HC-06,在小程序里基本可以放弃适配了,这些模块通常被用于手机App和单片机的串口通信,但场景固定为经典蓝牙通道。做小程序端的话,建议优先选HM-10、CC2541、nRF52832这些支持BLE从机模式的模块,或者直接用ESP32,它的BLE功能非常成熟,而且开发资料多,串口日志输出也方便。选型这个事情一定要在项目启动前确认,否则等你代码写完了才发现协议栈不兼容,整个方案都得推翻。
从架构设计角度讲,小程序的BLE能力可以用一条主线概括:扫描发现 → 连接设备 → 发现服务 → 获取特征值 → 读写或订阅。理解这条主线后,你写代码时才有全局观,知道每一步到底在做哪一件事、为什么必须按这个顺序来。后续我会按这条主线拆开讲。
1.2 这个Demo适合什么场景和硬件
一个实用的蓝牙Demo,通常不只是“连接一下”就完事,它必须能落地到自己手头硬件的控制逻辑上。我拆解过这个Demo的常见用途,大概能覆盖三类场景。
第一类是控制类,最典型的就是用小程序给硬件发指令,比如控制ESP32开发板上的LED开关、电机转动、智能窗帘启闭。这种场景的特点是数据量小、实时性要求高,指令通常就是几个字节的协议帧,比如“0xA5 0x01 0x00 0x00 0x5A”代表开灯。第二类是数据采集类,硬件端周期性地把传感器读数上报到小程序,比如温湿度、心率、电量、姿态角等。这类场景的特点是数据是硬件主动推上来的,小程序侧需要订阅特征值通知,并且要处理好粘包和分包问题。第三类是设备调试类,通过小程序查看蓝牙设备暴露的所有服务和特征值,相当于把nRF Connect这类工具做成了小白可用的界面,常用于产品开发初期的自测。
硬件选型上,我给出一个对比建议,方便你结合手里的设备快速对号入座:
| 硬件方案 | 蓝牙协议 | 开发难度 | 典型用途 | 备注 |
|---|---|---|---|---|
| ESP32开发板 | BLE(也支持经典蓝牙) | 中 | 控制类、数据采集类 | 支持Arduino/MicroPython开发,带串口日志,强烈推荐用来开发验证 |
| HM-10模块 | BLE | 低 | 串口透传类 | 价格便宜,但调试相对麻烦,固件版本杂 |
| nRF52832开发板 | BLE | 高 | 专业产品原型 | 功耗低,可定制性强,但上手门槛较高 |
| HC-05/HC-06模块 | 经典蓝牙(SPP) | 低 | 传统串口透传 | 小程序不支持,不建议用于本项目 |
我自己做Demo验证时最常用ESP32,因为可以一边在小程序里点按钮,一边在串口监视器里看收到的原始字节,这对定位协议问题帮助巨大。硬件端蓝牙服务的UUID一定要在代码里写清楚,Demo里通常通过服务发现动态获取,实话说比硬编码UUID更靠谱,因为不同厂商的固件配置可能不一样。
1.3 总体代码结构规划
刚开始写这类项目,最忌讳把所有逻辑都堆在Page里,不然蓝牙回调、页面生命周期、用户交互搅在一起,排查问题会非常痛苦。我习惯把蓝牙能力封装成一个独立的工具模块,比如utils/ble.js,所有wx原生蓝牙接口都走这个模块,Page只负责调用和渲染,这样写起来清爽,后面做多页面复用也方便。
模块内部再按职责划分成三层:第一层是基础适配层,负责打开/关闭蓝牙适配器、监听适配器状态变化、处理系统权限;第二层是设备管理层,负责扫描、连接、断开、订阅通知,它要维护一个当前连接设备的状态对象,避免重复连接和回调野指针问题;第三层是数据收发层,负责write和notify的封装,以及分包处理。这个设计思路不复杂,但它是整个项目后续能不能稳定联调的关键。
2. 核心API链路与数据协议设计
2.1 从打开适配器到收到数据,一次完整的流程
微信小程序的蓝牙API链路,表面上看起来就是几个wx接口的调用,但每一步都有它存在的意义。我按实际运行顺序给你梳理一遍。
第一步是初始化蓝牙适配器,调用wx.openBluetoothAdapter。这一步要做两件事:检查手机蓝牙是否打开、初始化系统底层蓝牙资源。关键注意点是,这个接口在部分基础库版本或部分Android机型上,如果重复调用会返回错误,所以初始化前最好先用wx.getBluetoothAdapterState查询一下当前状态,确认是on再继续,否则会浪费一次错误处理逻辑。
第二步是开始扫描,调用wx.startBluetoothDevicesDiscovery。这里有两个隐藏参数值得注意:services过滤数组和allowDuplicatesKey。如果你在扫描前已经知道目标设备的服务UUID,可以直接传services,系统会只回调包含这些服务的设备,扫描效率和准确性都大幅提升。allowDuplicatesKey为true时,同一个设备会持续重复上报,方便你在界面上动态更新信号强度RSSI;但如果只是为了发现设备列表,我建议设false,避免同一个设备疯狂刷屏,列表去重逻辑会变得复杂。
第三步是监听设备发现,用wx.onBluetoothDeviceFound注册回调。这里要留个心眼,扫描回调返回的设备对象里,deviceId在小程序里是唯一的,你应该拿它作为设备的业务主键;设备名称不一定存在,有些硬件厂商为了省电不在广播包里广播名字,需要从advertisData广播数据里解析出来,甚至有些设备name干脆是空的,这种情况列表上只能显示“未知设备”,你要在UI上做好兜底。
第四步是建立连接,调用wx.createBLEConnection。连接是异步的,成功后设备才会进入可通信状态。根据我的经验,连接这个环节最容易踩的坑是忘记停止扫描。部分Android手机在扫描状态下直接建立BLE连接会失败或异常卡住,稳妥的做法是:等onBluetoothDeviceFound找到目标设备后,先调用wx.stopBluetoothDevicesDiscovery停止扫描,再调用createBLEConnection。停顿几百毫秒再连接更稳。
第五步是发现服务和特征值,依次调用wx.getBLEDeviceServices和wx.getBLEDeviceCharacteristics。很多新手以为连接成功后就能直接收发数据,其实不对。BLE的数据交换是在特征值上进行的,如果不先拿到服务列表和特征值列表,你根本不知道这个设备有哪些数据通道可用。拿到特征值后,要重点关注properties对象里的read、write、notify、indicate标志,这决定了你能对这个特征值做什么操作。
第六步是数据交互,要么小程序主动写数据,调用wx.writeBLECharacteristicValue向硬件发指令;要么订阅硬件上报,调用wx.notifyBLECharacteristicValueChange启用通知,并监听wx.onBLECharacteristicValueChange回调。订阅通知这步是BLE最有价值的地方,相当于硬件端可以随时随地主动推数据给小程序,不用小程序轮询。
整个链路走完,一个小程序蓝牙Demo的核心闭环就成立了。后面的代码实现我会按这个顺序写,你可以对照着看。
2.2 MTU限制与数据分包策略
BLE通信默认的MTU(最大传输单元)在Android和iOS上不完全一致,但传统BLE 4.x默认包大小是23字节,其中3字节被ATT层头占用,所以应用层单次能传的数据只有20字节。这是无数新手撞得头破血流的地方——你往writeBLECharacteristicValue里塞一个30字节的字符串,结果发现写入失败或者硬件只收到前20字节。
解决分包问题有两种常见思路。第一种是不要挑战硬件,直接约定应用层协议按20字节一包来传,超出就拆分。拆分时不能简单把字符串按长度截了发出去,因为接收方要能区分包的边界和顺序。我建议在应用层设计一个简单的帧格式,比如:帧头(2字节,如0xAA5A)+ 数据长度(1字节)+ 包序号(1字节)+ 数据(N字节)+ 校验(1字节,可选用异或校验)。每帧最多12字节数据,这样加上头部和尾部刚好不超20字节。这种协议虽然带了一点冗余,但非常可靠,我实测在Demo联调中几乎没有出过因为粘包导致的数据错乱。
第二种方案是在Android端尝试协商更大的MTU。微信小程序从基础库2.11.0开始提供了wx.setBLEMTU接口,可以主动请求把MTU提到最大517字节。但需要注意,iOS系统不支持这个接口,而且协商结果受硬件端最大MTU限制,所以不能把“大包发送”当成唯一方案。项目中我的做法是:能协商就协商,但应用层协议依然按20字节分帧,这样两端都能通用。
再补充一个硬件端的注意点:如果数据接收方是单片机ESP32这类设备,你的单片机代码最好也按同样的MTU逻辑处理接收缓存,因为有些蓝牙协议栈在收到超过MTU的完整包时,会直接丢弃或异常。协议一致性在这里体现得淋漓尽致,软件端和硬件端必须对齐帧格式和分包规则。
2.3 广播数据解析与设备识别
扫描列表里经常出现一些名称显示不全的设备,甚至有些设备明明在手机系统蓝牙设置里能看到名字,到小程序里却变成空名称。原因在于设备的广播包结构。BLE设备在广播时,会把设备名称塞在广播包里,有的厂商把名称放在AD Type为0x08/0x09的字段里,微信解析后会把localName返回给你;但有些设备因为广播长度限制,或者固件设计原因,只放了自定义的manufacturer data或service data,名称字段就是空的。
遇到这种情况,不能干等着iOS或Android给你补名称,你要么在硬件端固件里补全广播名称,要么在小程序端解析advertisData来识别设备。advertisData是一个ArrayBuffer,你可以按BLE广播包的规范解析它:广播包的每个AD Structure由长度(1字节)+ AD Type(1字节)+ 数据组成,遍历一遍就能拿到完整广播内容。不过说实话,如果你能控制硬件固件,直接在固件里加一个可读的名称字段是最省事的。在小程序里解析advertisData属于事后补救方案,代码量不小,而且不同芯片厂商的广播数据格式五花八门,通用性有限。
真实的项目里,我建议扫描列表展示用localName + 信号强度RSSI,设备唯一识别用deviceId。如果设备名称为空,可以展示成“BLE设备(MAC后四位)”,这样用户至少能分辨出哪台是目标设备。
3. 实操Demo结构与核心代码实现
3.1 工程目录与开发环境准备
项目工程结构其实不用很复杂,一个单页Demo就够用了。我习惯的目录结构是这样的:
miniprogram/ pages/ index/ index.js index.wxml index.wxss utils/ ble.js app.js app.jsonapp.json里需要声明一下小程序对蓝牙能力的权限说明,尤其是iOS会弹窗询问用户是否允许使用蓝牙,这个说明文字会展示在弹窗里。配置大致如下:
{ "permission": { "scope.bluetooth": { "desc": "需要使用蓝牙连接附近设备" } } }注意,这个配置在部分微信版本或Android平台上可能不是强制的,但在iOS上如果没有配置说明,系统弹窗会显示默认文案甚至直接自动拒绝。另外,基础库版本建议不低于2.11.0,因为需要用到wx.setBLEMTU这样的新接口,尽量在开发者工具里把调试基础库调高一点。
还有一个非常容易忽略的点:微信开发者工具的模拟器是不支持蓝牙调用的,你必须用手机真机预览或真机调试,而且手机的系统蓝牙开关必须在打开状态。第一次跑Demo,先用微信开发者工具的“真机调试”模式,再把vConsole打开,这样手机上的运行日志可以直接在开发者工具里看到,调起蓝牙时系统层的错误信息也能暴露得更充分。
3.2 蓝牙工具模块的完整封装
我先把utils/ble.js的核心代码贴出来,这段代码我经过多轮真机验证,是能直接用的。它把扫描、连接、服务发现、数据收发都封装成了Promise风格,方便Page里按调用链组织逻辑。
// utils/ble.js const ble = { _deviceId: '', _serviceId: '', _characterId: '', _notifyCallback: null, // 初始化蓝牙适配器 openAdapter() { return new Promise((resolve, reject) => { wx.openBluetoothAdapter({ success: resolve, fail: (err) => { // 如果已经开启,尝试直接获取状态 wx.getBluetoothAdapterState({ success: (res) => { if (res.available) { resolve(res); } else { reject(err); } }, fail: () => reject(err) }); } }); }); }, // 开始扫描 startScan(services) { return new Promise((resolve, reject) => { wx.startBluetoothDevicesDiscovery({ services: services || [], allowDuplicatesKey: true, success: resolve, fail: reject }); }); }, // 停止扫描 stopScan() { return new Promise((resolve) => { wx.stopBluetoothDevicesDiscovery({ success: resolve, fail: resolve }); }); }, // 监听设备发现(由页面层调用并处理回调) onDeviceFound(callback) { wx.onBluetoothDeviceFound(callback); }, // 连接设备 connect(deviceId) { return new Promise((resolve, reject) => { wx.createBLEConnection({ deviceId, success: () => { this._deviceId = deviceId; resolve(); }, fail: reject }); }); }, // 断开连接 disconnect() { if (this._deviceId) { wx.closeBLEConnection({ deviceId: this._deviceId }); } }, // 获取主服务和特征值 discoverServices() { return new Promise((resolve, reject) => { wx.getBLEDeviceServices({ deviceId: this._deviceId, success: (res) => { const services = res.services || []; if (!services.length) { reject(new Error('未发现任何服务')); return; } // 这里简化处理:遍历每个服务,查找可用的特征值 const promises = services.map((service) => { return new Promise((resolveInner, rejectInner) => { wx.getBLEDeviceCharacteristics({ deviceId: this._deviceId, serviceId: service.uuid, success: (chrRes) => { resolveInner({ serviceId: service.uuid, characteristics: chrRes.characteristics || [] }); }, fail: rejectInner }); }); }); Promise.all(promises).then(resolve).catch(reject); }, fail: reject }); }); }, // 写入数据(自动分包) writeData(dataBuffer) { return new Promise((resolve, reject) => { if (!this._serviceId || !this._characterId) { reject(new Error('服务或特征值未设置')); return; } // 分包发送,单次最多20字节 const chunkSize = 20; const totalChunks = Math.ceil(dataBuffer.byteLength / chunkSize); let sentChunks = 0; const sendChunk = (offset) => { const chunk = dataBuffer.slice(offset, offset + chunkSize); wx.writeBLECharacteristicValue({ deviceId: this._deviceId, serviceId: this._serviceId, characteristicId: this._characterId, value: chunk, success: () => { sentChunks++; if (sentChunks < totalChunks) { const nextOffset = offset + chunkSize; // 每包间隔50ms,避免BLE协议栈压力过大 setTimeout(() => sendChunk(nextOffset), 50); } else { resolve(); } }, fail: reject }); }; sendChunk(0); }); }, // 启用通知 startNotify(serviceId, characteristicId, callback) { this._serviceId = serviceId; this._characterId = characteristicId; this._notifyCallback = callback; wx.onBLECharacteristicValueChange((res) => { if (this._notifyCallback) { this._notifyCallback(res.value); } }); return new Promise((resolve, reject) => { wx.notifyBLECharacteristicValueChange({ deviceId: this._deviceId, serviceId, characteristicId, state: true, success: resolve, fail: reject }); }); }, offNotify() { if (this._notifyCallback) { this._notifyCallback = null; } } }; module.exports = ble;这里有几个细节我说一下。writeData里我用了一个递归调用setTimeout的方式分包发送,每包间隔50毫秒,这个间隔是我在实际项目中测出来的。如果你把间隔压到10毫秒或20毫秒,部分Android机型或某些BLE芯片会丢包;隔得太慢则用户体验差,50毫秒属于临界值附近比较稳的。当然,如果你的数据量很小(比如控制指令不超过20字节),根本不需要走分包逻辑,直接一次写入就行。
另外,startNotify里我先把回调存到模块变量,再绑定wx.onBLECharacteristicValueChange。这个设计是为了防止页面多个地方同时注册监听导致回调重复执行。实际项目里,你还可以在onBLECharacteristicValueChange的回调里做一些简单的数据解析,比如把ArrayBuffer转成文本或十六进制,再交给页面渲染。
3.3 Page层调用与设备列表渲染
工具模块封装好之后,Page层的代码就清爽多了。核心逻辑是:页面加载时初始化蓝牙适配器,用户点击扫描后开始扫描并监听设备回调,设备列表去重展示,用户点击某一项后停止扫描并连接,连接成功后执行服务发现,选定读写特征值后进入数据收发界面。
下面我给出一个精简但完整的Page代码,关键注释我写到代码里:
// pages/index/index.js const ble = require('../../utils/ble.js'); Page({ data: { devices: [], connected: false, log: [], serviceList: [], characteristicList: [], sendText: '' }, onLoad() { this.initBluetooth(); }, async initBluetooth() { try { await ble.openAdapter(); this.addLog('蓝牙适配器已初始化'); } catch (err) { this.addLog('蓝牙初始化失败:' + JSON.stringify(err)); } }, startScan() { this.setData({ devices: [] }); ble.onDeviceFound((res) => { const newDevices = res.devices || []; const current = this.data.devices; newDevices.forEach((device) => { const exists = current.some((d) => d.deviceId === device.deviceId); if (!exists) { current.push(device); } }); this.setData({ devices: current }); }); ble.startScan([]).then(() => { this.addLog('开始扫描'); }).catch((err) => { this.addLog('扫描启动失败:' + JSON.stringify(err)); }); }, stopScan() { ble.stopScan().then(() => { this.addLog('停止扫描'); }); }, async onTapDevice(e) { const deviceId = e.currentTarget.dataset.deviceId; this.addLog('准备连接:' + deviceId); await ble.stopScan(); try { await ble.connect(deviceId); this.setData({ connected: true }); this.addLog('连接成功'); const services = await ble.discoverServices(); // 这里简化:默认选择第一个可写/可通知的特征值 services.forEach((service) => { service.characteristics.forEach((chr) => { if (chr.properties.write || chr.properties.notify) { this.setData({ serviceList: [{ serviceId: service.serviceId, characteristic: chr }] }); } }); }); this.addLog('服务发现完成'); } catch (err) { this.addLog('连接或发现服务失败:' + JSON.stringify(err)); } }, async sendData() { if (!this.data.connected) return; const text = this.data.sendText; const buffer = new ArrayBuffer(text.length); const dataView = new DataView(buffer); for (let i = 0; i < text.length; i++) { dataView.setUint8(i, text.charCodeAt(i)); } try { await ble.writeData(buffer); this.addLog('发送成功:' + text); } catch (err) { this.addLog('发送失败:' + JSON.stringify(err)); } }, addLog(msg) { const log = this.data.log; log.push(new Date().toLocaleTimeString() + ' ' + msg); this.setData({ log }); }, onUnload() { ble.disconnect(); ble.offNotify(); } });这里我故意省略了wxml的细节,因为不同的UI风格差异很大。但有几个交互细节值得提醒你:扫描列表最好显示RSSI信号强度,方便判断设备距离;连接过程中要禁用按钮,防止用户重复点击;日志区用scroll-view滚动到底部,避免调试时看不到最新日志。
另外,页面onUnload生命周期里一定要做清理工作:断开连接、关闭notify监听。如果不做这一步,用户在页面间跳转后,底层蓝牙回调还在触发,轻则内存泄漏,重则出现“当前页面已卸载但回调还在执行”的诡异BUG。
3.4 使用真机调试与其他蓝牙工具配合
微信开发者工具里的模拟器没有蓝牙硬件,所以调试这类项目,第一步就是把代码跑到真机上。真机调试模式下,Console面板会输出所有console日志,vConsole也能在手机页面上直接展示日志,这让排查问题方便很多。我一般会在蓝牙相关的所有回调里都打console.log,把入参、出参、错误信息全部记录下来,这比事后猜要高效得多。
除了微信开发者工具,我强烈建议手机里装一个nRF Connect或者BLE调试助手。它有两个大用处:第一,开发前用它对硬件设备做一次服务特征值扫描,确认硬件端到底暴露了哪些服务、哪些特征值,这样你在小程序里做筛选时就心里有数了;第二,当微信小程序连不上时,先用nRF Connect试连一下硬件,如果nRF Connect能连上说明硬件没问题,问题大概率在小程序代码或者权限处理上,这一点可以快速缩小排查范围。
注意,nRF Connect这类工具本质是协议分析调试器,不属于任何违规操作,它是蓝牙开发的标准工具。用它来查看设备广播包、服务UUID、特征值属性,是安全合规的。
4. 常见问题与排查技巧实录
4.1 扫描不到设备,先按清单逐项排查
扫描不到设备是我被问得最多的问题,而且一半以上其实不是代码问题。我把排查顺序整理成一张表,你可以照着逐项过一遍。
| 排查项 | 检查方法 | 解决方式 |
|---|---|---|
| 手机蓝牙总开关 | 进入系统设置确认蓝牙已打开 | 打开蓝牙 |
| 定位权限 | Android 6.0以上BLE扫描依赖定位权限 | 在弹出的权限窗口允许位置权限 |
| 基础库版本 | 微信版本过旧或基础库过低 | 升级微信,开发者工具设置基础库>=2.11.0 |
| 扫描过滤参数 | services参数填了不存在的UUID | 去掉services过滤重新扫描 |
| 设备广播间隔 | 部分硬件广播间隔太长,扫描窗口错过 | 增加扫描时间,或硬件端调小广播间隔 |
| 设备距离过远 | BLE有效距离一般10-30米 | 靠近设备再扫描 |
| Android系统蓝牙缓存 | 系统缓存旧设备信息导致扫描异常 | 关闭蓝牙再打开,清理系统蓝牙缓存 |
| 设备已被连接 | 手机系统蓝牙或另一台手机已占用设备 | 断开设备后重新扫描 |
| 广播数据不完整 | 硬件只广播部分数据,名称为空 | 解析advertisData或硬件端补全广播名称 |
其中,Android系统蓝牙缓存问题非常隐蔽。设备改过固件、改过广播名称后,手机里可能还留着旧的蓝牙配置,导致小程序扫描到的是一个“旧身份”的设备,连接后行为异常。这种情况参考经验做法是:手机设置里找到蓝牙,取消配对该设备,然后重启手机蓝牙,再重新扫描。iOS相对好一些,但也有类似情况,重启蓝牙能解决七成问题。
4.2 连接失败、反复断连的典型原因
连接失败或连上后过几秒自动断开,这类问题比扫描不到更让人头大,因为涉及的系统层和硬件层因素更多。
第一个常见原因是连接前没有停止扫描。我前面说过,部分Android机型在扫描状态下直接调用createBLEConnection会失败或异常,解决方式就是先stopScan再connect,最好加一个小延时让系统蓝牙协议栈缓一口气。
第二个常见原因是iOS的deviceId变化。iOS系统会对BLE MAC地址做隐私处理,同一个设备在不同时间扫描到的deviceId可能不一样。所以你不能在本地持久化保存deviceId,跨会话直接重连时会失败。正确做法是每次都走完整的扫描、发现、连接流程,或者参考外设的某些固定标识(比如广播数据里的mac字段)来二次匹配。
第三个常见原因是硬件端连接参数配置问题。BLE的连接间隔、从机延迟、超时时间这些参数由硬件固件决定,如果连接间隔过密(比如7.5ms),iOS和部分Android系统上容易断连;如果硬件端功耗设计不合理,也可能在传输数据时供电不稳导致断开。这一点只能改硬件固件配置,软件端能做的只是监听onBLEConnectionStateChange回调,把断开原因打出来。
第四个常见原因是重复连接。有些开发者在未断开旧连接的情况下尝试重新连接同一台设备,这在小程序API上不会报错,但系统底层可能表现异常。建议在connect之前先对同一设备做一次closeBLEConnection,再做连接;连接成功后把已连接状态存到全局变量或缓存里。
4.3 数据收发异常,可能卡在编码或协议上
数据能连上但收发不对,这类问题往往不是蓝牙链路断,而是数据格式或协议不对。我遇到最多的是下面几种情况。
写入不成功,先确认特征值属性。有些特征值是只读的,根本没有write权限,你调用writeBLECharacteristicValue自然会失败。调试时把getBLEDeviceCharacteristics返回的properties对象打印出来,确认write字段为true,再执行写入。另外一次写入长度超过20字节也会失败,先检查分包逻辑。
notify收不到数据,先看硬件端有没有真的发数据。有些硬件模块在连接成功后需要先发一个“打开通知”的指令,然后才开始主动上报;有些硬件则需要延时1到2秒之后再启用notify,否则它还没准备好上报通道。这类问题只能靠硬件日志和手机端日志两边对照来定位。另外,wx.notifyBLECharacteristicValueChange的state参数一定要传true,而且每个特征值都要单独开启一次。
收到乱码或莫名数据,大概率是编码问题。BLE的数据是ArrayBuffer,如果你强行把它转成字符串,但硬件端发送的是UTF-8编码的中文,而你在小程序里用了Latin-1或其他编码解析,就会出现乱码。小程序端ArrayBuffer转字符串,我推荐先用TextDecoder迭代解码,或者用官方提供的ab2hex、ab2str等工具函数。如果只是调试用,直接转十六进制看数据更直观。
4.4 Android和iOS的兼容性差异
Android和iOS在BLE实现上有不少行为差异,同一个Demo在这两个平台上表现完全不同是常态。我总结几个核心差异,供你在设计时提前规避。
Android的deviceId就是蓝牙MAC地址,相对稳定,但部分国产ROM会做随机化;iOS的deviceId是系统生成的UUID,每次扫描结果都可能变化。这意味着扫描列表去重逻辑要基于deviceId,但重连逻辑不能依赖它。iOS对后台蓝牙限制严格,小程序一旦切到后台,蓝牙回调可能被挂起,数据交互基本停止,所以做长时间数据采集类应用时,要提醒用户保持小程序在前台运行。Android后台限制相对宽松,但不同厂商ROM杀后台策略也不一样,最稳妥的做法还是保持前台或使用“回到前台自动重连”机制。
还有一个MTU的差异:Android一般能协商更大MTU,iOS的MTU在GATT协商后通常也是23字节的倍数,但具体数值不确定。所以结论就是我前面反复强调的:协议层按20字节分包是唯一能保证两端通用的做法。不要指望iOS能像Android一样发大包。
4.5 日志调试与问题复现技巧
踩过这么多坑之后,我的调试习惯已经固化成一套流程。第一,所有蓝牙相关回调必须打日志,尤其是fail回调,哪怕是一个成功回调也要打出来,因为很多“成功”之后的异常行为是需要对比时间线才能发现的。第二,尽量把代码里的操作步骤和硬件行为对照起来,比如小程序里点了“连接”,你同时看硬件端的串口日志是不是有连接事件,中间卡在哪一步一目了然。
第三,用好微信的“真机调试”和vConsole,把手机端的报错信息带回电脑端分析。有些Android机型的错误码会提示很具体的系统层原因,比如“connection fail”或“gatt error”,这些信息在模拟器里根本看不到。第四,问题复现时保持环境一致性,比如固定同一台手机、同一个硬件、同一路线,否则你很难判断是代码问题还是网络环境问题。
我不建议用传统意义上的“抓包”手段去调试这个Demo,一是微信小程序运行在自己的沙箱里,数据加密解密链路复杂;二是这些手段涉及对第三方应用的逆向分析,合规风险很高,完全没必要。用系统级的蓝牙日志、nRF Connect跟Log plus对照,已经足够解决绝大多数问题。
5. 影响范围与后续应用扩展
5.1 从Demo到真实项目:典型的应用场景
别看这个Demo标题简单,它的框架可以直接迁移到很多实际项目中。我列几个我接触过的真实场景,你会发现它们的底层逻辑都是一样的。
第一个场景是智能硬件控制。ESP32或者Arduino配一个BLE模块,小程序当遥控器,控制灯光颜色、窗帘开合、风扇档位。这个场景的核心是“小程序到硬件”的下行链路,数据量小,实时性要求高,正好是BLE的强项。我见过一个项目用这个思路做了一个会议室的智能灯光控制面板,运维人员不用下载一个专用App,扫码打开小程序就能调光,部署成本非常低。
第二个场景是设备数据采集。例如一个蓝牙温湿度记录仪,硬件定时把温度和湿度塞到广播包里或者通过notify推给小程序,小程序端展示实时曲线并上传到后台数据库。这个场景的核心是“硬件到小程序”的上行链路,需要处理好粘包、分包和后台数据同步。用这个框架,前端部分基本不用大改。
第三个场景是工程巡检类的信息采集。标题热词里出现了“基于微信小程序的建筑工程质量缺陷图纸定位与智能信息采集系统”,这类项目本质上就是“扫设备蓝牙 + 数据上报 + 服务端记录”的组合。硬件端可能是一台带BLE信标的巡检设备或RFID阅读器,小程序靠近后扫描到设备,自动带入当前工位信息,再结合图纸定位和拍照填写缺陷内容。这套流程里,蓝牙Demo负责设备识别和连接这块,业务逻辑再往下接就平铺直叙了。
第四个场景是低精度的蓝牙测距。利用RSSI信号强度可以粗略估算设备距离,做成防丢提醒、门禁靠近识别等功能。虽然精度不如UWB,但在Demo层面完全够用,还能帮助新手理解信号强度衰减模型。
5.2 从小程序端看蓝牙Demo的未来扩展方向
这个Demo虽然基础,但它是一个很好的起点。往上扩展,你可以加入多个设备的并发管理,比如一个页面同时连接两台BLE设备,分别控制不同功能,这需要你把工具模块里的_deviceId扩展成设备Map结构。再往上扩展,你可以接入蓝牙Mesh类设备或者更多低功耗传感器,不过这类扩展更依赖硬件端固件能力,小程序端的API并没有质的变化。
另一个值得关注的方向是数据安全。BLE通信本身默认不带加密,广播数据可以被附近设备捕获,明文传输的数据在商业场景里有安全隐患。如果你做的是生产环境项目,最好在应用层增加加密逻辑,比如对关键指令做AES或异或混淆,再把密钥管理的逻辑放到小程序的服务端下发。这块虽然Demo里不涉及,但从一开始就留好扩展点会让后续演进省力很多。
还有一点是从工程化角度的建议:这个Demo里的工具模块应该独立维护,沉淀成团队内部的基础库,因为它不只服务于当前这个项目。微信小程序的蓝牙API在可预见的范围内不会有太大变化,这样一个成熟稳定的模块,复用到后续项目里几乎零成本。
5.3 我个人的一些工程经验总结
最后结合这个Demo,说一下我在实际项目中形成的几个习惯,谈不上高深,但确实能少走弯路。
一是做蓝牙项目前,花20分钟用nRF Connect把硬件端的服务模型列清楚,确认每个特征值的读写权限和数据含义,这一步比多写1000行代码更有价值。二是代码里所有UUID尽量用常量集中管理,不要让字符串散落在各个文件里,不然等你要适配不同固件版本的时候,改起来会让你想骂人。三是页面生命周期一定做好蓝牙资源的清理和重连策略,用户切换页面后能不能恢复连接,这部分体验往往决定了一个工具类小程序能不能被长期使用。
我还养成了一个习惯,就是把开发期间碰到的问题和解决方案维护成一个Markdown文档,类似一个团队内部的知识库。这个Demo相关的坑,比如Android的缓存问题、MTU分包、notify时序,我全部沉淀下来了。后续团队成员遇到类似问题,翻一下文档就能定位,根本不用重新踩一遍。
做小程序蓝牙开发,本质上是一个软件和硬件反复对齐的过程,耐心比聪明重要,日志比猜测可靠。这篇文章里写的每一个问题,都是我真机验证过的;如果你正好被某个问题卡住,按照里面的排查步骤走一遍,大概率能找到答案。
本文还有配套的精品资源,点击获取