小程序蓝牙与 IoT 设备连接实战

系统讲解小程序蓝牙与 IoT 设备连接实战:BLE 能力全景与权限准备、扫描与设备发现、GATT 服务与特征值读写、ArrayBuffer 二进制编解码、softAP 与蓝牙配网流程、连接稳定性与重连,以及 iOS 与 Android 的真机差异与踩坑。

一、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 数组为空时扫描所有设备,耗电高、噪音大
allowDuplicatesKeyfalse / truefalse 时同一设备只上报一次;true 可实时看到 RSSI 变化
interval毫秒配合 allowDuplicatesKey: true 使用,0 表示不限制
powerLevellow/medium/highAndroid 有效,低功耗模式扫描距离变短

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可协商上限单包载荷
iOS23185最大 182 字节
Android23512最大 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);
  });
}

配网失败的常见错误码需要映射成用户能懂的话:

错误码含义用户提示
1WiFi 密码错误请检查 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 的差异

维度iOSAndroid
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 与关键时序都上报到监控,才能定位「某些机型偶发连不上」这类只能靠数据解决的问题。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「miniprogram」更多文章

  1. 小程序第三方 SDK 集成与治理
  2. 小程序架构演进与遗留重构
  3. 小程序无障碍与适老化改造