☰
iOS蓝牙小票打印机开发实战:CoreBluetooth与ESC/POS指令集详解
2026/10/22 8:41:34 网站建设 项目流程

1. 为什么要在iOS上折腾蓝牙打印

做移动端零售系统、餐饮点单、快递面单、现场罚单、排队叫号这类场景,绕不开一个刚需:用iPhone或iPad直接驱动一台热敏小票打印机出票。很多人第一反应是走网络打印或者云打印,但门店现场经常没有稳定Wi-Fi,或者路由器跟打印机不在同一网段,顾客排队等着出票的时候你不可能去调路由。蓝牙连接打印机就成了最稳的兜底方案——不依赖外网、不依赖局域网、配对一次之后开机即用。

这个项目的核心目标很明确:在iOS应用里通过蓝牙发现附近的小票打印机,建立连接,然后把一行行文字、二维码、条码、甚至简单的位图排版成符合打印机指令集的字节流,最终从机器嘴里吐出一张完整小票。适合谁看?正在做零售收银、餐饮点单、物流面单、现场执法终端的iOS开发;也适合产品经理了解这条链路到底卡在哪里,方便评估工期和硬件选型。

需要先泼一盆冷水:iOS的蓝牙体系和安卓完全不是一回事。安卓上你可以随便扫经典蓝牙SPP协议,iOS不行。苹果对蓝牙的管控非常严格,外设必须走MFi认证或者走BLE(低功耗蓝牙)的GATT服务。市面上大量便宜的小票打印机用的是经典蓝牙SPP,插在安卓上即插即用,插到iOS上根本搜不到。所以这个项目的第一道门槛不是写代码,而是选对打印机。我踩过的第一个坑就是买了一批只支持SPP的机器,代码写完了发现iOS连扫描都扫不出来,只能整批换货。

下面我把整条链路拆开讲:从硬件选型、协议理解、CoreBluetooth框架使用、指令集拼包、中文编码处理,到实际调试中遇到的各种奇葩问题。内容基于我实际做过的几个门店收银和物流面单项目整理,代码片段可以直接参考。

2. 硬件与协议选型:先搞清楚你的打印机吃哪一套

2.1 经典蓝牙SPP和BLE的本质区别

小票打印机跟手机通信,底层无非两种:经典蓝牙(BR/EDR)和低功耗蓝牙(BLE)。经典蓝牙里的SPP(Serial Port Profile)本质上是模拟一条串口,数据透传,打印机厂商最喜欢用,因为固件简单、成本低。问题在于iOS的ExternalAccessory框架只对MFi认证设备开放SPP,普通未认证的SPP打印机在iOS上就是一块砖。

BLE则是另一条路。打印机厂商在BLE上定义一个或多个GATT服务,手机作为Central去发现Service和Characteristic,往可写Characteristic里写数据。这条路不需要MFi认证,只要打印机固件实现了标准的GATT服务就能用。现在主流的便携小票打印机,尤其是面向iOS市场的,基本都会同时支持SPP和BLE,或者干脆只做BLE。

选型的时候你要问供应商三个问题:第一,支不支持BLE 4.0以上;第二,GATT服务的Service UUID和Characteristic UUID是什么;第三,写入的时候是Write Without Response还是Write With Response。这三个信息决定了你代码怎么写。很多供应商给的文档含糊其辞,最靠谱的办法是拿一台样机,用LightBlue或者nRF Connect这类通用BLE调试工具扫一遍,把Service和Characteristic列表截图存下来。

2.2 常见打印机的服务UUID规律

虽然各家UUID不同,但市面上大量打印机用的是同一套公版固件,UUID有规律可循。我整理过一批常见型号的扫描结果,大致分两类:

类型Service UUIDWrite CharacteristicNotify Characteristic
公版A类0xFF00 或 18F00xFF01 / 2AF10xFF02 / 2AF0
公版B类0xFFE00xFFE10xFFE2
品牌定制厂商自定义128位厂商自定义厂商自定义

看到0xFFE0、0xFFE1这种16位短UUID,基本可以判断是透传模块,你往里写什么字节它就往串口吐什么字节,打印机固件再解析。这类最好处理,因为指令集就是标准的ESC/POS。看到128位自定义UUID的,往往厂商在协议上做了封装,可能需要先发握手包、鉴权包,这种就要拿厂商的SDK文档。

提示:不要假设UUID固定不变。同一品牌不同批次的固件可能改UUID,代码里最好把UUID做成可配置项,或者扫描时按服务数量和服务特征去匹配,而不是硬编码一个UUID就完事。

2.3 指令集:ESC/POS是绕不开的

打印机收到字节流之后怎么理解?靠指令集。小票打印机绝大多数兼容ESC/POS指令集,这是爱普生早年定的一套标准,现在成了事实行业标准。你发的不是"打印一行字"这种高级命令,而是一串十六进制字节:比如0x1B 0x40是初始化打印机,0x1B 0x61 0x01是居中对齐,0x1D 0x56 0x00是切纸。

这意味着两件事:第一,你得自己把文字、排版、二维码全部翻译成字节;第二,中文必须处理编码,因为ESC/POS默认走的是单字节代码页,中文要发GBK或者GB18030编码的字节,而且很多机器需要先发一个"进入汉字模式"的指令。这部分是新手最容易翻车的地方,后面单独用一节讲。

3. CoreBluetooth实战:从扫描到连接

3.1 权限配置与中心设备初始化

iOS 13之后蓝牙权限收紧了,Info.plist里必须加NSBluetoothAlwaysUsageDescription,否则一调用扫描就崩。这个描述文案要写清楚用途,比如"用于连接小票打印机完成出票",审核的时候也看这个。

初始化CBCentralManager的时候,把代理设好,然后等centralManagerDidUpdateState回调。只有state等于poweredOn才能开始扫描,其他状态(unauthorized、poweredOff、unsupported)都要给用户明确提示。我见过有开发者直接在viewDidLoad里调scanForPeripherals,结果state还没ready,扫描直接静默失败,排查半天。

import CoreBluetooth class PrinterManager: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate { private var centralManager: CBCentralManager! private var targetPeripheral: CBPeripheral? private var writeCharacteristic: CBCharacteristic? override init() { super.init() centralManager = CBCentralManager(delegate: self, queue: nil) } func centralManagerDidUpdateState(_ central: CBCentralManager) { switch central.state { case .poweredOn: central.scanForPeripherals(withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]) case .poweredOff: print("蓝牙未开启") case .unauthorized: print("蓝牙权限被拒绝") default: break } } }

扫描的时候withServices传nil会扫到所有BLE设备,包括一堆手环、耳机,列表很乱。更优雅的做法是传入你已知的Service UUID数组,只扫打印机。但如果你不确定UUID,就先传nil扫一遍,把打印机的identifier记下来,后续用identifier直接retrievePeripherals,省去每次扫描。

3.2 连接与发现服务

didDiscover回调里拿到peripheral之后,先别急着connect,把名字和 RSSI 过滤一下。打印机名字通常带"Printer""POS""RPP"这类关键词,RSSI大于-70基本就在附近。用户点选之后再调connect。

func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { guard let name = peripheral.name, name.contains("Printer") else { return } // 展示到UI让用户选择,选中后: targetPeripheral = peripheral centralManager.stopScan() centralManager.connect(peripheral, options: nil) } func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { peripheral.delegate = self peripheral.discoverServices(nil) }

连接成功之后discoverServices,然后在didDiscoverServices里遍历,找到那个可写的Characteristic。判断可写的依据是characteristic.properties包含.write或.writeWithoutResponse。这里有个细节:如果同时存在.write和.writeWithoutResponse,优先用writeWithoutResponse,因为小票数据量大,带响应的写每包都要等ACK,速度慢好几倍。

3.3 分包写入:MTU是硬约束

BLE单次写入的字节数受MTU限制,默认MTU是23字节,减去3字节ATT头,实际能写20字节。虽然iOS 10之后系统会自动协商更大的MTU(常见能到185字节),但你不能假设一定协商成功。一张小票动辄几百上千字节,必须分包。

分包策略很简单:把要发的Data切成每片不超过maxWriteLength的块,循环写入。maxWriteLength从peripheral.maximumWriteValueLength(for: .withoutResponse)拿。写的时候如果用的是writeWithoutResponse,不要在一个循环里疯狂写,BLE协议栈有缓冲上限,写太快会丢包。稳妥的做法是每写一包稍微延迟几毫秒,或者用带响应的写来天然限流。

func sendData(_ data: Data) { guard let peripheral = targetPeripheral, let characteristic = writeCharacteristic else { return } let maxLength = peripheral.maximumWriteValueLength(for: .withoutResponse) var offset = 0 while offset < data.count { let chunkLength = min(maxLength, data.count - offset) let chunk = data.subdata(in: offset..<(offset + chunkLength)) peripheral.writeValue(chunk, for: characteristic, type: .withoutResponse) offset += chunkLength // 轻微延迟,避免协议栈缓冲溢出 Thread.sleep(forTimeInterval: 0.02) } }

注意:Thread.sleep在主线程会卡UI,实际项目里应该把发送逻辑放到串行队列或者用DispatchQueue.asyncAfter做链式发送。我早期图省事在主线程sleep,结果打印大票的时候界面直接卡住,被测试提了bug。

4. 指令拼包:把一张小票翻译成字节

4.1 ESC/POS基础指令速查

打印小票本质上是拼一个字节数组。常用的指令我列一张表,这些是每天都要用的:

功能指令(十六进制)说明
初始化1B 40清空缓冲区,恢复默认
居中对齐1B 61 010左1中2右
加粗开1B 45 01
加粗关1B 45 00
双倍高宽1D 21 11标题常用
恢复正常大小1D 21 00
走纸n行1B 64 nn是行数
切纸1D 56 00全切
打开钱箱1B 70 00 19 FA收银场景常用

拼包的时候用一个Data或者[UInt8]累加就行。我习惯封装一个Builder类,提供text()、align()、bold()、feed()、cut()这些方法,内部往buffer里append字节。这样业务代码写起来就像写HTML一样直观。

class EscPosBuilder { private var buffer = [UInt8]() func initialize() -> Self { buffer += [0x1B, 0x40] return self } func align(_ mode: UInt8) -> Self { buffer += [0x1B, 0x61, mode] return self } func bold(_ on: Bool) -> Self { buffer += [0x1B, 0x45, on ? 0x01 : 0x00] return self } func feed(_ lines: UInt8) -> Self { buffer += [0x1B, 0x64, lines] return self } func cut() -> Self { buffer += [0x1D, 0x56, 0x00] return self } func build() -> Data { return Data(buffer) } }

4.2 中文编码:GBK是主战场

这是整个项目里最容易被低估的坑。ESC/POS打印机内置的汉字字库通常是GBK或者GB18030,不是UTF-8。你直接把Swift的String转成UTF-8的Data发过去,打出来全是乱码或者问号。

正确做法是把中文转成GBK编码的字节。Swift原生不支持GBK,需要用CFStringConvertEncodingToNSStringEncoding配合kCFStringEncodingGB_18030_2000。

func gbkData(from text: String) -> Data? { let encoding = CFStringConvertEncodingToNSStringEncoding( CFStringEncoding(CFStringEncodings.GB_18030_2000.rawValue)) return text.data(using: String.Encoding(rawValue: encoding)) }

另外,很多打印机在打印中文前需要先发一个"选择汉字模式"的指令,常见的是1C 26或者1B 52,具体看机型。有些机器不用发也能打,但发了更保险。如果打出来中文是乱码但英文正常,八成就是编码问题;如果中文直接不打或者打成空白,可能是没进汉字模式,或者字库不支持。

提示:GB18030兼容GBK且覆盖更全,优先用GB18030。但要注意,个别老机器只认GBK,遇到生僻字可能出问题,测试的时候拿常用汉字和生僻字都试一遍。

4.3 二维码和条码的生成

二维码用ESC/POS的原生指令生成最省事,不用自己画位图。GS ( k指令族可以设置二维码内容、模块大小、纠错等级,然后打印。以打印一个二维码为例,指令序列大致是:设置模型(1D 28 6B 04 00 31 41 32 00)、设置模块大小(1D 28 6B 03 00 31 43 n)、设置纠错等级(1D 28 6B 03 00 31 45 n)、存储数据(1D 28 6B pL pH 31 50 30 ...)、打印(1D 28 6B 03 00 31 51 30)。

数据长度pL pH是内容字节数加3,低字节在前。这个计算容易错,我建议封装成函数,把内容传进去自动算长度。

func qrCode(_ content: String) -> [UInt8] { guard let data = content.data(using: .utf8) else { return [] } let len = data.count + 3 let pL = UInt8(len & 0xFF) let pH = UInt8((len >> 8) & 0xFF) var cmd: [UInt8] = [0x1D, 0x28, 0x6B, 0x04, 0x00, 0x31, 0x41, 0x32, 0x00] cmd += [0x1D, 0x28, 0x6B, 0x03, 0x00, 0x31, 0x43, 0x06] // 模块大小6 cmd += [0x1D, 0x28, 0x6B, 0x03, 0x00, 0x31, 0x45, 0x31] // 纠错等级M cmd += [0x1D, 0x28, 0x6B, pL, pH, 0x31, 0x50, 0x30] cmd += [UInt8](data) cmd += [0x1D, 0x28, 0x6B, 0x03, 0x00, 0x31, 0x51, 0x30] return cmd }

条码用1D 6B指令,支持CODE128、EAN13等。CODE128最通用,能编码数字和字母。注意CODE128有A、B、C三种子集,纯数字用C子集最省空间,混合内容用B子集。指令里要指定子集和内容长度。

4.4 位图打印:Logo和复杂排版

如果小票上要打店铺Logo或者自定义图形,就得走位图打印。ESC/POS的位图指令是1D 76 30,后面跟模式、宽度、高度、数据。数据是黑白点阵,每个字节8个像素,1表示黑点。

把UIImage转成点阵是个细致活。先把图片缩放到目标宽度(比如384点,对应58mm打印机),然后逐像素判断灰度,大于阈值的算白,小于的算黑,按位打包。这个过程在主线程做会卡,放到后台队列。

func imageToRasterData(_ image: UIImage, maxWidth: Int = 384) -> [UInt8] { // 缩放、取灰度、二值化、按位打包 // 具体实现略,核心是遍历像素生成[UInt8] // 返回格式:[0x1D, 0x76, 0x30, 0x00, widthL, widthH, heightL, heightH, ...点阵数据] }

位图数据量大,一张384x200的图就是9600字节,分包发送要几十包,打印速度会明显变慢。所以Logo尽量做小、做简单,能用文字排版解决的不要用图。

5. 常见问题与排查实录

5.1 扫描不到设备

最常见的原因有三个:一是打印机只支持经典蓝牙SPP,iOS根本扫不到,这个无解,只能换机;二是打印机已经被别的设备连上了,BLE设备通常只允许一个Central连接,被占用就不再广播;三是打印机没进入配对模式,有些机器需要长按按键或者开机后几秒内才能被发现。

排查顺序:先用系统蓝牙设置看能不能搜到,再用LightBlue扫,最后才怀疑代码。如果LightBlue能扫到但你的App扫不到,检查withServices参数是不是传了错误的UUID把设备过滤掉了。

5.2 连接上了但发数据没反应

先确认writeCharacteristic找对了没有。有些打印机有多个可写Characteristic,一个是命令通道一个是数据通道,写错了就没反应。用nRF Connect手动往每个可写Characteristic发一条初始化指令,看哪个能触发打印。

另一个可能是需要先发唤醒包或者握手包。部分打印机在收到第一条指令前处于休眠,需要发一串特定字节唤醒。这个只能问厂商要协议文档。

5.3 中文乱码

前面讲过,九成是编码问题。确认三件事:转GBK了没有、进汉字模式指令发了没有、打印机字库支不支持你要打的字。如果英文正常中文乱码,基本就是编码;如果中英文都乱,可能是指令集不兼容,试试换初始化指令。

5.4 打印一半卡住

数据量大时容易出现。原因通常是写太快导致BLE缓冲溢出丢包,或者分包边界切在了多字节指令中间。解决方法是加延迟、降低发送速率,以及确保分包时不要把一条完整指令拆断——虽然理论上BLE是字节流可以任意切,但有些打印机固件解析有bug,指令被拆开就解析失败。稳妥做法是拼包时记录每条指令的边界,分包尽量在指令边界处切。

5.5 打印速度慢

BLE本身带宽有限,加上writeWithoutResponse的延迟,一张复杂小票打十几秒很正常。优化方向:减少位图、精简指令、提高MTU协商成功率、用writeWithoutResponse而不是write。如果对速度要求极高,考虑换支持高速BLE的机型,或者走USB/网络方案。

问题现象可能原因排查动作
扫不到设备SPP机型/被占用/未进配对模式换BLE机、断开其他连接、重启打印机
连上无反应Characteristic选错/需握手nRF Connect手动测试
中文乱码编码非GBK/未进汉字模式检查编码转换和模式指令
打印中断写太快/分包切断指令加延迟、按指令边界分包
速度慢位图多/MTU小精简内容、优化分包

5.6 后台打印与断连重连

App切到后台之后,BLE连接可能被系统挂起。如果业务要求后台也能打印,需要在Info.plist里声明bluetooth-central后台模式,并且保证有活跃的连接。但即便如此,后台停留时间过长还是可能断。稳妥做法是每次打印前检查连接状态,断了就重连,重连失败就提示用户。

重连的时候用centralManager.retrievePeripherals(withIdentifiers:)拿之前记住的identifier,比重新扫描快得多。identifier是系统分配的UUID,同一台设备在同一台手机上一般不变,可以持久化存起来。

6. 几个提升稳定性的实战心得

第一,把打印逻辑和UI彻底解耦。我习惯做一个PrinterService单例,对外暴露connect、print(escPosData)、disconnect三个方法,内部管理状态机。UI层只管调,不关心BLE细节。这样换打印机、换协议的时候改动面很小。

第二,一定要做打印队列。用户可能连续点好几次打印,如果并发发送,BLE通道会乱。用一个串行队列排队,一张打完再打下一张。队列里还要处理失败重试,比如某张票发到一半断了,重连之后要不要重发整张,这个策略要跟业务确认。

第三,测试阶段准备一台"问题机"。我专门留了一台固件老、UUID怪、中文支持差的打印机,每次改完代码先拿它测。能在这台机器上跑通,其他机器基本没问题。顺带说一句,不同品牌打印机的切纸指令、钱箱指令可能有差异,多备几台样机没坏处。

第四,日志要打全。BLE的失败往往是静默的,didFailToConnect、didDisconnectPeripheral这些回调一定要记日志,把error和timestamp都存下来。线上出问题的时候,用户截个日志图你就能定位。

最后分享一个调试小技巧:把要发送的字节数组用十六进制字符串打印出来,跟ESC/POS指令表逐条对照。我排查过一个"打印内容错位"的bug,最后发现是某条对齐指令的参数字节写成了0x02而不是0x01,肉眼看不出来,对着十六进制日志一眼就找到了。这个习惯帮我省了大量时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询