资讯动态

Android蓝牙热敏打印机ZPL图片打印开发实战解析

发布时间:2026/10/14 0:59:25 来源:尧图企业网站定制
最近做的一个 Android 平板项目要在门店现场帮客户升级一套标签打印方案。业务方的要求很直接手机或平板上装了 App通过蓝牙连上热敏打印机就能把商品图、价格签、二维码标签现场打出来。设备统一用了支持 ZPL业内习惯叫斑马指令就是指那套 Zebra Programming Language的热敏打印机。听起来不算复杂但 ZPL 打图片跟打文本完全是两个难度层级。我整个开发过程中真正耗时间的不是蓝牙配对反而是怎么把一张 Bitmap 转成打印机认识的位图点阵数据再把点阵拼进指令里。这篇文章就把我从权限适配、蓝牙 SPP 建链到位图二值化、ZPL 指令生成和传输优化的一整套实现过程写出来给同样要做 Android 蓝牙打印图片的朋友做个参考。1. 项目背景与技术选型1.1 需求场景Android 设备直连热敏打印机实际落地场景里门店前台往往没有电脑更没有老式并口打印机。标签内容却不少商品名称、价格、二维码、商品照片要求即扫即打。这种轻量化场景正好是蓝牙热敏打印机的强项部署成本低一个打印机几百块不需要网线也不需要交换机打开 App 连上就干活员工几乎不用额外培训。热敏打印机的分辨率普遍是 203dpi个别高端机器是 300dpi买设备的时候一定要问清楚。203dpi 的意思是每英寸 203 个点换算下来每毫米约 8 个点。这个数字后面做图片缩放时要反复用到是整个方案的基准。项目里我把功能拆成三块蓝牙连接、图片预处理、指令生成发送。蓝牙连接负责把链路打通图片预处理负责把 Bitmap 变成二值点阵指令生成发送负责把所有数据组装成 ZPL 并送到打印机。三块各自独立后续换打印机协议也方便。1.2 为什么选蓝牙 SPP 而不是 BLE、WiFi、USBAndroid 连接外设其实有几条路可选USB Host 要加 OTG 线走 WiFi Socket 依赖局域网还有经典蓝牙 SPP以及低功耗蓝牙 BLE。热敏打印机市场里大量型号走的都是蓝牙 SPP因为这类产品很多是从工业串口设备演进过来的SPP 对下位机来说就是一个虚拟串口打印固件只需要处理串口数据流成本低、兼容性也最好。BLE 在手机端的权限流程更好走但打印机侧需要额外做协议栈支持的型号明显偏少。更重要的是 BLE 的通知通道单包一般只有 20 字节传一张图要拆成几百包速度跟 SPP 差距很大。WiFi 打印虽然快但门店不一定有干净的局域网环境设备成本也高。USB 在 Android 平板上兼容性参差还要带 OTG 线操作体验不好。连接方案优点缺点适用场景蓝牙 SPP普及率高、数据流式无包长限制、速度快配对流程稍繁琐、部分机器只允许单连接通用标签/小票打印BLE功耗低、新机型多支持单包数据小、传输慢、固件兼容少低电量小型设备WiFi速度快、可打大图需要网络环境、设备成本高工业仓库、大批量USB/OTG稳定、无无线干扰Android 兼容差、需转接线台式专用场景所以最终方案很明确优先走经典蓝牙 SPP同时在设备类型判断上兼容个别 BLE 透传型号。对大多数打印场景SPP 足够稳没必要给自己加戏。1.3 ZPL 指令体系打印机的“母语”ZPL 是一套纯文本指令语言以 ^XA 开头^XZ 结束。内部每个 ^XX 都是一个控制命令^FO 设置起始打印位置^FS 表示字段结束^A 选字体^B 打条码^GFA 是图形字段。整个设计风格跟 HTML 有点像属于解释型标签语言打印机收到整段命令后再统一渲染。举个最简单的例子打印一行文字^XA ^FO20,20 ^A0N,32,32 ^FDHELLO WORLD^FS ^XZ图片打印的重点在 ^GFA 上。ZPL 会把一张位图看作横向 N 字节、纵向 M 字节的二维数组每个 bit 对应一个打印点bit 为 1 就打点为 0 就是空白。你要做的就是把 Android 的 Bitmap 变成这样一个二进制点阵再转成十六进制字符串塞进 ^GFA 参数里。理解了这一点后面的代码就顺理成章了。2. 蓝牙连接层的实现要点2.1 权限适配Android 版本差异与大坑蓝牙权限这块是新手最容易崩的地方不同 Android 版本差异非常大。Android 6.0 到 11.0需要普通权限 BLUETOOTH 和 BLUETOOTH_ADMIN扫描外部设备还需要运行时定位权限 ACCESS_FINE_LOCATION否则 startDiscovery 什么都搜不到。Android 12 也就是 API 31 之后系统把权限拆成了 BLUETOOTH_SCAN 和 BLUETOOTH_CONNECT都是运行时权限必须动态申请。我实际适配时踩过一个坑在 Android 12 以上如果只申请了 BLUETOOTH_SCAN 但没申请 BLUETOOTH_CONNECT调用 device.connect() 会直接抛 SecurityException。反过来如果只申请了 CONNECT 就执行扫描又会报权限不足。两个都得要而且最好一起申请。private static final String[] BLUETOOTH_PERMISSIONS; static { if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) { BLUETOOTH_PERMISSIONS new String[]{ Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT }; } else if (Build.VERSION.SDK_INT Build.VERSION_CODES.M) { BLUETOOTH_PERMISSIONS new String[]{ Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN }; } else { BLUETOOTH_PERMISSIONS new String[0]; } }Android 13 及以上对动态广播接收器也增加了限制注册扫描广播时需要显式传入 RECEIVER_EXPORTED 或 RECEIVER_NOT_EXPORTED 标志否则部分版本上会选错默认值导致扫描结果接收不到。2.2 连接流程搜索、配对、建链SPP 连接的典型过程我整理成了一个固定套路获取 BluetoothAdapter。先查已配对设备列表 getBondedDevices()按名称过滤。打印机名称里一般带品牌或型号关键字比如 Label、Printer 之类。没有配对过的设备就 startDiscovery() 扫描用 BroadcastReceiver 接收 ACTION_FOUND。拿到 BluetoothDevice 后调用 createRfcommSocketToServiceRecord(SPP_UUID) 创建 Socket。socket.connect() 是阻塞调用必须放到子线程否则主线程直接假死。connect 失败抛 IOException常见原因是设备已被其他终端占用很多热敏打印机同一时间只允许一个连接。SPP 的 UUID 是固定的也是个行业标准值private static final UUID SPP_UUID UUID.fromString(00001101-0000-1000-8000-00805F9B34FB);但有少数国产打印机厂商会魔改 UUID。如果连接失败可以用 device.getUuids() 把设备上报的 UUID 列表拉出来看看选其中兼容串口协议的那个来建链。这个方法联调时救过我一次。扫描设备那段代码比较模式化核心逻辑就是按名字过滤private final BroadcastReceiver receiver new BroadcastReceiver() { Override public void onReceive(Context context, Intent intent) { if (BluetoothDevice.ACTION_FOUND.equals(intent.getAction())) { BluetoothDevice device intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); String name device.getName(); if (name ! null name.contains(Printer)) { deviceList.add(device); } } } };配对 PIN 码大多是 1234 或 0000极少数支持自定义。如果设备处于未配对状态直接调用 socket.connect() 会触发系统的配对确认弹窗也可以在代码里先主动调用 device.createBond()。2.3 数据通信优化分块写入与断线重连连接建立后通过 BluetoothSocket.getOutputStream() 写数据。这里有一个很容易被忽视的点有些国产打印机对高速数据流很敏感。你把几 KB 字符串一股脑写进去它可能丢字节或者打出来是乱码、图形残缺。稳妥的做法是限制单次写入长度比如 1024 或 4096 字节写一段之后 flush再 sleep 几毫秒。虽然单张图会慢零点几秒但稳定压倒一切。门店场景没有人会对着打印机狂刷几百张图这个性能损失完全可接受。另外要认清一个事实蓝牙连接并不是建立了就永久可靠。很多打印机会在空闲一段时间后主动断开或者进入休眠模式。App 侧必须做断线重连机制。我的做法是封装一个 PrintService持有 socket 和输出流收到 IOException 时自动回调 OnDisconnected上层弹一个“打印机已断开”的提示并放一个一键重连按钮。还有一个容易误导的认知打印机通常没有 ACK 机制打印命令发完之后它不会告诉 App“我打完了”。所以软件 UI 上一定要区分“已发送”和“打印成功”不要给用户“打印成功”的假象。真要确认打印状态可以往指令里加查询命令但蓝牙热敏打印机支持查询的非常少大多数情况只能靠人工观察出纸结果。3. ZPL 图片打印的核心原理3.1 位图到打印机点阵的映射这是整个项目最精华的部分。ZPL 的 ^GFA 参数格式是^GFA,totalBytes,totalBytes,rowBytes,datatotalBytes 是整张图片的数据总字节数等于 height × rowBytes。rowBytes 是图片每一行占多少个字节。data 就是十六进制字符串每两个字符代表一字节一字节的 8 个 bit 对应横向 8 个打印点。打印机的点序是高位在前。也就是说一行从左到右前 8 个点的顺序是 0x80、0x40、0x20、0x10、0x08、0x04、0x02、0x01。举个例子一行有 12 个点那么 rowBytes (12 7) / 8 2 字节第一字节的 bit7 到 bit0 对应点 0 到 7第二字节的 bit7 到 bit4 对应点 8 到 11bit3 到 bit0 是补零打出来是空白。第一次做的人容易在这里出错把图片当成逐像素 Byte 转换结果打印出来的图是斜的、裂开的或者整行错位。3.2 DPI 换算与图片缩放标签纸哪怕标称 60mm 宽实际左右有间隙和边距可打印区域通常只有 55mm 左右。用 58mm 来算在 203dpi 下最大可打印点数是 58 × 8 ≈ 464 点。那么 Android 里读到一张 800 × 600 的图片标签最大宽度是 400 点就应该等比缩放到 400 × 300。不缩放直接打的话打印机会把超出部分截断图片少一截。这个坑非常常见而且打印预览时看不出来。代码里用 createScaledBitmap 做等比缩放注意保持宽高比private Bitmap scaleToPrinterWidth(Bitmap src, int maxWidthPx) { int w src.getWidth(); int h src.getHeight(); if (w maxWidthPx) { return src; } int newH Math.round(h * (maxWidthPx / (float) w)); return Bitmap.createScaledBitmap(src, maxWidthPx, newH, false); }如果是不固定尺寸的标签可能还要限制最大高度。比如那种热敏便签纸只有 40mm 高图片超出部分也会被截掉。我一般把最大宽度和最大高度都传进去统一缩放。3.3 二值化处理与反色陷阱热敏打印机只有“打”和“不打”两种状态没有灰度概念所以图片必须转成二值图。Android 上的标准流程是调 getPixel(x, y) 取像素。算灰度 gray (red green blue) / 3。给定阈值灰度小于 128 视为黑色置为打印点否则空白。这里有一个让人特别莫名其妙的坑反色。ZPL 的 bit 等于 1 表示打点输出黑色所以逻辑上应该把“深色”置 1。但很多人写代码时受了 BMP 位图格式的影响——BMP 里 0 往往代表黑色——于是下意识写反了结果打印出来整张图负片效果白底黑字变成黑底白字。我第一次打出来还以为是打印机坏了排查了半天才发现是这里的问题。正确写法就是这样if (gray 128) { rowBytes[x 3] | (byte) (0x80 (x 7)); }另外如果 Bitmap 是 ARGB_8888 格式且带透明通道getPixel 返回的 alpha 也要参与判断。透明像素算出来的灰度值没有意义稳妥做法是 alpha 小于 128 时一律当白色处理不置打印点。4. 核心代码实现与逐段解析4.1 生成 ZPL 指令的完整方法下面这段代码是我项目里的核心方法整体分三步缩放、二值化、拼指令。private static final String[] HEX_TABLE { 00,01,02,03,04,05,06,07,08,09,0A,0B,0C,0D,0E,0F, 10,11,12,13,14,15,16,17,18,19,1A,1B,1C,1D,1E,1F, // ... 实际代码里可以用循环生成这里略写 }; public static String buildPrintableZpl(Bitmap src, int maxWidthPx, int maxHeightPx) { Bitmap scaled scaleToMaxSize(src, maxWidthPx, maxHeightPx); int width scaled.getWidth(); int height scaled.getHeight(); int rowBytes (width 7) / 8; int totalBytes rowBytes * height; StringBuilder data new StringBuilder(totalBytes * 2); int threshold 128; for (int y 0; y height; y) { byte[] row new byte[rowBytes]; for (int x 0; x width; x) { int pixel scaled.getPixel(x, y); int alpha Color.alpha(pixel); int gray (Color.red(pixel) Color.green(pixel) Color.blue(pixel)) / 3; boolean ink alpha threshold gray threshold; if (ink) { row[x 3] | (byte) (0x80 (x 7)); } } for (byte b : row) { data.append(HEX_TABLE[b 0xFF]); } } StringBuilder zpl new StringBuilder(); zpl.append(^XA); zpl.append(^FO0,0); zpl.append(^GFA,).append(totalBytes).append(,) .append(totalBytes).append(,).append(rowBytes).append(,); zpl.append(data); zpl.append(^FS); zpl.append(^XZ); return zpl.toString(); }这段代码里几个细节要解释一下。第一(width 7) / 8是为了处理宽度不是 8 的倍数的情况。比如 10 像素就需要 2 字节。少了这个rowBytes 算少了后续数据直接错位图片打出来像被切碎。第二使用查表法而不是 String.format 拼接十六进制能省掉大量格式化的时间。大图情况下 data 字符串可能有几百 KB格式化的开销积少成多是真实存在的性能隐患。4.2 发送 ZPL 到蓝牙打印机ZPL 字符串生成之后剩下的就是往蓝牙输出流里写。我把发送逻辑封装成下面这样private void sendZplViaBluetooth(String zpl) throws IOException { if (bluetoothSocket null || !bluetoothSocket.isConnected()) { throw new IOException(printer not connected); } OutputStream os bluetoothSocket.getOutputStream(); byte[] payload zpl.getBytes(StandardCharsets.ISO_8859_1); final int CHUNK_SIZE 1024; for (int offset 0; offset payload.length; offset CHUNK_SIZE) { int len Math.min(CHUNK_SIZE, payload.length - offset); os.write(payload, offset, len); os.flush(); try { Thread.sleep(5); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } }这里用 ISO_8859_1 而不是 UTF-8是因为 ZPL 的十六进制字符串只用到 ASCII 字符两种编码结果完全一样但 ISO_8859_1 能保证每个字符严格占 1 字节避免某些语言环境下字符串编码变长导致指令截断。sleep 值要根据实际打印机调整缓存大的机器可以一次多写点缓存小的就慢慢发。4.3 条码和二维码用原生 ZPL 指令更高效项目中除了图片还有不少是二维码、EAN 条码。这类内容用 ZPL 原生指令比转图片靠谱得多清晰度更高代码量还少。QR 码的指令是这样的^XA ^FO50,50 ^BQN,2,6 ^FDMM,https://example.com^FS ^XZ^BQN 是二维码^BC 是 Code 128^B3 是 Code 39不同机器支持略有差异。把这些指令拼进同一个 ZPL 字符串里发送就可以了。注意一点^FD 后面的内容如果包含中文或者特殊符号ZPL 默认字体支持很不稳定除非打印机内置中文字库并做编码切换否则还是建议转成图片方式打印。我在项目里对中文内容一律走图片打印省心很多。5. 踩坑记录与问题排查5.1 常见问题速查表把实际开发中遇到的坑整理成表方便直接对号入座现象可能原因解决办法打印出来全黑或全白二值化反色、bit 与点序搞反确认 gray128 时置 1点序用 0x80 右移图片右边被裁掉宽度超出打印机可打印点数按 DPI 和标签纸尺寸等比缩放图片整体右斜/错位rowBytes 计算错误每行字节数没补位用 (width7)/8 计算行字节数中文变成方框问号ZPL 字体不支持中文中文内容改图片打印或选带中文字库机型打印一次后自动断开打印机休眠、蓝牙栈异常打印前检查连接状态失败自动重连打印过程中出现乱码数据发送过快、下位机丢字节分块写入加 sleep 控制节奏Android 12 直接崩溃缺少 BLUETOOTH_CONNECT 权限运行时动态申请扫描和连接权限透明背景图打出一团黑alpha 通道参与灰度计算alpha 小于阈值一律按白处理5.2 最容易翻车的三个细节这里补三个代码层面的细节都是我真实踩过的。第一是缩放时是否开抗锯齿。createScaledBitmap 最后一个参数 filter打文字和条码时建议设 false开了抗锯齿会在边缘产生灰色过渡二值化之后边缘就是一坨毛刺。打照片可以设 true但对阈值也要做调整否则边缘发虚。第二是字节序。很多人图省事写成row[x / 8] | (byte)(1 (x % 8))这样也能打出来但方向是反的最后出来的图是镜像的。ZPL 规定每字节高位对应最左的打印点所以必须用0x80 (x % 8)。这个点写注释都容易被忽略调试时却很耗时间。第三是传输大图时的内存与耗时问题。一个 400 × 300 的位图转成 ZPL 字符串大约 150KB这个量没问题。但如果业务方给的是 1000 × 1000 的照片字符串可能接近 1MB蓝牙传输下来可能要几十秒。一定要在生成指令前限制最大打印像素不要等发送了才发现卡死。5.3 打印浓度、速度与效果调节经验最后说说打印效果的主观问题。就算图片数据全对打印浓度和速度也会影响最终观感。ZPL 里可以用 ^PR 调打印速度用 ^MD 调打印浓度具体范围和命名各厂商略有差异以设备手册为准。实际经验是打印速度调慢黑色区域会打得更厚实边缘更清晰。默认速度打大面积黑色块时发热不足实心区域会出现发白条纹。遇到这种情况先降速度试还不行再加浓度。不过浓度也不是越高越好调太高容易把空白区域也给烘出淡淡的黑底耗纸也快。如果打印的是照片类渐变色图像简单阈值二值化会把照片打得一团黑。这时候需要灰度抖动算法比如误差扩散抖动Floyd-Steinberg保留更多细节。这个展开讲又是一篇文章了后续有空我会专门写一篇 300dpi 打印机与灰度抖动打印的踩坑记录。这次项目整体工程量不大真正花时间的全在“图片怎么变成打印机认识的字节”这个思维转换上。联调那天第一次打出一张完整的带图价格签时打印机咔嚓咔嚓出纸的瞬间确实有点小成就感。如果你也在做类似需求我的建议是先把 ZPL 的 ^GFA 参数含义弄明白再谈代码蓝牙连接部分反而按常规套路写就行。先让打印机打出最简单的十六进制矩形确认通路没问题再一步步加图片、加文本、加条码这样排查问题时能省下大量时间。

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

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

免费获取报价 →
↑