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

作者:刘锦泉

一、前言

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

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

  • 无固定网络或 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-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 判断当前权限状态,并在未授权时引导用户开启权限。

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 设备通常通过以下字段进行识别:

  • name
  • localName
  • 厂商自定义广播数据(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 的筛选可以遵循以下经验规则:

  1. 排除通用标准服务
    • 1800 / 1801 / 180A / 180F
    • 这些服务几乎不可能承载打印数据
  2. 优先选择 FFxx 类服务
    • FFF0 / FFE0 / FF00
    • 通常为串口透传服务(Serial over BLE)
    • 大多数打印机使用该通道接收 ESC/POS 指令
  3. 谨慎对待厂商自定义 UUID
    • 49535343-xxxx
    • 可能是蓝牙模块内部服务,而非打印通道
  4. 其余 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.write
  • properties.writeWithoutResponse
  • properties.notify
  • properties.read

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

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

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

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

启用特征值变化通知

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

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

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

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

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

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

说明

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

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

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

  1. 断开设备连接,
  2. 移除所有事件监听
  3. 关闭蓝牙适配器。
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.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 对象形式组织便于后续封装使用。

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 为例展示转换函数:

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

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

  1. 发送间隔控制:给打印机蓝牙模块留出处理时间。
  2. Notify 状态监听:通过监听打印机的"缓冲区满/可接收"状态,实现应用层流控。
  3. 整单校验:打印完成后,通过 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 蓝牙打印的探索之路上提供些许帮助。

相关推荐
Hilaku1 小时前
做了 6 年前端,技术不差却拿不到 Offer?
前端·javascript·程序员
LIO1 小时前
一文看懂 Vue Router:精简、易懂、直接用
前端·vue-router
Highcharts.js2 小时前
技术组合分析:Highcharts 的数据集成能力解析
java·前端·金融·echarts·saas·bi·highcharts
在下有个宝贝2 小时前
GIS前端开发之路——Openlayers为地图添加自定义标注(四)
前端
a1117762 小时前
RIPPLE 流体交互(html 开源)
前端·javascript·html
薛定猫AI2 小时前
【深度解析】Qwen 3.6 Max Preview:面向智能体编码、视觉推理与 Three.js 前端生成的能力拆解
开发语言·前端·javascript
HashTang2 小时前
我的开源项目帮独立开发者和 OPC 省掉的,不只是刷信息的时间
前端·ai编程·aiops
掘金者阿豪2 小时前
Spring Data JPA 接入金仓数据库:少写代码,多干活
前端·后端
Moment2 小时前
AI 时代,为什么全栈项目越来越离不开 Monorepo 和 TypeScript
前端·javascript·后端