四、小程序 BLE 打印设备连接与生命周期管理
在小程序中,通过 BLE 连接便携式打印机,本质上是对一系列异步系统能力的编排过程,而不仅仅是 API 的顺序调用。
在真实设备环境中,连接过程会受到权限、系统蓝牙状态、设备广播稳定性、信号强度等多种因素影响,因此整个流程需要以"状态机"的方式进行设计,而不是简单的线性调用。
蓝牙能力初始化
在调用任何 BLE 相关 API 之前,必须先初始化蓝牙模块。
const openBluetoothAdapter = () => {
return new Promise((resolve, reject) => {
uni.openBluetoothAdapter({
success() {
resolve()
},
fail(err) {
if (err.errCode === 10001) {
uni.showToast({
title: "蓝牙未开启",
icon: "none"
})
}
reject(err)
}
})
})
}
说明:
openBluetoothAdapter是所有 BLE 能力的前置条件- 未初始化时调用其他 BLE API 会直接报错
10001表示系统蓝牙不可用或未开启
同时可以监听蓝牙状态变化:onBluetoothAdapterStateChange 监听手机蓝牙状态的改变。
onMounted(() => {
uni.onBluetoothAdapterStateChange(res => {
console.log("uni.onBluetoothAdapterStateChange", res)
// available: 蓝牙模块是否可用(需支持 BLE 且蓝牙已开启)
// discovering: 蓝牙模块是否处于搜索状态
const { available } = res
if (!available) {
uni.showModal({
title: "提示",
content: "系统蓝牙未开启"
})
}
})
BLE 设备扫描
在完成蓝牙模块初始化后,即可开始扫描附近的 BLE 外围设备。
BLE 设备扫描本质上是一个基于广播包的实时发现机制,系统并不会返回"设备列表",而是通过持续监听广播信号来逐步构建可见设备集合。
在小程序中,可以通过 startBluetoothDevicesDiscovery 开始扫描:
const startBluetoothDevicesDiscovery = () => {
return new Promise((resolve, reject) => {
uni.startBluetoothDevicesDiscovery({
allowDuplicatesKey: false, // 是否允许重复上报同一设备, true 表示允许
interval: 0, // 搜索间隔 (1000ms ~ 120000ms)
success() {
timeoutId = setTimeout(() => {
resolve()
stopBluetoothDevicesDiscovery()
clearTimeout(timeoutId)
}, timeout)
},
fail(err) {
reject(err)
}
})
})
}
扫描过程中,通过 onBluetoothDeviceFound 监听设备广播信息:
// 搜索到的蓝牙设备列表
const blueDeviceList = ref([])
onMounted(() => {
uni.onBluetoothDeviceFound(res => {
res.devices.forEach(device => {
device.name && blueDeviceList.value.push(device)
})
})
})
在实际工程中,BLE 设备通常通过以下字段进行识别:
namelocalName- 厂商自定义广播数据(manufacturerData)
建议在扫描阶段就完成设备过滤,而不是在连接阶段再做判断,以减少无效连接尝试。
扫描超时控制
BLE 扫描默认是一个持续过程,如果不主动停止,会一直占用系统资源,并可能影响后续连接操作。因此,在工程实践中,通常需要为扫描设置一个合理的超时时间。
常见做法是:在调用 startBluetoothDevicesDiscovery 后,通过定时器在指定时间后自动停止扫描。
let timeoutId = null
const timeout = 5000
const startBluetoothDevicesDiscovery = () => {
return new Promise((resolve, reject) => {
uni.startBluetoothDevicesDiscovery({
allowDuplicatesKey: false, // 是否允许重复上报同一设备, true 表示允许
interval: 0, // 搜索间隔 (1000ms ~ 120000ms)
success() {
timeoutId = setTimeout(() => {
resolve()
stopBluetoothDevicesDiscovery()
clearTimeout(timeoutId)
}, timeout)
},
fail(err) {
reject(err)
}
})
})
}
const stopBluetoothDevicesDiscovery = () => {
return new Promise((resolve, reject) => {
uni.stopBluetoothDevicesDiscovery({
success() {
resolve()
},
fail(err) {
reject(err)
}
})
})
}
扫描异常与设备发现不完整问题
在真实设备环境中(尤其是 iOS 与 Android),BLE 扫描可能出现以下异常行为:
- 已扫描到的设备不会重复上报
- 某些设备在重新扫描后无法再次被发现
- 明确存在广播的设备,但扫描结果为空
- 多次调用扫描 API 后仍无法恢复历史设备显示
该问题通常并非设备异常,而是由于系统层 BLE 扫描状态、缓存机制或扫描会话未完全释放导致。
✔ 解决方案:重置蓝牙适配器状态
在小程序中,可以通过重启蓝牙适配器来清理系统扫描上下文,从而恢复设备发现能力:
const closeBluetoothAdapter = () => {
return new Promise((resolve, reject) => {
uni.closeBluetoothAdapter({
success() {
resolve()
},
fail(err) {
reject(err)
}
})
})
}
const resetBluetoothAdapter = async () => {
// 清空搜索到的蓝牙设备列表
blueDeviceList.value = []
// 清除定时器
clearTimeout(timeoutId)
// 停止搜索附近蓝牙设备
await stopBluetoothDevicesDiscovery()
// 关闭蓝牙模块
await closeBluetoothAdapter()
// 等待系统释放资源(非常重要)
await new Promise(resolve => setTimeout(resolve, 300))
// 重新初始化蓝牙模块
await openBluetoothAdapter()
uni.showToast({
title: "蓝牙已重置",
icon: "success"
})
}
由于 closeBluetoothAdapter → openBluetoothAdapter 会带来系统层状态重建开销,因此不建议频繁调用。
建立蓝牙连接
建立连接
在确定目标打印机后,使用 deviceId 建立连接。
const connectBluetooth = async deviceId => {
uni.createBLEConnection({
deviceId,
success() {
console.log("设备连接成功")
// 连接成功后立即停止扫描,避免干扰后续操作
stopBluetoothDevicesDiscovery()
// 获取服务列表
getDeviceServices(deviceId)
},
fail(err) {
console.error("连接失败", err)
}
})
}
说明:
- 若设备已连接,再次连接通常会直接返回成功
- 建议连接成功后立即停止扫描,避免资源竞争
- 若"可发现但无法连接",优先检查是否仍在扫描状
获取 Services
连接成功后,需要获取设备暴露的 Service 列表。
function getDeviceServices(deviceId) {
uni.getBLEDeviceServices({
deviceId,
success(res) {
console.log("Services列表:", res.services)
// 通常打印服务使用自定义 UUID
const services = res.services.sort((a, b) => getServicePriority(a.uuid) - getServicePriority(b.uuid))
const printService = services.find(service => service.uuid.includes("FF00") || service.isPrimary)
if (printService) {
getServiceCharacteristics(deviceId, printService.uuid)
}
},
fail(err) {
console.error("获取服务失败", err)
}
})
}
在实际设备中(尤其是打印机),往往会同时暴露多个 Service,其中只有极少数才是真正用于数据传输(打印)的通道。因此,需要建立一套合理的过滤与优先级策略,用于筛选候选 Service。
Service 的筛选可以遵循以下经验规则:
-
排除通用标准服务
- 如
1800 / 1801 / 180A / 180F - 这些服务几乎不可能承载打印数据
- 如
-
优先选择 FFxx 类服务
- 如
FFF0 / FFE0 / FF00 - 通常为串口透传服务(Serial over BLE)
- 大多数打印机使用该通道接收 ESC/POS 指令
- 如
-
谨慎对待厂商自定义 UUID
- 如
49535343-xxxx - 可能是蓝牙模块内部服务,而非打印通道
- 如
-
其余 Service 作为候选补充
- 不能完全忽略,但优先级较低
const getServicePriority = uuid => {
const u = uuid.toLowerCase()// 提取短 UUID(如 0000fff0)
const shortUUID = u.startsWith("0000") ? u.slice(4, 8) : ""// 1. 标准服务(最低优先级)
const standardServices = ["1800", "1801", "180a", "180f"]
if (standardServices.includes(shortUUID)) {
return 100
}// 2. 打印透传服务(最高优先级)
if (shortUUID.startsWith("ff")) {
return 0
}// 3. 纯 128 位厂商 UUID(中优先级,需验证)
if (!u.includes("0000-1000-8000-00805f9b34fb")) {
return 50
}// 4. 其他标准扩展服务
return 60
}
在获取 Service 列表后,可结合排序使用:
const services = res.services.sort((a, b) => getServicePriority(a.uuid) - getServicePriority(b.uuid))
const printService = services.find(service => service.uuid.includes("FF00") || service.isPrimary)
获取 Characteristics
确定目标 Service 后,需要进一步获取其下的 Characteristic。示例代码如下:
function getServiceCharacteristics(deviceId, serviceId) {
console.log("deviceId", deviceId)
console.log("serviceId", serviceId)
uni.getBLEDeviceCharacteristics({
deviceId,
serviceId,
success(res) {
console.log("Characteristics列表:", res.characteristics)
let writeCharId = null
let notifyCharId = null
res.characteristics.forEach(c => {
if (c.properties.write || c.properties.writeWithoutResponse) {
writeCharId = c.uuid
}
if (c.properties.notify || c.properties.indicate) {
notifyCharId = c.uuid
}
})
console.log("writeCharId", writeCharId)
console.log("notifyCharId", notifyCharId)
// 保存特征值 ID,启用 Notify
enableNotify(deviceId, serviceId, notifyCharId)
},
fail(err) {
console.error("获取特征值失败", err)
}
})
}
说明:
每个 Characteristic 都会声明自己的能力属性:
properties.writeproperties.writeWithoutResponseproperties.notifyproperties.read
这些属性,直接决定了是否能用于打印数据写入。打印通常至少需要两个特征:
- 可写特征 :用于写入打印数据,但需要特别注意
write≠ 一定可用于打印:
❗ Characteristic 支持 write,仅代表"可以写入数据",并不代表打印机会执行这些数据。
- 通知特征(可选):用于接收打印状态回传,状态通知特征并非必需,但在批量或大数据打印时非常重要
启用特征值变化通知
对于支持 notify 或 indicate 的特征值,需调用notifyBLECharacteristicValueChange 启用通知功能。示例代码如下:
function enableNotify(deviceId, serviceId, characteristicId) {
uni.notifyBLECharacteristicValueChange({
deviceId,
serviceId,
characteristicId,
state: true, // 启用 notify
success() {
console.log("Notify 已启用")
// 监听特征值变化事件
uni.onBLECharacteristicValueChange(res => {
const hexStr = res.value // 返回 hex 字符串
console.log("Notify 收到数据", hexStr)
})
},
fail(err) {
console.error("启用 Notify 失败", err)
}
})
}
说明:
- 必须先启用 notify 才能监听到设备
characteristicValueChange事件。 - 设备的特征值必须支持
notify或indicate才可以成功调用,具体参照 characteristic 的properties属性。 - 订阅操作成功后,需要设备主动更新特征值的 value,才会触发
onBLECharacteristicValueChange。 - 订阅方式效率比较高,推荐使用订阅代替 read 方式。
- 注意调用顺序 :最好在连接之后就调用
notifyBLECharacteristicValueChange方法。
被动断开:监听与自动重连
蓝牙连接随时可能因为距离过远、设备断电、信号干扰等原因而意外断开。我们必须通过监听连接状态变化事件来应对这种情况。
// 监听连接状态变化
uni.onBLEConnectionStateChanged((res) => {
if (!res.connected) {
// 做相应的处理
}
});
说明:
- 若对未连接的设备调用数据读写操作接口,会返回 10006 错误,此时应执行重连。
- 避免重复监听 :每次调用
on方法监听事件之前,最好先调用off方法关闭之前的事件监听,防止多次注册导致事件被多次触发。
主动断开与清理:完整的退出机制
当用户主动关闭页面或完成打印后,我们需要手动断开连接并释放系统资源。一个标准的清理流程应该包含三步:
-
停止搜索设备(如果还在搜索中)
-
断开设备连接,
-
关闭蓝牙适配器。
const releaseBluetoothResources = () => {
// 1. 停止搜索设备(如果还在搜索中)
stopBluetoothDevicesDiscovery()// 2. 断开与当前已连接蓝牙设备的连接
if (activeDeviceId.value) {
uni.closeBLEConnection({
deviceId: activeDeviceId.value,
success: () => {
console.log("成功断开设备连接")
},
fail: err => {
console.error("断开设备连接失败", err)
}
})
}// 3. 最后,关闭蓝牙适配器,彻底释放系统资源
closeBluetoothAdapter()
.then(() => {
console.log("蓝牙适配器已关闭,资源已释放")
// 重置所有连接相关状态
activeDeviceId.value = ""
blueDeviceList.value = []
})
.catch(err => {
console.error("关闭蓝牙适配器失败", err)
})
}
说明:
- 分步操作 :虽然
closeBluetoothAdapter会断开所有连接并释放资源,但为了逻辑清晰和状态可控,建议还是显式地调用 closeBLEConnection 进行清理。 - 调用时机 :此方法建议在页面的
onUnload生命周期中调用。因为closeBluetoothAdapter是异步操作,不建议将其与openBluetoothAdapter一起用作异常处理,效率低且易引发线程同步问题。 - 页面卸载 :点击小程序右上角关闭按钮时,小程序可能仅进入后台而非立即销毁,因此需要在
onHide或onUnload中主动调用清理逻辑,确保连接被及时断开。
工程化建议
蓝牙打印的交互链路很长,涉及权限、扫描、连接、状态管理、异常恢复等诸多环节。若将所有逻辑耦合在一起,代码会迅速膨胀且难以维护。建议将蓝牙能力拆分为两个独立实体:
BluetoothAdapter(能力适配层)
负责与蓝牙 API 的直接交互,向上屏蔽平台差异与底层细节。核心职责:
- API 适配 :封装
openBluetoothAdapter、startBluetoothDevicesDiscovery({ 等基础调用,统一返回 Promise 接口 - 权限校验:收敛蓝牙与定位权限的检查逻辑
- 异常告警:统一捕获蓝牙错误码,映射为业务可理解的提示(如"系统蓝牙未开启")
- 埋点上报:记录扫描耗时、连接成功率、异常断开次数等关键指标
createBLEConnection(连接实例层)
每一次打印任务对应一个连接实例,内置完整的状态机与连接属性。核心职责:
- 连接属性 :持有
deviceId、serviceId、writeCharId、notifyCharId等关键标识 - 状态机 :管理
idle → scanning → connecting → ready → disconnecting等状态流转,杜绝非法操作 - 生命周期:统一处理连接建立、心跳维持、异常断连重试、资源释放
- 事件管理 :自动绑定与解绑蓝牙 等事件监听,防止泄漏
小结
BLE 打印连接的本质并不是一次性调用成功,而是一个持续运行的状态机系统,其核心能力在于:
- 连接状态管理
- 异常恢复机制
- 事件驱动模型
只有在稳定连接的基础上,才能保证打印数据的可靠传输与状态反馈。
在完成稳定的 BLE 连接建立之后,下一步需要解决的问题是:如何将业务数据转换为打印机可识别的二进制指令流,这将引出打印领域的核心协议模型------ESC/POS 指令体系。
<template>
<view>
<scroll-view scroll-y class="box">
<view class="item" v-for="item in blueDeviceList" :key="item.deviceId" @click="connectBluetooth(item.deviceId)">
<view>
<text>id: {{ item.deviceId }}</text>
</view>
<view>
<text>name: {{ item.name }}</text>
</view>
</view>
</scroll-view>
<button @click="resetBluetoothAdapter">重置蓝牙</button>
<button @click="openBluetoothAdapter">1 初始化蓝牙</button>
<button @click="startBluetoothDevicesDiscovery">2 搜索附近蓝牙设备</button>
<button @click="releaseBluetoothResources">销毁</button>
</view>
</template>
<script setup>
import { onMounted, ref } from "vue" // 补上了 ref 的引入
let timeoutId = null
const timeout = 5000
// 搜索到的蓝牙设备列表
const blueDeviceList = ref([])
// 新增:全局记录当前连接的设备 ID
const activeDeviceId = ref("")
onMounted(() => {
uni.onBluetoothAdapterStateChange(res => {
console.log("uni.onBluetoothAdapterStateChange", res)
const { available } = res
if (!available) {
uni.showModal({
title: "提示",
content: "系统蓝牙未开启"
})
}
})
uni.onBluetoothDeviceFound(res => {
res.devices.forEach(device => {
device.name && blueDeviceList.value.push(device)
})
})
})
const openBluetoothAdapter = () => {
return new Promise((resolve, reject) => {
uni.openBluetoothAdapter({
success() {
resolve()
},
fail(err) {
if (err.errCode === 10001) {
uni.showToast({
title: "蓝牙未开启",
icon: "none"
})
}
reject(err)
}
})
})
}
const startBluetoothDevicesDiscovery = () => {
return new Promise((resolve, reject) => {
uni.startBluetoothDevicesDiscovery({
allowDuplicatesKey: false, // 是否允许重复上报同一设备, true 表示允许
interval: 0, // 搜索间隔 (1000ms ~ 120000ms)
success() {
timeoutId = setTimeout(() => {
resolve()
stopBluetoothDevicesDiscovery()
clearTimeout(timeoutId)
}, timeout)
},
fail(err) {
reject(err)
}
})
})
}
const stopBluetoothDevicesDiscovery = () => {
return new Promise((resolve, reject) => {
uni.stopBluetoothDevicesDiscovery({
success() {
resolve()
},
fail(err) {
reject(err)
}
})
})
}
const closeBluetoothAdapter = () => {
return new Promise((resolve, reject) => {
uni.closeBluetoothAdapter({
success() {
resolve()
},
fail(err) {
reject(err)
}
})
})
}
const resetBluetoothAdapter = async () => {
// 清空搜索到的蓝牙设备列表
blueDeviceList.value = []
// 清空当前连接的 ID
activeDeviceId.value = ""
// 清除定时器
clearTimeout(timeoutId)
// 停止搜索附近蓝牙设备
await stopBluetoothDevicesDiscovery()
// 关闭蓝牙模块
await closeBluetoothAdapter()
// 等待系统释放资源(非常重要)
await new Promise(resolve => setTimeout(resolve, 300))
// 重新初始化蓝牙模块
await openBluetoothAdapter()
uni.showToast({
title: "蓝牙已重置",
icon: "success"
})
}
const connectBluetooth = async deviceId => {
uni.createBLEConnection({
deviceId,
success() {
console.log("设备连接成功")
// 核心改动:连接成功后保存当前设备 ID
activeDeviceId.value = deviceId
// 连接成功后立即停止扫描,避免干扰后续操作
stopBluetoothDevicesDiscovery()
// 获取服务列表
getDeviceServices(deviceId)
},
fail(err) {
console.error("连接失败", err)
}
})
}
function getDeviceServices(deviceId) {
uni.getBLEDeviceServices({
deviceId,
success(res) {
console.log("Services列表:", res.services)
// 通常打印服务使用自定义 UUID
const services = res.services.sort((a, b) => getServicePriority(a.uuid) - getServicePriority(b.uuid))
const printService = services.find(service => service.uuid.includes("FF00") || service.isPrimary)
if (printService) {
getServiceCharacteristics(deviceId, printService.uuid)
}
},
fail(err) {
console.error("获取服务失败", err)
}
})
}
function getServiceCharacteristics(deviceId, serviceId) {
console.log("deviceId", deviceId)
console.log("serviceId", serviceId)
uni.getBLEDeviceCharacteristics({
deviceId,
serviceId,
success(res) {
console.log("Characteristics列表:", res.characteristics)
let writeCharId = null
let notifyCharId = null
res.characteristics.forEach(c => {
if (c.properties.write || c.properties.writeWithoutResponse) {
writeCharId = c.uuid
}
if (c.properties.notify || c.properties.indicate) {
notifyCharId = c.uuid
}
})
console.log("writeCharId", writeCharId)
console.log("notifyCharId", notifyCharId)
// 保存特征值 ID,启用 Notify
enableNotify(deviceId, serviceId, notifyCharId)
},
fail(err) {
console.error("获取特征值失败", err)
}
})
}
function enableNotify(deviceId, serviceId, characteristicId) {
uni.notifyBLECharacteristicValueChange({
deviceId,
serviceId,
characteristicId,
state: true, // 启用 notify
success() {
console.log("Notify 已启用")
// 监听特征值变化事件
uni.onBLECharacteristicValueChange(res => {
const hexStr = res.value // 返回 hex 字符串
console.log("Notify 收到数据", hexStr)
})
},
fail(err) {
console.error("启用 Notify 失败", err)
}
})
}
const getServicePriority = uuid => {
const u = uuid.toLowerCase()
// 提取短 UUID(如 0000fff0)
const shortUUID = u.startsWith("0000") ? u.slice(4, 8) : ""
// 1. 标准服务(最低优先级)
const standardServices = ["1800", "1801", "180a", "180f"]
if (standardServices.includes(shortUUID)) {
return 100
}
// 2. 打印透传服务(最高优先级)
if (shortUUID.startsWith("ff")) {
return 0
}
// 3. 纯 128 位厂商 UUID(中优先级,需验证)
if (!u.includes("0000-1000-8000-00805f9b34fb")) {
return 50
}
// 4. 其他标准扩展服务
return 60
}
// 核心改动:修改后的资源清理方法
const releaseBluetoothResources = () => {
// 1. 停止搜索设备(如果还在搜索中)
stopBluetoothDevicesDiscovery()
// 2. 断开与当前已连接蓝牙设备的连接
if (activeDeviceId.value) {
uni.closeBLEConnection({
deviceId: activeDeviceId.value,
success: () => {
console.log("成功断开设备连接")
},
fail: err => {
console.error("断开设备连接失败", err)
}
})
}
// 3. 最后,关闭蓝牙适配器,彻底释放系统资源
closeBluetoothAdapter()
.then(() => {
console.log("蓝牙适配器已关闭,资源已释放")
// 重置所有连接相关状态
activeDeviceId.value = ""
blueDeviceList.value = []
})
.catch(err => {
console.error("关闭蓝牙适配器失败", err)
})
}
</script>
<style>
.box {
width: 98%;
height: 400rpx;
box-sizing: border-box;
margin: 0 auto 20rpx;
border: 2px solid dodgerblue;
}
.item {
box-sizing: border-box;
padding: 10rpx;
border-bottom: 1px solid #ccc;
}
button {
margin-bottom: 20rpx;
}
.msg_x {
border: 2px solid seagreen;
width: 98%;
margin: 10rpx auto;
box-sizing: border-box;
padding: 20rpx;
}
.msg_x .msg_txt {
margin-bottom: 20rpx;
}
</style>