一、BLE 能力全景与前置准备
1.1 能力边界
小程序提供的是**低功耗蓝牙(BLE)**能力,不支持经典蓝牙(BR/EDR)的音频与文件传输协议:
| 能力 | 是否支持 | 说明 |
|---|---|---|
| BLE 扫描与连接 | 支持 | wx.startBluetoothDevicesDiscovery |
| GATT 服务发现与读写 | 支持 | 读写特征值、订阅通知 |
| 经典蓝牙 SPP | 不支持 | 无法做串口透传设备 |
| 蓝牙音频(A2DP/HFP) | 不支持 | 音频走系统设置配对 |
| 后台持续扫描 | 不支持 | 小程序切后台即停止 |
| iOS 与 Android 一致性 | 部分 | 设备 ID、权限、MTU 差异较大 |
1.2 初始化
一切从 wx.openBluetoothAdapter 开始,它是所有蓝牙 API 的前置条件:
function initBluetooth() {
return new Promise((resolve, reject) => {
wx.openBluetoothAdapter({
mode: 'central', // 手机作为中心设备
success: resolve,
fail: (err) => {
// 10000 未初始化适配器;10001 适配器不可用(通常是手机蓝牙未打开)
if (err.errCode === 10001) {
wx.showModal({
title: '请打开蓝牙',
content: '需要开启手机蓝牙才能搜索设备',
confirmText: '去设置',
success: (r) => { if (r.confirm) wx.openSystemBluetoothSetting(); }
});
}
reject(err);
}
});
});
}
// 用户中途关蓝牙时清理连接状态
wx.onBluetoothAdapterStateChange((res) => {
if (!res.available) resetConnectionState();
});
1.3 权限准备
| 平台 | 必需权限 | 申请方式 |
|---|---|---|
| iOS | 蓝牙权限 | 系统自动弹窗,openBluetoothAdapter 触发 |
| iOS | 定位权限(部分设备扫描) | wx.authorize({ scope: 'scope.userLocation' }) |
| Android 6 到 11 | 定位权限必须开启,否则扫描不到设备 | scope.userLocation 加系统定位开关 |
| Android 12+ | 蓝牙扫描权限 | 系统按新权限模型申请 |
Android 上「扫描不到任何设备」的第一排查项永远是:系统定位开关是否打开。这不是小程序的限制,而是 Android 对 BLE 扫描的强制要求。授权处理上,先用 wx.getSetting 读 authSetting['scope.userLocation']:为 false 说明用户曾拒绝,只能调 wx.openSetting() 引导去设置页开启;未申请过则调 wx.authorize({ scope: 'scope.userLocation' }) 发起申请。
二、扫描与设备发现
2.1 开始扫描
async function startScan() {
await initBluetooth();
wx.onBluetoothDeviceFound((res) => {
res.devices.forEach((device) => {
// device.name 可能为空字符串,需要按规则过滤
if (!/^SmartLock_/.test(device.localName || '')) return;
handleDevice({
deviceId: device.deviceId,
name: device.localName || device.name,
rssi: device.RSSI,
advertisData: device.advertisData, // ArrayBuffer
advertisServiceUUIDs: device.advertisServiceUUIDs
});
});
});
wx.startBluetoothDevicesDiscovery({
services: ['0000FFE0-0000-1000-8000-00805F9B34FB'], // 只扫特定服务,降功耗减噪音
allowDuplicatesKey: false, // 同一设备只上报一次
powerLevel: 'high', // Android 有效
fail: (err) => console.error('扫描失败', err)
});
}
2.2 扫描参数取舍
| 参数 | 取值 | 影响 |
|---|---|---|
services | 服务 UUID 数组 | 为空时扫描所有设备,耗电高、噪音大 |
allowDuplicatesKey | false / true | false 时同一设备只上报一次;true 可实时看到 RSSI 变化 |
interval | 毫秒 | 配合 allowDuplicatesKey: true 使用,0 表示不限制 |
powerLevel | low/medium/high | Android 有效,低功耗模式扫描距离变短 |
2.3 扫描治理
扫描是高耗电操作,必须严格管理生命周期:开始扫描时设一个 15 秒超时定时器,超时后调用 wx.stopBluetoothDevicesDiscovery() 并提示「未发现设备,请靠近重试」;页面的 onHide 与 onUnload 里也务必停止扫描。其他实用 API 还有 wx.getBluetoothDevices(已发现设备列表)、wx.getConnectedBluetoothDevices(按服务筛选已连接设备)与 wx.getBLEDeviceRSSI(读取信号强度,可用于「靠近设备」引导)。
三、建立连接与状态管理
3.1 连接与状态监听
function connect(deviceId, timeout = 10000) {
// 10012 连接超时;10003 连接失败
return new Promise((resolve, reject) => {
wx.createBLEConnection({ deviceId, timeout, success: resolve, fail: reject });
});
}
// 建立连接后立刻挂监听,否则断线时业务无感知
wx.onBLEConnectionStateChange((res) => {
if (!res.connected) {
markDisconnected(res.deviceId);
scheduleReconnect(res.deviceId);
}
});
3.2 重连策略
BLE 断线在移动端非常常见(系统省电、距离变远、设备重启),重连必须带退避与上限:
function scheduleReconnect(deviceId) {
const state = reconnectMap.get(deviceId) || { attempts: 0, timer: null };
if (state.attempts >= 5) {
wx.showToast({ title: '设备连接已断开', icon: 'none' });
return;
}
const delay = Math.pow(2, state.attempts) * 1000; // 1s, 2s, 4s, 8s, 16s
state.timer = setTimeout(async () => {
state.attempts += 1;
reconnectMap.set(deviceId, state);
try {
await connect(deviceId);
await discoverGatt(deviceId); // 必须重新发现服务
await resubscribe(deviceId); // 必须重新订阅通知
} catch (e) {
scheduleReconnect(deviceId); // 递归触发下一次退避重试
}
}, delay);
reconnectMap.set(deviceId, state);
}
3.3 连接生命周期
重连成功后必须重新发现服务并重新订阅通知:GATT 缓存与订阅关系在断线后失效,只重连不重订阅会导致「连上了但收不到数据」。完整的调用顺序是:openBluetoothAdapter 到 startBluetoothDevicesDiscovery 再到 onBluetoothDeviceFound 完成发现;createBLEConnection 配合 onBLEConnectionStateChange 完成连接;getBLEDeviceServices 与 getBLEDeviceCharacteristics 完成服务发现;notifyBLECharacteristicValueChange 与 onBLECharacteristicValueChange 收数据,writeBLECharacteristicValue 发数据;最后 closeBLEConnection 与 closeBluetoothAdapter 释放资源。一个页面同时连接的设备数建议不超过 3 台,超过后 Android 上失败率明显上升。
四、GATT 服务与特征值读写
4.1 发现服务与特征值
async function discoverGatt(deviceId) {
const services = await promisify(wx.getBLEDeviceServices)({ deviceId });
for (const service of services.services) {
const chars = await promisify(wx.getBLEDeviceCharacteristics)({
deviceId, serviceId: service.uuid
});
chars.characteristics.forEach((c) => console.log('特征值:', c.uuid, c.properties));
}
}
properties 的字段含义:
| 属性 | 含义 | 对应操作 |
|---|---|---|
read | 可读 | wx.readBLECharacteristicValue |
write | 可写(有响应) | wx.writeBLECharacteristicValue |
writeNoResponse | 可写(无响应) | 同上,速度快但不保证送达 |
notify | 支持通知 | notifyBLECharacteristicValueChange |
indicate | 支持指示 | 同上,带确认,更可靠但更慢 |
4.2 订阅通知
function subscribe(deviceId, serviceId, characteristicId, handler) {
wx.onBLECharacteristicValueChange((res) => {
// 全局回调会收到所有特征值的数据,必须按 id 分发
if (res.deviceId === deviceId && res.characteristicId === characteristicId) {
handler(res.value); // res.value 是 ArrayBuffer
}
});
wx.notifyBLECharacteristicValueChange({
deviceId, serviceId, characteristicId, state: true,
fail: (err) => console.error('订阅失败', err)
});
}
常见坑:wx.onBLECharacteristicValueChange 是全局注册,多次调用会叠加回调。正确做法是只注册一次全局回调,在回调内按 deviceId + serviceId + characteristicId 分发。读取单次值则用 wx.readBLECharacteristicValue,并在同一次回调里按 characteristicId 匹配后 wx.offBLECharacteristicValueChange 解绑。
4.3 MTU 与分包
BLE 单包有效载荷默认只有 20 字节(MTU 23 减去 3 字节 ATT 头),发送长数据必须分包:
async function writeLong(deviceId, serviceId, characteristicId, buffer, chunkSize = 20) {
const total = buffer.byteLength;
for (let offset = 0; offset < total; offset += chunkSize) {
const chunk = buffer.slice(offset, Math.min(offset + chunkSize, total));
await write(deviceId, serviceId, characteristicId, chunk);
await sleep(20); // 部分设备固件需要包间隔,过快会丢包
}
}
// Android 可协商更大 MTU 提升吞吐,成功时返回实际单包载荷
function requestMtu(deviceId, mtu) {
if (!wx.setBLEMTU) return Promise.resolve(20);
return new Promise((resolve) => {
wx.setBLEMTU({
deviceId, mtu, // iOS 最大 185,Android 最大 512
success: (res) => resolve(Math.max(res.mtu - 3, 20)),
fail: () => resolve(20)
});
});
}
| 平台 | 默认 MTU | 可协商上限 | 单包载荷 |
|---|---|---|---|
| iOS | 23 | 185 | 最大 182 字节 |
| Android | 23 | 512 | 最大 509 字节 |
五、二进制数据编解码
5.1 基础转换
小程序里 TextEncoder 与 TextDecoder 支持不完整,建议手写转换工具:
// utils/buffer.js
// ArrayBuffer -> 十六进制字符串(调试打印用)
function ab2hex(buffer) {
return Array.prototype.map
.call(new Uint8Array(buffer), (b) => b.toString(16).padStart(2, '0'))
.join('');
}
字符串与 ArrayBuffer 的互转需要手写 UTF-8 编解码:编码时按码点大小分别输出 1 字节、0xc0 | (code >> 6) 加 0x80 | (code & 0x3f) 的两字节形式,或 0xe0 | (code >> 12) 起头的三字节形式;解码时按首字节的高位判断字节数再逐位还原。hex2ab 则是把十六进制串按每两个字符 parseInt(hex.substr(i * 2, 2), 16) 还原成字节。这些函数互为逆操作,建议一起放进 utils/buffer.js 并补上单元测试。
5.2 二进制协议解析
设备协议通常是「固定包头 + 长度 + 命令字 + 载荷 + 校验」,本例中帧头为 0xAA、第 1 字节为版本、第 2 到 3 字节为小端载荷长度、第 4 字节为命令字,之后是载荷,最后 1 字节为前面所有字节累加取低 8 位的校验和:
function parseFrame(buffer) {
const view = new DataView(buffer);
const bytes = new Uint8Array(buffer);
if (bytes[0] !== 0xaa) throw new Error('非法帧头');
const length = view.getUint16(2, true); // 小端
const command = view.getUint8(4);
const payload = buffer.slice(5, 5 + length);
let sum = 0;
for (let i = 0; i < 5 + length; i++) sum = (sum + bytes[i]) & 0xff;
if (sum !== view.getUint8(5 + length)) throw new Error('校验失败');
return { command, payload };
}
5.3 粘包处理
设备连续上报时,一次回调可能只收到半帧,也可能收到多帧。做法是维护一个 Uint8Array 缓冲区:每次收到数据先拼接,然后循环判断——若首字节不是 0xAA 就向后寻找帧头重新对齐;若缓冲区长度不足 5 + length + 1 就跳出等待后续数据;长度足够时切出完整帧交给 parseFrame 解析,并把缓冲区剩余部分保留给下一轮。
push(chunk) {
const merged = new Uint8Array(this.buffer.length + chunk.byteLength);
merged.set(this.buffer, 0);
merged.set(new Uint8Array(chunk), this.buffer.length);
this.buffer = merged;
while (this.buffer.length >= 6) {
if (this.buffer[0] !== 0xaa) {
const idx = this.buffer.indexOf(0xaa, 1);
this.buffer = idx === -1 ? new Uint8Array(0) : this.buffer.slice(idx);
continue;
}
const length = this.buffer[2] | (this.buffer[3] << 8);
const frameLen = 5 + length + 1;
if (this.buffer.length < frameLen) break; // 半帧,等待后续数据
const frame = this.buffer.slice(0, frameLen);
this.buffer = this.buffer.slice(frameLen);
try { this.onFrame(parseFrame(frame.buffer)); }
catch (e) { console.warn('帧解析失败', e.message); }
}
}
每帧解析失败时只丢掉当前帧继续循环,避免一帧错误导致后续数据全部错位。
六、配网流程设计
6.1 三种配网方式对比
| 方式 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 蓝牙配网 | 通过 BLE 把 WiFi 账号密码发给设备 | 成功率高、可回传状态、体验好 | 设备需带 BLE |
| SoftAP 配网 | 手机连设备热点,HTTP 提交 WiFi 信息 | 兼容性最好、不依赖 BLE | 需手动切换 WiFi,iOS 体验差 |
| 一键配网(SmartConfig) | 广播含密码的 UDP 包,设备嗅探 | 无需切换网络 | 成功率低 |
当前主流方案是蓝牙配网:设备出厂处于 BLE 广播态,小程序扫描连接后通过特征值写入 WiFi 凭据,设备联网后通过 BLE 回传结果。另需注意,绝大多数 IoT 设备只支持 2.4GHz WiFi,不支持 5GHz,配网前应先检测手机当前连接频段并给出提示。
6.2 配网实现
async function provision(deviceId, serviceId, writeCharId, notifyCharId, wifi) {
const token = await fetchProvisionToken(); // 服务端下发的短时凭据
const resultPromise = waitForResult(deviceId, notifyCharId, 30000);
// 凭据通常超过 20 字节,需要按 MTU 分包写入
const payload = JSON.stringify({
ssid: wifi.ssid, password: wifi.password, token, endpoint: 'iot.example.com'
});
await writeLong(deviceId, serviceId, writeCharId, str2ab(payload));
const result = await resultPromise;
if (result.code !== 0) throw new Error(mapErrorCode(result.code));
return result;
}
function waitForResult(deviceId, characteristicId, timeout) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('配网超时')), timeout);
const handler = (res) => {
if (res.characteristicId !== characteristicId) return;
clearTimeout(timer);
wx.offBLECharacteristicValueChange(handler);
resolve(JSON.parse(ab2str(res.value)));
};
wx.onBLECharacteristicValueChange(handler);
});
}
配网失败的常见错误码需要映射成用户能懂的话:
| 错误码 | 含义 | 用户提示 |
|---|---|---|
| 1 | WiFi 密码错误 | 请检查 WiFi 密码后重试 |
| 2 | 未找到该 WiFi | 请确认路由器已开启 2.4G 频段 |
| 3 | 连接路由器超时 | 请让设备靠近路由器 |
| 4 | 服务器不可达 | 请检查网络后重试 |
| 5 | 凭据已过期 | 请重新发起配网 |
七、稳定性与平台差异
7.1 常见错误码速查
| errCode | 含义 | 处理 |
|---|---|---|
| 10000 | 未初始化蓝牙适配器 | 先调 openBluetoothAdapter |
| 10001 | 蓝牙适配器不可用 | 引导用户打开手机蓝牙 |
| 10002 | 没有找到指定设备 | 重新扫描 |
| 10003 | 连接失败 | 重试,检查设备是否已被其他手机连接 |
| 10004 / 10005 | 没有找到指定服务或特征值 | 重新发现,核对 UUID 大小写 |
| 10006 | 当前连接已断开 | 触发重连流程 |
| 10007 | 特征值不支持此操作 | 检查 properties 是否含 write 或 read |
| 10012 / 10013 | 连接超时或 deviceId 为空 | 加大 timeout,检查参数传递 |
7.2 iOS 与 Android 的差异
| 维度 | iOS | Android |
|---|---|---|
deviceId 含义 | 系统分配的 UUID,同一设备在不同手机上不同 | MAC 地址,跨手机一致 |
| 扫描权限 | 蓝牙权限,部分场景需定位 | 6 到 11 必须开系统定位 |
| 连接数上限 | 约 5 到 7 台 | 约 3 到 7 台,机型差异大 |
| MTU | 最大 185 | 最大 512 |
| 服务 UUID | 通常返回全大写 | 可能是短 UUID 或小写 |
| 写入间隔 | 可较快 | 部分机型需 20ms 以上间隔 |
UUID 大小写不一致是最常见的隐性坑:iOS 返回大写、Android 返回小写,导致按字符串匹配的代码在某个平台失效。统一在比较前 toUpperCase()。
7.3 真机调试清单
[ ] 手机蓝牙已开启,且已授权小程序;Android 还需打开系统定位
[ ] 设备未被其他手机占用(BLE 通常只允许单连接)
[ ] 设备电量充足,距离在 5 米内
[ ] 避开微波炉、USB 3.0 设备等 2.4G 干扰源
[ ] 使用 vConsole 或自定义日志面板查看回调时序
开发者工具不支持真实蓝牙通信,只能模拟部分 API 返回。所有 BLE 逻辑必须在真机上验证,这是小程序蓝牙开发与普通业务开发最大的区别。
7.4 状态机管理
蓝牙连接是多状态流程,用状态机管理比散落的布尔标记可靠得多。把状态定义为 IDLE、SCANNING、CONNECTING、CONNECTED、DISCOVERING、READY、RECONNECTING 七种,用 setState 统一切换并通知订阅者。页面的 UI 应该完全由状态驱动:IDLE 展示「开始搜索」,SCANNING 展示扫描动画,CONNECTING 展示进度,READY 展示设备控制面板。这样避免「连上了但按钮还是不可点」这类状态不一致问题。
八、总结
小程序蓝牙开发的难点不在 API 数量,而在状态管理与平台差异。API 层面记住三条主线即可:openBluetoothAdapter 到 startBluetoothDevicesDiscovery 的发现链路、createBLEConnection 到 getBLEDeviceCharacteristics 的 GATT 链路、notifyBLECharacteristicValueChange 与 writeBLECharacteristicValue 的收发链路。
工程上必须做对四件事:全局只注册一次特征值回调并按 ID 分发,避免回调叠加;断线重连后重新发现服务并重新订阅,否则连上也没数据;长数据按 MTU 分包并留包间隔,iOS 默认 20 字节、Android 可协商到 512;应用层做粘包拼装与校验,不要假设一次回调就是完整一帧。平台差异上,Android 必须打开系统定位才能扫描,iOS 与 Android 的 deviceId 语义完全不同(UUID 与 MAC),服务 UUID 大小写也不一致,所有比较前统一大写。
配网场景建议优先选蓝牙配网而非 SoftAP:成功率高、能回传失败原因、不用让用户切 WiFi。配网凭据要走服务端下发的短时 token,不要在小程序里长期保存 WiFi 密码。最后,开发者工具无法验证真实蓝牙行为,真机调试与错误码上报是唯一的可靠手段,把 errCode 与关键时序都上报到监控,才能定位「某些机型偶发连不上」这类只能靠数据解决的问题。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。