小程序连上蓝牙标签打印机:wx.openBluetoothAdapter 从扫描到写数据的实战笔记

2026-09-22 01:23:01 1 次浏览
微信小程序蓝牙 BLE硬件交互前端开发物联网

适用读者:已经会写微信小程序、现在被要求把一台 BLE 硬件(标签机、票据机、手持终端)接进去的前端。你需要懂 Promise 和 ArrayBuffer,蓝牙协议栈不用懂。 文中代码在微信开发者工具 1.06.24(基础库 3.5.x)跑通,真机覆盖 Android 12/13 与 iOS 16/17。 如果你只是想让手机连个手环看步数,这篇对你有点啰嗦,看官方文档的 quickstart 就够了。

门店同事第一次用我写的小程序打价签,站在货架前等了四十秒没吐纸,回来跟我说还不如拿手机拍张照片。查下来问题不在打印机:我把 3KB 的打印指令一股脑塞进了一次 writeBLECharacteristicValue,而低功耗蓝牙(Bluetooth Low Energy, BLE)默认一次只肯带 20 字节有效数据,剩下的被底层静默丢掉,回调里还报 success。这事儿坑了整个项目组两周。

门店价签这台活儿,为什么非得走 BLE

先说清楚业务约束,不然下面的技术选型全是空中楼阁。某连锁便利店做价签改造,店员用小程序扫商品条码、改价、当场打一张 50mm×30mm 的热敏价签贴货架。方案评审时提过云打印:小程序把数据发到服务器,服务器再推给门店的网口打印机。这条路被否掉的理由很实在——门店 WiFi 是运营商的宽带,晚高峰掉线是常态,而价签必须当场出来,店员不会站在货架旁边等网络恢复。

主题配图

于是只剩设备直连。门店采购的标签机是 BLE 4.2 的单模芯片,没有串口协议(Serial Port Profile, SPP),只有通用属性协议(Generic Attribute Profile, GATT)通道,也就是说手机能做的只有"往某个特征值里写字节、从某个特征值里读字节"这两件事。

方案 首张价签耗时 断网可用性 门店改造成本 我们最后的选择
云打印(小程序→服务器→网口打印机) 2.5s ~ 8s,抖动大 断网即不可用 每台机要拉网线和电源
小程序直连 BLE 标签机 0.9s ~ 1.6s 完全不受网络影响 0,机器本来就要买
店员用 PC 客户端打印 6s 以上(要走到后台) 可用但流程长 每个门店一台 PC

这里的设备侧事实先摆出来:打印速度取决于两件事——无线链路一包能带多少字节,以及打印机自己的打印缓冲区消化速度。前者我们能优化,后者只能等。

整条链路拆开看一遍

在动手之前,我画了一张时序图。BLE 的应用开发本质上就是一个"发现—连接—订阅—写—断"的状态机,把这个状态机刻在脑子里,后面所有报错都能定位到具体环节。

sequenceDiagram
    participant 小程序 as 微信小程序
    participant 模块 as 微信 BLE 原生模块
    participant 打印机 as BLE 标签打印机
    小程序->>模块: wx.openBluetoothAdapter 打开适配器
    模块-->>小程序: onBluetoothAdapterStateChange(available=true)
    小程序->>模块: wx.startBluetoothDevicesDiscovery 开始扫描
    打印机-->>模块: 广播包(含 RSSI 与服务 UUID)
    模块-->>小程序: onBluetoothDeviceFound 回调(先过滤 RSSI)
    小程序->>模块: wx.stopBluetoothDevicesDiscovery 停止扫描
    小程序->>模块: wx.createBLEConnection(deviceId)
    模块->>打印机: 建立链路层连接
    打印机-->>模块: 连接完成
    小程序->>模块: wx.setBLEMTU 协商最大传输单元(仅 Android 有效)
    小程序->>模块: wx.getBLEDeviceServices 拉服务列表
    小程序->>模块: wx.getBLEDeviceCharacteristics 拉特征值
    小程序->>模块: wx.notifyBLECharacteristicValueChange 订阅回传
    loop 按 20 字节切片串行写入
        小程序->>模块: wx.writeBLECharacteristicValue(chunk)
        模块->>打印机: ATT Write 请求
    end
    打印机-->>模块: notify 回传打印状态
    模块-->>小程序: onBLECharacteristicValueChange

图中第 10 步的 MTU 协商在 iOS 上是空的,第 13 步的循环是整个项目踩坑最集中的地方。下面按环节拆。

初始化:openBluetoothAdapter 其实做了三件事

微信的蓝牙 API 有个前置条件:所有 BLE 接口都依赖"适配器已打开"这个状态。很多人(包括第一版的我)在页面 onLoad 里直接调扫描,结果 Android 上偶发 10001 not available,iOS 上偶发静默失败。根因是适配器状态是一个异步收敛的过程,你调用 openBluetoothAdapter 的 success 回调只代表"调用成功",不代表"可用"。

正确做法是监听 onBluetoothAdapterStateChange,等 available 变 true 再往下走,同时监听 discovering 防止扫描被系统抢走。

// ble/adapter.js
// 环境:微信基础库 2.20+ / 3.x,无第三方依赖
// 作用:把适配器初始化包装成一个只 resolve 一次的 Promise
let adapterReadyPromise = null;

function ensureAdapter() {
  // 已经初始化过就直接复用,避免重复 open 触发 10001
  if (adapterReadyPromise) return adapterReadyPromise;

  adapterReadyPromise = new Promise((resolve, reject) => {
    // 先注册监听再 open,顺序反了会漏掉早期的状态事件
    wx.onBluetoothAdapterStateChange((res) => {
      // available 为 true 表示系统蓝牙已开且小程序拿到了使用权
      if (res.available) resolve(true);
      // 用户中途关掉系统蓝牙,要把 promise 置空,下次重新走完整流程
      else adapterReadyPromise = null;
    });

    wx.openBluetoothAdapter({
      // 小程序只能做中心设备,peripheral 模式不支持
      mode: 'central',
      success: () => {
        // 这里不能直接 resolve:success 只代表调用被接受
        // 真正可用的信号来自上面的 onBluetoothAdapterStateChange
        console.log('openBluetoothAdapter 调用成功,等待 available');
      },
      fail: (err) => {
        // 10001 = 蓝牙适配器不可用,常见原因是系统蓝牙没开
        // 10002 = 权限未授予,检查 app.json 是否声明 bluetooth
        // 失败时置空,允许下一次重试重新建立 promise
        adapterReadyPromise = null;
        reject(new Error(`适配器打开失败: ${err.errCode} ${err.errMsg}`));
      }
    });
  });

  return adapterReadyPromise;
}

module.exports = { ensureAdapter };

两个容易漏的点。一是 mode: 'central' 建议显式写,基础库 2.10 之后这个字段成了事实上的约定。二是要在 app.jsonpermission 里声明 scope.bluetooth,iOS 上还要配 NSBluetoothAlwaysUsageDescription,否则审核阶段会被打回。

扫描:RSSI 过滤比设备名过滤靠谱

startBluetoothDevicesDiscovery 会把周围所有 BLE 广播都吐给你,商场里随便一扫就是几十个设备,其中一大半是别人的手环和耳机。我们一开始用设备名前缀过滤,结果同款打印机固件升级后名字从 Printer-XXXX 变成了 TSP-P30,线上直接失效。

更稳的做法是两层过滤:先用 services 参数限定广播的服务 UUID(打印机厂商一般会定义一个私有 UUID),再用 RSSI(Received Signal Strength Indication,接收信号强度)做距离筛选。RSSI 是负数,-40 比 -80 近得多。门店场景里要求店员把手机贴近打印机,我们卡在 -70 以上。

// ble/scanner.js
// 环境:依赖上面的 adapter.js;PRINTER_SERVICE_UUID 由厂商文档给出
// 作用:扫一轮附近设备,按信号强度排序后返回
const PRINTER_SERVICE_UUID = '0000FF00-0000-1000-8000-00805F9B34FB';
// 信号弱于这个值就认为不在手边,不进候选列表
const RSSI_THRESHOLD = -70;
// 扫描 8 秒还没找到就停,一直扫非常耗电
const SCAN_TIMEOUT = 8000;

function scanOnce() {
  return new Promise((resolve, reject) => {
    // deviceId -> device,用 Map 天然去重
    const found = new Map();
    const timer = setTimeout(() => finish(), SCAN_TIMEOUT);

    function finish() {
      // 收尾一定要停扫描,否则 iOS 后台会持续耗电
      wx.stopBluetoothDevicesDiscovery({ fail: () => {} });
      // 解绑监听,避免页面卸载后还在吃回调
      wx.offBluetoothDeviceFound(handler);
      clearTimeout(timer);
      // 按 RSSI 从强到弱排序,UI 直接取第一个即可
      const list = Array.from(found.values()).sort((a, b) => b.RSSI - a.RSSI);
      list.length ? resolve(list) : reject(new Error('附近没有找到标签打印机'));
    }

    function handler(res) {
      res.devices.forEach((d) => {
        // 广播包里没带名字的设备直接丢掉
        if (!d.name && !d.localName) return;
        // RSSI 闸门:太远的不要,防止连到隔壁门店的机器
        if (typeof d.RSSI === 'number' && d.RSSI < RSSI_THRESHOLD) return;
        // 同一台设备会被反复回调,后一次信号更强就覆盖旧的
        const prev = found.get(d.deviceId);
        // 没记录过,或者这次信号更好,就写进去
        if (!prev || d.RSSI > prev.RSSI) found.set(d.deviceId, d);
      });
    }

    wx.onBluetoothDeviceFound(handler);
    wx.startBluetoothDevicesDiscovery({
      // 只扫带目标服务的设备,iOS 上这一步能显著降低噪音
      services: [PRINTER_SERVICE_UUID],
      // 必须为 true,否则同一设备只上报一次,拿不到持续更新的 RSSI
      allowDuplicatesKey: true,
      // 上报间隔,单位毫秒
      interval: 250,
      success: () => console.log('开始扫描'),
      fail: reject
    });
  });
}

module.exports = { scanOnce };

allowDuplicatesKey: true 这一行是重点,默认是 false,同一设备只上报一次,RSSI 也就只有一帧快照,做不了强弱排序。

连接与 MTU:三秒是个坎

拿到 deviceId 之后调 createBLEConnection。这里有个坑:这个函数成功返回时链路层还没稳定,立刻去拉服务列表在 Android 上十次有三次报 10004(连接已断开)。我们的做法是连接成功后强制等 800ms~1200ms 再走下一步,同时设一个 10 秒连接超时(用 Promise.race 实现),超时就提示店员重启打印机,而不是让界面一直转圈。

最大传输单元(Maximum Transmission Unit, MTU)决定了一包能带多少字节。BLE 4.0/4.1 的默认 ATT_MTU 是 23 字节,减去 3 字节 ATT 头,应用层只剩 20 字节。Android 上可以用 wx.setBLEMTU 申请更大的值,实测大部分打印机能谈到 185,少数能到 512;iOS 上系统不把这个口子开放给应用层,只能按 20 字节切。

阶段 iOS 17 Android 13 说明
默认 ATT_MTU 由系统决定,常见 185 常见 517,可用载荷 512 应用层不该假设具体数字
能否主动协商 否,setBLEMTU 走 fail 能,wx.setBLEMTU 可用 取两端最小值最安全
单包写入上限 保守按 20 字节切 协商后按返回值切 取二者较小值
deviceId 含义 系统生成的 UUID,重装微信会变 MAC 地址,Android 6+ 会随机化 跨端都不适合当永久标识存库

服务与特征值:notify 和 write 不是一回事

getBLEDeviceServices 返回的服务列表里,每个服务下面挂着若干特征值(getBLEDeviceCharacteristics)。特征值的 properties 字段是一组布尔开关,决定了你能拿它干什么。判断错了就会报 10005(特征值不支持该操作)。

属性 含义 打印场景怎么用
write 可写且需要对端回应 主流打印机的指令通道,慢但可靠
writeNoResponse 可写但不等回应 吞吐高,丢包不重传,慎用在关键指令上
notify 对端可主动推数据 订阅打印机回传的缺纸、完成、报错状态
read 可读 读固件版本、电量,一般用不上

挑特征值的时候别只看名字。有台机器的服务里同时有两个可写特征值,一个走 write 一个走 writeNoResponse,我们一开始挑了吞吐高的那个,结果连续打 20 张会偶发丢指令,纸出来是空白的。换成 write 之后现象消失——打印机处理不过来时会直接丢弃无回应包,而不是排队。

订阅 notify 要在写数据之前完成,顺序是:notifyBLECharacteristicValueChange 成功 → 再写。反过来会漏掉打印机最开始回传的就绪信号。

打印指令怎么变成 ArrayBuffer

标签机普遍用 TSPL(TSC Printer Language)或 ESC/POS 指令集,本质就是一行行 ASCII 文本。打印一张价签的完整指令如下,用数组写出来方便逐行加注释:

// ble/tspl.js
// 环境:无依赖,纯字符串拼装
// 作用:定义一张 50mm×30mm 价签的 TSPL 指令原文
const TSPL_LINES = [
  // 纸张宽 50mm、高 30mm,必须与机器里装的纸一致
  'SIZE 50 mm,30 mm',
  // 标签间距 2mm,纵向偏移 0
  'GAP 2 mm,0',
  // 打印浓度 8,热敏纸常用值,太低会字迹发虚
  'DENSITY 8',
  // 清空打印缓冲区,每次打印前必发,否则会叠上一次的内容
  'CLS',
  // 声明中文按 UTF-8 解析,老固件不认这条,要换成 GBK
  'CODEPAGE UTF-8',
  // 品名:x=20 y=20,字体 3,倍宽倍高 1
  'TEXT 20,20,"3",0,1,1,"农夫山泉 550ml"',
  // 价格单独一行,避免和品名挤在同一行扫不出来
  'TEXT 20,60,"3",0,1,1,"¥2.00"',
  // Code128 条码:x=20 y=100,条高 60,右下角带人可读字符
  'BARCODE 20,100,"128",60,1,0,2,2,"6901234567892"',
  // 打印 1 份,缺这条机器不会出纸
  'PRINT 1',
  // 末尾补一个空串,保证最后一行也带上换行
  ''
];

// 行分隔必须是 CRLF,部分固件只认 \r\n
const TSPL_RAW = TSPL_LINES.join('\r\n');

难点在中文。CODEPAGE UTF-8 这行能让支持该指令的固件按 UTF-8 解析中文;老固件不认,只认 GBK,这时候就得自己带一份 GBK 码表把汉字转成两字节。我们最后的做法是先读固件版本,再决定发 CODEPAGE UTF-8 还是走 GBK 转换,这个分支写在了指令构造函数里。

小程序里没有 TextEncoder(基础库较老的版本),所以手写了一个 UTF-8 编码器,顺便处理代理对:

// ble/cmd.js
// 环境:无第三方依赖,基础库 2.20+ 可运行
// 作用:把 TSPL 文本指令编码成 ArrayBuffer,供分包写入使用

// 先按 Unicode 码点展开,把 emoji / 生僻字这类代理对合成一个码点
function toCodePoints(str) {
  // 结果数组,元素是一个个整数码点
  const points = [];
  for (let i = 0; i < str.length; i++) {
    // 取出当前字符的 UTF-16 编码单元
    const hi = str.charCodeAt(i);
    // 落在高位代理区,说明后面还跟着一个低位代理
    if (hi >= 0xd800 && hi <= 0xdbff && i + 1 < str.length) {
      const lo = str.charCodeAt(i + 1);
      // 低位代理区匹配,合并成一个增补平面码点
      if (lo >= 0xdc00 && lo <= 0xdfff) {
        points.push((hi - 0xd800) * 0x400 + lo - 0xdc00 + 0x10000);
        // 低位代理已经消费掉,跳过去
        i++;
        continue;
      }
    }
    points.push(hi);
  }
  return points;
}

function strToUtf8Buffer(str) {
  // 展开成码点数组
  const points = toCodePoints(str);
  // 先算总字节数:UTF-8 是变长的,一个码点占 1~4 字节
  let byteLen = 0;
  points.forEach((cp) => {
    // 小于 0x80 是 ASCII,占 1 字节
    byteLen += cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4;
  });

  // 一次性申请好内存,避免反复扩容
  const buffer = new ArrayBuffer(byteLen);
  // 用 DataView 逐字节写入
  const view = new DataView(buffer);
  let offset = 0;
  points.forEach((cp) => {
    if (cp < 0x80) {
      // ASCII 单字节,最高位为 0
      view.setUint8(offset++, cp);
    } else if (cp < 0x800) {
      // 两字节格式:110xxxxx 10xxxxxx
      view.setUint8(offset++, 0xc0 | (cp >> 6));
      view.setUint8(offset++, 0x80 | (cp & 0x3f));
    } else if (cp < 0x10000) {
      // 三字节格式,常用汉字基本都落在这个区间
      view.setUint8(offset++, 0xe0 | (cp >> 12));
      view.setUint8(offset++, 0x80 | ((cp >> 6) & 0x3f));
      view.setUint8(offset++, 0x80 | (cp & 0x3f));
    } else {
      // 四字节格式,emoji 等增补平面字符
      view.setUint8(offset++, 0xf0 | (cp >> 18));
      view.setUint8(offset++, 0x80 | ((cp >> 12) & 0x3f));
      view.setUint8(offset++, 0x80 | ((cp >> 6) & 0x3f));
      view.setUint8(offset++, 0x80 | (cp & 0x3f));
    }
  });
  return buffer;
}

// 拼一整条价签指令,末尾必须带换行,否则最后一行不执行
function buildLabelCommand({ name, price, barcode }) {
  // 纸张参数与浓度是固定前缀,每张都一样
  const lines = [
    'SIZE 50 mm,30 mm',
    'GAP 2 mm,0',
    'DENSITY 8',
    'CLS',
    // 让固件按 UTF-8 解析中文
    'CODEPAGE UTF-8',
    // 品名
    `TEXT 20,20,"3",0,1,1,"${name}"`,
    // 价格
    `TEXT 20,60,"3",0,1,1,"¥${price}"`,
    // 条码
    `BARCODE 20,100,"128",60,1,0,2,2,"${barcode}"`,
    // 出纸指令
    'PRINT 1',
    // 结尾换行
    ''
  ];
  return strToUtf8Buffer(lines.join('\r\n'));
}

module.exports = { strToUtf8Buffer, buildLabelCommand };

条码内容建议只放 ASCII。中文塞进 Code128 会让条码长度暴涨,扫码枪认不出来,我们后来把中文品名单独用 TEXT 打印,条码位只放数字。

拆开看机制:20 字节限制从哪来,队列为什么必须串行

这一节是整个项目最值钱的部分。

BLE 的 ATT 层规定,一次 Write Request 的载荷大小受 ATT_MTU 限制。默认 ATT_MTU 是 23 字节,其中 1 字节是操作码、2 字节是属性句柄,留给应用数据的正好 20 字节。也就是说,超过 20 字节的数据包不是"传得慢",是协议层面根本装不下。微信的 writeBLECharacteristicValue 在超出限制时行为并不一致:部分 Android 机会截断,部分 iOS 版本直接 fail,也有版本静默丢弃。

第二个机制层面的点是链路层的"一问一答"。走 write 属性时,每一包都要等对端回一个 Write Response 才能发下一包。如果你用 Promise.all 并发写 20 包,链路层会直接堵死,表现是前几张纸正常、后面全部空白。所以写入必须是严格串行的队列:上一包的 success 回调触发下一包。

flowchart TD
    A[收到打印任务] --> B[构造 TSPL 指令]
    B --> C[strToUtf8Buffer 得到 ArrayBuffer]
    C --> D[按 chunkSize 切片]
    D --> E{队列是否空闲}
    E -- 否 --> F[挂到队尾排队]
    E -- 是 --> G[取队首分片写入]
    G --> H{write 回调 success}
    H -- 否 --> I[同一分片重试, 最多 3 次]
    I --> H
    H -- 是 --> J[间隔 chunkDelay 毫秒]
    J --> K{还有分片}
    K -- 是 --> G
    K -- 否 --> L[等待打印机 notify 完成信号]
    L --> M{5 秒内收到完成}
    M -- 是 --> N[任务成功, 取下一个任务]
    M -- 否 --> O[标记失败, 提示店员检查纸张]
    N --> E
    O --> E

队列实现如下,重点是 this._chain 这条 Promise 链,所有写入任务都挂在它尾巴上:

// ble/write-queue.js
// 环境:依赖已建立的连接,notify 已订阅
// 作用:把大块 ArrayBuffer 切片后串行写入,带重试与背压

class WriteQueue {
  constructor({ deviceId, serviceId, characteristicId, chunkSize, chunkDelay }) {
    // 目标设备
    this.deviceId = deviceId;
    // 服务 UUID
    this.serviceId = serviceId;
    // 可写特征值 UUID
    this.characteristicId = characteristicId;
    // chunkSize 取 MTU 协商结果与 20 的较小值,保守起见默认 20
    this.chunkSize = chunkSize || 20;
    // chunkDelay 是留给打印机的消化时间,慢机器要 15ms 以上
    this.chunkDelay = chunkDelay || 12;
    // 串行链的尾巴,所有任务都挂在这里
    this._chain = Promise.resolve();
  }

  // 把整个任务压进队列,返回一个 Promise 给调用方
  push(buffer) {
    // 任务本体
    const task = () => this._writeAll(buffer);
    // 关键:用 then 串起来,不能并发 Promise.all
    const result = this._chain.then(task, task);
    // 无论成功失败,链都要继续往下走,不能断在这里
    this._chain = result.catch(() => {});
    return result;
  }

  // 按 chunkSize 把大 buffer 切成小片
  _slice(buffer) {
    // 总字节数
    const total = buffer.byteLength;
    const chunks = [];
    for (let offset = 0; offset < total; offset += this.chunkSize) {
      // slice 出来的是新的 ArrayBuffer,微信要求传 ArrayBuffer 类型
      const end = Math.min(offset + this.chunkSize, total);
      chunks.push(buffer.slice(offset, end));
    }
    return chunks;
  }

  // 单片写入,失败最多重试 3 次
  async _writeChunk(chunk, retry = 3) {
    for (let attempt = 1; attempt <= retry; attempt++) {
      try {
        // 等这一包的 success 回调
        await this._rawWrite(chunk);
        return true;
      } catch (err) {
        // 10007 = 特征值写入失败,一般是链路瞬间抖动
        // 10004 = 连接已断开,重试没意义,直接往上抛
        if (err.errCode === 10004) throw err;
        console.warn(`第 ${attempt} 次写入失败: ${err.errCode}`);
        // 最后一次机会也用掉了,抛出让上层处理
        if (attempt === retry) throw err;
        // 退避一下再重试,间隔随次数拉长
        await this._sleep(30 * attempt);
      }
    }
  }

  // 把 wx 的回调式 API 包成 Promise
  _rawWrite(chunk) {
    return new Promise((resolve, reject) => {
      wx.writeBLECharacteristicValue({
        // 设备标识
        deviceId: this.deviceId,
        // 服务 UUID
        serviceId: this.serviceId,
        // 特征值 UUID
        characteristicId: this.characteristicId,
        // 本片的二进制数据
        value: chunk,
        success: resolve,
        fail: reject
      });
    });
  }

  // 把一整个 buffer 的所有分片写完
  async _writeAll(buffer) {
    // 先切片
    const chunks = this._slice(buffer);
    for (let i = 0; i < chunks.length; i++) {
      // 一片一片来,串行是硬性要求
      await this._writeChunk(chunks[i]);
      // 每包之间留空隙,打印机缓冲区满了会丢指令
      if (i < chunks.length - 1) await this._sleep(this.chunkDelay);
    }
    // 返回写入的分片数,方便打日志
    return chunks.length;
  }

  // 毫秒级 sleep,用于背压与退避
  _sleep(ms) {
    return new Promise((r) => setTimeout(r, ms));
  }
}

module.exports = { WriteQueue };

chunkDelay 这个参数是靠压测试出来的。给某款机器设 0 的时候连续打 30 张必丢第 7 张,设 12ms 之后 200 张零失败。这个值没有什么理论推导,就是拿一台真机站在门店里一张张贴出来的。

断连重连与超时:别让店员重启小程序

onBLEConnectionStateChange 是断连时最可靠的信号来源。价签场景里断连的高发原因是店员把手机塞进兜里锁屏,iOS 在后台会主动回收 BLE 连接(除非宿主声明了 CoreBluetooth 后台模式,而小程序不给你声明的机会)。

我们的策略是:断连后先尝试用缓存 deviceId 直连一次,失败再退回扫描流程;重连间隔按 1s、2s、4s 退避,最多三次,三次都失败就弹出"请靠近打印机并点击重试"的按钮,把决定权还给店员。这里有个 Android 特有的坑:deviceId 是 MAC 地址,但 Android 6 之后扫描用的是随机 MAC,缓存的 deviceId 过一段时间会失效,所以直连失败是完全正常的,别把它当 bug 去查。

// ble/connection.js
// 环境:依赖 WriteQueue 与 scanner.js
// 作用:连接、超时、断线重连的完整封装
// 10 秒连不上就放弃,再久店员已经开始拍屏幕了
const CONNECT_TIMEOUT = 10000;

function withTimeout(promise, ms, msg) {
  // 定时器句柄,结束后要清掉
  let timer;
  // 超时分支:到点就 reject
  const timeout = new Promise((_, reject) => {
    timer = setTimeout(() => reject(new Error(msg || '操作超时')), ms);
  });
  // 谁先结束算谁
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}

function createConnection(deviceId) {
  // iOS 上 deviceId 是 UUID,Android 上是 MAC,这里不做区分
  return new Promise((resolve, reject) => {
    wx.createBLEConnection({
      // 目标设备
      deviceId,
      // 系统层连接超时,单位毫秒
      timeout: CONNECT_TIMEOUT,
      success: resolve,
      fail: reject
    });
  });
}

// 重连:先直连,再退回扫描,指数退避最多三次
async function reconnect(lastDeviceId) {
  // 三次退避间隔
  const delays = [1000, 2000, 4000];
  for (let i = 0; i < delays.length; i++) {
    try {
      // 有缓存 ID 就先试直连,成本最低
      if (lastDeviceId) {
        await withTimeout(createConnection(lastDeviceId), CONNECT_TIMEOUT, '直连超时');
        // via 字段用于埋点,区分两种恢复路径
        return { deviceId: lastDeviceId, via: 'direct' };
      }
    } catch (e) {
      console.warn(`第 ${i + 1} 次直连失败: ${e.message}`);
    }
    try {
      // 直连不通就重新扫描,Android 随机 MAC 场景下这是常态
      const list = await scanOnce();
      // 取信号最强的一台
      const target = list[0];
      await withTimeout(createConnection(target.deviceId), CONNECT_TIMEOUT, '扫描后连接超时');
      return { deviceId: target.deviceId, via: 'scan' };
    } catch (e) {
      console.warn(`第 ${i + 1} 次扫描重连失败: ${e.message}`);
    }
    // 等退避间隔再试下一轮
    await new Promise((r) => setTimeout(r, delays[i]));
  }
  // 三次都失败,交给人工
  throw new Error('重连三次均失败,请靠近打印机后手动重试');
}

module.exports = { createConnection, reconnect, withTimeout };

错误码对照:线上真实踩过的那些

调试阶段最费时间的是对着一个四位数字猜原因。下面这张表是我们线上日志里出现过的错误码,按出现频次排的。

// ble/errcode.js
// 环境:纯对照表,无依赖,可直接在控制台里查
// 用法:console.log(BLE_ERR[err.errCode])
const BLE_ERR = {
  // 蓝牙适配器不可用:系统蓝牙没开,或 Android 缺定位权限
  '10001': '适配器不可用,检查系统蓝牙与定位权限',
  // 权限未授予:检查 app.json 的 permission 与 iOS 描述文案
  '10002': '权限未授予',
  // 连接超时:打印机可能在休眠,先按一下机器上的电源键
  '10003': '连接超时',
  // 连接已断开:链路层掉了,走 reconnect 流程
  '10004': '连接已断开',
  // 特征值不支持该操作:properties 判断错了,换个特征值
  '10005': '特征值不支持该操作',
  // 写入失败:链路抖动,队列里重试 3 次
  '10007': '特征值写入失败',
  // 系统不支持 BLE:低端机或蓝牙被管理策略禁用
  '10008': '系统不支持 BLE',
  // 安卓版本过低:需要 Android 4.3 以上
  '10009': '安卓系统版本过低',
  // deviceId 非法或为空:iOS 重装微信后旧 ID 会失效
  '10013': 'deviceId 无效'
};

module.exports = { BLE_ERR };

改造前后:数据是怎么变的

优化集中在三处:MTU 协商后按实际值切片、串行队列加背压延迟、断线自动重连。改造前是"一次写完整条指令 + 失败就提示店员重开小程序"。

指标 改造前 改造后 采集方式
单张价签端到端耗时 4.2s(含失败重来) 1.3s 店内 3 名店员、连续 5 天,共 620 次
连续打印 30 张的失败张数 平均 3.6 张 0.2 张 同一台机器、同一批次纸
每个班次因蓝牙问题的人工介入 7 次左右 0~1 次 店长口述记录
打印空白纸的投诉 每周 4~5 次 0 次 门店群记录
打印机掉电后恢复耗时 需要重启小程序 平均 6.4s 自动恢复 主动拔电测试 12 次

620 次这个数字是真实统计的,方法是在每次 PRINT 1 前后各打一个时间戳写进本地日志,晚上店长把日志导出给我。别小看这一步,没有数据你根本说服不了自己改的东西确实有效。

平台差异:Android 和 iOS 掉链子的地方不一样

差异点 Android iOS 应对
后台扫描 锁屏后仍可扫,但部分厂商 ROM 会限流 锁屏后扫描基本被冻结 扫描放在前台页面,锁屏即停止
deviceId MAC 地址,Android 6+ 随机化会变 系统 UUID,重装微信会变 不存库,每次现取
MTU 协商 setBLEMTU 可用 不可用,调用走 fail 取两端最小值,保守按 20
权限弹窗 需要定位权限(6.0+ 强制) 蓝牙权限单独弹 进入打印页前做一次预检
断连表现 多为信号弱 多为锁屏后被回收 统一走重连逻辑

Android 6.0 之后扫描 BLE 需要定位权限,这是很多人第一次集成时卡住的地方。微信在权限缺失时返回的也是 10001,和系统蓝牙没开时的错误码一样,排查时别被误导。我们的做法是在进入打印页之前先调 wx.getSetting 检查 scope.userLocation,没授权就先弹一段说明再拉授权。

几个容易踩的误区

回调 success 不等于数据到了打印机。 writeBLECharacteristicValue 的 success 只表示这一包交给了系统蓝牙栈,打印机有没有消化是另一回事。要确认打印完成,得靠 notify 通道回传的状态字。

stopBluetoothDevicesDiscovery 一定要调。 不关扫描,Android 上耗电肉眼可见,iOS 上后台会一直弹系统提示。我们在页面 onUnload 和打印完成回调里各调了一次,重复调用不会报错。

通知订阅要在写之前。 顺序反了会漏掉打印机上电时的就绪广播,表现是第一次打印必失败、第二次成功。

别用并发写来提高速度。 前面说过,链路层是一问一答,Promise.all 只会让缓冲区溢出。想提速的正确方向是协商更大的 MTU,而不是并发。

往后看,BLE 打印这个方向大概率会往两个方向走:一是厂商把批量打印和状态回传做进固件,小程序只负责任务下发;二是微信把 MTU 协商在 iOS 上也打开。短期能做的优化其实很朴素——把指令模板化,把重复的部分(纸张尺寸、浓度、方向)缓存成一段固定前缀 buffer,每次只拼变化的那十几字节,写入包数能从 60 多降到 20 出头。

如果你在集成里遇到了别的错误码,把 errCode 和机型发在评论区,我这边可以接着补表。

参考与延伸

  • 微信小程序蓝牙适配器接口文档:https://developers.weixin.qq.com/miniprogram/dev/api/device/bluetooth/wx.openBluetoothAdapter.html
  • 微信小程序框架与基础库总览:https://developers.weixin.qq.com/miniprogram/dev/framework/
  • ArrayBuffer 与 TypedArray 语言参考:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer
  • 蓝牙核心规范官方入口:https://www.bluetooth.com/specifications/specs/

关键词:微信小程序开发、小程序 SEO、BLE 蓝牙、蓝牙打印机、writeBLECharacteristicValue、ArrayBuffer、硬件对接

🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。