做移动端小票打印的人应该都有过这种体验:打印机本身不难,难的是把“业务系统”“模板排版”“蓝牙链路”这三条线全部串起来。我最近在几个Uniapp项目里反复遇到同样的需求,外勤开单、仓库拣货、移动收款,全是要求手机直接出一张小票。最开始想省事,直接找现成的蓝牙打印插件,结果发现插件只解决了“连上设备和发指令”,模板怎么设计、字段怎么排列、加了新联仍然要改代码,完全没有机制。后来把济强JLP352和hiprint搭在一起,才算是把整条链路真正走通。整套折腾下来,有很多细节属于常规文档里不会写、但在现场联调时一定会撞上的东西,我决定把这套方案完整整理出来。
1. 方向确定:为什么最终是 JLP352 加 hiprint,而不是现成打印插件
1.1 现成打印插件解决不了模板问题
很多Uniapp项目会先走“接一个蓝牙打印插件”这条路。插件的好处是上手快,装个原生SDK、调用几个API就能把文本、条码、二维码发到打印机上。但一旦单据稍微复杂一点,问题就全冒出来了。
第一类是排版僵化。插件暴露出的API往往只有一行一行发文本、设置粗体、设置对齐方式这些最基础的能力。遇到“商品名称左边、数量中间、金额右边”这种最常见的三联对不齐需求,就得自己在字符串里拼空格。这不是不能做,而是每次改模板都要改前端代码,还要重新发版。业务上改一张单子的列宽,技术团队就要跟着上线一次,时间成本很高。
第二类是设备绑定。另一款插件的指令集可能是厂商私有封装,对着自己的设备没问题,一换打印机型号就没法用。仓库里可能同时有几台不同批次的机器,甚至不同厂商的备用机,这时插件方案就非常僵。再加上本地打印机插件大多要求打包原生依赖,如果项目后期要做网页端、小程序端、App端三端统一,这些插件在Web端大概率是不可用的,技术路线直接被锁死。
第三类是打印效果。文本模式只能发字符,遇到表格线、图标、促销区域、手写签名区这类元素,文本指令根本无法表达。最终除非退化为图片整张打印,否则很难在美观度和信息完整性上改。
所以我的结论是:连接层可以用底层API自己做,模板层必须交给一个可以独立于业务代码维护的系统。这就是hiprint切入的点。
1.2 JLP352 的设备定位和选型原因
选定打印设备时,我主要考虑的是便携性、协议开放程度和耐久性。济强的JLP352属于便携式热敏小票机,纸宽58mm,日常外勤携带没有负担。更重要的是它支持蓝牙BLE和经典蓝牙两种模式,BLE模式下可以直接用系统蓝牙API操作,而不需要额外跑一个厂商特定插件。
协议方面,它对ESC/POS指令的兼容性不错,尤其是光栅位图打印指令。这意味着我可以在手机上把模板渲染成像素图,再整体发给打印机,绕开文本模式的各种排版限制。对我们来说,它的工作更像一块“把字节流变成纸面内容”的显示设备,而不是一个需要被厂商SDK封装的黑盒。
实际测试时我用过几条选型标准,对比后很容易判断:
| 方案 | 模板灵活性 | 打印清晰度 | 是否需要原生依赖 | 最终选择 |
|---|---|---|---|---|
| 厂商官方SDK插件 | 低,基本都是文本接口 | 文本模式,表格难控制 | 需要 | 不选 |
| 通用蓝牙插件 | 中,部分支持位图 | 可以,但模板要前端写死 | 需要 | 不太适合 |
| Uniapp原生API + hiprint模板 | 高,模板独立维护 | 高,光栅位图 | 无 | 最终采用 |
确定这个组合的另一个原因是JLP352的BLE特征比较规整,器件服务里能直接找到具备写属性的特征值,Uniapp的uni.writeBLECharacteristicValue可以直接对接。不需要像某些打印机那样还要先走一遍复杂的握手协议,这对接下来的开发省了很多事。
2. 协议层准备:JLP352 的蓝牙特征与 ESC/POS 位图指令
2.1 Uniapp 完成 BLE 连接的基本链路
Uniapp提供了一套完整的蓝牙BLE API,基本思路是:打开适配器、扫描设备、发现目标、建立连接、获取服务、获取特征值、写入数据。整条链路的代码写下来不算复杂,但每一步都要有状态判断。
第一步先开适配器并扫描:
uni.openBluetoothAdapter({ success() { uni.startBluetoothDevicesDiscovery({ allowDuplicatesKey: false, fail(err) { console.error('扫描失败', err); }, }); }, fail(err) { console.error('初始化蓝牙失败:', err); }, });扫描结果通过uni.onBluetoothDeviceFound回调获取。我在真实环境里遇到的设备名有时带前缀、有时带编号,所以匹配时最好同时判断蓝牙信号强度和设备名关键词,避免连到同名但不可连接的历史缓存设备:
uni.onBluetoothDeviceFound((res) => { const devices = res.devices || []; const target = devices.find((item) => { return item.name && (item.name.indexOf('JLP') > -1 || item.name.indexOf('JL') > -1); }); if (target) { uni.stopBluetoothDevicesDiscovery(); connectDevice(target.deviceId); } });这里的deviceId在US中会保存下来,后续的连接、服务和写入全都要用。
连接到设备后,需要遍历服务列表,找到打印机实际对外开放的那个服务。纸条上连接成功后,通过uni.getBLEDeviceServices拿到所有服务,找出其中带有Write属性的特征值。不同的批次号UART服务UUID可能有差异,我在磨合中遇到过不止一次打印机显示在列表里但找不到任何可写特征的情况,这种时候多半是选错了服务。调试时最好的办法就是全遍历一次,把每个特征值的属性打印出来确认:
uni.getBLEDeviceServices({ deviceId, success(res) { for (const service of res.services) { uni.getBLEDeviceCharacteristics({ deviceId, serviceId: service.uuid, success(characterRes) { const writable = characterRes.characteristics.filter((item) => { return item.properties.write || item.properties.writeNoResponse; }); if (writable.length > 0) { // 这里保存 service.uuid 和 writable[0].uuid } }, }); } }, });服务协议上如果同时出现Write和WriteNoResponse,优先用WriteNoResponse属性对应的特征值。因为小票数据本身不需要逐包应答,使用带应答的写入会让发送速度明显变慢。有些安卓手机还会因为MTU协商问题直接拒绝大包写入,这个后面聊踩坑时再说。
2.2 ESC/POS 常用命令和初始化序列
拿到可写特征值之后,剩下的就是如何组织数据。JLP352走的是ESC/POS协议,绝大部分指令可以归结为几个固定字节头加参数。我最常用到的有这几个:
| 命令 | 十六进制 | 说明 |
|---|---|---|
| 初始化打印机 | 1B 40 | 清除缓存、复位设置 |
| 走纸 n 行 | 1B 64 n | 打印结束后补充出纸量 |
| 光栅位图打印 | 1D 76 30 m xL xH yL yH d1...dk | 一次输出整张位图 |
| 打印并走纸 | 0A | 相当于换行 |
其中光栅位图打印命令是整个方案的核心。它的参数含义是:m为模式选择,一般选0就是标准位图模式;xL xH是图片宽度的低字节和高字节;yL yH是图片高度的低字节和高字节;后面跟的d1...dk就是按行排列的位图数据。
初始化序列一般这么构造:
const initCommand = new Uint8Array([0x1b, 0x40]);每次打印任务开始时先发这串指令,把打印机从上一个任务里残留的状态清干净,避免出现后续数据被当成状态参数解析。打印结束后再发一个走纸指令,保证小票出完整:
// 走纸 3 行 const feedCommand = new Uint8Array([0x1b, 0x64, 0x03]);这里有个容易忽略的点:光栅位图命令中的宽高必须跟实际像素完全一致。如果传入的宽度大于打印机实际打印宽度,后面整段数据就有溃塌的可能,不是简单裁一下就行。所以准备数据前,先拿到网纸实际幅面对应的点位数,例如58mm纸在常用分辨率下一般是384点宽,那每一行的字节数应当是384除以8,也就是48字节。这个数在后面转换位图时是硬指标。
3. 从 hiprint 模板到字节流:渲染、取图、转光栅数据
3.1 模板的设计与维护方式
hiprint是一套基于HTML模板的打印方案,使用方可以在设计器里拖拽字段、线条、条码、二维码,然后生成一份JSON结构的模板配置。对我们而言,它的价值在于把“模板长什么样”和“业务代码逻辑”彻底分开了。
在实际落地时,我没有把hiprint放到Uniapp页面里直接跑,而是用WebView承载一个HTML页面。这个HTML页面引入了hiprint需要的JS和CSS,负责做三件事:解析模板JSON、接收业务数据、渲染出小票效果。必要的场景下还能在里面做模板预览,人工检查一遍再打印。
WebView和Uniapp主体之间的通讯用uni.postMessage实现。模板配置和打印数据由业务端传给WebView,WebView渲染完图片后把像素数据传回业务端,业务端再通过蓝牙API发给打印机。这样的分层结构有一个额外的好处:当项目主程序打包方式变化时,只要WebView承载的页面没有变,打印逻辑基本不受影响。
3.2 把渲染结果转成可打印的位图
hiprint本身可以生成最终效果的HTML节点,但我更倾向于把它渲染成一个canvas,再从canvas读取像素数据。因为canvas读出来的ImageData结构非常规则:每个像素用RGBA四个字节表示,宽度高度清清楚楚,后续转换成打印机指令时非常方便。
渲染这一步我通常用html2canvas这个工具库,在WebView里加载,然后把hiprint生成的内容区域截出来:
const templateObj = JSON.parse(templateJson); const hp = new hiprint.PrintTemplate({ template: templateObj, }); const html = hp.getHtml(data); // 根据业务数据生成最终排版HTML container.innerHTML = html; await html2canvas(container, { scale: 2, useCORS: true, }); const canvasData = canvas.getContext('2d').getImageData(0, 0, width, height);这里的scale值得特别说明。58mm纸宽的物理宽度对应384点,而屏幕上1px不代表1个物理打印点,所以如果直接按CSS像素取值,打印出来的图像会发虚。实际操作中我先把模板的实际打印宽度设置成与纸宽对应的点数值,再通过scale调整canvas的分辨率,最后在转位图时统一用canvas的宽高,不再关心CSS尺寸。这样才能得到边缘足够锐利的文字和条码。
3.3 彩色像素转单色光栅的核心算法
打印机绝大多数都是黑白热敏,所以RGBA像素必须转成单色位图。这个转换的细节直接决定打印清晰度。最简单的方式是取RGB平均值,然后跟一个灰度阈值比较。但这样对浅色背景文字的适应性比较差。我在实际代码里用了加权灰度公式,对眼睛更友好:
function imageDataToRaster(imageData, width, height, threshold = 140) { const bytesPerRow = Math.ceil(width / 8); const rows = []; for (let y = 0; y < height; y++) { const row = new Uint8Array(bytesPerRow); let byteIndex = 0; let bitIndex = 0; for (let x = 0; x < width; x++) { const idx = (y * width + x) * 4; const r = imageData.data[idx]; const g = imageData.data[idx + 1]; const b = imageData.data[idx + 2]; const gray = (r * 299 + g * 587 + b * 114) / 1000; // 黑色像素置1 if (gray < threshold) { row[byteIndex] |= 0x80 >> bitIndex; } bitIndex++; if (bitIndex === 8) { byteIndex++; bitIndex = 0; } } rows.push(row); } return rows; }这里有一个不少初学者会搞反的地方:第几列对应字节里的哪一位。多数ESC/POS打印机要求每行第一个字节的最高位对应图片最左边的像素。所以代码里用的是0x80 >> bitIndex,而不是从低位开始。方向搞反了,打印出来的图片就是左右镜像。这个我已经在设备上亲测过,排除法确定的就是这个方向。
转换完成后,我把每一行拼成一个字节数组,前面加上光栅命令头:
function buildRasterCommand(rows, width, height) { const bytesPerRow = Math.ceil(width / 8); const cmd = new Uint8Array(8 + rows.length * bytesPerRow); cmd[0] = 0x1d; cmd[1] = 0x76; cmd[2] = 0x30; cmd[3] = 0x00; // 模式0 cmd[4] = width & 0xff; cmd[5] = (width >> 8) & 0xff; cmd[6] = height & 0xff; cmd[7] = (height >> 8) & 0xff; let offset = 8; for (const row of rows) { cmd.set(row, offset); offset += bytesPerRow; } return cmd; }高度参数完全由转换结果决定,所以如果不做滚动就一次发送。凡是涉及高度的计算,都要用实际canvas高度。如果业务端做了图片缩放,高宽度都要同步重新相乘,不然打印机拿到错误的高度值,会出现后半段数据被当成后续命令的情况。
4. 联调踩坑:中文乱码、分包截断与应用兼容性
4.1 中文乱码的根因和处理
用文本模式直接拼中文字符串时,最容易遇到的就是乱码。乱码问题的根因在于Uniapp字符串默认按UTF-8编码,而打印机的文本模式很多默认是按GBK或GB2312解析。同一个“测试”两个字,UTF-8字节和GBK字节完全不同,打印机把它按自己的字典去解码,就生成了天书。
解决方式有三种路径。第一种是检查打印机是否支持ESC t n指令切换字符集,JLP352这类设备不同固件版本支持度不一样,需要现场测试。第二种是在发送文本前把UTF-8字符串转成GBK字节数组,再交给写入队列。第三种就是我实际项目里最常用的:放弃文本模式,直接以位图模式打印整个模板。因为位图模式下汉字已经被渲染成像素点,不存在字符集问题。JLP352的光栅位图指令对中文非常稳定,这也是我一开始就选择hiprint的重要原因。
不过这里要留一个心眼,如果用文本模式打条码,条码数据本身是数字字符串,不涉及中文编码问题;但如果里面混了中文字段,最好也走位图。我见过有人在文本模式里打中文地址,换了一台打印机后就乱,排查几天才发现是字符集不同。统一走位图后这类问题再没出现过。
4.2 蓝牙分包写导致的指令截断
BLE有一个绕不开的特性:单次写入长度受MTU限制。很多入门者会把整包数据一次性塞进writeBLECharacteristicValue,结果收到“数据长度超出”的报错,或者打印到一半就停掉。早期调试时我试过直接把1KB的位图数据写过去,打印头吐出的纸上一半有内容一半空白,就是因为数据被系统截断了。
解决方法是自己实现一个分包发送队列。核心逻辑是先把整个Uint8Array切成长度不超过某个安全值的分段,前一段写入成功后再写下一段。我项目里按120字节一段切割,为了兼容部分安卓手机和更保守的MTU协商结果:
function sendDataInChunks(deviceId, serviceId, charId, data, onComplete, onError) { const chunkSize = 120; let offset = 0; function writeChunk() { if (offset >= data.length) { onComplete(); return; } const end = Math.min(offset + chunkSize, data.length); const chunk = data.slice(offset, end); // 必须复制一份真正的ArrayBuffer,避免部分机型因共享buffer导致数据错乱 const buffer = new Uint8Array(chunk).buffer; uni.writeBLECharacteristicValue({ deviceId, serviceId, characteristicId: charId, value: buffer, success() { offset = end; writeChunk(); }, fail(err) { onError && onError(err, offset); }, }); } writeChunk(); }这里要注意的是不能直接在data.slice(offset, end)的结果上拿.buffer就传。因为slice生成的Uint8Array是一个视图,它的buffer长度可能大于需要的长度,系统底层的写入可能把多余部分也带进去,导致某些机型数据错乱。稳妥做法是用new Uint8Array(chunk)强制拷贝成新的、大小正好等于分段长度的字节数组,再取它的buffer。
4.3 安卓和 iOS 的蓝牙差异
联调时最让我费时间的是不同手机系统对BLE写入行为的差异。
在iOS上,系统执行writeBLECharacteristicValue时对分包频率控制比较严格,慢一点但不会丢数据。安卓这边问题更多:有用华为手机测试时,MTU协商结果只有23字节,我设的120字节分段直接被拒;用小米手机时又发现writeNoResponse特征虽然能写,但如果连续写得过快,打印机内部的接收缓冲区满了会静默丢包。
通用的处理策略是:发送完一个分段后预留一个短暂间隔,或者根据打印机是否支持流控来决定。最务实的方案是把分段长度调到一个保守值,并在连续分片之间加2到5毫秒延时。对一般小票来说,整张数据一二百KB内,多花几十毫秒并不影响体验,换来的是稳定不出废纸,非常划算。
另外,安卓扫描时经常会发现同一台JLP352同时出现两个设备名,一个带BLE特征,一个是经典蓝牙SPP。如果代码里只按设备名前缀匹配,很可能连到SPP那个,结果拿不到任何服务特征,白等半天。建议在找到设备后增加信号强度过滤,同时用RSSI参数拉近距离阈值,并在没有可写特征时继续扫描而不是停留在错误连接上。
5. 打印效果和稳定性:浓度、走纸、断线重发
5.1 图像宽度和纸宽的一致性
打印清晰度问题并不只在软件层。如果模板最终渲染出来的像素宽度和打印机实际点阵宽度不一致,会出现两种结果:宽度小于纸宽,打印内容偏小;宽度大于纸宽,打印机按自己的逻辑裁掉右侧部分,小票内容被切掉。
这一点上我做过一次对比测试。同一份模板,用384点宽渲染、再用300点宽渲染,结果差异非常明显。384点宽的内容正好铺满58mm纸,左右边缘和纸张边缘基本齐平;300点宽的内容明显缩小一圈,像把A4内容硬塞到半张纸上。后续所有模板都统一以384点作为设计基准。
实际设计时,我建议在hiprint模板里首先把打印区域宽度固定为对应打印机的点阵宽度,而不是用mm或cm做单位。因为最终转换时宽度的数值就是打印头实际点阵数,先按点阵数拿模板,后续不用再换算,少一层出错机会。
5.2 浓度调整和走纸量
JLP352可以调整打印浓度。如果同一张单子里有大面积黑底图案,打印浓度默认时容易出现中间泛白或者边缘糊掉。文字内容为主时浓度需求不高,默认值即可;但如果有二维码或条码,就需要把浓度略调高一点,这样扫描枪更容易读取。
走纸量的控制也讲究。热敏纸小票结束时要留出足够距离让用户撕下,但走纸过多又会浪费纸。我通常会按实际需求设置固定走纸行数。例如结束命令后追加三到五行的走纸,会比依赖打印内容自然结束更可控。
5.3 掉线重发和打印队列
蓝牙打印最大的稳定性威胁是连接闪断。小票数据比较大时,发送到一半蓝牙因为系统原因断开,如果不做重试,用户只能重新操作一遍,体验很差。
我在完整方案里维护了一个打印任务队列。所有待打印任务进入队列后按顺序执行,每份任务内部记录当前已发送的偏移量。如果发送过程中出现失败,先尝试重新连接设备,再从失败位置继续发送,而不需要整张重新来。缩短重传数据和时间,也降低打印机重复打印半张废纸的概率。
简单的伪代码如下:
class PrintQueue { constructor() { this.tasks = []; this.isWorking = false; } enqueue(task) { this.tasks.push(task); this.processNext(); } processNext() { if (this.isWorking || this.tasks.length === 0) return; this.isWorking = true; const task = this.tasks.shift(); task.run().then(() => { this.isWorking = false; this.processNext(); }).catch(() => { // 断线重连后从断点续传 task.retry().then(() => { this.isWorking = false; this.processNext(); }); }); } }如果你做的是高频连续打印场景,队列这个机制在JLP352上几乎是必须的,因为Enter和BLE通道不适合并发出多个任务。把任务串行化后,每张纸都能稳稳走完,整套流程才经得起长时间跑。
6. 可扩展方向:模板集中下发与远程打印
6.1 模板配置放到服务端
当hiprint模板通过JSON维护后,最自然的一个扩展就是把模板配置部署到服务端。业务上新加一个字段、调整一个列宽,只需在后台编辑模板JSON并推动更新,前端Uniapp主程序完全不用重新发版。
我之前在多个项目里尝到这个甜头以后,现在凡是涉及打印模块的新项目,都会提前设计好模板的上传和分发接口。这样外勤人员手里的旧版本App到了新的打印设备前,只要联网拉到最新模板,打印效果就能保持一致。这个能力在传统SDK插件方案里实现起来非常痛苦,因为模板基本是焊死在原生层甚至设备固件层。
6.2 由本地打印扩展到远程打印
这套方案本身走的是手机蓝牙直连打印机,但在一些管理场景里,还需要支持远程触发打印。比如总部的订单系统收到一个新订单,自动推送到外勤人员的手机上,外勤人员点确认后,手机蓝牙连接随身打印机出单。这里唯一的改动是把打印任务从本地重发变成了服务端消息推送,蓝牙发送部分完全可以复用。
再进一步,如果打印机被固定安装在门店里,你完全可以做成“手机不在现场也能打印”。手机App只需要把光栅位图数据通过TCP或HTTP协议转发给门店内的一台常驻设备,那台设备再通过USB或蓝牙把数据交给打印机。内存展开之后,JLP352这类便携打印机就不只是跟着人走的摆脱工具,也能变成门店固定开单设备的一部分。
不过这里要提醒一句,远程打印链路越长,越需要完整的状态回执。至少要包含“任务已收到”“正在打印”“打印完成”“打印失败”四类状态,否则用户下单后无法判断小票到底有没有出来。我在项目里就是按这个状态粒度去设计的,跟单体验才说得过去。
这个项目做下来,我最深的体会是三件事。第一,不要把模板排版塞进业务代码里,hiprint这类模板工具不是锦上添花,是架构上真正帮你节省维护成本的;第二,蓝牙打印的核心不是蓝牙API本身,而是字节流的组织和错误恢复策略,分包队列写好后,绝大多数莫名其妙的打印问题都会消失;第三,打印机的选型要在协议层面想清楚,像JLP352这样支持标准ESC/POS和BLE的设备,远比那些必须依赖专用SDK的打印机好接得多。如果有朋友正好在折腾Uniapp接便携打印机,希望这套方案能替你们少踩几个我踩过的坑。