Appearance
01. 蓝牙与 BLE 硬件交互
一句话定位:Android 工程师接触 BLE 的核心难点不是“能不能扫到设备”,而是把扫描、连接、服务发现、MTU、通知订阅、串行操作队列、断线重连收成一条稳定状态机;Flutter 侧通常只是 UI 和业务外壳,真正决定稳定性的仍是 Android / 原生插件这一层。
代码索引
| 主题 | Lab 说明 | 源码 |
|---|---|---|
| BLE 最小稳定链路 | ble-gatt-playground | BleGattPlaygroundActivity.kt |
为什么需要
为什么要区分 BLE 和经典蓝牙?
- 一句话答:BLE(低功耗蓝牙)专为周期性小数据设计,连接建立更快、功耗更低;经典蓝牙适合音频流传输,二者协议栈不互通,需根据场景选型。
为什么 Android 上 BLE 往往“能扫到,但一连就不稳”?
- 一句话答:GATT 所有操作回调均在 Binder 线程,且每次只能串行执行一个操作;没有操作队列、MTU 时序没收住、
BluetoothGatt未close()时,都会表现成133、收不到通知或断线重连失控。
- 一句话答:GATT 所有操作回调均在 Binder 线程,且每次只能串行执行一个操作;没有操作队列、MTU 时序没收住、
为什么断线重连需要专门治理?
- 一句话答:BLE 连接随时可能因系统回收
BluetoothGatt或设备主动断开而丢失,需要应用层维护状态机并结合指数退避策略主动重连。
- 一句话答:BLE 连接随时可能因系统回收
为什么 Flutter 项目做 BLE 时,稳定性问题大多最后都要下沉到原生层处理?
- 一句话答:Flutter 插件只是桥,底层权限、扫描回调、GATT 状态机、系统蓝牙栈限制都发生在 Android / iOS 宿主;跨端层能统一 API,不能抹掉平台差异。
核心概念速览
| 概念 | 说明 |
|---|---|
| GAP (Generic Access Profile) | 定义广播 / 扫描 / 连接建立;BluetoothLeScanner 在此层 |
| GATT (Generic Attribute Profile) | 定义数据读写模型:Server ↔ Client;BluetoothGatt 在此层 |
| Service | GATT 中的功能单元,如「心率服务」,用 128-bit UUID 标识 |
| Characteristic | Service 内的数据项,可读 / 写 / 订阅;是实际传输数据的载体 |
| Descriptor | Characteristic 的附属配置,最重要的是 CCCD(用于启用 Notification) |
| MTU | 单次传输最大字节数,默认 23 字节,可协商到 512 字节 |
如果把 BLE 理解成“蓝牙连上就能发数据”,几乎一定会在第一版代码里踩坑。真实链路至少是:
text
申请权限
-> 扫描设备
-> 连接 connectGatt
-> onConnectionStateChange(connected)
-> discoverServices
-> 找到 service/characteristic
-> requestMtu(可选)
-> enable notification(写 CCCD)
-> 串行 read/write/notify
-> disconnect + close一、设备扫描与过滤
权限申请(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()
}
}- 可能执行顺序
- 先把
requestMtu、enableNotification、writeCharacteristic入队。 - 只有前一个回调完成,才发下一个。
- 失败、超时、断线都要显式把队列推进或清空。
- 先把
- 可能输出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)本质上是两件事:
- 告诉本地蓝牙栈“我要接这个 characteristic 的变化”。
- 告诉外设“以后你要主动给我推送”。
四、断线重连策略
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 对照
| 维度 | Android | Flutter | iOS | Backend |
|---|---|---|---|---|
| 主战场 | 蓝牙权限、扫描、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-playground | Android BLE 最小稳定样板:权限 → 扫描 → 连接 → 发现服务 → MTU / 通知 / 串行队列 / 重连日志 | BleGattPlaygroundActivity.kt |
复习检查题
BLE 和经典蓝牙的核心区别是什么?各适合什么场景?
答:BLE 面向周期性小数据(传感器、健康设备),功耗极低,连接建立快,单帧数据量小(默认 MTU 23 字节);经典蓝牙(BR/EDR)面向连续高带宽数据(音频),二者协议栈独立。一个设备可以同时支持双模(Dual Mode)。
为什么 BLE 读写操作通常必须进队列串行执行?
答:因为 Android BLE 栈通常同一时刻只允许一个 GATT 操作在途;并发发多个操作会造成调用返回
false、回调错乱或133等失败,所以必须等前一条回调结束再发下一条。打开 notification 为什么不能只调
setCharacteristicNotification?答:因为那只是在本地蓝牙栈打开监听;真正让设备开始推送,还必须写远端 CCCD descriptor,否则设备端可能完全不会发送通知。
为什么建议在
STATE_CONNECTED后延迟 500ms 再调用discoverServices?答:BLE 连接建立后,设备端 GATT Server 还需短暂时间初始化连接参数;立即调用
discoverServices会在部分设备(尤其国产嵌入式)上返回失败;延迟 500~800ms 是业界经验值。gatt.close()为什么必须在断开后调用?答:Android 系统限制同一应用最多建立约 7 个 GATT 连接;不调用
close()会导致 GATT 连接槽泄漏,后续再连接时会立即返回status=133。为什么 Flutter BLE 项目的稳定性问题往往最终要回到原生层处理?
答:因为底层蓝牙权限、系统扫描策略、GATT 生命周期和平台限制都发生在 Android / iOS 宿主;Flutter 层可以统一状态展示和业务接口,但无法绕过平台栈本身的约束。
速记
- GATT 操作一律串行,用队列包裹,等回调再发下一个。
- close() 必须调用,否则 GATT 槽泄漏导致再也连不上。
- 权限判断分 Android 版本:< 12 需要 Location 权限;≥ 12 只需 BLUETOOTH_SCAN + BLUETOOTH_CONNECT。
- 订阅通知三步走:setCharacteristicNotification → 写 CCCD → 等 onDescriptorWrite。
- Flutter 负责界面,原生层负责把 BLE 真正做稳。