uni-app 小程序集成 NIIMBOT B3S 蓝牙打印机实践
背景
在项目中,需要为农产品溯源系统实现标签打印功能。企业用户需要为每个产品打印带有二维码的溯源码标签,包含企业名称、商品名称、溯源码编号等信息,并支持批量打印。
技术选型:
- 硬件:NIIMBOT B3S 智能标签打印机
- 框架:uni-app(微信小程序为主)
- SDK:精臣官方 JavaScript SDK
- 语言:TypeScript + Vue 3
核心挑战:
- 小程序环境下蓝牙设备的连接稳定性
- 批量打印的性能和用户体验
- 标签模板的动态生成和渲染
- 不同平台(微信/钉钉/飞书)的兼容性
- 异步打印任务的顺序执行
架构设计
分层架构
采用清晰的分层架构,将硬件相关的逻辑与业务逻辑分离:
┌─────────────────────────────────┐
│ 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 需要针对不同平台(微信、企业微信、钉钉、飞书)进行初始化配置。
解决方案:
- 使用单例模式确保 SDK 只初始化一次
- 通过
uni.getSystemInfoSync().hostName检测宿主环境 - 根据检测结果设置对应的平台常量
- 异常情况下默认使用微信平台配置
关键点:
- 懒加载:首次调用时才初始化
- 幂等性:多次调用不会产生副作用
- 运行时检测:动态识别宿主环境
设备扫描与去重
问题:蓝牙扫描可能返回重复设备,需要去重并过滤无效设备。
解决方案:
- 将回调风格的 SDK API 转换为 Promise
- 使用 Map 数据结构进行去重,key 优先使用 deviceId,降级使用 name
- 过滤掉名称包含“未知设备”的项
- 标准化设备对象,统一字段格式
算法复杂度:时间 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()方法供多页面调用
优势:
- 避免状态不一致
- 简化状态管理逻辑
- 提高可靠性
连接状态主动探测
问题:被动等待断开回调不够及时,需要在打印前验证连接有效性。
解决方案:
- 先检查本地记录的连接状态
- 调用 SDK 的
getSN()获取设备序列号进行主动探测 - 如果返回码为 -4,判定为连接已断开
- 重置状态并抛出错误
使用时机:在实际打印任务执行前调用,作为预检步骤。
标签绘制流程
问题:SDK 的绘图 API 是回调风格,且必须按特定顺序执行。
Promise 化封装: 将三个关键步骤转换为 Promise:
startJobAsync:启动打印任务endDrawLabelAsync:结束当前标签绘制waitNextPageSignal:等待上一页打印完成信号
单个标签绘制流程:
- 调用
startDrawLabel开始绘制,传入 canvas 信息和标签尺寸 - 遍历标签元素数组,逐个调用对应的绘图方法
- 调用
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:超过最大长度时截断并添加省略号
标签构建逻辑
数据处理规则:
- 企业名称:默认“未命名企业”,最大 22 字符
- 商品名称:默认“未命名商品”,最大 22 字符
- 溯源码编号:默认使用 id,最大 28 字符
- 二维码内容:优先使用 qrCode,降级使用 code
布局结构(6 个元素):
┌──────────────────────────────┐
│ 企业名称(居中加粗) │ ← textRect, 自动换行
│ │
│ ┌────────┐ 产品名称 │ ← qrcode + text
│ │ │ XXXXX │
│ │ QR码 │ │
│ │ │ 溯源码编号 │ ← text
│ └────────┘ XXXXXXXXXXXX │ ← textRect, 自动换行
└──────────────────────────────┘
- 企业名称 - textRect 类型,顶部居中,支持自动换行
- 二维码 - qrcode 类型,左侧,尺寸 21x21
- “产品名称”文字 - text 类型,右侧上方,加粗
- 商品名称 - textRect 类型,右侧,支持自动换行
- “溯源码编号”文字 - text 类型,右侧中部,加粗
- 编号内容 - 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. 单个打印流程
- 检查 isPrinting 锁,防止重复提交
- 设置状态为 printing
- 获取溯源码详情并转换为打印数据
- 调用 printTraceabilityLabel
- 同步打印机状态
- 显示提示信息
- 释放锁
7. 批量打印流程
- 校验是否有选中项
- 检查 isPrinting 锁
- 获取选中项的完整对象(优先使用 selectedItems,降级从 currentList 筛选)
- 使用 Promise.all 并行获取所有溯源码详情
- 调用 printTraceabilityBatch
- 同步打印机状态
- 清空选中状态
- 显示提示信息
- 释放锁
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 适配策略
- Promise 化:将所有回调风格的 SDK API 转换为 Promise,便于 async/await 使用
- 超时控制:连接操作设置 15 秒超时,避免无限等待
- 防重复结算:使用 settled 标志防止 Promise 多次结算
- 状态同步:每次操作后调用 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); // 最后一页
}
}
状态管理原则
- 单一数据源:printerStatus 和 currentPrinter 是全局唯一的状态来源
- 实时查询:通过 JCAPI.getConnName() 实时获取连接状态,不使用缓存
- 主动探测:打印前调用 getSN 验证连接有效性
- 异常恢复:捕获错误后立即重置状态
标签模板设计
- 声明式布局:使用数据结构描述标签,而非命令式调用
- 文本处理:
- trimText:去除空格,提供默认值
- truncateText:超长截断并添加省略号
- 自动换行:使用 textRect 类型 + lineModel: 3 实现
- 二维码优先级:qrCode > code
Canvas 处理
为什么必须保留隐藏 Canvas?
- SDK 需要 canvas 节点进行离屏渲染
- 不能删除,即使页面不直接操作 SDK
- 使用负坐标隐藏(left: -2000px)
- opacity 必须为 1,否则某些平台 canvas 不被初始化
代码统计
| 文件 | 行数 | 主要功能 |
|---|---|---|
| niimbot.ts | 380 | SDK 封装,9 个导出方法 |
| template.ts | 119 | 标签模板构建 |
| types.ts | 130 | TypeScript 类型定义 |
| printer-connect.vue | 400 | 打印机连接页 |
| code-list.vue | 579 | 溯源码列表页 |
| PrintList.vue | 154 | 批量选择组件 |
总计:约 1762 行代码
导出的公共 API
服务层向外暴露以下方法:
| 方法 | 说明 |
|---|---|
| initPrinterSdk | 初始化 SDK(幂等) |
| getPrinterStatus | 获取当前状态 |
| scanPrinters | 扫描设备 |
| openPrinter | 连接打印机 |
| closePrinter | 断开连接 |
| getConnectedPrinter | 获取已连接设备 |
| ensurePrinterConnected | 确保已连接 |
| printTraceabilityLabel | 单张打印 |
| printTraceabilityBatch | 批量打印 |
导出的类型:
- PrinterDevice
- PrinterRenderContext
- PrinterStatus
- TraceabilityLabelData
- TraceabilityPrintItem
已知限制
基于当前实现的客观限制:
- 只支持 NIIMBOT 打印机:硬编码使用 JCAPI SDK
- 固定标签尺寸:80x40,rotation=90,无法动态配置
- 固定打印参数:gapType=1, darkness=3,无法调整
- 串行打印:批量打印必须顺序执行,无法并发
- 需要隐藏 Canvas:必须在页面中保留隐藏的 canvas 节点
- 依赖网络请求:打印前需要调用 API 获取溯源码详情
- 二维码 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 蓝牙打印机的技术方案,重点介绍了:
- 分层架构设计:将硬件逻辑与业务逻辑分离
- SDK 适配器模式:隔离第三方 SDK 的侵入性
- 异步打印队列:基于 Promise 的顺序执行机制
- 声明式标签模板:数据驱动的布局方案
- 状态管理最佳实践:单一数据源与实时同步
这套方案的核心价值在于分层清晰、职责单一、易于维护,可以应用到其他硬件集成场景中。
给读者的建议:
- 先跑通单张打印,再优化批量打印
- 真机测试非常重要,模拟器无法测试蓝牙
- 注意 SDK 的回调时序,避免并发调用
- 保留必要的调试信息,方便排查问题
相关链接: