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

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

第一,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
相关推荐
ao-weilai12 分钟前
MySQL数据库:复合查询
android·数据库·mysql
三少爷的鞋13 小时前
AI 时代,我们都将成为通才型开发者:只懂 Android,已经不够了
android
Dovis(誓平步青云)17 小时前
家里设备越来越多,如何用一张空间地图控制灯光和温度![
android·java·前端·javascript·人工智能·电脑
晚风叙码19 小时前
MySQL 数据类型详解:从数值到字符串,一篇讲透
android·mysql·adb
传奇开心果编程19 小时前
【Compose Multiplatform 跨端开发学与练】第3课 布局与组件
android·windows·学习·ui·ios·kotlin·composer
传奇开心果编程1 天前
【Compose Multiplatform 跨端开发学与练】第8课 资源管理与主题
android·windows·学习·ios·kotlin·web·composer
传奇开心果编程1 天前
【Compose Multiplatform 跨端开发学与练】第9课 测试与调试
android·学习·macos·ios·kotlin·web·composer
传奇开心果编程1 天前
【Compose Multiplatform 跨端开发学与练】第4课 导航与路由
android·windows·学习·ui·ios·kotlin·composer
传奇开心果编程1 天前
【Compose Multiplatform 跨端开发学与练】第6课 状态管理与架构
android·学习·ui·ios·架构·kotlin·composer
事圆则缓1 天前
Android AOSP 定制常见概念:源码目录、系统镜像与刷机流程
android