第一次接硬件的时候我以为跟调接口差不多,拿到设备 ID、连上、发数据、收回复,四步搞定。真写起来才知道差得远。安卓上跑得好好的流程,换到 iPhone 上搜不到设备;开完 notify 立刻发消息,回调一次都不进;设备 ID 在安卓上是固定的 MAC 地址,在 iOS 上却每次都变。这些坑都不在文档的显眼位置,得自己撞一遍。这篇把小程序蓝牙从基础概念到完整连接流程整理了一遍,包括那 18 个 API 各自管什么、双端差异怎么处理、哪几个地方必须加延时,以及两个可以直接拿去改的完整例子。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 蓝牙、BLE、GATT 这几个词分别指什么,写代码前必须先分清
- deviceId、serviceId、characteristicId 三层结构是怎么套起来的
- 小程序蓝牙 18 个 API 的分工,哪些是动作,哪些是监听
- 从开适配器到收发数据的完整九步流程,每一步的返回值怎么用
- iOS 拿不到 MAC 地址时,靠什么来认设备
- 两处必须加
setTimeout的地方,不加就是必现失败 ArrayBuffer和十六进制字符串怎么互转,为什么必须转- 两个完整可跑的示例,以及一份踩坑清单
# 一、先把蓝牙这几个概念分清
蓝牙是爱立信公司创立的一种无线技术标准,为短距离的硬件设备提供低成本的通信规范。蓝牙规范由蓝牙技术联盟(Bluetooth Special Interest Group,简称 SIG)管理,在计算机、手机、传真机、耳机、汽车、家用电器等很多场景广泛使用。蓝牙有这么几个特点:
- 免费使用:工作频段在 2.4GHz 的工科医(ISM)频段,无需申请许可证
- 功耗低:BLE4.0 包含了一个低功耗标准(Bluetooth Low Energy),可以让蓝牙的功耗显著降低
- 安全性高:蓝牙规范提供了一套安全加密机制和授权机制,可以有效防范数据被窃取
- 传输率高:BLE4.0 版本理论传输速率可达 3Mbit/s(实际肯定达不到),理论覆盖范围可达 100 米
第四条要补充一句。蓝牙 4.0 这个规范其实包含两条并行的技术线,经典蓝牙那条(BR/EDR)速率高,3Mbit/s 说的是它;低功耗那条(LE)物理层速率低得多,换来的是省电。小程序里操作的是低功耗这条线,所以 API 名字里全都带着 BLE。真实的吞吐能力跟这个理论值差着一个量级,做产品设计的时候按小数据包来规划,别指望拿蓝牙传文件。
搞清楚这个区分很重要,因为它直接决定了你的协议要怎么设计。
# 二、小程序蓝牙 API 总览
# 2.1 几个必须先了解的术语
小程序 API 提供了一套蓝牙操作接口,作为前端开发人员可以更方便地进行蓝牙设备开发,而无需了解安卓和 iOS 的各种蓝牙底层概念。小程序的蓝牙操作大多都是通过异步调用来处理的,这里面就存在着一些坑,后面会详细介绍。
在使用小程序蓝牙 API 之前,有几个术语需要预先了解:
- 蓝牙终端:我们常说的硬件设备,包括手机、电脑等等
- UUID:由字母和数字组成的标识串,跟硬件设备关联的唯一 ID
- 设备地址:每个蓝牙设备都有一个设备地址
deviceId,但是安卓和 iOS 差别很大。安卓下设备地址就是 MAC 地址,但是 iOS 无法获取 MAC 地址,所以设备地址是针对本机范围有效的 UUID,这里需要注意 - 设备服务列表:每个设备都存在一些服务列表,可以跟不同的设备进行通信,服务有一个
serviceId来维护,每个服务包含了一组特征值 - 服务特征值:包含一个单独的 value 值和 0 到 n 个用来描述 characteristic 值(value)的 descriptors。一个 characteristic 可以被认为是一种类型的,类似于一个类
- ArrayBuffer:小程序中对蓝牙数据的传递是使用 ArrayBuffer 的二进制类型来的,所以在我们的使用过程中需要进行转码
关于 UUID 那条,原文写的是「40 个字符串的序号」,这个数字不太准确,这里顺手改一下。UUID 是 128 位,标准的字符串表示是 32 个十六进制字符加 4 个连字符,一共 36 个字符。不过小程序里你还会碰到 16 位的短 UUID,比如 0000FFE0-0000-1000-8000-00805F9B34FB 这种蓝牙 SIG 规定的标准服务,日常写代码时按拿到的原值用就行,别自己拼。
设备、服务、特征值三者是层层嵌套的关系,这张图把它们的包含关系画出来了:

理解这张图是写好蓝牙代码的前提。一台设备下面挂多个服务,一个服务下面挂多个特征值,真正能读写的只有最底层的特征值。所以从连上设备到能发数据,中间必须走完「找服务」和「找特征值」两步,每一步都是异步的。
这也是为什么蓝牙代码天生就是一串回调套回调。
# 2.2 18 个 API 各管什么
小程序对蓝牙设备的操作有 18 个 API:
| API名称 | 说明 |
|---|---|
openBluetoothAdapter |
初始化蓝牙适配器,在此可用判断蓝牙是否可用 |
closeBluetoothAdapter |
关闭蓝牙连接,释放资源 |
getBluetoothAdapterState |
获取蓝牙适配器状态,如果蓝牙未开或不可用,这里可用检测到 |
onBluetoothAdapterStateChange |
蓝牙适配器状态发生变化事件,这里可用监控蓝牙的关闭和打开动作 |
startBluetoothDevicesDiscovery |
开始搜索设备,蓝牙初始化成功后就可以搜索设备 |
stopBluetoothDevicesDiscovery |
当找到目标设备以后需要停止搜索,因为搜索设备是比较消耗资源的操作 |
getBluetoothDevices |
获取已经搜索到的设备列表 |
onBluetoothDeviceFound |
当搜索到一个设备时的事件,在此可用过滤目标设备 |
getConnectedBluetoothDevices |
获取已连接的设备 |
createBLEConnection |
创建BLE连接 |
closeBLEConnection |
关闭BLE连接 |
getBLEDeviceServices |
获取设备的服务列表,每个蓝牙设备都有一些服务 |
getBLEDeviceCharacteristics |
获取蓝牙设备某个服务的特征值列表 |
readBLECharacteristicValue |
读取低功耗蓝牙设备的特征值的二进制数据值 |
writeBLECharacteristicValue |
向蓝牙设备写入数据 |
notifyBLECharacteristicValueChange |
开启蓝牙设备notify提醒功能,只有开启这个功能才能接受到蓝牙推送的数据 |
onBLEConnectionStateChange |
监听蓝牙设备错误事件,包括异常断开等等 |
onBLECharacteristicValueChange |
监听蓝牙推送的数据,也就是notify数据 |
这 18 个 API 可以按名字前缀分成三类,分清楚之后就好记多了。
on 开头的是监听,注册一次之后被动等回调,它们不是「执行一次拿结果」的那种。get 开头的是查询,一次调用返回一次快照。剩下的是动作,开关适配器、开关搜索、连接断连、读写数据。
另外注意 API 名字里带不带 BLE。带 BLE 的是针对已连接的某台低功耗设备做操作,不带的是针对本机适配器或者搜索行为。搞混这一层,你会发现自己在没连设备的时候调了 getBLEDeviceServices,然后收到一个看不懂的错误码。
# 三、完整流程走一遍
蓝牙通信的一个正常流程是下面的图示:

这九步是有严格先后顺序的,前一步的返回值就是后一步的入参,跳步就会失败。下面逐步拆。
# 3.1 开启蓝牙与检查状态
调用 openBluetoothAdapter 来开启和初始化蓝牙,这时候可以根据状态判断用户设备是否支持蓝牙。接着调用 getBluetoothAdapterState 来检查蓝牙是否开启,没有开启就在这里提醒用户开启,并且能在开启后自动启动下面的步骤。
这里有一个坑:iOS 里面蓝牙状态变化以后不能马上开始搜索,否则会搜索不到设备,必须要等待 2 秒以上。
function connect(){
wx.openBluetoothAdapter({
success: function (res) {
},
fail(res){
},
complete(res){
wx.onBluetoothAdapterStateChange(function(res) {
if(res.available){
setTimeout(function(){
connect();
},2000);
}
})
//开始搜索
}
})
}
这段代码的思路是:不管 openBluetoothAdapter 成功还是失败,都先把状态监听挂上。用户当时没开蓝牙,openBluetoothAdapter 会失败;等他去系统设置里打开了,onBluetoothAdapterStateChange 就会触发,res.available 变成 true,延时 2 秒之后重新走一遍 connect。
那 2 秒是必须的。系统上报「蓝牙可用」和蓝牙协议栈真正准备好之间有一段空档,iOS 上尤其明显。这个空档里发起搜索,回调一次都不进,也不报错,就是安安静静地什么都搜不到。排查这种没有报错的问题最费时间,因为你会先怀疑自己的过滤逻辑写错了。
不加延时不是「可能失败」,是「必现失败」。
# 3.2 搜索设备,以及双端最大的差异
startBluetoothDevicesDiscovery 开始搜索设备,当发现一个设备会触发 onBluetoothDeviceFound 事件。先看下标准 API:

由于 iOS 无法获取 MAC 地址,所以这里需要区分两个场景。
安卓下可以根据 MAC 地址来搜索设备,或者跳过此步直接连接到设备。当搜索到一个设备以后,可以在 onBluetoothDeviceFound 事件回调中判断当前设备的 deviceId 是否为指定的 MAC 地址:
let mac = "XXXXXXXXXXXXXXX";
wx.startBluetoothDevicesDiscovery({
services:[],
success(res) {
wx.onBluetoothDeviceFound(res=>{
let devices = res.devices;
for(let i = 0;i<devices.length;i++){
if(devices[i].deviceId === mac){
console.log("find");
wx.stopBluetoothDevicesDiscovery({
success:res=>console.log(res),
fail:res=>console.log(res),
})
}
}
});
},
fail(res){
console.log(res);
}
})
这里改了原文的一处代码错误。原文写的是 if(devices[i].deviceId = mac),一个等号,那是赋值不是比较,结果就是每次判断都返回真值,第一个搜到的设备就会被当成目标。这类错误编译不报、eslint 不开也不提示,跑起来的现象是「随便连上一台设备然后一直失败」,非常难查。改成了 ===。
iOS 下获取设备 MAC 地址的方法已经被屏蔽,所以不存在 MAC 地址,此时只能通过其他方式来判断,比如在蓝牙设备 advertisData 字段添加一些特别的信息来判断,可以转字符串来判断,也可以直接用二进制来判断: