Android 12+ 无 WiFi 时 PrintService 不显示 BT/USB 打印机
结论
addPrinters() 只会把打印机加入当前 PrinterDiscoverySession。它无法让系统打印对话框将这台打印机设为默认目标,也无法保证它固定出现在主列表中。自定义 PrintService 也不要求必须使用 WiFi,服务可以自行实现 Bluetooth、USB 或其他传输方式。
Android 的公开打印 API 不支持以下操作:
- 强制打印机出现在主打印机列表
- 跨打印会话预选指定打印机
- 为 Chrome 设置默认打印机
- 跳过系统打印对话框进行静默打印
当前代码还需要补上打印机验证逻辑。onValidatePrinters() 现在是空实现,onStartPrinterDiscovery() 收到的优先打印机列表也没有被处理,这可能影响系统重新验证历史打印机。补全这些逻辑后,最近使用的打印机可能更容易正常显示,但系统仍不一定会自动选中或固定展示它。
对于每天需要打印 50 至 100 次的 POS,建议绕过 Chrome Share → Print → Android PrintManager,改由受控的 Android 应用直接管理 Bluetooth/USB 连接和默认打印机。
为什么调用 addPrinters() 后仍未显示
Android 打印框中的“主打印机列表”不会完整列出所有已发现的打印机。哪些打印机会显示、如何排序、怎样记录最近使用情况,以及如何呈现打印服务入口,都由系统 Print Spooler 和设备厂商的打印界面决定。
以下两种状态并不相同:
PrintService已通过addPrinters()报告打印机。- 系统把这台打印机放入当前界面的主列表并自动选中。
addPrinters() 只能完成第一项,没有“提升到主列表”或“设为默认打印机”的参数。
不同 Android 系统和厂商 ROM 可能采用不同的打印机展示策略。现有现象不足以证明 Android 12 开始正式屏蔽非 WiFi 打印机。能够确定的是,公开 API 没有提供控制这一界面的入口。
WiFi 不是 PrintService 的硬性要求
Chrome 帮助页面中关于 WiFi 或移动网络打印机的说明,主要面向普通用户,并不构成 PrintService 的传输协议限制。
Android 打印框会把文档交给用户选中的 PrintService。服务收到打印任务后,可以自行选择数据传输方式,包括:
- Bluetooth Classic SPP/RFCOMM
- USB Host
- TCP/IP
- 厂商 SDK
- 其他私有传输协议
当前服务在用户手动选择后能够正常打印,说明 Bluetooth/USB 传输已经接入 Android 打印流程。系统没有额外配置可以把非网络打印机声明为“主列表打印机”。
补全打印机验证逻辑
系统可能会把以前发现过的 PrinterId 传给 onValidatePrinters(),让服务确认这些打印机是否仍然有效,因此这里不应留空。
onStartPrinterDiscovery() 的参数是系统希望优先发现的打印机列表。服务应先处理这些 ID,再进行普通发现。
override fun onCreatePrinterDiscoverySession(): PrinterDiscoverySession {
return object : PrinterDiscoverySession() {
private val savedPrinterId: PrinterId
get() = generatePrinterId("saved_printer")
private fun buildPrinterInfo(
status: Int = PrinterInfo.STATUS_IDLE
): PrinterInfo {
val id = savedPrinterId
val capabilities = PrinterCapabilitiesInfo.Builder(id)
.addMediaSize(RECEIPT_58MM, true)
.addResolution(
PrintAttributes.Resolution(
"res203",
"203dpi",
203,
203
),
true
)
.setColorModes(
PrintAttributes.COLOR_MODE_MONOCHROME,
PrintAttributes.COLOR_MODE_MONOCHROME
)
.setMinMargins(PrintAttributes.Margins.NO_MARGINS)
.build()
return PrinterInfo.Builder(id, label, status)
.setCapabilities(capabilities)
.build()
}
override fun onStartPrinterDiscovery(
priorityList: MutableList<PrinterId>
) {
// 如果系统要求优先发现以前使用过的打印机,先报告它。
if (savedPrinterId in priorityList) {
addPrinters(listOf(buildPrinterInfo()))
return
}
// 实际项目中可以先异步检查 Bluetooth/USB 状态,
// 然后根据结果报告 IDLE 或 UNAVAILABLE。
addPrinters(listOf(buildPrinterInfo()))
}
override fun onValidatePrinters(
printerIds: MutableList<PrinterId>
) {
if (savedPrinterId in printerIds) {
addPrinters(listOf(buildPrinterInfo()))
}
}
override fun onStartPrinterStateTracking(printerId: PrinterId) {
if (printerId == savedPrinterId) {
addPrinters(listOf(buildPrinterInfo()))
}
}
override fun onStopPrinterStateTracking(printerId: PrinterId) = Unit
override fun onStopPrinterDiscovery() = Unit
override fun onDestroy() = Unit
}
}
"saved_printer" 必须保持稳定。如果每次发现时都生成随机 UUID,系统会把同一台物理打印机识别为不同的打印机。
设备暂时断开时,可以使用原来的 PrinterId 报告 PrinterInfo.STATUS_UNAVAILABLE,连接恢复后再更新为 PrinterInfo.STATUS_IDLE。保留同一个 ID,有助于系统持续识别同一台打印机。
这些修改可以补全发现生命周期,但无法代替默认打印机 API。
无法通过公开 API 持久化系统默认打印机
PrinterDiscoverySession 只管理当前发现会话。PrintManager.print() 用于创建打印任务并打开系统打印界面,但它不接受 PrinterId、打印服务或默认目标参数。
应用可以在自己的配置中保存打印机信息,例如通过 DataStore 保存:
- 逻辑打印机 ID
- Bluetooth 设备标识
- USB 设备匹配信息
- 纸宽和字符集
- 钱箱、切刀等能力配置
这些配置只能用于应用自己的打印通道,无法写入 Android 打印对话框的默认打印机设置。修改系统设置、调用隐藏 API 或使用 ADB 命令强行设置,都不适合作为正式产品方案,也很难兼容不同 Android 版本和厂商设备。
Chrome 可以使用非网络打印机
Chrome 会把页面生成打印文档,再交给 Android 打印框处理,通常不会区分最终使用的是 WiFi、Bluetooth 还是 USB。只要用户选中对应的 PrintService,非网络打印机就能工作。
这套流程面向交互式文档打印,默认由用户确认打印目标、纸张和参数。对于高频 POS 场景所需的静默打印、固定目标和自动重连,它没有提供相应能力。
Bluetooth/USB 打印任务本身可以执行,只是系统打印交互不适合每天几十次的收银打印。
POS 场景的推荐方案
方案一:Android 容器加 WebView 打印桥接
如果可以让 POS 页面运行在自有 Android 应用的 WebView 中,建议由原生层连接打印机,网页只提交结构化订单或已经生成的 ESC/POS 数据。
class PosPrintBridge(
private val printer: ReceiptPrinter,
private val executor: Executor
) {
@JavascriptInterface
fun printReceipt(base64Data: String) {
executor.execute {
val data = Base64.decode(base64Data, Base64.DEFAULT)
printer.write(data)
}
}
}
webView.settings.javaScriptEnabled = true
webView.addJavascriptInterface(
PosPrintBridge(printer, executor),
"PosPrinter"
)
网页调用示例:
const data = buildEscPosReceipt(order);
PosPrinter.printReceipt(toBase64(data));
应用可以通过这种方式保存默认打印机、维护连接和执行重试,也可以直接处理切纸、钱箱及 KOT 分单。addJavascriptInterface() 只能暴露给受信任的页面,WebView 不应随意打开第三方站点。
方案二:Chrome 加本地打印代理
如果必须继续使用完整 Chrome,可以安装一个本地 Android 辅助应用,在 127.0.0.1 上提供 HTTP 或 WebSocket 接口。网页把打印任务提交给本地代理,再由代理连接 Bluetooth/USB 打印机。
await fetch("http://127.0.0.1:18080/print", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${localToken}`
},
body: JSON.stringify({
printerId: "kitchen",
payload: receiptData
})
});
本地打印代理是浏览器 POS 常用的集成方式,但不是 Android 官方标准。部署前需要在目标 Chrome 版本和设备策略下验证。HTTPS 页面访问本地 HTTP 服务时,可能会受到混合内容、CORS 或浏览器私有网络访问规则的限制。
代理至少需要满足以下要求:
- 只监听
127.0.0.1,不要监听全部网络接口 - 使用随机令牌或请求签名验证调用方
- 校验允许的页面来源
- 限制请求大小、打印机 ID 和指令类型
- 防止重复提交和重放
- 不把 CORS 当作身份认证
- 对 USB 授权、Bluetooth 断线和打印失败提供明确状态
方案三:直接使用打印机厂商 SDK
如果打印机厂商提供稳定的 Android SDK,可以让原生应用通过 SDK 打印。设备需要状态查询、切刀、蜂鸣器、钱箱或错误恢复时,厂商 SDK 通常比通用 PrintService 更方便处理。
实施建议
如果产品允许安装受控的 Android 客户端,优先使用 WebView 加原生打印桥接。必须保留 Chrome 时,可以再考虑本地打印代理。系统 PrintService 适合作为偶尔使用的手动打印入口,不适合承担高频 POS 的默认打印流程。
Android 12 及以上版本的 Bluetooth 权限、USB 用户授权和后台执行限制仍需单独处理。这些限制会影响连接能否成功,但无法控制系统主打印机列表或默认选择。
备注:内容仅供参考。
