恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
微信小程序BLE蓝牙连接读写实战:从扫描到数据交互全解析
首页
资讯中心
/
微信小程序BLE蓝牙连接读写实战:从扫描到数据交互全解析
微信小程序BLE蓝牙连接读写实战:从扫描到数据交互全解析
发布时间:2026/10/15 15:16:19
1. 项目概述与整体思路拆解1.1 这个实战项目到底在解决什么问题最近帮人调一个微信小程序蓝牙连接读写数据的项目折腾了整整两个周末总算是把整条链路从扫描设备、建立连接、发现服务、读写特征值到接收实时通知都跑通了。如果你也是第一次接触小程序蓝牙这块看到那一堆wx.开头的 API 多半会有点懵更不用说还要面对安卓和 iOS 的不同表现。这个实战项目的目标其实很单纯做一个能搜索到 BLE 设备的小程序点一下连接然后向设备发送指令同时接收设备上报的数据并显示到页面上。听起来很简单但真做起来会踩到大量文档里没写清楚的坑。比如安卓要定位权限、服务发现时机不对会拿不到特征值、写入超过 20 字节会被系统拒绝、iOS 和安卓对广播包的解析逻辑完全不一样等等。这篇文章不是官方文档的复述而是我把完整调试过程拆开把搜索、连接、读写背后的“为什么”讲清楚再给出可直接抄作业的代码。适合三类人看手里已经有 BLE 硬件但不知道怎么接小程序的人、想在小程序里做调试工具或设备控制面板的人、以及被各种蓝牙报错折腾到怀疑人生的人。1.2 为什么微信小程序能用的只有 BLE微信小程序蓝牙 API 只支持低功耗蓝牙BLE不支持经典蓝牙。这不是功能阉割而是合理的取舍。BLE 主打低功耗、小数据量传输比如温湿度计、智能灯、体脂秤、门锁这类设备一次传输几十个字节就够了没必要走经典蓝牙那种高带宽大功率的老路。用生活里的事来类比经典蓝牙像是打电话一旦接通就会持续占线功耗高、需要配对BLE 更像是自助取餐柜设备在门口发广播你拿手机去扫一下然后凭小票连接参数打开柜门取数据取完就关门走人整个过程很省电。正因为它低功耗且不需要传统配对流程BLE 非常适合小程序这种“用完即走”的场景。但也因为省电BLE 设备不会像 Wi-Fi 一样一直保持完整连接每次通信前都要经历“扫描、连接、发现服务、读写”这一套流程。这也解释了为什么小程序蓝牙开发里最常遇到的报错几乎都集中在连接不稳定、服务发现失败和设备返回数据为空上。1.3 动手前必须搞懂的三个蓝牙概念写代码之前先花五分钟把三个概念理清楚后面会少走很多弯路。第一个是 GATT 协议。BLE 通信不是随便发一串字节的而是按照 GATT通用属性协议来组织的。一个设备会有一个或多个 Service服务每个 Service 下面挂着多个 Characteristic特征特征才是真正读写数据的通道。这个结构很像文件系统根目录下有几个文件夹文件夹里面是不同的文件你操作的是文件。第二个是 UUID。Service 和 Characteristic 都用 UUID 来标识比如FFE0、FFE1这种 16 位的简写实际开发中也会遇到长 UUID。你需要提前从设备厂商的硬件手册里拿到这些值否则就只能用暴力扫描方式把设备上所有服务和特征都拉出来再一个一个试。第三个是 Notify 和 Indicate。很多传感器类设备不是等你来问才给数据而是主动上报。这时小程序需要先向设备开启“通知开关”设备才会在数据变化时把数据推过来。这和普通 HTTP 请求完全不一样理解这一点后面调试通知收不到数据的时候才知道该查哪里。2. 核心API与关键技术细节2.1 蓝牙适配器的初始化和生命周期所有小程序蓝牙操作都从打开蓝牙适配器开始。很多人直接把wx.openBluetoothAdapter写在首页onLoad里就完事了实际上这一步很值得讲究。首次打开时最好检查一下返回值。如果失败常见原因是用户没打开系统蓝牙。另外安卓端从 6.0 开始扫描蓝牙设备需要定位权限第一次调用可能直接报10001这并不一定是代码问题而是权限没有申请。我在项目里就遇到过一次用户手机系统蓝牙明明是打开的但小程序扫描不到任何设备最后发现是定位权限没有弹窗授权因为小程序默认不会主动提示这个权限。调用时机上还有一个细节如果用户在小程序运行中退出蓝牙然后重新进入页面直接重复调用openBluetoothAdapter可能会收到already open之类的状态但并不代表适配器可用。经验做法是先调用wx.getBluetoothAdapterState看看available字段如果为false再尝试重新打开。小程序还提供了wx.onBluetoothAdapterStateChange来监听适配器状态变化比如用户从系统下拉菜单里把蓝牙关闭了或者重新打开此时页面应该及时更新状态。我在项目里是把它放在onShow里注册然后在onHide里移除监听避免页面隐藏时还在重复处理状态变化。2.2 设备发现与筛选比想象中多踩两个坑设备发现用的是wx.startBluetoothDevicesDiscovery配合wx.onBluetoothDeviceFound接收结果。这一步看着简单但有两个坑是新手必踩的。第一个坑是重复上报。同一个设备在一次扫描周期内会被上报很多次如果不做去重页面上会出现一堆相同名字的设备条目。我在项目里用deviceId作为唯一键放到一个对象里做缓存收到新一次上报时先判断是否已经存在存在就更新 RSSI 强度不存在才加入列表。这样页面会越扫越全而且信号强度还能动态变化展示出来。第二个坑是安卓端扫描比较慢。默认扫描时间可能长达 10 秒甚至更久用户等得不耐烦。可以用timeout参数控制并在超时后调用wx.stopBluetoothDevicesDiscovery主动结束扫描。我在项目里的策略是进入页面自动开始扫描同时启动一个 10 秒定时器倒计时结束就停止保留已经发现的设备列表让用户手动刷新。这里还有一个很现实的建议如果有明确的 service UUID尽量在startBluetoothDevicesDiscovery里通过services参数过滤让系统只返回包含指定服务的设备。好处有两个搜得快而且不容易把周围无关的蓝牙外设扫进来。2.3 连接建立与服务发现找到设备后用户点击列表项小程序调用wx.createBLEConnection开始连接。连接看起来很简单但下面的服务发现环节才是项目中最容易出问题的地方。wx.getBLEDeviceServices用于获取设备的所有服务wx.getBLEDeviceCharacteristics用于获取某个服务下的所有特征。问题在于这两个接口返回的结果是异步的而且不少手机在刚连接成功的瞬间服务列表还没准备好。如果连续调用经常出现返回空数组的情况。我在这个项目里采取的做法是用 Promise 封装连接过程每走一步就等待前一步完成并在getBLEDeviceServices下发一个 500ms 的延迟。虽然官方没有要求这么做但从实际调试来看这个延迟能显著降低安卓设备上服务发现失败的概率。原理很朴素给系统蓝牙协议栈一个“缓口气”的时间等连接状态稳定再去读服务表。服务发现完后并不能直接读写所有特征值。你要先判断特征的properties里哪些可以读、哪些可以写、哪些支持 notify。常见特征值权限标志包括read、write、notify、indicate代码里判断方式很简单比如characteristic.properties.write。这一步如果跳过了后面调用写入接口时可能会收到一个很抽象的10006错误设备不支持该操作。2.4 特征值读写与实时通知数据怎么“动起来”拿到目标特征后就可以开始读写操作了。写入用wx.writeBLECharacteristicValue需要传入四个关键参数deviceId、serviceId、characteristicId和一个ArrayBuffer类型的value。这里有个最容易搞混的概念小程序里所有读写数据都必须是ArrayBuffer不能直接传入字符串。我在项目封装里先做了一个字符串转 ArrayBuffer 的工具函数确保发给设备的数据符合要求。读取回来的数据同样是ArrayBuffer显示到页面前需要转成十六进制字符串或者直接按设备协议解析成可读内容。接收设备主动上报的数据靠的是wx.notifyBLECharacteristicValueChange开启通知然后用wx.onBLECharacteristicValueChange监听。我必须强调一个细节这个监听是全局性的不是绑定在某个页面上的。若不注意页面 A 打开的通知回调在页面 B 里依然可能会被触发导致业务逻辑混乱。我在项目里把监听统一放在app.js的全局对象里或者每次页面隐藏时移除监听并在重新进入时重新注册。数据交互的时序也很重要。设备通知的数据通常会按固定频率到达比如每 100ms 一包。此时渲染层如果同步把每包都setData到页面上页面会卡到没法看。我的做法是维护一个环形缓冲数组把多次上报的数据累积到一定数量后再统一刷一次页面实际体验会顺滑很多。3. 实战从扫描到读写的完整流程3.1 真机调试前的准备小程序蓝牙开发不能用模拟器必须用真机。但有个容易被忽略的前提需要在微信开发者工具里打开“不校验合法域名”并开启调试模式同时手机和电脑保持在同一个局域网内这样才能稳定地使用真机调试。另一个前置准备是硬件。如果没有现成的 BLE 设备可以用手机上下载一个“BLE 调试工具”来模拟外设也可以在网上买一块常见的低功耗蓝牙开发板烧录一个简单的温湿度广播程序进去。我在项目中使用的是某款常见 BLE 温湿度计它提供两个特征值一个用于读取设备信息一个用于订阅温湿度数据正好覆盖了“读”和“写”两种场景。真机调试中还要注意手机设置。安卓机必须在系统设置里开启定位服务同时在授权管理里允许小程序使用定位权限iPhone 虽然不需要定位但要在隐私设置里允许蓝牙否则 API 调用会静默失败控制台既不报错也不出结果。3.2 扫描页面的核心实现直接上一个可用的扫描核心代码片段。这段代码实现了进入页面自动扫描、实时去重、固定时长后停止以及手动刷新功能。Page({ data: { devices: [], scanning: false }, deviceMap: {}, onLoad() { this.initBluetooth(); }, onUnload() { this.stopScan(); wx.closeBluetoothAdapter({}); }, async initBluetooth() { const res await this.promisify(wx.openBluetoothAdapter); if (!res || res.errCode ! 0) { wx.showModal({ title: 提示, content: 请检查手机蓝牙是否打开 }); return; } this.startScan(); }, async startScan() { this.deviceMap {}; this.setData({ devices: [], scanning: true }); const res await this.promisify(wx.startBluetoothDevicesDiscovery, { allowDuplicatesKey: true, interval: 0 }); if (!res || res.errCode ! 0) { this.setData({ scanning: false }); return; } wx.onBluetoothDeviceFound((found) { const list found.devices || []; list.forEach((item) { if (!item.deviceId) return; const name item.name || item.localName || 未知设备; if (!this.deviceMap[item.deviceId]) { this.deviceMap[item.deviceId] { deviceId: item.deviceId, name, rssi: item.RSSI }; } else { this.deviceMap[item.deviceId].rssi item.RSSI; } }); const devices Object.keys(this.deviceMap).map((key) this.deviceMap[key]); this.setData({ devices }); }); setTimeout(() this.stopScan(), 10000); }, stopScan() { wx.stopBluetoothDevicesDiscovery({}); this.setData({ scanning: false }); }, promisify(api, options {}) { return new Promise((resolve) { api({ ...options, success: (res) resolve(res), fail: (err) resolve(err) }); }); } });这段代码里有两个经验值得说。第一interval: 0表示尽可能快地上报设备发现但如果觉得过于耗电或者重复上报太频繁可以适当调整为100或200。第二allowDuplicatesKey: true是为了让同一台设备在信号强度变化时仍然会收到回调这样 RSSI 字段可以动态刷新。缺点是回调频率会比较高所以必须用 map 去重否则列表会疯狂抖动。3.3 建立连接并找到可读写特征扫描列表点击后进入连接阶段。下面这段代码封装了“连接设备、获取服务、获取特征、定位目标特征”的完整流程并在每一步做了错误处理。async connectDevice(deviceId) { const connRes await this.promisify(wx.createBLEConnection, { deviceId }); if (connRes.errCode ! 0) { wx.showToast({ title: 连接失败, icon: none }); return; } await this.sleep(500); const serviceRes await this.promisify(wx.getBLEDeviceServices, { deviceId }); const services serviceRes.services || []; let targetServiceId ; let targetWriteCharId ; let targetNotifyCharId ; for (const service of services) { if (!service.isPrimary) continue; const charRes await this.promisify(wx.getBLEDeviceCharacteristics, { deviceId, serviceId: service.uuid }); const chars charRes.characteristics || []; for (const char of chars) { // 这里可以替换成你手动查到的目标 UUID比如 FFE1 if (char.uuid.toUpperCase().includes(FFE1) char.properties.write) { targetServiceId service.uuid; targetWriteCharId char.uuid; } if (char.uuid.toUpperCase().includes(FFE2) char.properties.notify) { targetNotifyCharId char.uuid; } } } if (!targetServiceId) { wx.showToast({ title: 未找到目标服务, icon: none }); return; } this.setData({ deviceId, serviceId: targetServiceId, writeCharId: targetWriteCharId, notifyCharId: targetNotifyCharId }); if (targetNotifyCharId) { wx.notifyBLECharacteristicValueChange({ deviceId, serviceId: targetServiceId, characteristicId: targetNotifyCharId, state: true }); } }这里最值得说的就是那个sleep(500)。为什么非要等 500ms因为在真实测试中连续调用createBLEConnection和getBLEDeviceServices的失败率相当高尤其是安卓手机上。你可以把它理解成蓝牙模块还在适应连接状态立刻读它的服务表它往往还来不及把“目录”整理出来自然会空手而归。3.4 与设备完成一次指令交互连接并打开通知后就可以读写数据了。这段代码包含三个部分字符串转 ArrayBuffer、写入指令、接收设备通知并解析。function stringToArrayBuffer(str) { const buffer new ArrayBuffer(str.length); const view new DataView(buffer); for (let i 0; i str.length; i) { view.setUint8(i, str.charCodeAt(i)); } return buffer; }发数据的核心逻辑async sendCommand(hexString) { const cmd hexString.replace(/\s/g, ); const length cmd.length / 2; const buffer new ArrayBuffer(length); const view new DataView(buffer); for (let i 0; i length; i) { view.setUint8(i, parseInt(cmd.substr(i * 2, 2), 16)); } const res await this.promisify(wx.writeBLECharacteristicValue, { deviceId: this.data.deviceId, serviceId: this.data.serviceId, characteristicId: this.data.writeCharId, value: buffer }); if (res.errCode ! 0) { wx.showToast({ title: 写入失败, icon: none }); } }接收通知的部分wx.onBLECharacteristicValueChange((res) { const value res.value; const bytes new Uint8Array(value); let hexStr ; bytes.forEach((b) { hexStr b.toString(16).padStart(2, 0); }); console.log(蓝牙数据, hexStr); });设备通知的数据通常是十六进制字节流控制台打出来特别直观。拿到原始字节后按硬件手册里的协议把字段切分出来就行。大多数传感器设备都是这样设计的第一个字节是数据头中间几个字节是实际测量值最后一个字节是校验位。这个协议不是通用的必须以你手头设备的硬件手册为准。4. 常见问题与排查技巧实录4.1 扫描不到设备怎么办这是最常被问到的问题通常有四种情况。第一种是用户没授权定位权限尤其安卓手机需要在app.json里配置权限说明并引导用户在系统设置里打开。第二种是系统蓝牙虽然打开但适配器不在可用状态可以调用wx.getBluetoothAdapterState检查available是否为true。第三种是设备没有在广播很多 BLE 设备只有在处于可连接状态时才发送广播如果刚从休眠中唤醒广播包可能还没发出来等几秒再扫。第四种是安卓手机的蓝牙扫描被系统限制某些系统会在一分钟内限制高频扫描这时停止扫描再重新启动往往能解决。我遇到过最绕的一个场景是某次扫描完全没有回调控制台什么都没有最终发现是手机定位服务被关掉了。打开定位服务后小程序一句话没改立刻就能搜到设备。这种事情看起来像是系统 bug但对用户来说就是“打不开”。4.2 连接成功却迟迟收不到数据连接是成功了特征值也找到了但设备就是不上报数据。这通常是因为没有开启notifyBLECharacteristicValueChange。不少初学者以为只要调用了onBLECharacteristicValueChange就能收到数据但前者只是注册了一个“耳朵”设备并不知道你要听它说话必须通过notifyBLECharacteristicValueChange告诉设备请开始发吧。另一个原因是要分清notify和indicate。notify 是设备单方面上报但不确认indicate 则需要小程序回一个确认包。如果设备用的是indicate方式你光打开notify开关是不够的需要在设备特征属性里判断indicate为true然后同样发送一个 state 为true的操作。4.3 写入失败、只能写 20 字节怎么办很多传统 BLE 设备在默认 MTU 只有 23 字节的情况下单次写入的最大数据长度是 20 字节。如果你一股脑把一个 100 字节的 JSON 字符串塞进去大概率会收到写入失败的错误。部分新版本的小程序提供了wx.setBLEMTU接口来协商 MTU但不是所有设备都支持而且不同手机支持的上限也不同。项目里更稳妥的方案是在业务层做分包。比如每次最多写 16 字节一包写完后等待一小段时间再写下一包直到全部写完。这个分包大小要留出余量不要卡着 20 字节上限。我在实际项目里把单包最大长度设成 16主要是因为很多线上设备固件上有额外开销写满 20 字节可能导致缓冲溢出。4.4 连接不稳定与断线重连BLE 设备在远离、休眠、掉电时都会断开连接。小程序里有wx.onBLEConnectionStateChange可以监听拿到connected为false时需要做好 UI 提示和重连逻辑。但重连不能做得太激进否则设备还没恢复广播你的代码已经疯狂发起连接了。我的做法是采用指数退避第一次断开后等 1 秒重连第二次等 2 秒第三次等 4 秒最多等 30 秒。同时在重连成功后要把缓存的服务与特征信息重新拉取一遍不能直接沿用旧的 serviceId。这个坑特别隐蔽因为有些设备连接成功后会重新分配句柄导致上一次保存的特征值失效。5. 实测体验与个性化心得5.1 iOS 与安卓的兼容性差异同一个蓝牙 API在两端的表现差别真的很大。我把实际测试中的差异整理成了一张表方便大家对照排查。差异点iOS安卓定位权限不需要必须打开定位服务权限描述需要蓝牙权限描述需要动态申请定位权限设备名获取多数可以获取到 name部分设备只返回 localName甚至名字为空服务发现速度较快且稳定受厂商实现影响有时需要延迟重试广播过滤支持 service UUID 过滤部分老机型过滤无效需要全量扫描写入长度默认支持较长数据老机型限制较多建议分包RSSI 更新比较平稳跳动幅度大需做平滑处理这些差异不是小概率事件在我调试的多个设备上都出现过因此开发时最好准备一台安卓一台 iPhone 交叉测试。如果团队里只有一台手机至少在选择广播包格式、设计数据分包时给两端都留好余量。5.2 几个不常被提到的调试技巧第一个技巧是打印所有 ArrayBuffer。调试蓝牙数据时千万不要只看字符串格式因为二进制数据不是所有字节都能转成可见字符。我在项目里封装了一个统一的工具函数把收到的Uint8Array转成十六进制字符串并打印到控制台一眼就能看出发送方有没有按协议输出正确字节。第二个技巧是记录读写时间戳。所有writeBLECharacteristicValue和onBLECharacteristicValueChange的回调里都带上时间戳方便判断一条指令从发出到收到设备响应到底耗时多久。BLE 的响应通常有几十到几百毫秒的延迟如果时间超过 1 秒多半是设备本身的协议处理得很慢或者信号质量不好。第三个技巧是善用“获取设备所有服务”的暴力扫描法。当你不确定设备具体用哪个服务 UUID 时把所有服务拉出来逐个打印。虽然开发阶段这么干效率不高但能避免漏掉设备端扩展的私有特征。5.3 后续扩展自动重连与多设备管理这次的微信小程序蓝牙读写数据项目只是最基础的单设备连接链路。如果再往前走主要会碰两件事。一是自动重连。完整版要维护一个全局的连接状态机记录当前连接设备、上次连接成功的时间、重试次数。设备断开后页面能自动回到扫描列表里并且优先展示最近连接过的设备。这个逻辑不难难点在于重连过程中用户又手动点了另一台设备状态冲突必须及时处理。二是多设备管理。真实场景里可能出现同时连接多个相同型号设备的情况这时候微信的wx.onBLECharacteristicValueChange保持单一的全局回调就不是很好用了必须在回调里根据res.deviceId做分发。比较好的做法是设计一个小型订阅分发器把设备消息按 deviceId 派发到各个页面实例。如果让我再做一次这个项目我可能会先花一个小时把设备协议验证清楚再写一行代码。很多人卡在蓝牙连接上真正耗时间的往往不是连接本身而是不知道设备发过来的那串字节到底代表什么。有了清晰的可视化调试数据后小程序代码写得再粗糙也能快速跑通反过来则是连报错都看不明白。做蓝牙开发耐心比技术更重要。设备、手机、小程序三端只要有一端状态不对整个链路就是断的。但只要把基础概念理清楚每个阶段多留一点调试时间微信小程序蓝牙连接读写数据这件事其实比想象中要稳妥得多。