作者:刘锦泉
一、前言
在实际物流配送业务中,电子签收在部分场景下仍无法完全替代纸质回单。配送员在门店现场需要打印配送单据,与收货方逐项核对并完成签字确认。纸质回单不仅是履约凭证,也是后续对账与责任追溯的重要依据。
但在真实配送环境中,作业条件通常较为复杂:
- 无固定网络或 PC 设备
- 网络环境不稳定,甚至可能离线
- 作业地点分散且高度流动
在这种场景下,传统依赖固定设备与网络的打印方案难以落地。另一方面,配送业务的单据流转、任务执行与签收确认均在钉钉小程序中完成。
基于上述业务形态和环境约束,我们采用了如下方案:
钉钉小程序 + 便携式热敏打印机 + BLE
实现移动端直连打印能力,完成现场打印与签收闭环。
本文将围绕该方案,从工程实现角度拆解 BLE 打印链路,包含以下几个方面:
- 为什么选择 BLE:对比经典蓝牙与 BLE 的差异,明确选型依据
- BLE 通信模型:理解 GATT 如何抽象打印机能力
- 连接建立与生命周期管理:从权限校验到连接释放的完整流程
- ESC/POS 指令模型:构建打印机可识别的指令数据
- 数据传输机制:解决分包、队列与发送可靠性问题
- 图片打印:实现从彩色图像到黑白点阵的转换
二、为什么选择 BLE,而不是传统蓝牙?
在移动端驱动便携式热敏打印机的方案中,蓝牙是最常见的设备通信方式。然而,我们日常所说的"蓝牙",并非单一技术标准,而是两个独立演进的无线通信分支。
蓝牙技术分支:经典蓝牙 vs BLE
蓝牙技术联盟自 1999 年发布蓝牙 1.0 以来,技术规范经历了多次迭代。其中最关键的分水岭出现在 2010 年------蓝牙 4.0 规范的发布,首次将低功耗蓝牙(Bluetooth Low Energy,BLE)作为独立技术分支引入,与此前的经典蓝牙(BR/EDR)形成并行体系。
经典蓝牙(BR/EDR):
- 诞生于蓝牙 1.0 ~ 3.0
- 设计目标:替代短距离有线电缆,承载持续数据流
- 典型应用:音频传输(A2DP)、免提通话(HFP)、文件传输
- 特点:持续连接、高吞吐、功耗较高
低功耗蓝牙(BLE):
- 随蓝牙 4.0 引入,并在 5.x 持续增强
- 设计目标:以极低功耗支持间歇性、小数据量通信
- 典型应用:传感器设备、IoT 终端、便携式打印机
- 特点:短连接、小数据包、超低功耗
可以用一句话概括两者差异:
经典蓝牙是为"持续对话"设计的,而 BLE 是为"偶尔说一句"设计的。
从工程角度看:
- 经典蓝牙解决的是"持续数据传输"问题
- BLE 解决的是"高效指令交互"问题
虽然共享"蓝牙"之名,但两者在协议模型、连接机制与功耗设计上完全不同。接下来将从连接模型、系统支持与通信特性等维度展开对比。
连接模型差异
经典蓝牙(BR/EDR)通常基于SPP(Serial Port Profile ,串口仿真协议 ) 向上层提供数据传输能力,本质上是建立一条持续的字节流通道,主要用于串口数据通信等场景。其特点是:
- 面向流式数据传输
- 提供连续字节流
- 需要应用层自行实现分帧与协议切分
而 BLE 基于 GATT(Generic Attribute Profile,通用属性规范),将设备能力抽象为服务(Service)与特征值(Characteristic)的层级化数据结构,通信方式是围绕"特征值(Characteristic)"进行的读写与通知机制,更适合:
- 小数据包
- 指令型交互
- 间歇性通信
在打印场景中,本质上传输的是 ESC/POS 指令流------一组结构清晰、长度有限的二进制命令序列,而不是持续大流量数据,因此:
GATT 模型天然更适合打印这种"指令驱动型通信"。
系统与小程序能力约束
在钉钉小程序运行环境中,蓝牙能力通过 JSAPI 封装,其底层依赖操作系统蓝牙协议栈。
经典蓝牙的困境:
- iOS 对经典蓝牙串口协议(SPP/RFCOMM)实施严格的 MFi 认证管控,未经认证的设备无法被第三方 App 通过公开 API 访问。
- Android 虽理论上支持经典蓝牙 SPP,但各厂商系统定制层碎片化严重,且 Android 12+ 对蓝牙权限的收紧进一步增加了连接的不确定性。
- 在小程序体系中,蓝牙能力主要围绕 BLE(GATT)模型开放。经典蓝牙相关能力即便在部分平台存在,也缺乏统一标准与跨平台一致性,且在 iOS 上受限于 MFi 机制基本不可用。
因此,在工程实践中:
经典蓝牙难以作为稳定、可控的通信方案使用。
BLE 的确定性优势:
- iOS 自 iOS 5 起通过 CoreBluetooth 框架完全开放 BLE 协议栈。
- Android 自 4.3(API 18)起原生支持 BLE 中心模式。
- 钉钉小程序提供完整的 BLE 链路 API。
换句话说:
BLE 是"系统优先支持"的标准能力,而经典蓝牙存在平台差异性风险。
连接稳定性与重连成本
在门店实际使用中,打印设备通常呈现"短连接、多设备、频繁切换"的特征。经典蓝牙的连接过程通常包括:
- 设备发现(Inquiry / Page)
- 配对(Pairing)
- 绑定(Bonding)
- 建链(SPP Channel Establish)
这一过程在实际设备上往往存在:
- 首次连接耗时 2-5 秒,体验迟滞;
- 配对状态强依赖系统蓝牙缓存,清除缓存后需重新配对;
- 多设备切换时,原绑定关系可能干扰新连接;
- 异常断连后,底层恢复机制不稳定,偶发需重启蓝牙服务。
而 BLE 的连接模型相对轻量:
- 无强制配对流程(可采用 Just Works 模式,无弹窗交互)
- 连接建立速度通常在 100-300 ms;
- 断开后重连仅需重新发起
connect请求,无历史绑定负担; - 天然适配"按单连接、用完即断"的业务模式。
因此:
BLE 更适合"按需连接、用完即断"的业务模式。
数据传输模型更适合打印协议
热敏打印本质是 ESC/POS 指令流输出,其数据特征为:
- 数据量小(单次回单通常为 1-10 KB);
- 结构固定(初始化命令 + 文本行 + 条码/二维码位图 + 走纸切纸命令);
- 强时序要求(指令顺序不可乱,丢包将导致格式错乱或走纸异常)。
BLE 的写入方式(Write Characteristic / Write Without Response)配合 MTU 分包机制,可以很好支持:
- 小包分片传输(单次写入数据受 ATT MTU 限制,默认约 20 字节,部分设备可协商至更大);
- 应用层流控(根据
write回调成功率控制发送节奏); - 避免阻塞 UI 线程(API 均为异步回调设计);
- 支持 Notify 状态回传(实时感知打印机缺纸、过热等异常)。
相比之下:
BLE 的"受限分包模型"反而更适合 ESC/POS 这种指令流传输。
多维度对比总览
从工程落地角度来看,将经典蓝牙与 BLE 的核心差异归纳如下:
| 维度 | 经典蓝牙 | BLE |
|---|---|---|
| 通信模型 | 流式 | 特征值 |
| 协议 | SPP | GATT |
| 连接 | 长连接 | 短连接 |
| 建连耗时 | 2~5s | 100~300ms |
| 功耗 | 高 | 低 |
| iOS 支持 | 受限 | 完全开放 |
| 小程序支持 | 不统一 | 标准支持 |
小结
综合协议模型、系统支持以及小程序运行环境的约束可以看到:
经典蓝牙虽然在带宽与成熟度上具备优势,但在移动端尤其是 iOS 与小程序体系中,存在明显的可达性与一致性问题;而 BLE 在系统支持、连接模型与工程可控性上更符合当前场景。因此,在钉钉小程序驱动便携式打印机的场景下:
BLE 并不是"更优选择",而是在现有平台约束下"可落地且稳定"的通信方案。
在明确选择 BLE 后,需要理解其核心通信机制------GATT。这是理解后续设备连接、服务发现与数据交互流程的认知基础。
三、BLE 通信模型
在 BLE 中,并不存在类似串口或 Socket 的持续数据通道。与经典蓝牙基于 SPP 提供"流式传输"不同,BLE 的通信建立在 GATT(Generic Attribute Profile)模型之上。
GATT 将设备能力抽象为一组层级化的数据结构,所有数据交互都围绕这些结构展开,而不是通过一条持续的数据流进行传输。
GATT 的基本结构
在 GATT 模型中,一个 BLE 设备可以抽象为为一棵层级结构:
plain
设备(Device)
└── 服务(Service)
└── 特征值(Characteristic)
└── 描述符(Descriptor)
在打印场景,BLE 打印机作为 GATT 服务端(Server),其内部属性表可简化为以下结构:

服务(Service)
服务是设备某项功能的逻辑集合,由一个或多个特征值组成。简单来说就是:
设备"对外声明的功能模块"------它能提供哪些能力
每个服务通过 UUID(通用唯一标识符) 唯一标识。UUID 可以分两类:
- 16-bit 标准 UUID
- 128-bit 自定义
16-bit 标准 UUID(SIG 定义)
格式:
plain
0000xxxx-0000-1000-8000-00805f9b34fb
其中:
xxxx= 标准服务编号- 后缀
0000-1000-8000-00805f9b34fb= Bluetooth Base UUID
常见标准 Service(SIG 定义)
| Service | UUID | 含义 |
|---|---|---|
| Generic Access | 0x1800 | 设备基础信息 |
| Generic Attribute | 0x1801 | GATT 控制服务 |
| Device Information | 0x180A | 设备信息 |
| Battery Service | 0x180F | 电池信息 |
| Heart Rate | 0x180D | 心率(穿戴设备) |
特点:
- 属于 BLE 官方标准
- 这些服务本身不承载打印数据,但在工程中可用于读取设备信息、电量等辅助功能
128-bit 自定义 UUID
格式:
plain
xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
例如:
49535343-FE7D-4AE5-8FA9-9FAFD205E4550000FFF0-0000-1000-8000-00805F9B34FB
特点
- 厂商自定义
- 用于:
- 打印机
- 扫码枪
- IoT 设备
- ❗ 是否支持打印完全取决于厂商实现
示例
serviceId 示意图:

注意:ios 系统的serviceId 会进行简化,如下图:

特征值(Characteristic)
特征值是服务下的具体数据节点,是客户端真正进行读写操作的对象。可以把它理解为:
每个功能模块下的"具体操作入口"------能力如何被访问
一个特征值通常包含三个关键信息:
- UUID:该特征值的唯一标识。
- Properties(操作属性) :定义支持的操作类型,常见值包括:
Read:可读Write:可写(带响应)Write Without Response:无响应写入Notify:可通知
- Value(值):实际存储的数据。
在打印场景中,最核心的特征值有两类:
- 写入特征值 :Property 包含
Write或Write Without Response,用于向打印机发送 ESC/POS 指令流。 - 通知特征值 :Property 包含
Notify,用于打印机主动向客户端上报状态(缺纸、打印完成等)。
示例图如下:

描述符(Descriptor)与 CCCD
描述符是特征值的附加元数据,用于补充说明特征值的行为。其中最重要的是 CCCD(Client Characteristic Configuration Descriptor,客户端特征配置描述符),
CCCD 的作用,是让客户端配置某个特征值是否启用通知或指示:
- 启用
Notify:通常写入0x0001 - 启用
Indicate:通常写入0x0002
对于支持 Notify 的特征值,客户端必须向 CCCD 写入 0x0001 才能启用通知功能。此后当打印机状态发生变化时,才会主动向客户端推送数据。当你调用 dd.notifyBLECharacteristicValueChange 并设置 state: true 时,钉钉小程序底层就是在向该特征值的 CCCD 描述符写入 0x0001。
Write 两种写入方式
向写入特征值发送数据时,BLE 提供两种写入模式:
- Write Request(带响应写入):客户端每发送一包数据,打印机必须回复 Write Response 以确认接收成功。优点是可靠------应用层能明确感知每一包是否送达;缺点是吞吐量较低,每次写入都需等待对端确认。
- Write Command / Write Without Response(无响应写入):客户端连续发送数据包,打印机不回复确认。这种方式显著提升了传输速率,但可靠性依赖于底层链路层的重传机制。
在打印场景中,由于单次回单数据量较小(1-10KB),且对实时性要求较高,Write Without Response 是常用选择。
Notify 状态主动上报
Notify 是 BLE 设备主动向客户端推送数据的能力。在打印场景中,Notify 用于接收打印机的状态反馈,典型事件包括:
- 打印完成
- 缺纸报警
- 机盖打开
- 热敏头过热保护
- 缓冲区状态(可接收新数据)
Notify 的本质是设备主动推送状态变化,而非客户端轮询。客户端订阅成功后,只要打印机状态发生变化,设备就可以主动推送数据给小程序。
小结
GATT 模型将 BLE 打印机的交互简化为两个核心操作:
- 向"写入特征值"写入数据 → 发送 ESC/POS 打印指令
- 订阅"通知特征值" → 接收打印机状态上报
有了这套模型,后续的 BLE 连接建立、服务发现、特征筛选、写入与订阅流程就会非常清晰。\ 在理解了 BLE 的 GATT 通信模型之后,接下来将进入实际工程实现层面,介绍在钉钉小程序中如何完成 BLE 打印设备的扫描、连接建立与断开管理流程。
四、钉钉小程序 BLE 打印设备连接与生命周期管理
在钉钉小程序中,通过 BLE 连接便携式打印机,本质上是对一系列异步系统能力的编排过程,而不仅仅是 API 的顺序调用。
在真实设备环境中,连接过程会受到权限、系统蓝牙状态、设备广播稳定性、信号强度等多种因素影响,因此整个流程需要以"状态机"的方式进行设计,而不是简单的线性调用。
权限校验与蓝牙可用性判断
在发起 BLE 操作之前,需要先完成两个层面的检查:
- 权限是否具备(Permission Level)
- 蓝牙是否可用(Adapter Level)
二者缺一不可,且需要分别校验。
权限校验
在钉钉小程序环境中,蓝牙能力依赖用户授权。可通过 dd.checkAuth 判断当前权限状态,并在未授权时引导用户开启权限。
typescript
/*
* 检查授权
*/
checkAuthorization(authType: 'LBS' | 'BLUETOOTH'): Promise<boolean> {
return new Promise((resolve) => {
dd.checkAuth({
authType,
success: (res) => {
const { granted } = res;
if (!granted) {
dd.showAuthGuide({
authType,
});
}
resolve(granted);
},
fail: () => {
resolve(false);
},
});
});
}
定位权限说明
在 Android 系统中,BLE 扫描能力与定位权限强绑定:
- 未授权定位权限时,无法获取周边蓝牙设备
- 即使蓝牙开启,扫描结果也可能为空
因此,在工程实践中,一般需要同时检查: "蓝牙权限 + 定位权限"
typescript
async checkBluetoothAuthorization() {
const bluetooth = await this.checkAuthorization('BLUETOOTH');
const sys = dd.getSystemInfoSync();
if (sys.platform === 'android') {
const lbs = await this.checkAuthorization('LBS');
return bluetooth && lbs;
}
return bluetooth;
}
蓝牙能力初始化
在调用任何 BLE 相关 API 之前,必须先初始化蓝牙模块。
typescript
const openBluetoothAdapter = () => {
return new Promise((resolve, reject) => {
dd.openBluetoothAdapter({
success() {
resolve();
},
fail(err) {
if (err.errorCode === 10001) {
dd.showToast({
content: '系统蓝牙未开启',
type: 'fail'
});
}
reject(err);
}
});
});
};
说明:
dd.openBluetoothAdapter是所有 BLE 能力的前置条件- 未初始化时调用其他 BLE API 会直接报错
10001表示系统蓝牙不可用或未开启
同时可以监听蓝牙状态变化:dd.onBluetoothAdapterStateChange 监听手机蓝牙状态的改变。
javascript
dd.onBluetoothAdapterStateChange((res) => {
// available: 蓝牙模块是否可用(需支持 BLE 且蓝牙已开启)
// discovering: 蓝牙模块是否处于搜索状态
const { available, discovering } = res;
if (!available) {
// 蓝牙不可用,提示用户开启
}
});
BLE 设备扫描
在完成蓝牙模块初始化后,即可开始扫描附近的 BLE 外围设备。
BLE 设备扫描本质上是一个基于广播包的实时发现机制,系统并不会返回"设备列表",而是通过持续监听广播信号来逐步构建可见设备集合。
在钉钉小程序中,可以通过 dd.startBluetoothDevicesDiscovery 开始扫描:
javascript
dd.startBluetoothDevicesDiscovery({
allowDuplicatesKey: false, // 是否允许重复上报同一设备
interval: 0, // 上报间隔,0 表示立即上报
success() {
// 超时自动停止
timeoutId = setTimeout(() => {
resolve();
this.stopNearDeviceScan();
clearTimeout(timeoutId);
}, timeout);
}
});
扫描过程中,通过 dd.onBluetoothDeviceFound 监听设备广播信息:
javascript
dd.onBluetoothDeviceFound((res) => {
res.devices.forEach(device => {
// 根据设备名称或制造商数据过滤打印机
if (device.name && device.name.includes('Printer')) {
console.log('发现打印机:', device.name, device.deviceId);
// 保存设备信息,用于后续连接
storeDeviceInfo(device);
}
});
});
在实际工程中,BLE 设备通常通过以下字段进行识别:
namelocalName- 厂商自定义广播数据(manufacturerData)
建议在扫描阶段就完成设备过滤,而不是在连接阶段再做判断,以减少无效连接尝试。
扫描超时控制
BLE 扫描默认是一个持续过程,如果不主动停止,会一直占用系统资源,并可能影响后续连接操作。因此,在工程实践中,通常需要为扫描设置一个合理的超时时间。
常见做法是:在调用 startBluetoothDevicesDiscovery 后,通过定时器在指定时间后自动停止扫描。
javascript
function startScanWithTimeout(timeout = 5000) {
dd.startBluetoothDevicesDiscovery({
allowDuplicatesKey: false,
interval: 0,
success() {
console.log('开始扫描蓝牙设备');
// 超时自动停止扫描
setTimeout(() => {
dd.stopBluetoothDevicesDiscovery({
success() {
console.log('扫描超时,已停止');
}
});
}, timeout);
}
});
}
扫描异常与设备发现不完整问题
在真实设备环境中(尤其是 iOS 与 Android),BLE 扫描可能出现以下异常行为:
- 已扫描到的设备不会重复上报
- 某些设备在重新扫描后无法再次被发现
- 明确存在广播的设备,但扫描结果为空
- 多次调用扫描 API 后仍无法恢复历史设备显示
该问题通常并非设备异常,而是由于系统层 BLE 扫描状态、缓存机制或扫描会话未完全释放导致。
✔ 解决方案:重置蓝牙适配器状态
在钉钉小程序中,可以通过重启蓝牙适配器来清理系统扫描上下文,从而恢复设备发现能力:
typescript
async resetBluetoothAdapter() {
try {
await dd.closeBluetoothAdapter();
// 给予系统释放扫描上下文的时间(非常关键)
await new Promise(resolve => setTimeout(resolve, 300));
await dd.openBluetoothAdapter();
console.log('蓝牙适配器已重置');
} catch (err) {
console.error('蓝牙适配器重置失败', err);
}
}
由于 closeBluetoothAdapter → openBluetoothAdapter 会带来系统层状态重建开销,因此不建议频繁调用。
建立蓝牙连接
建立连接
在确定目标打印机后,使用 deviceId 建立连接。
javascript
dd.connectBLEDevice({
deviceId,
success() {
console.log('设备连接成功');
// 连接成功后立即停止扫描,避免干扰后续操作
dd.stopBluetoothDevicesDiscovery();
// 获取服务列表
getDeviceServices(deviceId);
},
fail(err) {
console.error('连接失败', err);
}
});
说明:
- 若设备已连接,再次连接通常会直接返回成功
- 建议连接成功后立即停止扫描,避免资源竞争
- 若"可发现但无法连接",优先检查是否仍在扫描状态
获取 Services
连接成功后,需要获取设备暴露的 Service 列表。
javascript
function getDeviceServices(deviceId) {
dd.getBLEDeviceServices({
deviceId,
success(res) {
console.log('Services列表:', res.services);
// 通常打印服务使用自定义 UUID
const printService = res.services.find(service =>
service.uuid.includes('FF00') || service.isPrimary
);
if (printService) {
getServiceCharacteristics(deviceId, printService.uuid);
}
},
fail(err) {
console.error('获取服务失败', err);
}
});
}
在实际设备中(尤其是打印机),往往会同时暴露多个 Service,其中只有极少数才是真正用于数据传输(打印)的通道。因此,需要建立一套合理的过滤与优先级策略,用于筛选候选 Service。
Service 的筛选可以遵循以下经验规则:
- 排除通用标准服务
- 如
1800 / 1801 / 180A / 180F - 这些服务几乎不可能承载打印数据
- 如
- 优先选择 FFxx 类服务
- 如
FFF0 / FFE0 / FF00 - 通常为串口透传服务(Serial over BLE)
- 大多数打印机使用该通道接收 ESC/POS 指令
- 如
- 谨慎对待厂商自定义 UUID
- 如
49535343-xxxx - 可能是蓝牙模块内部服务,而非打印通道
- 如
- 其余 Service 作为候选补充
- 不能完全忽略,但优先级较低
javascript
private getServicePriority(uuid: string): number {
const u = uuid.toLowerCase();
// 提取短 UUID(如 0000fff0)
const shortUUID = u.startsWith('0000') ? u.slice(4, 8) : '';
// 1. 标准服务(最低优先级)
const standardServices = ['1800', '1801', '180a', '180f'];
if (standardServices.includes(shortUUID)) {
return 100;
}
// 2. 打印透传服务(最高优先级)
if (shortUUID.startsWith('ff')) {
return 0;
}
// 3. 纯 128 位厂商 UUID(中优先级,需验证)
if (!u.includes('0000-1000-8000-00805f9b34fb')) {
return 50;
}
// 4. 其他标准扩展服务
return 60;
}
在获取 Service 列表后,可结合排序使用:
javascript
services.sort((a, b) =>
this.getServicePriority(a.uuid) - this.getServicePriority(b.uuid));
获取 Characteristics
确定目标 Service 后,需要进一步获取其下的 Characteristic。示例代码如下:
javascript
function getServiceCharacteristics(deviceId, serviceId) {
dd.getBLEDeviceCharacteristics({
deviceId,
serviceId,
success(res) {
console.log('Characteristics列表:', res.characteristics);
let writeCharId = null;
let notifyCharId = null;
res.characteristics.forEach(c => {
// 注意:钉钉使用 characteristicId,不是 uuid
if (c.properties.write || c.properties.writeWithoutResponse) {
writeCharId = c.characteristicId;
}
if (c.properties.notify || c.properties.indicate) {
notifyCharId = c.characteristicId;
}
});
// 保存特征值 ID,启用 Notify
enableNotify(deviceId, serviceId, notifyCharId);
},
fail(err) {
console.error('获取特征值失败', err);
}
});
}
说明:
每个 Characteristic 都会声明自己的能力属性:
properties.writeproperties.writeWithoutResponseproperties.notifyproperties.read
这些属性,直接决定了是否能用于打印数据写入。打印通常至少需要两个特征:
- 可写特征 :用于写入打印数据,但需要特别注意
write≠ 一定可用于打印:
❗ Characteristic 支持 write,仅代表"可以写入数据",并不代表打印机会执行这些数据。
- 通知特征(可选):用于接收打印状态回传,状态通知特征并非必需,但在批量或大数据打印时非常重要
启用特征值变化通知
对于支持 notify 或 indicate 的特征值,需调用 dd.notifyBLECharacteristicValueChange 启用通知功能。示例代码如下:
typescript
function enableNotify(deviceId, serviceId, characteristicId) {
dd.notifyBLECharacteristicValueChange({
deviceId,
serviceId,
characteristicId,
state: true, // 启用 notify
success() {
console.log('Notify 已启用');
// 监听特征值变化事件
dd.onBLECharacteristicValueChange((res) => {
const hexStr = res.value; // 钉钉返回 hex 字符串
parsePrinterStatus(hexStr);
});
// 连接就绪,可以开始发送打印数据
onPrinterReady();
},
fail(err) {
console.error('启用 Notify 失败', err);
}
});
}
说明:
- 必须先启用 notify 才能监听到设备
characteristicValueChange事件。 - 设备的特征值必须支持
notify或indicate才可以成功调用,具体参照 characteristic 的properties属性。 - 订阅操作成功后,需要设备主动更新特征值的 value,才会触发
dd.onBLECharacteristicValueChange。 - 订阅方式效率比较高,推荐使用订阅代替 read 方式。
- 注意调用顺序 :最好在连接之后就调用
dd.notifyBLECharacteristicValueChange方法。
蓝牙连接的断开与资源清理
BLE 连接具有天然不稳定性,因此必须设计完整的断开与恢复机制。
被动断开:监听与自动重连
蓝牙连接随时可能因为距离过远、设备断电、信号干扰等原因而意外断开。我们必须通过监听连接状态变化事件来应对这种情况。
javascript
// 监听连接状态变化
dd.onBLEConnectionStateChanged((res) => {
if (!res.connected) {
// 做相应的处理
}
});
说明:
- 若对未连接的设备调用数据读写操作接口,会返回 10006 错误,此时应执行重连。
- 避免重复监听 :每次调用
on方法监听事件之前,最好先调用off方法关闭之前的事件监听,防止多次注册导致事件被多次触发。
主动断开与清理:完整的退出机制
当用户主动关闭页面或完成打印后,我们需要手动断开连接并释放系统资源。一个标准的清理流程应该包含三步:
- 断开设备连接,
- 移除所有事件监听
- 关闭蓝牙适配器。
javascript
// 完整的资源清理方法,建议在页面 onUnload 或退出打印时调用
releaseBluetoothResources() {
// 1. 停止搜索设备(如果还在搜索中)
this.stopBluetoothDevicesDiscovery();
// 2. 断开与蓝牙设备的连接
if (this.isConnected) {
dd.disconnectBLEDevice({
deviceId: this.data.deviceId,
success: () => {
console.log('成功断开设备连接');
},
fail: (err) => {
console.error('断开设备连接失败', err);
}
});
}
// 3. 移除所有蓝牙相关的事件监听,防止内存泄漏
// 设备发现监听、连接状态监听、特征值变化监听、适配器状态监听
this.removeAllListener();
// 4. 最后,关闭蓝牙适配器,彻底释放系统资源
dd.closeBluetoothAdapter({
success: () => {
console.log('蓝牙适配器已关闭,资源已释放');
// 重置所有连接相关状态
this.resetBleStatus();
},
fail: (err) => {
console.error('关闭蓝牙适配器失败', err);
}
});
}
说明:
- 分步操作 :虽然
dd.closeBluetoothAdapter会断开所有连接并释放资源,但为了逻辑清晰和状态可控,建议还是显式地调用dd.disconnectBLEDevice和off系列方法进行清理。 - 调用时机 :此方法建议在页面的
onUnload生命周期中调用。因为closeBluetoothAdapter是异步操作,不建议将其与openBluetoothAdapter一起用作异常处理,效率低且易引发线程同步问题。 - 页面卸载 :点击小程序右上角关闭按钮时,小程序可能仅进入后台而非立即销毁,因此需要在
onHide或onUnload中主动调用清理逻辑,确保连接被及时断开。
工程化建议
蓝牙打印的交互链路很长,涉及权限、扫描、连接、状态管理、异常恢复等诸多环节。若将所有逻辑耦合在一起,代码会迅速膨胀且难以维护。建议将蓝牙能力拆分为两个独立实体:
BluetoothAdapter(能力适配层)
负责与钉钉蓝牙 API 的直接交互,向上屏蔽平台差异与底层细节。核心职责:
- API 适配 :封装
openBluetoothAdapter、startDiscovery等基础调用,统一返回 Promise 接口 - 权限校验:收敛蓝牙与定位权限的检查逻辑
- 异常告警:统一捕获蓝牙错误码,映射为业务可理解的提示(如"系统蓝牙未开启")
- 埋点上报:记录扫描耗时、连接成功率、异常断开次数等关键指标
BluetoothConnection(连接实例层)
每一次打印任务对应一个连接实例,内置完整的状态机与连接属性。核心职责:
- 连接属性 :持有
deviceId、serviceId、writeCharId、notifyCharId等关键标识 - 状态机 :管理
idle → scanning → connecting → ready → disconnecting等状态流转,杜绝非法操作 - 生命周期:统一处理连接建立、心跳维持、异常断连重试、资源释放
- 事件管理 :自动绑定与解绑
onBLEConnectionStateChanged等事件监听,防止泄漏
小结
BLE 打印连接的本质并不是一次性调用成功,而是一个持续运行的状态机系统,其核心能力在于:
- 连接状态管理
- 异常恢复机制
- 事件驱动模型
只有在稳定连接的基础上,才能保证打印数据的可靠传输与状态反馈。
在完成稳定的 BLE 连接建立之后,下一步需要解决的问题是:如何将业务数据转换为打印机可识别的二进制指令流,这将引出打印领域的核心协议模型------ESC/POS 指令体系。
五、ESC/POS 指令模型
打印机本质上是一个顺序执行的硬件设备:
- 不解析 HTML
- 不理解 JSON
- 不具备页面布局能力
它唯一能够处理的,是一段按顺序输入的字节流(Byte Stream)。
因此,要驱动打印机完成打印任务,必须将业务数据转换为其所支持的打印控制语言。在便携式热敏打印机领域,这一标准就是 ESC/POS。
ESC/POS 概述
ESC/POS 是由 EPSON 定义的一套打印控制指令体系,现已成为热敏票据打印的事实标准。
其核心特征是:
- 以控制字符开头:
ESC(0x1B)GS(0x1D)
- 后跟一个或多个参数字节
- 构成一条完整指令
控制指令通常由以下几部分组成:

指令流结构
一条完整的 ESC/POS 指令流通常由以下部分组成:
- 初始化命令:复位打印机状态
- 格式控制命令:对齐、字体、加粗、行距等
- 内容数据:文本、条码、二维码、图片
- 结束控制命令:走纸、切纸
流式执行模型
打印机采用流式处理机制:
边接收 → 边解析 → 边执行
不存在"完整接收后再统一执行"的过程。
因此:
👉 指令发送顺序必须严格等于打印顺序
常用 ESC/POS 指令
下面是配送回单场景中最常用的一组指令,以 JavaScript 对象形式组织便于后续封装使用。
javascript
const ESC_POS_COMMANDS = {
// 初始化
INIT: [0x1B, 0x40],
// 换行
LF: [0x0A],
CR: [0x0D],
CRLF: [0x0D, 0x0A],
// 切纸(GS V m)
CUT_FULL: [0x1D, 0x56, 0x41, 0x00], // 全切(部分打印机支持)
CUT_PARTIAL: [0x1D, 0x56, 0x42, 0x00], // 半切(留一个连接点)
CUT: [0x1D, 0x56, 0x01], // 标准切纸指令
// 对齐(ESC a n)
ALIGN_LEFT: [0x1B, 0x61, 0x00],
ALIGN_CENTER: [0x1B, 0x61, 0x01],
ALIGN_RIGHT: [0x1B, 0x61, 0x02],
// 字体样式(ESC E n)
BOLD_ON: [0x1B, 0x45, 0x01],
BOLD_OFF: [0x1B, 0x45, 0x00],
// 字体大小(GS ! n)
FONT_NORMAL: [0x1D, 0x21, 0x00],
FONT_DOUBLE_HEIGHT: [0x1D, 0x21, 0x01],
FONT_DOUBLE_WIDTH: [0x1D, 0x21, 0x10],
FONT_DOUBLE: [0x1D, 0x21, 0x11],
// 行间距(ESC 3 n)
LINE_SPACING_DEFAULT: [0x1B, 0x32],
LINE_SPACING: [0x1B, 0x33], // 后跟一个字节表示间距
};
完整指令参考 :更多指令请查阅打印机厂商提供的 ESC/POS 编程手册。 传送门
文本编码转换
为什么会出现乱码?
在小程序环境中,
- JavaScript 字符串内部使用 UTF-16 编码
- BLE 发送通常使用 UTF-8
- 而大多数便携式热敏打印机只支持 GBK 或 GB2312 这类中文字符集。
如果直接将 UTF-8 编码的中文发送给打印机,就会出现经典的"乱码"问题。
因此,必须在发送前完成编码转换:
UTF-16(JS) → GBK(打印机)
小程序环境下的转换方案
小程序不支持 Node.js 的 Buffer 或标准 Web API TextEncoder(其编码参数 encoding 在部分环境中无效)。工程上推荐使用纯 JavaScript 编码库,通过"查表法"实现编码转换:
iconv-lite:功能强大的纯 JavaScript 编码转换库,支持 GBK、GB2312、GB18030 等多种中文编码,体积适中。GBK.js:专注于 GBK 编码的轻量库,如果只需支持 GBK,可进一步减小包体积。
以下以 iconv-lite 为例展示转换函数:
javascript
// 引入 iconv-lite(需通过 npm 安装后构建到小程序中)
import * as iconv from 'iconv-lite';
function textEncode(str) {
// 将 UTF-16 字符串编码为 GBK 字节数组
return iconv.encode(str, 'gbk');
}
打印任务的工程化封装
在实际项目中,如果直接拼接字节数组,会带来以下问题:
- 可读性差
- 维护成本高
- 易出错
因此建议封装打印任务。
PrintJob封装示例
javascript
import iconv, { Iconv } from 'iconv-lite';
type Alignment = 'left' | 'center' | 'right';
export class ESCPOSGenerator {
private commands: number[] = [];
private currentEncoding: string;
// 页面宽度(字符数)
private pageWidth = 0;
// 当前状态
private currentState: TextOptions = {
bold: false,
align: 'left',
lineSpacing: 64,
size: 1,
};
private encoder: typeof Iconv;
constructor(encoding = 'gb2312', pageWidth = 48) {
this.currentEncoding = encoding;
this.pageWidth = pageWidth;
this.encoder = iconv;
}
/**
* 初始化打印机
*/
init(): this {
this.pushCommand(ESC_POS_COMMANDS.INIT);
return this;
}
/**
* 添加文本
*/
text(content: string, options: Partial<TextOptions> = {}): this {
const nextState = { ...this.currentState, ...options };
const prevState = { ...this.currentState };
// 对齐每次都加
this.align(nextState.align);
// 👉 只发送"变化的指令"
this.applyDiffStyle(nextState);
// 添加文本内容
const encoded = this.encoder.encode(content, this.currentEncoding);
this.commands.push(...encoded);
if (Object.keys(options).length > 0) {
this.applyDiffStyle(prevState);
}
return this;
}
/*
* 添加文本并换行
*/
lineText(content: string, options: Partial<TextOptions> = {}): this {
return this.text(content, options).newline();
}
/**
* 换行
*/
newline(lines = 1): this {
for (let i = 0; i < lines; i++) {
this.pushCommand(ESC_POS_COMMANDS.LF);
}
return this;
}
/**
* 添加分隔线
*/
separator(char = '-'): this {
const repeatCount = char.length ? Math.floor(this.pageWidth / char.length) : 0;
if (repeatCount <= 0) return this;
const line = char.repeat(repeatCount);
// 保存当前对齐方式
const prevAlign = this.currentState.align;
// 临时设置为居中
this.align('center');
this.text(line);
this.newline();
// 恢复原对齐方式
if (prevAlign !== 'center') {
this.align(prevAlign as Alignment);
}
return this;
}
/**
* 切纸
*/
cut(type: 'full' | 'partial' = 'full'): this {
if (type === 'partial') {
this.pushCommand(ESC_POS_COMMANDS.CUT_PARTIAL);
} else {
this.pushCommand(ESC_POS_COMMANDS.CUT_FULL);
}
return this;
}
/**
* 构建最终字节流
*/
build(): Uint8Array {
return new Uint8Array(this.commands);
}
/**
* 获取指令长度
*/
getLength(): number {
return this.commands.length;
}
/**
* 清空指令
*/
clear(): this {
this.commands = [];
return this;
}
/**
* 推送指令到命令列表
*/
private pushCommand(command: number[]): void {
this.commands.push(...command);
}
/**
* 设置对齐方式
*/
private align(alignment: Alignment) {
const alignmentMap = {
left: ESC_POS_COMMANDS.ALIGN_LEFT,
center: ESC_POS_COMMANDS.ALIGN_CENTER,
right: ESC_POS_COMMANDS.ALIGN_RIGHT,
};
const alignCommand = alignmentMap[alignment];
this.pushCommand(alignCommand);
}
/**
* 设置粗体
*/
private bold(enable = true) {
if (enable) {
this.pushCommand(ESC_POS_COMMANDS.BOLD_ON);
} else {
this.pushCommand(ESC_POS_COMMANDS.BOLD_OFF);
}
}
/**
* 设置字体大小
*/
private size(font: number) {
if (font < 1 || font > 8) {
return;
}
const n = ((font - 1) << 4) | (font - 1);
this.pushCommand([...ESC_POS_COMMANDS.FONT, n]);
}
/**
* 设置行间距
*/
private lineSpacing(spacing: number) {
this.pushCommand([...ESC_POS_COMMANDS.LINE_SPACING, spacing]);
}
/**
* 应用样式差异(用于 text 方法外部)
*/
private applyDiffStyle(next: Required<TextOptions>) {
// ✅ bold
if (next.bold !== this.currentState.bold) {
this.bold(next.bold);
this.currentState.bold = next.bold;
}
// ✅ size
if (next.size !== this.currentState.size) {
this.size(next.size);
this.currentState.size = next.size;
}
// ✅ line spacing
if (next.lineSpacing !== this.currentState.lineSpacing) {
this.lineSpacing(next.lineSpacing);
this.currentState.lineSpacing = next.lineSpacing;
}
}
}
使用示例
javascript
// 创建配送回单打印任务
const printJob = new PrintJob();
const printData = printJob
.init() // 初始化打印机
.text('配送回单', { bold: true, size: 2, align: 'center' }) // 文本
.newline(2) // 空两行
.cut() // 切纸
.build(); // 构建字节流
console.log(`打印数据大小: ${printData.length} 字节`);
小结
本章系统介绍了热敏打印的事实标准------ESC/POS 指令模型,包括:
- 指令基础:ESC/POS 的组成结构与流式执行特性。
- 常用命令速查:以 JavaScript 对象形式整理了初始化、换行、切纸、对齐、加粗等高频指令。
- 文本编码转换 :解释了小程序环境下中文乱码的根源,并给出了基于
iconv-lite的 GBK 编码转换方案。 - 工程化封装 :提供了一个完整的
PrintJob类实现,将复杂的指令拼接隐藏在语义化的链式 API 之后,同时输出钉钉小程序可直接使用的十六进制字符串。
指令构建完成之后,文本指令的发送链路已经打通,接下来让我们看看另一个常见但更复杂的场景:图片打印。
六、BLE 数据传输机制
在完成 BLE 连接建立以及 ESC/POS 指令构建之后,打印流程才真正进入核心阶段。
需要再次强调一个关键认知:
BLE 打印并不是一次"写入字符串"的操作,而是一套受协议严格约束的数据传输过程。
这一过程涉及分包、顺序控制、发送节奏以及可靠性保障等多个方面。
BLE 的分包通信模型
BLE 并非为大数据连续传输而设计,其底层采用的是基于 MTU(Maximum Transmission Unit)的分包通信模型。
在协议栈中:
- 应用层数据通过 ATT(Attribute Protocol)承载
- ATT 层定义了单次传输的最大数据长度(MTU)
MTU 与有效载荷
根据蓝牙核心规范,ATT 协议的默认 MTU 为 23 字节。这 23 字节的构成如下:
| 组成部分 | 字节数 | 说明 |
|---|---|---|
| Opcode | 1 字节 | 操作码(如 Write Request = 0x12) |
| Attribute Handle | 2 字节 | 特征值句柄 |
| 有效载荷 | 20 字节 | 应用层实际可用的数据 |
因此,应用层单次写入的实际可用数据量仅为 20 字节。这意味着,即使发送一条简单的"打印文本"指令,也可能被拆分为多个数据包。
跨平台 MTU 差异
MTU 并非固定不变,连接建立后双方可协商更大的 MTU 值。不同平台的 MTU 能力存在显著差异:
| 平台 | MTU 协商能力 | 最大 MTU | 说明 |
|---|---|---|---|
| Android | requestMtu(int mtu) |
512 字节 | Android 5.1+ 支持主动协商 |
| iOS | 系统自动协商 | 185 字节 | 无开放 API,由外设发起协商 |
| 钉钉小程序 | 不支持协商 | 23 字节(有效 20 字节) | API 未提供 MTU 协商接口 |
钉钉小程序的关键约束:
dd.writeBLECharacteristicValueAPI 要求单次写入数据"限制在 20 字节内"。- 钉钉小程序未提供 MTU 协商相关 API,开发者无法在应用层主动请求提升 MTU。
- 这意味着钉钉小程序环境下的 BLE 通信,始终受限于默认的 20 字节单包上限。
钉钉小程序的数据格式差异
钉钉小程序 BLE API 与微信小程序存在一个易被忽视的差异:
- 微信小程序 :
writeBLECharacteristicValue的value参数为 ArrayBuffer。 - 钉钉小程序 :要求传入 十六进制字符串(hexString)。
因此,开发者需将 ESC/POS 二进制指令转换为 hex 格式后再调用 API。这一差异不影响传输能力,但需要在编码时注意格式转换。
分包策略:指令切片与顺序发送
受限于单包 20 字节的约束,完整 ESC/POS 指令流必须被切分为多个小包。分包的核心原则:
- 按顺序切片:将完整的 hex 字符串按 40 个字符(对应 20 字节)为一组进行切分。
- 保持顺序:分包必须严格按原始顺序发送,保证打印机接收的指令顺序正确。
- 最后一包处理:最后一包可能不足 20 字节,直接发送剩余部分。
javascript
// 将完整指令流切分为 20 字节的分包
function splitIntoPackets(hexString) {
const packets = [];
// 每 40 个 hex 字符 = 20 字节
for (let i = 0; i < hexString.length; i += 40) {
packets.push(hexString.slice(i, i + 40));
}
return packets;
}
发送节奏控制与队列管理
蓝牙缓冲区
打印机内部并不是"收到数据就立刻执行",而是有一个临时存储区域:
蓝牙接收缓冲区(Bluetooth RX Buffer)
它的作用是:
- 暂存 BLE 发送过来的数据
- 再交给打印引擎逐条解析执行
可以理解为:
BLE 是"快递员",缓冲区是"收件筐",打印机是"处理工人"
为什么会发生溢出?
问题出在一个"速度不匹配":
⚡ BLE 发送速度:
- 可以连续快速 write
- 无响应模式甚至几乎不等待
🐢 打印机处理速度
- 需要解析 ESC/POS 指令
- 热敏头逐行打印
- 图片还要逐点绘制
💥** 结果就是:**
当你发送速度 > 打印机处理速度时:
📌 缓冲区被塞满 → 新数据进不来 → 旧数据被覆盖或丢弃
队列管理方案
Write Without Response 模式下,若连续写入速度过快,可能导致打印机蓝牙模块缓冲区溢出而丢包。因此必须控制发送节奏。
javascript
class BLEPacketQueue {
constructor(deviceId, serviceId, characteristicId) {
this.queue = [];
this.isSending = false;
this.deviceId = deviceId;
this.serviceId = serviceId;
this.characteristicId = characteristicId;
}
// 添加分包到队列
addPackets(packets) {
this.queue.push(...packets);
if (!this.isSending) {
this.sendNext();
}
}
// 发送下一包
sendNext() {
if (this.queue.length === 0) {
this.isSending = false;
console.log('所有分包发送完成');
return;
}
this.isSending = true;
const packet = this.queue.shift();
dd.writeBLECharacteristicValue({
deviceId: this.deviceId,
serviceId: this.serviceId,
characteristicId: this.characteristicId,
value: packet,
success: () => {
// 发送成功后延时 15-20ms,再发送下一包
setTimeout(() => this.sendNext(), 20);
},
fail: (err) => {
console.error('分包发送失败', err);
// 可在此处实现重试逻辑
this.queue.unshift(packet); // 放回队列头部
setTimeout(() => this.sendNext(), 50);
}
});
}
}
发送间隔的实践经验:
- 间隔过小(<10ms)可能导致打印机缓冲区溢出,表现为乱码或丢包。
- 间隔过大则延长整体打印时间,影响配送员体验。
- 15-20ms 是一个经过实践验证的平衡值。
Write Without Response 的可靠性与流控权衡
打印场景通常选用 Write Without Response 以提升吞吐效率。但这一模式放弃了应用层的单包确认,可靠性依赖于底层链路层的重传机制。
在工程实践中,可通过以下策略平衡可靠性与效率:
- 发送间隔控制:给打印机蓝牙模块留出处理时间。
- Notify 状态监听:通过监听打印机的"缓冲区满/可接收"状态,实现应用层流控。
- 整单校验:打印完成后,通过 Notify 接收"打印完成"确认。若超时未收到,触发重打逻辑。
完整发送流程示例
javascript
async function printOrder(orderInfo) {
// 1. 构建指令流
const command = buildPrintCommand(orderInfo);
// 2. 转 hex
const hex = toHex(command);
// 3. 分包
const packets = splitIntoPackets(hex);
// 4. 队列发送
const queue = new BLEPacketQueue(deviceId, serviceId, writeCharId);
queue.addPackets(packets);
// 5. 等待完成通知
waitForPrintComplete();
}
小结
本章从协议层到工程实现,系统说明了 BLE 打印的数据传输机制:
- BLE 基于 MTU 的分包通信模型
- 钉钉小程序 20 字节硬限制
- hex 数据格式转换
- 分包切片策略
- 队列发送与节奏控制
- 应用层可靠性与流控设计
通过这些机制,才能在 BLE 受限环境下,实现稳定的打印数据传输。
那么,如何在同样的 BLE 限制下,高效传输体积更大、数据更密集的图片内容?
七、BLE 图片打印
在大多数业务场景中,文本打印已经能够覆盖核心需求。但在实际落地过程中,很快会遇到一些无法回避的场景:
- 回单需要打印签字图片
- 单据需要展示公司 Logo
- 业务要求打印盖章或二维码图片
相比文本,图片打印的复杂度会显著提升。
需要先建立一个关键认知:
打印机并不认识"图片",它只认识"点"。
打印机如何理解图片
热敏打印机的本质,是一排密集排列的加热点阵列。以常见 58mm 打印机为例:
- 打印宽度:通常为 384 点
- 每一行:384 个独立加热点
- 每个点状态:
- 加热 → 黑点
- 不加热 → 白点
👉 换句话说:
打印图片,本质是逐行描述:哪些点需要打印
黑白点阵模型
假设一行 8 像素宽的图像:

每个像素的状态可以抽象为可以抽象为:
javascript
1 1 0 0 1 1 0 0
其中:
- 1 = 打印(加热)
- 0 = 不打印
这组 0/1 数据,就是所谓的黑白点阵。
为什么 8 个像素 = 1 字节
- 1字节 = 8 位二进制
- 每一位对应一个像素
javascript
11001100 → 0xCC
这也是核心位运算的来源:
javascript
byte |= (0x80 >> bit);
含义:
- 从高位开始写入
- 每一位映射一个像素点
获取图片像素数据
在小程序中,图片通常来源于:
- Canvas(签名)
- 本地图片
- 网络图片
统一方式是通过 Canvas 获取像素数据::
javascript
async function getImagePixelData(imagePath, targetWidth = 384) {
return new Promise((resolve, reject) => {
dd.getImageInfo({
src: imagePath,
success: (imgInfo) => {
const scale = targetWidth / imgInfo.width;
const targetHeight = Math.floor(imgInfo.height * scale);
const ctx = dd.createCanvasContext('printCanvas');
ctx.clearRect(0, 0, targetWidth, targetHeight);
ctx.drawImage(imagePath, 0, 0, targetWidth, targetHeight);
ctx.draw(false, () => {
dd.canvasGetImageData({
canvasId: 'printCanvas',
x: 0,
y: 0,
width: targetWidth,
height: targetHeight,
success: (res) => {
resolve({
data: res.data,
width: targetWidth,
height: targetHeight
});
},
fail: reject
});
});
},
fail: reject
});
});
}
此时得到的 data 是一个 RGBA 像素数组, 每个像素由 4 个字节组成(R、G、B、A),取值范围均为 0-255。
像素转换:彩色 → 黑白
现实中的图片是 RGB 彩色图,而打印机只能打印黑色。所以我们必须把:彩色像素 → 黑或白
这就需要两步:
- 灰度化:把彩色图片变成亮度图。
- 二值化: 把亮度图变成黑白图。
最终的黑白图本质上就是:
每个像素是否打印的布尔矩阵。
而这个布尔矩阵,就是点阵数据。
灰度化
灰度表示:
这个像素"亮"还是"暗"
一个灰度值通常在: 0 ~ 255,0 = 黑,255 = 白。
图像处理标准处理公式如下:
javascript
function rgbToGray(r, g, b) {
// 使用加权平均法计算灰度值
return Math.round(r * 0.299 + g * 0.587 + b * 0.114);
}
为什么是这个比例?因为:
- 人眼对绿色最敏感
- 对红色次之
- 对蓝色最不敏感
所以不是简单平均 (r + g + b)/3,而是加权平均。
举个例子
javascript
// 红色 灰度化
(255, 0, 0) --> 255 * 0.299 ≈ 76
// 蓝色 灰度化
(0, 0, 255) --> 255 * 0.114 ≈ 29
所以蓝色会更"暗"。
二值化处理
灰度化之后,我们得到了一个亮度值:0 ~ 255,但打印机不能打印"灰色"。它只能:
- 打印
- 不打印
所以我们必须做一个判断:
javascript
if (gray < threshold) {
// 打印
} else {
// 不打印
}
这一步叫:
二值化(Binary Thresholding)
最终:灰度图 → 黑白图
常见算法对比
| 方法 | 特点 | 适用场景 |
|---|---|---|
| 固定阈值 | 简单快速 | Logo / 二维码 |
| OTSU | 自动阈值 | 通用图片 |
| Floyd-Steinberg | 抖动优化 | 提升细节表现 |
转换为点阵
经过灰度 + 二值化后,每个像素变成:
javascript
1 = 打印
0 = 不打印
例如一行 8 像素:
javascript
灰度: 30 80 210 220 60 40 200 190
结果: 1 1 0 0 1 1 0 0
这就是:黑白点阵,然后:
- 每 8 个像素
- 压缩成 1 个字节
- 按行发送给打印机
打印机就会:
- 第1行按位加热
- 第2行按位加热
于是图片就"被打印出来"了。
生成点阵数据
javascript
function convertImageToRaster(imageData, width, height) {
const { data } = imageData;
const bytesPerLine = Math.ceil(width / 8);
const raster = new Uint8Array(bytesPerLine * height);
const grayData = new Array(width * height);
// 灰度化
for (let i = 0, j = 0; i < data.length; i += 4, j++) {
grayData[j] = rgbToGray(data[i], data[i + 1], data[i + 2]);
}
// 二值化(OTSU)
const threshold = otsuThreshold(grayData);
for (let y = 0; y < height; y++) {
for (let x = 0; x < bytesPerLine; x++) {
let byte = 0;
for (let bit = 0; bit < 8; bit++) {
const px = x * 8 + bit;
if (px < width) {
const idx = y * width + px;
if (grayData[idx] < threshold) {
byte |= (0x80 >> bit);
}
}
}
raster[y * bytesPerLine + x] = byte;
}
}
return raster;
}
图片宽度对齐与补零处理
在生成点阵数据时,有一个非常重要的规则:
图片宽度必须按 8 像素对齐
原因
- 1 字节 = 8 位
- 每位对应一个像素
因此每一行必须是完整的字节数据。
不对齐的后果
如果宽度不是 8 的倍数:
- 数据错位
- 行解析错误
- 图片打印异常(偏移、乱码)
处理方式
在每一行末尾补 0(白点)
例如:
plain
原始:10 像素
补齐:16 像素(补 6 个 0)
代码中通过以下逻辑天然实现:
javascript
const bytesPerLine = Math.ceil(width / 8);
if (px < width) {
// 原始像素
} else {
// 自动补 0(白点)
}
ESC/POS 图片指令
最常用指令 GS v 0:
javascript
function buildImageCommand(raster, width, height) {
const bytesPerLine = Math.ceil(width / 8);
const header = [
0x1D, 0x76, 0x30, 0x00,
bytesPerLine & 0xFF,
(bytesPerLine >> 8) & 0xFF,
height & 0xFF,
(height >> 8) & 0xFF
];
const result = new Uint8Array(header.length + raster.length);
result.set(header);
result.set(raster, header.length);
return result;
}
本质仍然是:一段 ESC/POS 字节流
BLE 图片打印注意事项
在实际使用中,图片打印相比文本更容易出现失败或效果不佳,主要需要注意以下几点:
- 控制图片尺寸
- 图片宽度不要超过打印机最大宽度(58mm 机型通常为 384px)
- 图片越大,数据量越大,传输时间越长,失败概率也越高
- 简化图片内容
- 尽量使用黑白图
- 减少灰度和细节
- 避免复杂图案或高精度图片
- 控制发送节奏
- 单包数据不超过 20 字节
- 发送间隔建议 ≥ 30ms
- 必须按顺序逐包发送,避免并发写入
- 做好数据缓存
- 对于固定图片(如 Logo、二维码),建议提前转换为点阵数据
- 避免每次打印都重复处理图片
- 合理评估使用场景
- BLE 图片打印更适合:小尺寸图片、简单标识或二维码
- 不适合:大图、长图、高精度图片、高频连续打印场景
八、总结
本文围绕钉钉小程序 + BLE + 便携打印机的方案,从技术选型、GATT 通信模型、连接生命周期管理,到 ESC/POS 指令构建、分包传输与图片打印,完整梳理了移动端蓝牙打印的工程链路与关键实践,为类似场景下的实现提供参考。
希望本文的实践经验,能对你在 BLE 蓝牙打印的探索之路上提供些许帮助。