Skip to content

01. 蓝牙与 BLE 硬件交互 ​

一句话定位:Android 工程师接触 BLE 的核心难点不是“能不能扫到设备”,而是把扫描、连接、服务发现、MTU、通知订阅、串行操作队列、断线重连收成一条稳定状态机;Flutter 侧通常只是 UI 和业务外壳,真正决定稳定性的仍是 Android / 原生插件这一层。

代码索引 ​

主题Lab 说明源码
BLE 最小稳定链路ble-gatt-playgroundBleGattPlaygroundActivity.kt

为什么需要 ​

  • 为什么要区分 BLE 和经典蓝牙?

    • 一句话答:BLE(低功耗蓝牙)专为周期性小数据设计,连接建立更快、功耗更低;经典蓝牙适合音频流传输,二者协议栈不互通,需根据场景选型。
  • 为什么 Android 上 BLE 往往“能扫到,但一连就不稳”?

    • 一句话答:GATT 所有操作回调均在 Binder 线程,且每次只能串行执行一个操作;没有操作队列、MTU 时序没收住、BluetoothGatt 未 close() 时,都会表现成 133、收不到通知或断线重连失控。
  • 为什么断线重连需要专门治理?

    • 一句话答:BLE 连接随时可能因系统回收 BluetoothGatt 或设备主动断开而丢失,需要应用层维护状态机并结合指数退避策略主动重连。
  • 为什么 Flutter 项目做 BLE 时,稳定性问题大多最后都要下沉到原生层处理?

    • 一句话答:Flutter 插件只是桥,底层权限、扫描回调、GATT 状态机、系统蓝牙栈限制都发生在 Android / iOS 宿主;跨端层能统一 API,不能抹掉平台差异。

核心概念速览 ​

概念说明
GAP (Generic Access Profile)定义广播 / 扫描 / 连接建立;BluetoothLeScanner 在此层
GATT (Generic Attribute Profile)定义数据读写模型:Server ↔ Client;BluetoothGatt 在此层
ServiceGATT 中的功能单元,如「心率服务」,用 128-bit UUID 标识
CharacteristicService 内的数据项,可读 / 写 / 订阅;是实际传输数据的载体
DescriptorCharacteristic 的附属配置,最重要的是 CCCD(用于启用 Notification)
MTU单次传输最大字节数,默认 23 字节,可协商到 512 字节

如果把 BLE 理解成“蓝牙连上就能发数据”,几乎一定会在第一版代码里踩坑。真实链路至少是:

text
申请权限
  -> 扫描设备
  -> 连接 connectGatt
  -> onConnectionStateChange(connected)
  -> discoverServices
  -> 找到 service/characteristic
  -> requestMtu(可选)
  -> enable notification(写 CCCD)
  -> 串行 read/write/notify
  -> disconnect + close

一、设备扫描与过滤 ​

对应 Lab:ble-gatt-playground · BleGattPlaygroundActivity.kt

权限申请(Android 12+) ​

Android 12 将蓝牙权限从 ACCESS_FINE_LOCATION 拆分:

xml
<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- 若扫描结果需要位置信息才需要 -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

注意:Android 12 以下仍需 BLUETOOTH + BLUETOOTH_ADMIN + ACCESS_FINE_LOCATION,按 minSdk 做兼容分支。

工程判断:

  • Android 12+ 不能再只靠 ACCESS_FINE_LOCATION 混过去。
  • 前台高频扫描才适合 SCAN_MODE_LOW_LATENCY;后台 / 长时扫描要保守。
  • 扫描必须有收口:超时停止、页面销毁停止、切后台按策略降频。

启动扫描与过滤 ​

kotlin
val scanner: BluetoothLeScanner = bluetoothAdapter.bluetoothLeScanner

val filters = listOf(
    ScanFilter.Builder()
        .setServiceUuid(ParcelUuid.fromString("0000180D-0000-1000-8000-00805F9B34FB")) // 心率服务 UUID
        .build()
)
val settings = ScanSettings.Builder()
    .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) // 前台用 LOW_LATENCY;后台用 LOW_POWER
    .build()

scanner.startScan(filters, settings, scanCallback)
// 必须在 Activity/Fragment 销毁或超时时停止
scanner.stopScan(scanCallback)
  • 可能输出:onScanResult 回调携带 ScanResult(含 RSSI、广播数据包)
  • 观察重点:过滤不生效时检查 UUID 是否与设备广播的服务 UUID 完全匹配;后台扫描需要前台服务权限(Android 8+)

二、连接生命周期与 GATT 状态机 ​

完整状态转移 ​

text
扫描到设备
    ↓
connectGatt()           → onConnectionStateChange(STATE_CONNECTING)
    ↓
STATE_CONNECTED         → discoverServices()
    ↓
onServicesDiscovered()  → 找到目标 Service & Characteristic
    ↓
readCharacteristic()    → onCharacteristicRead()
writeCharacteristic()   → onCharacteristicWrite()
setCharacteristicNotification + 写 CCCD → onCharacteristicChanged()
    ↓
gatt.disconnect()       → onConnectionStateChange(STATE_DISCONNECTED)
    ↓
gatt.close()            ← 必须调用,否则泄漏 GATT 连接槽

关键代码骨架 ​

kotlin
class BleManager(private val context: Context) {
    private var bluetoothGatt: BluetoothGatt? = null

    fun connect(device: BluetoothDevice) {
        // autoConnect=false:立即连接;autoConnect=true:等设备广播时才连
        bluetoothGatt = device.connectGatt(context, false, gattCallback)
    }

    private val gattCallback = object : BluetoothGattCallback() {

        override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
            when (newState) {
                BluetoothProfile.STATE_CONNECTED -> {
                    // ⚠️ 延迟 600ms 再 discoverServices,避免部分设备服务发现失败
                    handler.postDelayed({ gatt.discoverServices() }, 600)
                }
                BluetoothProfile.STATE_DISCONNECTED -> {
                    gatt.close() // 必须 close,释放 GATT 资源
                    scheduleReconnect()
                }
            }
            if (status != BluetoothGatt.GATT_SUCCESS) {
                // status=133(0x85)= 连接超时或被系统强制断开
                handleError(status)
            }
        }

        override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
            if (status != BluetoothGatt.GATT_SUCCESS) return
            val char = gatt.getService(SERVICE_UUID)?.getCharacteristic(CHAR_UUID)
            char?.let { enableNotification(gatt, it) }
        }

        override fun onCharacteristicChanged(
            gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic
        ) {
            val data = characteristic.value
        }
    }

    private fun enableNotification(gatt: BluetoothGatt, char: BluetoothGattCharacteristic) {
        gatt.setCharacteristicNotification(char, true)
        val descriptor = char.getDescriptor(CCCD_UUID)
        descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
        gatt.writeDescriptor(descriptor) // 异步操作,等 onDescriptorWrite 回调
    }
}
  • 预期现象:onCharacteristicChanged 以设备设定的频率(通常 100ms~1s)持续回调
  • 观察重点:GATT 操作返回 true 只表示「成功加入底层队列」,真正结果看回调;不等回调直接发下一个操作会返回 false

三、操作队列(解决并发问题) ​

GATT 底层是串行的,多个操作并发调用只有第一个能成功。标准解法是维护 操作队列:

kotlin
class GattOperationQueue {
    private val queue: ArrayDeque<() -> Unit> = ArrayDeque()
    private var isExecuting = false

    fun enqueue(operation: () -> Unit) {
        queue.addLast(operation)
        if (!isExecuting) executeNext()
    }

    fun onOperationComplete() {
        isExecuting = false
        executeNext()
    }

    private fun executeNext() {
        if (queue.isEmpty()) return
        isExecuting = true
        queue.removeFirst().invoke()
    }
}
  • 可能执行顺序
    1. 先把 requestMtu、enableNotification、writeCharacteristic 入队。
    2. 只有前一个回调完成,才发下一个。
    3. 失败、超时、断线都要显式把队列推进或清空。
  • 可能输出
    text
    enqueue -> requestMtu
    callback -> mtuChanged
    enqueue -> enableNotification
    callback -> descriptorWrite
    enqueue -> writeCharacteristic
    callback -> characteristicWrite
  • 使用方式:在每个 GATT 回调(onCharacteristicRead、onDescriptorWrite 等)末尾调用 queue.onOperationComplete()

Notification 不是只调一个 setCharacteristicNotification ​

很多人第一次做 BLE 收不到推送,就是因为少走了一个关键步骤:除了本地打开 notification,还要写远端的 CCCD descriptor。

kotlin
gatt.setCharacteristicNotification(characteristic, true)
val cccd = characteristic.getDescriptor(CCCD_UUID)
cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
gatt.writeDescriptor(cccd)

本质上是两件事:

  1. 告诉本地蓝牙栈“我要接这个 characteristic 的变化”。
  2. 告诉外设“以后你要主动给我推送”。

四、断线重连策略 ​

kotlin
private var reconnectAttempts = 0
private val maxReconnectAttempts = 5

private fun scheduleReconnect() {
    if (reconnectAttempts >= maxReconnectAttempts) {
        notifyConnectionFailed(); return
    }
    // 指数退避:1s, 2s, 4s, 8s, 16s,最大 30s
    val delayMs = (Math.pow(2.0, reconnectAttempts.toDouble()) * 1000L).toLong().coerceAtMost(30_000L)
    reconnectAttempts++
    handler.postDelayed({ connect(targetDevice) }, delayMs)
}

扫描 / 重连不要做成无上限后台常驻;增加指数退避、前后台分策略、业务超时后明确失败。

五、MTU、分包与广播数据解析 ​

MTU 与分包 ​

BLE 默认 ATT MTU 常见是 23 字节,其中真正业务可用负载常只有 20 字节左右。传固件升级块、设备配置 JSON、较长业务指令时,通常要:

  • 请求更大 MTU:如 185 / 247 / 512,但不保证对端支持;连接后调用 gatt.requestMtu(512) 并等 onMtuChanged。
  • 应用层分包:协商大 MTU 不等于“每次都能稳定发大包”。
  • 失败重传与状态恢复:断线后不能从头盲发,要知道断在第几包。

广播数据包解析 ​

BLE 广播包由多个 AD Structure 组成,每个 Structure 格式:[length][type][data]

kotlin
fun parseManufacturerData(scanRecord: ScanRecord): ByteArray? {
    // 获取厂商自定义数据(type = 0xFF),前两字节为 company ID(Little-Endian)
    return scanRecord.manufacturerSpecificData?.get(0x004C) // Apple company ID = 0x004C
}

Android / Flutter / Web / Backend 对照 ​

维度AndroidFlutteriOSBackend
主战场蓝牙权限、扫描、GATT 状态机UI / 业务状态展示,通常经插件调用宿主CoreBluetooth 生命周期与状态回调通常不直接参与 BLE,更多做设备协议映射与云端同步
最常见事故133、不回调、GATT 泄漏、通知收不到误以为插件返回成功就等于链路稳定蓝牙状态机回调更严格,后台策略不同把 BLE 当 WebSocket 一样设计导致协议不适配
迁移判断优先把原生层做稳Flutter 层只抽统一状态,不做底层补丁魔法API 不同,但“串行 GATT + 状态机”逻辑相通服务端更多处理设备上报后的业务状态收敛

iOS 这里只做必要对照:CoreBluetooth 的 API 形式不同,但“扫描 → 连接 → 服务发现 → 串行操作 → 状态回收”的工程心智是共通的。

常见场景 ​

1. 穿戴设备实时通知 ​

  • 手环、血糖仪、体脂秤通常通过 notification 主动推送短数据。
  • 重点:连接后何时发现服务、何时订阅通知、断线后是否自动重连、页面退出后是否还保留连接。

2. 设备控制与串行命令队列 ​

门锁、灯控、传感器设置类设备,常见模式是:写入命令 → 等设备 ACK → 再写下一条。这本质是 BLE 版串行状态机,和 HTTP 并发请求不是一个思路。

3. Flutter BLE 项目的平台分层 ​

  • Flutter 页面:展示扫描结果、连接态、日志和按钮。
  • 插件 / MethodChannel:统一 Dart ↔ 原生消息。
  • Android 原生管理器:真正处理扫描、连接、队列、重连、超时。

常见误配、事故后果与排障 ​

错误 / 事故原因修法
status=133 (0x85)连接超时、设备不可达、GATT 资源耗尽gatt.close() 后延迟重连;检查是否泄漏旧 GATT
discoverServices 失败连接刚建立即调用,GATT 协议握手未完成延迟 500~800ms 再调用
Notification 收不到忘记写 CCCD Descriptor,或写完未等回调写 CCCD 后收到成功回调再读数据
扫描结果为空权限未授权 / 过滤 UUID 不匹配用 nRF Connect App 验证设备广播的 UUID
大包传输截断未协商 MTU,默认只有 20 字节有效数据连接后调用 gatt.requestMtu(512) 并等 onMtuChanged
多个 GATT 操作并发发出把 BLE 当 HTTP,按钮点一下就直接发读写加操作队列;每个回调结束推进下一条
断开后只 disconnect() 不 close()以为断线就等于资源释放确定链路结束后 close(),清空本地引用、队列和订阅状态
无上限后台扫描 / 重连为了“提高连接率”持续高频扫描指数退避、前后台分策略、业务超时后明确失败

与相近概念对比 ​

概念它是什么易混点
BLE低功耗短包协议族不是经典蓝牙音频链路
扫描(GAP)找设备、看广播不等于已经能读写业务数据
GATT服务 / 特征读写模型不等于“随便并发发命令”
Notification设备主动推送特征变化不等于 App 轮询读取
MTU 协商调整单次可传大小不是“协商成功就不用分包”

对应实验 ​

Lab说明源码
ble-gatt-playgroundAndroid BLE 最小稳定样板:权限 → 扫描 → 连接 → 发现服务 → MTU / 通知 / 串行队列 / 重连日志BleGattPlaygroundActivity.kt

复习检查题 ​

  1. BLE 和经典蓝牙的核心区别是什么?各适合什么场景?

    答:BLE 面向周期性小数据(传感器、健康设备),功耗极低,连接建立快,单帧数据量小(默认 MTU 23 字节);经典蓝牙(BR/EDR)面向连续高带宽数据(音频),二者协议栈独立。一个设备可以同时支持双模(Dual Mode)。

  2. 为什么 BLE 读写操作通常必须进队列串行执行?

    答:因为 Android BLE 栈通常同一时刻只允许一个 GATT 操作在途;并发发多个操作会造成调用返回 false、回调错乱或 133 等失败,所以必须等前一条回调结束再发下一条。

  3. 打开 notification 为什么不能只调 setCharacteristicNotification?

    答:因为那只是在本地蓝牙栈打开监听;真正让设备开始推送,还必须写远端 CCCD descriptor,否则设备端可能完全不会发送通知。

  4. 为什么建议在 STATE_CONNECTED 后延迟 500ms 再调用 discoverServices?

    答:BLE 连接建立后,设备端 GATT Server 还需短暂时间初始化连接参数;立即调用 discoverServices 会在部分设备(尤其国产嵌入式)上返回失败;延迟 500~800ms 是业界经验值。

  5. gatt.close() 为什么必须在断开后调用?

    答:Android 系统限制同一应用最多建立约 7 个 GATT 连接;不调用 close() 会导致 GATT 连接槽泄漏,后续再连接时会立即返回 status=133。

  6. 为什么 Flutter BLE 项目的稳定性问题往往最终要回到原生层处理?

    答:因为底层蓝牙权限、系统扫描策略、GATT 生命周期和平台限制都发生在 Android / iOS 宿主;Flutter 层可以统一状态展示和业务接口,但无法绕过平台栈本身的约束。

速记 ​

  • GATT 操作一律串行,用队列包裹,等回调再发下一个。
  • close() 必须调用,否则 GATT 槽泄漏导致再也连不上。
  • 权限判断分 Android 版本:< 12 需要 Location 权限;≥ 12 只需 BLUETOOTH_SCAN + BLUETOOTH_CONNECT。
  • 订阅通知三步走:setCharacteristicNotification → 写 CCCD → 等 onDescriptorWrite。
  • Flutter 负责界面,原生层负责把 BLE 真正做稳。

站点构建时间:2026/8/24 23:43:17