uni-app 小程序集成 NIIMBOT B3S 蓝牙打印机实践

背景

在项目中,需要为农产品溯源系统实现标签打印功能。企业用户需要为每个产品打印带有二维码的溯源码标签,包含企业名称、商品名称、溯源码编号等信息,并支持批量打印。

技术选型

  • 硬件:NIIMBOT B3S 智能标签打印机
  • 框架:uni-app(微信小程序为主)
  • SDK:精臣官方 JavaScript SDK
  • 语言:TypeScript + Vue 3

核心挑战

  1. 小程序环境下蓝牙设备的连接稳定性
  2. 批量打印的性能和用户体验
  3. 标签模板的动态生成和渲染
  4. 不同平台(微信/钉钉/飞书)的兼容性
  5. 异步打印任务的顺序执行

架构设计

分层架构

采用清晰的分层架构,将硬件相关的逻辑与业务逻辑分离:

┌─────────────────────────────────┐
│     Presentation Layer          │
│  - 溯源码列表页                  │
│  - 打印机连接页                  │
│  - 批量选择组件                  │
└──────────────┬──────────────────┘
               │ 调用服务层 API
┌──────────────▼──────────────────┐
│     Service Layer (Adapter)     │
│  - SDK 初始化与平台检测          │
│  - 设备扫描与连接管理            │
│  - 打印任务编排                  │
│  - 状态同步与错误处理            │
└──────────────┬──────────────────┘
               │ 数据转换
┌──────────────▼──────────────────┐
│     Template Layer              │
│  - 业务数据 → 标签数据结构       │
│  - 文本处理(截断、默认值)      │
│  - 布局配置                      │
└──────────────┬──────────────────┘
               │ 类型定义
┌──────────────▼──────────────────┐
│     Type Layer                  │
│  - TypeScript 接口定义           │
│  - 类型约束                      │
└──────────────┬──────────────────┘
               │ 调用
┌──────────────▼──────────────────┐
│     SDK Layer (Third-party)     │
│  - 精臣 JCAPI                   │
│  - BLE 通信                     │
└─────────────────────────────────┘

设计原则

单一职责原则

  • 服务层只负责与 SDK 交互,不包含任何 UI 逻辑
  • 模板层只负责数据转换,不关心打印流程
  • 页面组件只负责用户交互,不直接调用 SDK

依赖倒置原则

  • 高层模块(页面)依赖抽象(服务层 API)
  • 不直接依赖低层模块(第三方 SDK)
  • 便于未来替换 SDK 或支持其他打印机品牌

适配器模式

  • 通过适配层隔离第三方 SDK 的侵入性
  • 将回调风格的 API 转换为 Promise 风格
  • 统一错误处理和状态管理

核心技术实现

SDK 初始化与平台适配

问题:精臣 SDK 需要针对不同平台(微信、企业微信、钉钉、飞书)进行初始化配置。

解决方案

  1. 使用单例模式确保 SDK 只初始化一次
  2. 通过 uni.getSystemInfoSync().hostName 检测宿主环境
  3. 根据检测结果设置对应的平台常量
  4. 异常情况下默认使用微信平台配置

关键点

  • 懒加载:首次调用时才初始化
  • 幂等性:多次调用不会产生副作用
  • 运行时检测:动态识别宿主环境

设备扫描与去重

问题:蓝牙扫描可能返回重复设备,需要去重并过滤无效设备。

解决方案

  1. 将回调风格的 SDK API 转换为 Promise
  2. 使用 Map 数据结构进行去重,key 优先使用 deviceId,降级使用 name
  3. 过滤掉名称包含“未知设备”的项
  4. 标准化设备对象,统一字段格式

算法复杂度:时间 O(n),空间 O(n)

normalize 函数处理三种情况

  • 字符串输入:直接作为 name
  • 对象输入:提取 name/localName/deviceId
  • 无效值:返回 null

打印机连接实现

问题:SDK 的连接方法是回调风格,需要转换为 Promise,并处理超时和状态同步。

关键技术点

1. 防重复结算机制

let settled = false;
// 在成功/失败回调中检查
if (settled) return;
settled = true;

防止成功回调和失败回调同时触发导致 Promise 多次结算。

2. 超时控制

  • 设置 15 秒超时定时器
  • 超时后自动拒绝 Promise
  • 重置连接状态

3. 状态同步

  • 连接前:设置为 “connecting”
  • 连接成功:刷新全局状态 + 设置为 “connected”
  • 连接失败:清空设备信息 + 设置为 “disconnected”

4. 资源清理

  • 成功/失败后清除定时器
  • 避免内存泄漏

连接状态管理

问题:页面销毁重建后,本地存储的连接状态会丢失,导致状态不同步。

解决方案

  • 不依赖本地缓存
  • 每次调用都通过 SDK 的 getConnName() 实时查询
  • 提供统一的 getConnectedPrinter() 方法供多页面调用

优势

  • 避免状态不一致
  • 简化状态管理逻辑
  • 提高可靠性

连接状态主动探测

问题:被动等待断开回调不够及时,需要在打印前验证连接有效性。

解决方案

  1. 先检查本地记录的连接状态
  2. 调用 SDK 的 getSN() 获取设备序列号进行主动探测
  3. 如果返回码为 -4,判定为连接已断开
  4. 重置状态并抛出错误

使用时机:在实际打印任务执行前调用,作为预检步骤。


标签绘制流程

问题:SDK 的绘图 API 是回调风格,且必须按特定顺序执行。

Promise 化封装: 将三个关键步骤转换为 Promise:

  • startJobAsync:启动打印任务
  • endDrawLabelAsync:结束当前标签绘制
  • waitNextPageSignal:等待上一页打印完成信号

单个标签绘制流程

  1. 调用 startDrawLabel 开始绘制,传入 canvas 信息和标签尺寸
  2. 遍历标签元素数组,逐个调用对应的绘图方法
  3. 调用 endDrawLabelAsync 等待绘制完成

元素绘制分发器: 支持 7 种元素类型:

  • text:普通文本
  • textRect:矩形区域文本(支持自动换行)
  • qrcode:二维码
  • rectangle:矩形框
  • line:直线
  • barcode:条形码
  • image:图片

根据 element.type 分发到对应的 SDK 绘图方法。


批量打印的顺序执行机制

问题:为什么不能并发打印?

原因分析

  • SDK 的 print 回调表示“可以提交下一页”,不是“物理打印完成”
  • 并发调用会导致 SDK 内部状态机混乱
  • 蓝牙缓冲区可能溢出
  • 无法准确追踪任务进度

正确实现

// 1. 启动任务,指定总页数
await startJobAsync(labels.length);

// 2. 顺序绘制每一页
for (let index = 0; index < labels.length; index++) {
  await drawLabel(labels[index], renderContext);
  
  // 3. 非最后一页等待回调信号
  if (index < labels.length - 1) {
    await waitNextPageSignal();
  } else {
    // 4. 最后一页直接提交
    JCAPI.print(1);
  }
}

前置校验

  • 检查渲染上下文(canvasId 和 component)
  • 检查打印机连接状态

异常处理

  • 捕获错误后立即重置状态
  • 向上抛出错误供页面层处理

公开 API 设计

服务层向外暴露两个核心方法:

单张打印

  • 接收单个溯源码数据
  • 转换为标签数据结构
  • 调用批量打印方法(传入单元素数组)

批量打印

  • 接收溯源码数组
  • 校验数组非空
  • 映射为标签数据结构数组
  • 调用底层提交方法

设计思路

  • 单张打印复用批量打印逻辑,减少代码重复
  • 统一入口便于维护和测试

标签模板设计

模板配置

标签规格

  • 宽度:80(单位由 SDK 定义)
  • 高度:40
  • 旋转角度:90 度(横向打印)

文本处理工具

  • trimText:去除首尾空格,提供默认值
  • truncateText:超过最大长度时截断并添加省略号

标签构建逻辑

数据处理规则

  1. 企业名称:默认“未命名企业”,最大 22 字符
  2. 商品名称:默认“未命名商品”,最大 22 字符
  3. 溯源码编号:默认使用 id,最大 28 字符
  4. 二维码内容:优先使用 qrCode,降级使用 code

布局结构(6 个元素):

┌──────────────────────────────┐
│     企业名称(居中加粗)       │  ← textRect, 自动换行
│                              │
│  ┌────────┐  产品名称        │  ← qrcode + text
│  │        │  XXXXX          │
│  │ QR码   │                 │
│  │        │  溯源码编号      │  ← text
│  └────────┘  XXXXXXXXXXXX   │  ← textRect, 自动换行
└──────────────────────────────┘
  1. 企业名称 - textRect 类型,顶部居中,支持自动换行
  2. 二维码 - qrcode 类型,左侧,尺寸 21x21
  3. “产品名称”文字 - text 类型,右侧上方,加粗
  4. 商品名称 - textRect 类型,右侧,支持自动换行
  5. “溯源码编号”文字 - text 类型,右侧中部,加粗
  6. 编号内容 - textRect 类型,右侧下方,支持自动换行

设计要点

  • 使用 textRect 实现长文本自动换行
  • 重要信息(企业名称、商品名)设置较大字号
  • 二维码纠错等级设置为中等(ecc=2)
  • 所有文本加粗显示,提高可读性

页面层实现

打印机连接页

核心功能

1. 扫描设备

  • 使用 isScanning 标志防止重复点击
  • 调用 refreshAdapterState 刷新蓝牙适配器状态
  • 过滤掉已连接的设备,避免重复显示
  • 显示 Loading 状态提升用户体验

2. 连接设备

  • 使用 connectingName 防止同时连接多个设备
  • 连接成功后从列表中移除该设备
  • 调用 syncPrinterState 同步全局状态
  • 显示成功/失败提示

3. 断开连接

  • 调用 closePrinter 断开连接
  • 清空本地状态
  • 重新扫描设备列表

4. 页面显示时同步状态

  • 在 onShow 生命周期中同步打印机状态
  • 刷新蓝牙适配器诊断信息
  • 自动扫描附近设备

蓝牙适配器诊断: 显示详细的蓝牙状态信息,用于调试:

  • available:适配器是否可用
  • discovering:是否正在扫描
  • errCode / errMsg:错误信息
  • updatedAt:更新时间

溯源码列表页

核心功能

1. 状态管理

  • printCanvasId:canvas 节点 ID
  • canvasWidth/canvasHeight:画布尺寸(800x400)
  • isPrint:是否批量打印模式
  • isPrinting:是否正在打印(互斥锁)
  • isSelectedAll:是否全选
  • selectedList:选中的 ID 列表
  • selectedItems:选中的完整对象数组
  • currentList:当前列表数据
  • connectedPrinter:已连接的打印机
  • printerStatus:打印机状态

2. 打印机状态文本计算 使用 computed 根据状态动态显示:

  • 打印中:“打印任务提交中…”
  • 已连接:“已连接:XXX”(XXX 为设备名称)
  • 未连接:“未连接打印机”

3. 同步打印机状态

  • 调用 getConnectedPrinter() 获取最新状态
  • 更新本地引用
  • 在 onMounted 和 onShow 中调用

4. 构建渲染上下文

  • 检查 Vue 组件实例是否初始化
  • 返回包含 canvasId、component、isRefresh 的对象
  • 供服务层绘制标签时使用

5. 构建打印数据

  • 先调用 API 获取溯源码详情
  • 从详情中提取产品信息和企业信息
  • 构建溯源详情页 URL 作为二维码内容
  • 转换为 TraceabilityPrintItem 格式

6. 单个打印流程

  1. 检查 isPrinting 锁,防止重复提交
  2. 设置状态为 printing
  3. 获取溯源码详情并转换为打印数据
  4. 调用 printTraceabilityLabel
  5. 同步打印机状态
  6. 显示提示信息
  7. 释放锁

7. 批量打印流程

  1. 校验是否有选中项
  2. 检查 isPrinting 锁
  3. 获取选中项的完整对象(优先使用 selectedItems,降级从 currentList 筛选)
  4. 使用 Promise.all 并行获取所有溯源码详情
  5. 调用 printTraceabilityBatch
  6. 同步打印机状态
  7. 清空选中状态
  8. 显示提示信息
  9. 释放锁

8. 隐藏 Canvas

<canvas
  :id="printCanvasId"
  class="print-canvas"
  style="position: fixed; left: -2000px; opacity: 1;" />

必须保留的原因

  • SDK 的 startDrawLabel 需要 canvas 节点进行离屏渲染
  • 不能删除,即使页面不直接操作 SDK
  • 使用负坐标移出可视区域
  • opacity 必须为 1,否则某些平台 canvas 不被初始化

批量选择组件

核心实现

1. 选中状态管理

  • selectedList:存储选中的 ID 数组
  • selectedItems:computed 属性,根据 ID 过滤出完整对象
  • isSelectedAll:computed 属性,判断是否全选

2. 全选/清空方法

  • selectAll:将所有 ID 加入选中列表
  • clear:清空选中列表
  • 通过 defineExpose 暴露给父组件调用

3. 监听列表变化

  • 当 props.list 变化时,自动过滤掉已不存在的选中项
  • 使用 Set 提高查找效率
  • 深监听确保响应式更新

4. 监听选中变化并通知父组件

  • 当 selectedList 变化时,向外抛出事件
  • 传递三个参数:ids、items、isAll
  • 父组件根据这些数据执行批量打印

5. 点击切换选中状态

  • 查找当前项是否在选中列表中
  • 存在则移除,不存在则添加
  • 触发 watch 监听器,自动通知父组件

关键技术总结

SDK 适配策略

  1. Promise 化:将所有回调风格的 SDK API 转换为 Promise,便于 async/await 使用
  2. 超时控制:连接操作设置 15 秒超时,避免无限等待
  3. 防重复结算:使用 settled 标志防止 Promise 多次结算
  4. 状态同步:每次操作后调用 refreshConnectedPrinter 同步状态

批量打印顺序执行

为什么必须顺序执行?

  • SDK 的 print 回调表示“可以提交下一页”,不是“物理打印完成”
  • 并发调用会导致 SDK 内部状态机混乱
  • 蓝牙缓冲区可能溢出
  • 无法准确追踪任务进度

正确做法

await startJobAsync(labels.length);

for (let index = 0; index < labels.length; index++) {
  await drawLabel(labels[index], renderContext);
  
  if (index < labels.length - 1) {
    await waitNextPageSignal();  // 等待上一页完成
  } else {
    JCAPI.print(1);  // 最后一页
  }
}

状态管理原则

  1. 单一数据源:printerStatus 和 currentPrinter 是全局唯一的状态来源
  2. 实时查询:通过 JCAPI.getConnName() 实时获取连接状态,不使用缓存
  3. 主动探测:打印前调用 getSN 验证连接有效性
  4. 异常恢复:捕获错误后立即重置状态

标签模板设计

  1. 声明式布局:使用数据结构描述标签,而非命令式调用
  2. 文本处理
    • trimText:去除空格,提供默认值
    • truncateText:超长截断并添加省略号
  3. 自动换行:使用 textRect 类型 + lineModel: 3 实现
  4. 二维码优先级:qrCode > code

Canvas 处理

为什么必须保留隐藏 Canvas?

  • SDK 需要 canvas 节点进行离屏渲染
  • 不能删除,即使页面不直接操作 SDK
  • 使用负坐标隐藏(left: -2000px)
  • opacity 必须为 1,否则某些平台 canvas 不被初始化

代码统计

文件行数主要功能
niimbot.ts380SDK 封装,9 个导出方法
template.ts119标签模板构建
types.ts130TypeScript 类型定义
printer-connect.vue400打印机连接页
code-list.vue579溯源码列表页
PrintList.vue154批量选择组件

总计:约 1762 行代码


导出的公共 API

服务层向外暴露以下方法:

方法说明
initPrinterSdk初始化 SDK(幂等)
getPrinterStatus获取当前状态
scanPrinters扫描设备
openPrinter连接打印机
closePrinter断开连接
getConnectedPrinter获取已连接设备
ensurePrinterConnected确保已连接
printTraceabilityLabel单张打印
printTraceabilityBatch批量打印

导出的类型

  • PrinterDevice
  • PrinterRenderContext
  • PrinterStatus
  • TraceabilityLabelData
  • TraceabilityPrintItem

已知限制

基于当前实现的客观限制:

  1. 只支持 NIIMBOT 打印机:硬编码使用 JCAPI SDK
  2. 固定标签尺寸:80x40,rotation=90,无法动态配置
  3. 固定打印参数:gapType=1, darkness=3,无法调整
  4. 串行打印:批量打印必须顺序执行,无法并发
  5. 需要隐藏 Canvas:必须在页面中保留隐藏的 canvas 节点
  6. 依赖网络请求:打印前需要调用 API 获取溯源码详情
  7. 二维码 URL 硬编码:buildTraceabilityDetailUrl 中使用的是示例 URL

踩坑记录

坑1:假连接状态

问题:之前用本地变量存储连接状态,重启页面后状态丢失。

解决:每次通过 JCAPI.getConnName() 实时查询。


坑2:批量打印只传 ID

问题:最初只传递选中项的 ID,打印时缺少商品名等信息。

解决:改为传递完整对象数组 selectedItems。


坑3:Canvas 被误删

问题:认为页面不直接操作 SDK,就删除了隐藏 canvas。

解决:SDK 内部需要 canvas 节点进行绘制,必须保留。


坑4:连接参数混淆

问题:SDK 的 openPrinter 只用 name,但列表里有 deviceId。

解决:deviceId 用于 UI 去重和展示,实际连接靠 name。


坑5:并发打印导致混乱

问题:最初尝试并发调用打印方法,导致 SDK 状态混乱。

解决:改为顺序执行,等待上一页回调后再提交下一页。


真机测试清单

连接测试

  • 首次扫描到 B3S
  • 首次连接成功
  • 断开后重新扫描
  • 返回列表页状态正确刷新
  • 打印过程中断开蓝牙,有正确提示

打印测试

  • 单张溯源码打印
  • 批量打印 2 张
  • 批量打印 10 张
  • 二维码内容正确(扫码能跳转)
  • 商品名过长时正确截断
  • 企业名称显示正常

边界情况

  • 未连接时点击打印,提示“请先连接”
  • 打印中再次点击打印按钮,被拦截
  • 选中项在列表刷新后保持选中状态
  • 全选/取消全选功能正常

总结

本文分享了在 uni-app 环境下集成 NIIMBOT B3S 蓝牙打印机的技术方案,重点介绍了:

  1. 分层架构设计:将硬件逻辑与业务逻辑分离
  2. SDK 适配器模式:隔离第三方 SDK 的侵入性
  3. 异步打印队列:基于 Promise 的顺序执行机制
  4. 声明式标签模板:数据驱动的布局方案
  5. 状态管理最佳实践:单一数据源与实时同步

这套方案的核心价值在于分层清晰、职责单一、易于维护,可以应用到其他硬件集成场景中。

给读者的建议

  • 先跑通单张打印,再优化批量打印
  • 真机测试非常重要,模拟器无法测试蓝牙
  • 注意 SDK 的回调时序,避免并发调用
  • 保留必要的调试信息,方便排查问题

相关链接