资讯动态

Android蓝牙热敏打印机图片打印:ZPL指令与Bitmap点阵转换全流程解析

发布时间:2026/10/14 17:46:55 来源:尧图企业网站定制
去年接了一个需求客户要在 Android 平板上选一张图片通过蓝牙连接热敏打印机然后用“斑马指令”把图片绘制打印出来。当时听上去就是“连上蓝牙发一段数据”的事结果真正调起来才发现蓝牙链路、热敏打印、ZPL 指令这三件事是三个独立的大坑任何一个环节理解不到位打印出来的就是乱码、白纸或者干脆连接失败。这篇文章我会把这条链路完整拆开从蓝牙 SPP 连接、ZPL 指令格式到 Bitmap 转 1 位点阵数据的转换管线再把可直接用的代码骨架和排障经验一起写出来给正在做同类需求的朋友一个完整的参考。1. 先把需求拆开蓝牙通道、热敏打印、斑马指令是三件独立的事1.1 为什么“Android 热敏打印机”的小票场景这么常见热敏打印机的优势不需要多讲体积小、成本低、没有耗材中的色带或墨盒只需要热敏纸。点餐系统、排队叫号、快递面单、现场登记、医疗导诊、移动收银几乎都能看到 Android 设备通过蓝牙驱动一台小热敏机的组合。但这个组合之所以经常把开发者坑了是因为 Android 端和打印机端的链路是“半封闭”的。Android 系统只负责提供蓝牙连接的基础能力打印机固件只认自己的指令集。两端之间没有像网络 HTTP 那样统一的“请求-响应”语义很多问题是两端各自为政造成的。刚开始我以为是图片传输的格式问题还在想能不能直接发 PNG 或者 JPEG 给打印机。结果打印机根本不认图片文件只认指令脚本。这个脚本里有明确的“坐标”“宽度”“高度”“点阵数据”概念而不是一整个文件。所以做这个需求第一步不是写代码而是先搞清楚对端打印机到底接收什么。1.2 斑马指令 ZPL 在这条链路里的位置斑马指令的正式名称一般写成 ZPL业内很多人直接叫“斑马指令”。它本质上是一种页面描述语言像一段打印脚本。打印机固件拿到这段脚本后会解析里面的字段把字符、条码、图形、图片转换成热敏头上一行一行的加热点。典型的 ZPL 脚本会这样组织^XA ^FO20,20 ^GFA,总字节数,每行字节数,0,十六进制点阵数据 ^FS ^XZ^XA是开始指令^XZ是结束指令^FO是设置图像输出的原点坐标^FS表示当前字段结束。图像数据放在^GF指令里这也是“图片绘制打印”最核心的部分。需要特别强调一点ZPL 不是所有热敏打印机都认。有些小票机默认走的是 ESC/POS另外一些是 TSPL还有厂家自己的私有指令。如果你的项目标题里明确写了“斑马指令”那么你的打印机必须要支持 ZPL或者至少能切换到 ZPL 模式。否则后面做的一切转换工作都是白费。1.3 ESC/POS 与 ZPL 的简单选择很多第一次接触的人会问既然很多热敏机支持 ESC/POS为什么还要用 ZPL我也做过对比简单说维度ESC/POSZPL指令风格短字节控制码文本脚本可读性高图像打印需要拼图像光栅流按列/按行组织通过^GF直接给点阵数据适用设备收银小票机、部分便携热敏机标签机、面单机、部分小票机排错难度控制码容易乱肉眼难查脚本可直接打印出来看对开发者友好度中低中高如果打印机同时支持两者我建议优先用 ZPL。原因是 ZPL 脚本可以用一个文本文件保存出了问题可以把指令拼接出来看检查是不是某个参数不对排错效率明显更高。这也正是标题里强调“斑马指令图片绘制打印”的原因。2. 蓝牙连接层为什么我坚持走 SPP 而不是 BLE2.1 SPP 是大多数热敏打印机的默认通道Android 蓝牙分为经典蓝牙和低功耗蓝牙BLE两条路线。绝大多数已经出货的热敏小票机、便携标签机自带的是经典蓝牙 SPP 串口协议。SPP 本质上就是一条虚拟串口Android 端拿到一个BluetoothSocket像写文件一样把字节流写进去打印机会从另一端读。BLE 不是不能用但坑更多。BLE 的数据传输是“特征值”读写一次能塞进单个数据包的字节数有限超过 MTU 还要分包。而且很多廉价打印机虽然带 BLE却没有完整实现 GATT 的服务发现你连上后找不到可写的特征或者找到了写了几包就断开。所以我的建议很直接项目优先探测 SPP只有确认设备只支持 BLE 时再走 GATT。标题里没有写明具体协议但从实际设备分布来看SPP 的概率超过九成。2.2 搜索设备与过滤规则不要什么设备都列出来用系统蓝牙搜索时会搜到手机、耳机、音箱、手环直接全列出来对用户很不友好。我的做法是维护一个设备过滤列表根据设备名称里的关键字过滤例如包含POS、Printer、58mm、80mm、Thermal等名称的设备优先显示排除前面几个常见 API 级别上搜到的手机、耳机设备保留没有名称但返回了 SPP UUID 的设备。代码上可以先注册BroadcastReceiver监听BluetoothDevice.ACTION_FOUND收到设备后读取名称和BluetoothClass再做过滤。需要特别提醒Android 6.0 到 Android 11搜索蓝牙需要定位权限ACCESS_FINE_LOCATION因为系统认为蓝牙扫描可能推断位置。Android 12 开始又增加了BLUETOOTH_SCAN、BLUETOOTH_CONNECT这类运行时权限。这个不配置好设备列表永远是空的。2.3 连接背后最容易忽略的 UUID 和线程模型SPP 连接有一个固定 UUID绝大多数设备都认private static final UUID SPP_UUID UUID.fromString(00001101-0000-1000-8000-00805F9B34FB);部分打印机固件兼容性差标准 SPP UUID 连不上需要通过BluetoothDevice.fetchUuidsWithSdp()获取设备公开的 UUID 列表再用列表里的值创建 socket。另一个大坑是线程。BluetoothSocket.connect()和OutputStream.write()都会阻塞。如果在主线程调用轻则界面卡死几秒重则直接ANR。正确做法是用ExecutorService或子线程处理整个连接和发送流程完成后通过Handler或回调通知 UI。连接前还要做一件事BluetoothAdapter.cancelDiscovery()。如果不取消系统搜索连接速度会非常慢甚至一直连不上。我曾经在这个地方卡了两三个小时最后发现只是忘了取消搜索。3. ZPL 图像指令核心一张图片在打印机眼里是点阵不是文件3.1^GF参数逐一拆解ZPL 里用于图像绘制的最核心指令是^GF。不同固件对参数个数的接受程度略有差异我实际项目里验证过最通用的一种写法是^GFA,总字节数,每行字节数,0,十六进制数据四个参数的含义分别是第一个图像总共占多少字节算法是“每行字节数 × 总行数”第二个每行多少字节由图像宽度决定第三个0部分固件要求的数据描述位不需要就填 0第四个ASCII 编码的十六进制点阵数据。也有打印机只接受三参数把中间的 0 去掉即可^GFA,总字节数,每行字节数,十六进制数据建议做功能之前先用一张 16×16 的纯黑测试图把两种格式都发给打印机看哪张能正常出图。这个步骤能帮你快速确定固件版本比反复调试大图高效得多。3.2 图像数据为什么要求“1 像素 1 bit、按行补齐”ZPL 的^GF不会帮你解析 PNG也不会帮你处理图片压缩它只认二值化后的点阵。每个像素点用 1 个 bit 表示1 表示加热打印出黑点0 表示不加热留白。8 个像素按顺序组成一个字节字节再转换成两位十六进制字符。如果图片宽度不是 8 的倍数比如宽度是 380 像素那么每行实际需要的字节数是(380 7) / 8 48字节也就是 384 个 bit。右边多出来的 4 个 bit 填 0不参与打印但必须参与字节对齐。不做对齐的话从第二行开始所有像素都会错位打印出来就是斜的。每一行的像素顺序遵循“高位在前”即第一个像素放在字节的最高位。举例来说一行 8 个像素从左到右是1 0 1 0 1 0 1 0对应二进制10101010十六进制就是AA。理解了这个规则你就知道十六进制字符串里的每一位都对应物理矩阵里的一个点。3.3 先打一张测试图校准格式我第一次调 ZPL 图像时没有先做小样直接发了一张 800×600 的大图结果打印机吐出来一长串乱码完全不知道是蓝牙问题还是指令问题。后来老老实实先生成一张小测试图^XA ^FO10,10 ^GFA,32,2,0,64 个 FF ^FS ^XZ这条指令里的意思是画一个 16 像素宽、16 像素高的全黑方块。如果打印机出来是一个正方形说明格式正确如果出来的是斜线、条纹、或者没有图形就说明参数集或位序不对。测试图过了再上真正的业务图片能省掉 80% 的排查时间。4. 图片转换管线从 Bitmap 到 ZPL 十六进制流4.1 缩放、灰度、阈值的取舍拿到一张 Bitmap 后第一步不是遍历像素而是先搞清楚打印机的物理分辨率。常见热敏小票机打印宽度有 58mm 和 80mm 两种203dpi 是常见分辨率58mm 纸可用打印宽度约 48mm对应约 384 个点80mm 纸可用打印宽度约 72mm对应约 576 个点。我在代码里默认把目标宽度控制在 384如果图片宽度小于 384 就不放大大于 384 就按比例缩小。这样既能保持比例也不会让一张小图被放大成马赛克。缩放过后的图片还要把每个像素从 ARGB 转成灰度。最简单的方式是取 RGB 的加权平均值int gray (r * 299 g * 587 b * 114) / 1000;拿到灰度值后设置一个阈值比如 128。小于阈值就认为是黑点大于阈值就认为是白点。这个方式对logo、截图、签名绰绰有余。如果一定要打印照片建议再做抖动算法否则照片会丢失大量层次但热敏纸本身的灰阶效果也有限不要抱太高期望。4.2 按字节打包每 8 个像素拼成一个字节核心转换逻辑并不复杂难点在于处理宽度补齐和位序。private static String toHexPacked(Bitmap bmp, int threshold) { int width bmp.getWidth(); int height bmp.getHeight(); int bytesPerRow (width 7) / 8; StringBuilder hex new StringBuilder(); for (int row 0; row height; row) { int currentByte 0; int bitIndex 0; for (int col 0; col bytesPerRow * 8; col) { int bit 0; if (col width) { int pixel bmp.getPixel(col, row); int r Color.red(pixel); int g Color.green(pixel); int b Color.blue(pixel); int luminance (r * 299 g * 587 b * 114) / 1000; bit luminance threshold ? 1 : 0; } currentByte (currentByte 1) | (bit 0x01); bitIndex; if (bitIndex 8) { hex.append(String.format(%02X, currentByte)); currentByte 0; bitIndex 0; } } } return hex.toString(); }这里我把超出真实宽度的补位像素也一起纳入循环但它们对应的bit永远是 0。这样做的好处是不需要单独处理边界代码更不容易出错。很多人在这一步容易搞反“黑点到底是 1 还是 0”。不同打印机可能相反如果你打印出来发现图形和预期完全反色黑色部分变白、白色部分变黑那就把bit 1和bit 0对调或者在得到字节后执行0xFF - currentByte。4.3 拼 ZPL 指令时的小心机图像数据拼好后ZPL 指令的组装方式如下private static String buildZpl(Bitmap bmp, int targetWidth, int threshold) { Bitmap scaled scaleToWidth(bmp, targetWidth); int bytesPerRow (scaled.getWidth() 7) / 8; int totalBytes bytesPerRow * scaled.getHeight(); String hexData toHexPacked(scaled, threshold); return ^XA^FO0,0^GFA, totalBytes , bytesPerRow ,0, hexData ^FS^XZ; }有一点要注意^FO0,0会把图片靠在打印区域的最左上角。如果打印机本身有固定的不可打印边距图片第一列可能被切掉一点。可以在指令里适当给一个偏移比如^FO10,10打印内容往右、往下各移 10 个点预留安全距离。另外ZPL 是文本指令发送时必须用 ASCII 兼容编码。我习惯用ISO-8859-1不要用 UTF-8避免某些固件在解析时把多余字节当成指令的一部分。5. 完整可用的 Android 打印代码骨架5.1 蓝牙工具类的简化版本先放一个最基础的蓝牙连接工具类去掉了业务包装保留核心连接和发送逻辑public class BluetoothPrinter { private static final UUID SPP_UUID UUID.fromString(00001101-0000-1000-8000-00805F9B34FB); private BluetoothSocket socket; public BluetoothSocket connect(BluetoothDevice device) throws IOException { BluetoothAdapter adapter BluetoothAdapter.getDefaultAdapter(); if (adapter ! null adapter.isDiscovering()) { adapter.cancelDiscovery(); } BluetoothSocket tmp device.createRfcommSocketToServiceRecord(SPP_UUID); tmp.connect(); this.socket tmp; return socket; } public void send(byte[] data) throws IOException { if (socket null || !socket.isConnected()) { throw new IOException(socket not connected); } OutputStream out socket.getOutputStream(); out.write(data); out.flush(); } public void close() { if (socket ! null) { try { socket.close(); } catch (IOException ignored) { } } } }这个类的关键点在于connect()前执行了cancelDiscovery()避免搜索结果干扰后续连接。如果设备需要自定义 UUID把createRfcommSocketToServiceRecord换成从设备 UUID 列表里遍历创建即可。5.2 图片转换与 ZPL 指令拼接再来看图片处理部分。为了控制篇幅我把核心函数拆成两个入口一个是按宽度缩放一个是把 Bitmap 转成 ZPL 字符串。public class ZplImageBuilder { public static String build(Bitmap src, int targetWidth, int threshold) { Bitmap scaled scaleToWidth(src, targetWidth); int bytesPerRow (scaled.getWidth() 7) / 8; int totalBytes bytesPerRow * scaled.getHeight(); String hexData toHexPacked(scaled, threshold); return ^XA^FO10,10^GFA, totalBytes , bytesPerRow ,0, hexData ^FS^XZ; } private static Bitmap scaleToWidth(Bitmap src, int targetWidth) { float scale Math.min(1f, (float) targetWidth / src.getWidth()); int width Math.round(src.getWidth() * scale); int height Math.round(src.getHeight() * scale); return Bitmap.createScaledBitmap(src, width, height, true); } private static String toHexPacked(Bitmap bmp, int threshold) { int width bmp.getWidth(); int height bmp.getHeight(); int bytesPerRow (width 7) / 8; StringBuilder hex new StringBuilder(); for (int row 0; row height; row) { int currentByte 0; int bitIndex 0; for (int col 0; col bytesPerRow * 8; col) { int bit 0; if (col width) { int pixel bmp.getPixel(col, row); int r Color.red(pixel); int g Color.green(pixel); int b Color.blue(pixel); int luminance (r * 299 g * 587 b * 114) / 1000; bit luminance threshold ? 1 : 0; } currentByte (currentByte 1) | (bit 0x01); bitIndex; if (bitIndex 8) { hex.append(String.format(%02X, currentByte)); currentByte 0; bitIndex 0; } } } return hex.toString(); } }threshold这里我给的是灰度阈值默认取 128。如果想调大调小要根据图片的实际明暗情况来。比如深色 logo 用 128 没问题但浅色图片用 128 可能丢内容可以降到 100 或 90。5.3 主流程调用顺序业务层的调用顺序必须严格按下面几步来用户选择一个设备并确认连接在子线程里执行connect()读取或解码 Bitmap调用ZplImageBuilder.build()生成 ZPL 字符串把字符串用ISO-8859-1转成字节数组调用send()发送发送完成后close()。示例代码ExecutorService executor Executors.newSingleThreadExecutor(); executor.execute(() - { try { BluetoothPrinter printer new BluetoothPrinter(); printer.connect(device); Bitmap bitmap BitmapFactory.decodeFile(imagePath); String zpl ZplImageBuilder.build(bitmap, 384, 128); byte[] payload zpl.getBytes(ISO-8859-1); printer.send(payload); printer.close(); runOnUiThread(() - Toast.makeText(this, 打印完成, LENGTH_SHORT).show()); } catch (Exception e) { runOnUiThread(() - Toast.makeText(this, 打印失败: e.getMessage(), LENGTH_SHORT).show()); } });注意BitmapFactory.decodeFile解码大图时很容易 OOM最好先读取BitmapFactory.Options的尺寸信息再做采样率解码。targetWidth设置为 384 时采样率控制在 2 到 4 就足够了。6. 上线前后绕不开的排障清单乱码、空白、黑块、断连6.1 打印乱码或直接吐一长串字符乱码是最常见的问题。遇到这种状况第一步不是查转换代码而是确认打印机当前是否处于 ZPL 模式。有些热敏机出厂默认是 ESC/POS 模式收到^XA开头的文本后完全不解析直接当作普通文字打出来。解决方法是看打印机说明书用指令或按键切换模式。最好先用一个最简单文本测试^XA^FO10,10^A0,30,30^FDHello^FS^XZ如果能打出Hello说明 ZPL 模式正常。如果打出来是^XA^FO...一长串字符说明设备模式不对跟你的图片转换代码一点关系都没有。另外一个乱码诱因是编码问题。发送 ZPL 时一定要用ISO-8859-1不要用系统默认编码。在中文环境里系统字符串转字节默认可能是UTF-8多字节编码会破坏指令结构。6.2 图片变黑块或整体空白图片能出图但整块都是黑的或者什么都看不见这多半是二值化阈值或位序的问题。先确认阈值如果原图是白底黑字而打印出来是一整块黑的很可能灰度值判断反了。打印机的加热点是“1 表示黑”你是不是把白底当成了 1解决办法是打印一张半黑半白的测试图肉眼判断哪个方向需要反转。再确认字节序如果你发现图形是左右镜像、斜向错位优先检查行的补齐逻辑。宽度不是 8 的倍数时每一行都必须补齐到 8 的整数倍否则第二行开始就错位。左右镜像则说明每行的位序反了需要从字节低位开始读或者把每行的像素从右往左遍历。6.3 蓝牙连上后写数据就断连接成功但一发送大数据就断开这个问题我在现场遇到过很多次。常见原因有三个图片太大ZPL 指令字符串超过打印机缓存打印机直接放弃发送速度太快打印机的蓝牙缓冲区来不及处理主线程卡顿导致蓝牙链路超时。解决办法是把大图数据拆包发送。比如每 1024 个字节写入一次中间短暂停顿几毫秒byte[] payload zpl.getBytes(ISO-8859-1); int chunkSize 1024; for (int i 0; i payload.length; i chunkSize) { int end Math.min(i chunkSize, payload.length); out.write(payload, i, end - i); out.flush(); Thread.sleep(5); }这个时间值不固定要根据打印机实际处理能力调整。有的打印机缓冲大可以连续写有的必须加延时。稳妥起见先用小图跑通再逐步加大图片尺寸找到当前设备的临界点。还有一个容易被忽略的细节不要把BluetoothSocket.getInputStream()和OutputStream混用。如果打印机不主动回传数据你开一个线程去循环读流可能会把链路读挂。大多数场景下我们只写不读写完直接close()即可。下面这张表是我整理的快速自查清单遇到异常时按顺序排查现象优先检查项常见根因打出来是文字指令ZPL 模式打印机处于 ESC/POS 模式乱码编码方式使用了 UTF-8 而非 ISO-8859-1空白阈值和反色黑白点判断反了或阈值过低黑块字节补齐宽度不是 8 的整数倍未补齐图形错位位序高位低位顺序理解反了发送中断数据分包图片过大或写入过快连接卡住搜索未取消未执行 cancelDiscovery7. 最后再分享几条实战经验这个项目做完后我最大的体会是蓝牙热敏打印的难点不在蓝牙而在图片转换和指令格式之间的匹配。蓝牙连接本身有标准套路只要线程、UUID、权限没问题基本都能连上。但一张图能不能打得出来取决于你是否理解打印机的“点阵世界观”。我自己后来在项目里留了一个隐藏入口长按某处可以发送一份测试样张里面包含纯黑块、纯白块、竖线和横线。不管是换打印机型号还是换固件版本先打样张再调代码。这个方法帮我避开过很多无效调试。还有一个经验是想清楚场景再优化。如果业务里只是打印 logo、签名或者简单截图二值化阈值完全够用不要为了“更清晰”去加复杂抖动算法。热敏纸本身的物理分辨率就那么高过度处理反而让图形显得脏。真正要打照片的时候优先考虑压缩图片尺寸到打印分辨率能承受的范围而不是盲目提高目标宽度。希望这份从蓝牙连接到 ZPL 图片绘制的完整方案能让你少走一段弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑