钉钉小程序蓝牙打印探索与实践

avatar
@古茗科技

作者:刘锦泉

一、前言

在实际物流配送业务中,电子签收在部分场景下仍无法完全替代纸质回单。配送员在门店现场需要打印配送单据,与收货方逐项核对并完成签字确认。纸质回单不仅是履约凭证,也是后续对账与责任追溯的重要依据。

但在真实配送环境中,作业条件通常较为复杂:

  • 无固定网络或 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
通信模型流式特征值
协议SPPGATT
连接长连接短连接
建连耗时2~5s100~300ms
功耗
iOS 支持受限完全开放
小程序支持不统一标准支持

小结

综合协议模型、系统支持以及小程序运行环境的约束可以看到:

经典蓝牙虽然在带宽与成熟度上具备优势,但在移动端尤其是 iOS 与小程序体系中,存在明显的可达性与一致性问题;而 BLE 在系统支持、连接模型与工程可控性上更符合当前场景。因此,在钉钉小程序驱动便携式打印机的场景下:

BLE 并不是“更优选择”,而是在现有平台约束下“可落地且稳定”的通信方案。

在明确选择 BLE 后,需要理解其核心通信机制——GATT。这是理解后续设备连接、服务发现与数据交互流程的认知基础。

三、BLE 通信模型

在 BLE 中,并不存在类似串口或 Socket 的持续数据通道。与经典蓝牙基于 SPP 提供“流式传输”不同,BLE 的通信建立在 GATT(Generic Attribute Profile)模型之上。

GATT 将设备能力抽象为一组层级化的数据结构,所有数据交互都围绕这些结构展开,而不是通过一条持续的数据流进行传输。

GATT 的基本结构

在 GATT 模型中,一个 BLE 设备可以抽象为为一棵层级结构:

设备(Device)
  └── 服务(Service)
        └── 特征值(Characteristic)
              └── 描述符(Descriptor)

在打印场景,BLE 打印机作为 GATT 服务端(Server),其内部属性表可简化为以下结构:

服务(Service)

服务是设备某项功能的逻辑集合,由一个或多个特征值组成。简单来说就是:

设备“对外声明的功能模块”——它能提供哪些能力

每个服务通过 UUID(通用唯一标识符) 唯一标识。UUID 可以分两类:

  • 16-bit 标准 UUID
  • 128-bit 自定义

16-bit 标准 UUID(SIG 定义)

格式:

0000xxxx-0000-1000-8000-00805f9b34fb

其中:

  • xxxx = 标准服务编号
  • 后缀 0000-1000-8000-00805f9b34fb = Bluetooth Base UUID

常见标准 Service(SIG 定义)

ServiceUUID含义
Generic Access0x1800设备基础信息
Generic Attribute0x1801GATT 控制服务
Device Information0x180A设备信息
Battery Service0x180F电池信息
Heart Rate0x180D心率(穿戴设备)

特点:

  • 属于 BLE 官方标准
  • 这些服务本身不承载打印数据,但在工程中可用于读取设备信息、电量等辅助功能

128-bit 自定义 UUID

格式:

xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

例如:

  • 49535343-FE7D-4AE5-8FA9-9FAFD205E455
  • 0000FFF0-0000-1000-8000-00805F9B34FB

特点

  • 厂商自定义
  • 用于:
    • 打印机
    • 扫码枪
    • IoT 设备
  • ❗ 是否支持打印完全取决于厂商实现

示例

serviceId 示意图:

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

特征值(Characteristic)

特征值是服务下的具体数据节点,是客户端真正进行读写操作的对象。可以把它理解为:

每个功能模块下的“具体操作入口”——能力如何被访问

一个特征值通常包含三个关键信息:

  • UUID:该特征值的唯一标识。
  • Properties(操作属性):定义支持的操作类型,常见值包括:
    • Read:可读
    • Write:可写(带响应)
    • Write Without Response:无响应写入
    • Notify:可通知
  • Value(值):实际存储的数据。

在打印场景中,最核心的特征值有两类:

  • 写入特征值:Property 包含 WriteWrite 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 判断当前权限状态,并在未授权时引导用户开启权限。

/*
   * 检查授权
   */
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 扫描能力与定位权限强绑定:

  • 未授权定位权限时,无法获取周边蓝牙设备
  • 即使蓝牙开启,扫描结果也可能为空

因此,在工程实践中,一般需要同时检查: ”蓝牙权限 + 定位权限“

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 之前,必须先初始化蓝牙模块。

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 监听手机蓝牙状态的改变。

dd.onBluetoothAdapterStateChange((res) => {
  // available: 蓝牙模块是否可用(需支持 BLE 且蓝牙已开启)
  // discovering: 蓝牙模块是否处于搜索状态
  const { available, discovering } = res;
  if (!available) {
    // 蓝牙不可用,提示用户开启
  }
});

BLE 设备扫描

在完成蓝牙模块初始化后,即可开始扫描附近的 BLE 外围设备。

BLE 设备扫描本质上是一个基于广播包的实时发现机制,系统并不会返回“设备列表”,而是通过持续监听广播信号来逐步构建可见设备集合。

在钉钉小程序中,可以通过 dd.startBluetoothDevicesDiscovery 开始扫描:

dd.startBluetoothDevicesDiscovery({
  allowDuplicatesKey: false, // 是否允许重复上报同一设备
  interval: 0,               // 上报间隔,0 表示立即上报
  success() {
    // 超时自动停止
    timeoutId = setTimeout(() => {
      resolve();
      this.stopNearDeviceScan();
      clearTimeout(timeoutId);
    }, timeout);
  }
});

扫描过程中,通过 dd.onBluetoothDeviceFound 监听设备广播信息:

dd.onBluetoothDeviceFound((res) => {
  res.devices.forEach(device => {
    // 根据设备名称或制造商数据过滤打印机
    if (device.name && device.name.includes('Printer')) {
      console.log('发现打印机:', device.name, device.deviceId);
      // 保存设备信息,用于后续连接
      storeDeviceInfo(device);
    }
  });
});

在实际工程中,BLE 设备通常通过以下字段进行识别:

  • name
  • localName
  • 厂商自定义广播数据(manufacturerData)

建议在扫描阶段就完成设备过滤,而不是在连接阶段再做判断,以减少无效连接尝试。

扫描超时控制

BLE 扫描默认是一个持续过程,如果不主动停止,会一直占用系统资源,并可能影响后续连接操作。因此,在工程实践中,通常需要为扫描设置一个合理的超时时间。

常见做法是:在调用 startBluetoothDevicesDiscovery 后,通过定时器在指定时间后自动停止扫描。

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 扫描状态、缓存机制或扫描会话未完全释放导致。

✔ 解决方案:重置蓝牙适配器状态

在钉钉小程序中,可以通过重启蓝牙适配器来清理系统扫描上下文,从而恢复设备发现能力:

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 建立连接。

dd.connectBLEDevice({
  deviceId,
  success() {
    console.log('设备连接成功');
    // 连接成功后立即停止扫描,避免干扰后续操作
    dd.stopBluetoothDevicesDiscovery();
    // 获取服务列表
    getDeviceServices(deviceId);
  },
  fail(err) {
    console.error('连接失败', err);
  }
});

说明

  • 若设备已连接,再次连接通常会直接返回成功
  • 建议连接成功后立即停止扫描,避免资源竞争
  • 若“可发现但无法连接”,优先检查是否仍在扫描状态

获取 Services

连接成功后,需要获取设备暴露的 Service 列表。

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 的筛选可以遵循以下经验规则:

  1. 排除通用标准服务
    • 1800 / 1801 / 180A / 180F
    • 这些服务几乎不可能承载打印数据
  2. 优先选择 FFxx 类服务
    • FFF0 / FFE0 / FF00
    • 通常为串口透传服务(Serial over BLE)
    • 大多数打印机使用该通道接收 ESC/POS 指令
  3. 谨慎对待厂商自定义 UUID
    • 49535343-xxxx
    • 可能是蓝牙模块内部服务,而非打印通道
  4. 其余 Service 作为候选补充
    • 不能完全忽略,但优先级较低
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 列表后,可结合排序使用:

services.sort((a, b) => 
  this.getServicePriority(a.uuid) - this.getServicePriority(b.uuid));

获取 Characteristics

确定目标 Service 后,需要进一步获取其下的 Characteristic。示例代码如下:

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.write
  • properties.writeWithoutResponse
  • properties.notify
  • properties.read

这些属性,直接决定了是否能用于打印数据写入。打印通常至少需要两个特征:

  • 可写特征:用于写入打印数据,但需要特别注意 write ≠ 一定可用于打印

Characteristic 支持 write,仅代表“可以写入数据”,并不代表打印机会执行这些数据。

  • 通知特征(可选):用于接收打印状态回传,状态通知特征并非必需,但在批量或大数据打印时非常重要

启用特征值变化通知

对于支持 notifyindicate 的特征值,需调用 dd.notifyBLECharacteristicValueChange 启用通知功能。示例代码如下:

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 事件。
  • 设备的特征值必须支持 notifyindicate 才可以成功调用,具体参照 characteristic 的 properties 属性。
  • 订阅操作成功后,需要设备主动更新特征值的 value,才会触发 dd.onBLECharacteristicValueChange
  • 订阅方式效率比较高,推荐使用订阅代替 read 方式。
  • 注意调用顺序:最好在连接之后就调用 dd.notifyBLECharacteristicValueChange 方法。

蓝牙连接的断开与资源清理

BLE 连接具有天然不稳定性,因此必须设计完整的断开与恢复机制。

被动断开:监听与自动重连

蓝牙连接随时可能因为距离过远、设备断电、信号干扰等原因而意外断开。我们必须通过监听连接状态变化事件来应对这种情况。

// 监听连接状态变化
dd.onBLEConnectionStateChanged((res) => {
  if (!res.connected) {
    // 做相应的处理
  }
});

说明

  • 若对未连接的设备调用数据读写操作接口,会返回 10006 错误,此时应执行重连。
  • 避免重复监听:每次调用 on 方法监听事件之前,最好先调用 off 方法关闭之前的事件监听,防止多次注册导致事件被多次触发。

主动断开与清理:完整的退出机制

当用户主动关闭页面或完成打印后,我们需要手动断开连接并释放系统资源。一个标准的清理流程应该包含三步:

  1. 断开设备连接,
  2. 移除所有事件监听
  3. 关闭蓝牙适配器。
// 完整的资源清理方法,建议在页面 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.disconnectBLEDeviceoff 系列方法进行清理。
  • 调用时机:此方法建议在页面的 onUnload 生命周期中调用。因为 closeBluetoothAdapter 是异步操作,不建议将其与 openBluetoothAdapter 一起用作异常处理,效率低且易引发线程同步问题。
  • 页面卸载:点击小程序右上角关闭按钮时,小程序可能仅进入后台而非立即销毁,因此需要在 onHideonUnload 中主动调用清理逻辑,确保连接被及时断开。

工程化建议

蓝牙打印的交互链路很长,涉及权限、扫描、连接、状态管理、异常恢复等诸多环节。若将所有逻辑耦合在一起,代码会迅速膨胀且难以维护。建议将蓝牙能力拆分为两个独立实体:

BluetoothAdapter(能力适配层)

负责与钉钉蓝牙 API 的直接交互,向上屏蔽平台差异与底层细节。核心职责:

  • API 适配:封装 openBluetoothAdapterstartDiscovery 等基础调用,统一返回 Promise 接口
  • 权限校验:收敛蓝牙与定位权限的检查逻辑
  • 异常告警:统一捕获蓝牙错误码,映射为业务可理解的提示(如“系统蓝牙未开启”)
  • 埋点上报:记录扫描耗时、连接成功率、异常断开次数等关键指标

BluetoothConnection(连接实例层)

每一次打印任务对应一个连接实例,内置完整的状态机与连接属性。核心职责:

  • 连接属性:持有 deviceIdserviceIdwriteCharIdnotifyCharId 等关键标识
  • 状态机:管理 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 指令流通常由以下部分组成:

  1. 初始化命令:复位打印机状态
  2. 格式控制命令:对齐、字体、加粗、行距等
  3. 内容数据:文本、条码、二维码、图片
  4. 结束控制命令:走纸、切纸

流式执行模型

打印机采用流式处理机制

边接收 → 边解析 → 边执行

不存在“完整接收后再统一执行”的过程。

因此:

👉 指令发送顺序必须严格等于打印顺序

常用 ESC/POS 指令

下面是配送回单场景中最常用的一组指令,以 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
  • 而大多数便携式热敏打印机只支持 GBKGB2312 这类中文字符集。

如果直接将 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 为例展示转换函数:

// 引入 iconv-lite(需通过 npm 安装后构建到小程序中)
import * as iconv from 'iconv-lite';


function textEncode(str) {
  // 将 UTF-16 字符串编码为 GBK 字节数组
  return iconv.encode(str, 'gbk');
}

打印任务的工程化封装

在实际项目中,如果直接拼接字节数组,会带来以下问题:

  • 可读性差
  • 维护成本高
  • 易出错

因此建议封装打印任务。

PrintJob封装示例

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;
    }
  }

}

使用示例

// 创建配送回单打印任务
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 字节的构成如下:

组成部分字节数说明
Opcode1 字节操作码(如 Write Request = 0x12)
Attribute Handle2 字节特征值句柄
有效载荷20 字节应用层实际可用的数据

因此,应用层单次写入的实际可用数据量仅为 20 字节。这意味着,即使发送一条简单的“打印文本”指令,也可能被拆分为多个数据包。

跨平台 MTU 差异

MTU 并非固定不变,连接建立后双方可协商更大的 MTU 值。不同平台的 MTU 能力存在显著差异:

平台MTU 协商能力最大 MTU说明
AndroidrequestMtu(int mtu)512 字节Android 5.1+ 支持主动协商
iOS系统自动协商185 字节无开放 API,由外设发起协商
钉钉小程序不支持协商23 字节(有效 20 字节)API 未提供 MTU 协商接口

钉钉小程序的关键约束

  • dd.writeBLECharacteristicValue API 要求单次写入数据“限制在 20 字节内”。
  • 钉钉小程序未提供 MTU 协商相关 API,开发者无法在应用层主动请求提升 MTU。
  • 这意味着钉钉小程序环境下的 BLE 通信,始终受限于默认的 20 字节单包上限

钉钉小程序的数据格式差异

钉钉小程序 BLE API 与微信小程序存在一个易被忽视的差异:

  • 微信小程序writeBLECharacteristicValuevalue 参数为 ArrayBuffer。
  • 钉钉小程序:要求传入 十六进制字符串(hexString)

因此,开发者需将 ESC/POS 二进制指令转换为 hex 格式后再调用 API。这一差异不影响传输能力,但需要在编码时注意格式转换。

分包策略:指令切片与顺序发送

受限于单包 20 字节的约束,完整 ESC/POS 指令流必须被切分为多个小包。分包的核心原则:

  1. 按顺序切片:将完整的 hex 字符串按 40 个字符(对应 20 字节)为一组进行切分。
  2. 保持顺序:分包必须严格按原始顺序发送,保证打印机接收的指令顺序正确。
  3. 最后一包处理:最后一包可能不足 20 字节,直接发送剩余部分。
// 将完整指令流切分为 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 模式下,若连续写入速度过快,可能导致打印机蓝牙模块缓冲区溢出而丢包。因此必须控制发送节奏。

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 以提升吞吐效率。但这一模式放弃了应用层的单包确认,可靠性依赖于底层链路层的重传机制。

在工程实践中,可通过以下策略平衡可靠性与效率:

  1. 发送间隔控制:给打印机蓝牙模块留出处理时间。
  2. Notify 状态监听:通过监听打印机的“缓冲区满/可接收”状态,实现应用层流控。
  3. 整单校验:打印完成后,通过 Notify 接收“打印完成”确认。若超时未收到,触发重打逻辑。

完整发送流程示例

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 像素宽的图像:

每个像素的状态可以抽象为可以抽象为:

1 1 0 0 1 1 0 0

其中:

  • 1 = 打印(加热)
  • 0 = 不打印

这组 0/1 数据,就是所谓的黑白点阵。

为什么 8 个像素 = 1 字节

  • 1字节 = 8 位二进制
  • 每一位对应一个像素
110011000xCC

这也是核心位运算的来源:

byte |= (0x80 >> bit);

含义:

  • 从高位开始写入
  • 每一位映射一个像素点

获取图片像素数据

在小程序中,图片通常来源于:

  • Canvas(签名)
  • 本地图片
  • 网络图片

统一方式是通过 Canvas 获取像素数据::

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 = 白。

图像处理标准处理公式如下:

function rgbToGray(r, g, b) {
  // 使用加权平均法计算灰度值
  return Math.round(r * 0.299 + g * 0.587 + b * 0.114);
}

为什么是这个比例?因为:

  • 人眼对绿色最敏感
  • 对红色次之
  • 对蓝色最不敏感

所以不是简单平均 (r + g + b)/3,而是加权平均。

举个例子
// 红色 灰度化
(255, 0, 0) --> 255 * 0.29976

// 蓝色 灰度化
(0, 0, 255) --> 255 * 0.11429

所以蓝色会更“暗”。

二值化处理

灰度化之后,我们得到了一个亮度值:0 ~ 255,但打印机不能打印“灰色”。它只能:

  • 打印
  • 不打印

所以我们必须做一个判断:

if (gray < threshold) {
   // 打印
} else {
   // 不打印
}

这一步叫:

二值化(Binary Thresholding)

最终:灰度图 → 黑白图

常见算法对比

方法特点适用场景
固定阈值简单快速Logo / 二维码
OTSU自动阈值通用图片
Floyd-Steinberg抖动优化提升细节表现

转换为点阵

经过灰度 + 二值化后,每个像素变成:

1 = 打印
0 = 不打印

例如一行 8 像素:

灰度:  30  80  210 220  60  40  200  190
结果:   1   1    0   0   1   1    0    0

这就是:黑白点阵,然后:

  • 每 8 个像素
  • 压缩成 1 个字节
  • 按行发送给打印机

打印机就会:

  • 第1行按位加热
  • 第2行按位加热

于是图片就“被打印出来”了。

生成点阵数据
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(白点)

例如:

原始:10 像素
补齐:16 像素(补 6 个 0)

代码中通过以下逻辑天然实现:

const bytesPerLine = Math.ceil(width / 8);

if (px < width) {
  // 原始像素
} else {
  // 自动补 0(白点)
}

ESC/POS 图片指令

最常用指令 GS v 0

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 蓝牙打印的探索之路上提供些许帮助。