小程序连上蓝牙标签打印机:wx.openBluetoothAdapter 从扫描到写数据的实战笔记
适用读者:已经会写微信小程序、现在被要求把一台 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.json 的 permission 里声明 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、硬件对接