BLE 这个东西,看文档的时候总觉得不复杂。扫描设备,连上,发现服务,读写特征值,完了。 但真写 Android BLE,大家最常见的感受一般不是"终于连上了",而是"怎么又没扫到""怎么又 133 了""怎么回调顺序和我想的不一样"。 BLE 真正难的地方,从来都不是 API 名字多,而是它有很多前提条件。权限、系统版本、扫描节流、连接时机、GATT 串行、线程、设备兼容性,这些东西只要漏一个,代码看着没错,结果照样跑不通。 这篇不讲基础概念,就讲 Android BLE 里最容易踩坑的几个地方。
1. BLE 不是"申请个蓝牙权限"就完了
很多人一开始会在清单里写:
xml
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
然后发现新系统上还是不行。
从 Android 12,也就是 API 31 开始,BLE 相关权限已经换成了更细的这一组:
xml
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
如果你还要兼容老版本,通常会一起写:
xml
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
这里最容易搞混的是两件事。
第一,scan 和 connect 是分开的。能扫到设备,不代表你就能连。
第二,新系统的 BLE 代码很多时候不是"蓝牙坏了",而是你漏申请了 BLUETOOTH_CONNECT。
权限请求代码一般也得分开写:
kotlin
private val blePermissions = arrayOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT
)
如果你写的是老代码,很多 bug 其实不是 BLE 本身的问题,是权限模型已经变了。
2. 扫描不到设备,未必是代码有问题
Android BLE 扫描是最容易让人怀疑人生的一步。代码明明执行了,回调也开着,但设备就是不出现。
一个最小扫描大概长这样:
kotlin
val bluetoothLeScanner = bluetoothAdapter.bluetoothLeScanner
val callback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
Log.d("BLE", "device=${result.device.address} rssi=${result.rssi}")
}
override fun onScanFailed(errorCode: Int) {
Log.e("BLE", "scan failed: $errorCode")
}
}
bluetoothLeScanner.startScan(callback)
这段代码本身没什么问题,但 BLE 扫描成功与否,往往还取决于这些前提:
- 设备蓝牙真的开了
- 权限真的申请到了
- 目标设备真的在广播
- 广播内容不是你过滤掉了
- 手机系统没有把你的扫描节流掉
- 你不是在后台瞎扫
如果你想先把问题收窄,不要一上来就加各种 filter。先裸扫,看设备能不能出来。
kotlin
bluetoothLeScanner.startScan(
null,
ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build(),
callback
)
先证明"能扫到",再去做服务 UUID 过滤,不然你会很难判断到底是权限问题、过滤条件问题,还是目标设备根本没发对广播。
3. 扫描和连接不要写成一坨
很多 BLE demo 喜欢在 onScanResult() 里直接连:
kotlin
override fun onScanResult(callbackType: Int, result: ScanResult) {
result.device.connectGatt(context, false, gattCallback)
}
这类代码 demo 能跑,实际项目很容易出事。因为扫描回调会来很多次,同一个设备可能会重复上报。你如果没有去重,没有停扫描,没有状态保护,很容易一口气发出去多个连接请求。
更稳一点的写法是先停扫,再连,而且做连接状态保护:
kotlin
private var isConnecting = false
override fun onScanResult(callbackType: Int, result: ScanResult) {
if (isConnecting) return
if (result.device.address == targetAddress) {
isConnecting = true
bluetoothLeScanner.stopScan(callback)
result.device.connectGatt(context, false, gattCallback)
}
}
BLE 代码最怕"看着流程没问题,其实状态已经乱了"。扫描、连接、发现服务、读写数据,最好每一步都拆开。
4. GATT 操作不是并发随便发
这个坑非常常见。很多人连上以后,马上:
- 读特征值
- 写特征值
- 开通知
- 再读一次描述符
全都一起发。
然后就会发现,有的成功,有的没回调,有的直接失败。
Android 的 GATT 操作不能当成普通方法调用来理解。大多数读写、描述符设置、MTU 请求,本质上都应该按队列串行执行,前一个操作完成,再发下一个。
比如写特征值,不要直接一通乱写:
kotlin
bluetoothGatt.writeCharacteristic(characteristic)
bluetoothGatt.readCharacteristic(otherCharacteristic)
更合理的思路是做一个简单队列:
kotlin
private val operationQueue: ArrayDeque<() -> Unit> = ArrayDeque()
private var operationInProgress = false
private fun enqueueOperation(op: () -> Unit) {
operationQueue.add(op)
if (!operationInProgress) {
doNextOperation()
}
}
private fun doNextOperation() {
val op = operationQueue.removeFirstOrNull() ?: run {
operationInProgress = false
return
}
operationInProgress = true
op()
}
private fun finishOperation() {
operationInProgress = false
doNextOperation()
}
然后在 GATT 回调里推进:
kotlin
override fun onCharacteristicWrite(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
status: Int
) {
finishOperation()
}
很多 BLE 稳定性问题,最后都不是设备差,而是你把 GATT 当成同步 API 了。
5. writeCharacteristic() 在新 API 上写法变了
这也是官方文档里明确提到的点。API 33+ 更推荐用带 value 参数的新写法:
kotlin
val status = bluetoothGatt.writeCharacteristic(
characteristic,
value,
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
)
相比老写法,这个版本更直接,也更安全一点。你不用先去改 characteristic.value,再调用写入。
如果你项目版本跨度比较大,最好把新老写法封成一层,不要散落在业务代码里。
6. 133 不是一个 bug,它是一类坏状态
Android BLE 开发里最著名的错误之一就是 133。很多人第一次看到会觉得自己代码哪行写错了,但它往往不是这种"精确定位型错误"。
133 更像是一个笼统的 GATT 失败状态,背后可能是:
- 连接时机不对
- 上一次连接没清干净
- 设备广播和连接状态已经漂了
- 系统蓝牙栈状态不好
- 外设自己有问题
所以面对 133,别想着一句代码永治,先把基本动作做对:
- 失败后及时
close() - 不要重复持有旧的
BluetoothGatt - 重连前稍微等一下
- 不要一边扫一边疯狂连
- 操作串行化
断开和释放别偷懒:
kotlin
fun releaseGatt() {
bluetoothGatt?.disconnect()
bluetoothGatt?.close()
bluetoothGatt = null
}
这行代码不酷,但很多 BLE 项目后期稳定性就靠它续命。
7. 通知不是 setCharacteristicNotification() 就结束了
很多人会这样写:
kotlin
bluetoothGatt.setCharacteristicNotification(characteristic, true)
然后发现怎么一直收不到通知。
原因是这通常只开了本地监听,真正要让外设开始推数据,很多场景下你还得写对应的 descriptor,最常见的就是 CCCD。
kotlin
val cccd = characteristic.getDescriptor(
UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
)
cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
bluetoothGatt.writeDescriptor(cccd)
也就是说,通知这件事经常是两步:
- 本地打开 notification
- 写 descriptor 通知外设开始推送
如果只做一步,很多设备是不会给你推的。
8. BLE 广播也不是 startAdvertising() 就行
如果你做的是外设模拟、广播器、近场发现这类场景,就会碰到 BLE advertising。
最基础的代码大概这样:
kotlin
val settings = AdvertiseSettings.Builder()
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
.setConnectable(true)
.build()
val data = AdvertiseData.Builder()
.setIncludeDeviceName(true)
.build()
bluetoothLeAdvertiser.startAdvertising(settings, data, advertiseCallback)
如果是 API 31+,官方还给了更细粒度的 AdvertisingSetParameters。这套更适合需要控制更多广播行为的场景:
kotlin
val parameters = AdvertisingSetParameters.Builder()
.setConnectable(true)
.setDiscoverable(true)
.setScannable(true)
.setPrimaryPhy(BluetoothDevice.PHY_LE_1M)
.setSecondaryPhy(BluetoothDevice.PHY_LE_2M)
.build()
但广播这块也别想得太理想。不是所有设备都支持所有能力,功耗、机型、系统版本、厂商裁剪都会影响结果。BLE 开发里"官方支持"和"你手里这台机子真能稳定跑"不是一回事。
9. BLE 真正要做的是状态机,不是回调拼图
很多 BLE 代码后期越来越难维护,不是因为代码量大,而是因为整个流程没有状态机。
BLE 最少都应该有一套明确状态:
kotlin
enum class BleState {
Idle,
Scanning,
Connecting,
Connected,
DiscoveringServices,
Ready,
Disconnecting,
Disconnected,
Error
}
然后每个回调只负责推进状态,而不是顺手把所有事情都干了。
如果你的 BluetoothGattCallback 里开始出现:
- 连上就直接读
- 读完又直接写
- 写完顺手开通知
- 失败又立刻重连
那后面基本一定会乱。
BLE 项目越往后,越像在维护一套通信状态机,而不是写几个蓝牙 API。
10. 最后一个经验:先做"稳定",再做"快"
很多 BLE 项目一开始就想追求:
- 秒连
- 高频扫描
- 自动重连
- 后台持续保活
- 多设备并发
这些不是不能做,但 BLE 最怕一步跨太大。
更靠谱的路线通常是:
先单设备稳定连接。
再把服务发现和读写队列理顺。
再把通知链路打通。
最后再考虑自动重连、后台、并发连接这些高级能力。
不然你最后调的不是 BLE,而是在和一堆不稳定状态互相拉扯。
结尾
Android BLE 难,不是难在不会调 API,而是它对"顺序""状态""前提条件"特别敏感。
权限漏一点,扫不到。
状态乱一点,连不上。
GATT 并发一点,回调就飘。
清理不彻底一点,下次就给你一个 133。
所以 BLE 真正该写的,从来不是"如何 5 分钟上手蓝牙",而是"怎么把 BLE 代码写得像一个靠谱的通信模块"。
参考资料
- Android Developers: Bluetooth LE package summary
- Android Developers:
BluetoothGatt - Android Developers:
AdvertiseSettings - Android Developers:
AdvertisingSetParameters - Android Developers: App permissions overview