Android BLE 扫描连接与收发消息实战

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" />

这里最容易搞混的是两件事。

第一,scanconnect 是分开的。能扫到设备,不代表你就能连。

第二,新系统的 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
相关推荐
古法安卓11 小时前
Android-LowmemoryKiller机制
android·后端·android studio
kerli11 小时前
Compose 组件:BoxWithConstraints作用及其原理
android·前端
努力学习的小廉11 小时前
Python 零基础入门——基础语法(二)
android·开发语言·python
北漂Zachary11 小时前
Laravel 7.x 新特性全解析
android
我命由我1234511 小时前
Android Jetpack Compose - 组件分类:布局组件、交互组件、文本组件
android·java·java-ee·kotlin·android studio·android jetpack·android-studio
BLUcoding11 小时前
Android 底部导航栏(TabHost + TabWidget)实现方案
android
AirDroid_cn11 小时前
荣耀MagicOS10系统:如何设置热点限速,防止其他设备过度消耗流量?
android·智能手机·电脑·荣耀手机
Dream of maid12 小时前
Mysql(2)DML
android·数据库·mysql
前端初见12 小时前
Android零基础入门
android