智能手环、体脂秤、蓝牙打印机、工业传感器——当 App 需要和物理世界对话时,BLE(Bluetooth Low Energy,低功耗蓝牙)几乎总是第一选择。它功耗低、成本低、iOS/Android 都原生支持,但它的协议模型与「用 HTTP 调接口」的思维差异极大:没有请求-响应那么简单,而是围绕「服务(Service)—特征(Characteristic)」的订阅模型,还叠加了 MTU 协商、分包、连接参数、平台权限等一堆底层细节。
Flutter 里做 BLE 主要靠 flutter_blue_plus 这类插件,它把原生 API 包装成 Dart 接口。但插件只解决「怎么调」,不解决「怎么调对」——连接为什么会莫名断开、为什么写大包会丢数据、为什么 iOS 后台收不到通知,这些才是真正吃时间的地方。本文从 GATT 协议模型讲起,逐层拆解扫描、连接、读写、通知、分包传输,再到权限、后台限制、断线重连的状态机设计。BLE 协议的完整分层可对照 IoT BLE 协议栈
,小程序端的蓝牙集成思路见 小程序 IoT 蓝牙
。
一、GATT 协议模型:先理解再写代码
BLE 通信的核心是 GATT(Generic Attribute Profile,通用属性配置文件)。它的数据组织是一个三层树:
Peripheral(外设,如手环)
└── Service(服务,UUID 标识一类功能)
├── Characteristic(特征,实际读写的数据点)
│ ├── Value(值)
│ ├── Properties(read/write/notify/indicate)
│ └── Descriptor(描述符,如 CCCD 通知开关)
└── Characteristic ...
关键概念:
- UUID:每个 Service 和 Characteristic 用 128 位或 16 位 UUID 标识。16 位 UUID 是蓝牙 SIG 定义的标准服务(如心率服务
0x180D),128 位是厂商自定义。写代码前必须拿到外设的 GATT 表——通常由硬件工程师提供,或用 nRF Connect 这类调试 App 扫描得到。 - Properties:一个 Characteristic 支持哪些操作(
read/write/writeWithoutResponse/notify/indicate)。读写方向搞错是最常见的低级错误——把只支持notify的特征拿来write,会直接抛异常。 - CCCD(Client Characteristic Configuration Descriptor):订阅通知时必须写这个描述符开启通知。插件通常把这一步封装在
setNotifyValue(true)里。 - notify vs indicate:
notify是外设主动推送、不要求 ACK(快但可能丢);indicate要求接收方回 ACK(可靠但慢)。传感器高频数据用notify,关键指令回执用indicate。
常见标准服务的 16 位 UUID:
| 服务 | UUID | 用途 |
|---|---|---|
| Generic Access | 0x1800 | 设备名、外观 |
| Device Information | 0x180A | 厂商、固件版本 |
| Battery Service | 0x180F | 电量 |
| Heart Rate | 0x180D | 心率 |
| 厂商自定义 | 128 位 | 业务数据 |
调试阶段强烈建议先用 nRF Connect(Android/iOS 都有)把外设的 GATT 表完整导出:哪些 Service、哪些 Characteristic、各自支持什么属性、读写后返回什么字节。拿到这张表再写 Flutter 代码,能省下大量「猜 UUID」的时间。
二、扫描与连接
扫描是第一步,也是最耗电的一步。flutter_blue_plus 的基本用法:
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
// 检查蓝牙状态
if (await FlutterBluePlus.isSupported == false) {
throw Exception('设备不支持蓝牙');
}
// 开始扫描(可按 serviceUuid 过滤,避免扫出一堆无关设备)
await FlutterBluePlus.startScan(
withServices: [Guid('0000180d-0000-1000-8000-00805f9b34fb')],
timeout: const Duration(seconds: 15),
);
// 监听扫描结果
FlutterBluePlus.scanResults.listen((results) {
for (final r in results) {
print('${r.device.platformName} RSSI=${r.rssi} id=${r.device.remoteId}');
}
});
// 记得停止扫描,否则会持续耗电
await FlutterBluePlus.stopScan();
扫描优化要点:
- 按 service UUID 过滤:不过滤会扫到附近所有 BLE 设备,耗电且慢。
- 限制扫描时长:iOS 上无限制扫描会被系统限制,务必设
timeout。 - RSSI 排序:信号强度(RSSI,Received Signal Strength Indicator)越接近 0 越强。连接前优先选 RSSI > -70dBm 的设备。
- 设备标识:Android 用 MAC 地址,iOS 用系统生成的 UUID(同一设备在不同手机上 UUID 不同,不能跨设备持久化)。
连接:
final device = result.device;
await device.connect(timeout: const Duration(seconds: 15));
// 监听连接状态
device.connectionState.listen((state) {
switch (state) {
case BluetoothConnectionState.connected:
print('已连接');
break;
case BluetoothConnectionState.disconnected:
print('已断开');
break;
default:
break;
}
});
// 发现服务
final services = await device.discoverServices();
for (final s in services) {
print('Service: ${s.uuid}');
for (final c in s.characteristics) {
print(' Char: ${c.uuid} ${c.properties}');
}
}
discoverServices() 必须在连接后调用一次,之后 GATT 表缓存在 services 里,不要重复发现(开销大)。
RSSI 判据的参考区间:
| RSSI | 信号质量 | 建议 |
|---|---|---|
| > -60 dBm | 极强 | 理想连接距离 |
| -60 ~ -70 | 良好 | 可正常通信 |
| -70 ~ -85 | 一般 | 可能丢包,谨慎 |
| < -85 dBm | 弱 | 不建议连接 |
注意 RSSI 是瞬时值,会随人体遮挡、设备朝向剧烈波动。判断「能否稳定连接」应看一段时间的平均值,而不是扫描到的那一个瞬时值。实践中可以在扫描结果里对同一设备多次采样取中位数,再决定是否连接。
另一个实践细节是「扫描与连接不能同时进行」:多数平台在连接建立后会占用射频资源,此时继续扫描会显著降低吞吐。正确顺序是「扫描 → 停止扫描 → 连接」。
三、读写与通知
读写特征:
// 定位目标特征
final service = services.firstWhere(
(s) => s.uuid == Guid('0000ffe0-0000-1000-8000-00805f9b34fb'),
);
final writeChar = service.characteristics.firstWhere(
(c) => c.uuid == Guid('0000ffe1-0000-1000-8000-00805f9b34fb'),
);
// 读
final value = await writeChar.read();
print('读取到: ${value}');
// 写(有无响应两种)
await writeChar.write([0x01, 0x02], withoutResponse: false);
// 订阅通知
await writeChar.setNotifyValue(true);
writeChar.onValueReceived.listen((data) {
print('收到通知: ${data.length} 字节');
});
写操作的两个陷阱:
withoutResponse与withResponse的区别:前者快但无确认、连发太快会丢包;后者每包等 ACK、可靠但慢。连续发送时建议用withResponse,或自己在应用层加序列号与重传。- 单次写入长度受 MTU 限制:默认 MTU 是 23 字节(有效载荷 20 字节),超过会失败或截断。
读写的选择矩阵:
| 场景 | 方法 | 原因 |
|---|---|---|
| 读取静态配置 | read() | 一次性获取 |
| 发送指令 | write(withoutResponse: false) | 需要确认 |
| 高频数据上报 | setNotifyValue(true) | 外设主动推送 |
| 关键回执 | indicate | 要求 ACK 可靠 |
四、MTU 协商与分包传输
BLE 默认 MTU(Maximum Transmission Unit,最大传输单元)是 23 字节,减去 3 字节 ATT 头,单包有效载荷只有 20 字节。传一张 100KB 的图要拆成 5000 多包,效率极低。所以第一步是协商更大的 MTU:
// Android 支持主动请求 MTU(iOS 由系统自动协商,不可主动设置)
final mtu = await device.requestMtu(512);
print('协商后 MTU: $mtu'); // 实际值由外设能力决定,通常 247 或 512
// 有效载荷 = MTU - 3
final payloadSize = mtu - 3;
平台差异:Android 支持 requestMtu 主动协商(最大 517);iOS 不允许 App 主动设置,系统会在连接后自动协商到外设支持的最大值(通常 185)。所以代码里必须先读 device.mtuNow 再决定分包大小,不能写死。
分包发送大数据的标准做法是「分片 + 序号 + 校验」:
Future<void> sendLargeData(BluetoothCharacteristic char, Uint8List data) async {
final chunkSize = device.mtuNow - 3;
final total = (data.length / chunkSize).ceil();
for (var i = 0; i < total; i++) {
final start = i * chunkSize;
final end = (start + chunkSize).clamp(0, data.length);
// 包头:2 字节序号 + 1 字节总片数 + 数据
final packet = Uint8List(3 + (end - start));
packet[0] = (i >> 8) & 0xFF;
packet[1] = i & 0xFF;
packet[2] = total;
packet.setRange(3, packet.length, data, start);
await char.write(packet, withoutResponse: false);
}
}
发送速率要控制:连续 write 之间加 5~20ms 延时,或等外设回 ACK 再发下一包。发太快会导致外设缓冲区溢出丢包,表现为「传了一部分就卡住」。接收侧同理,通知数据也要按序号重组。
不同 MTU 下的传输效率对比:
| MTU | 有效载荷 | 传 100KB 的包数 | 相对耗时 |
|---|---|---|---|
| 23(默认) | 20 B | 5120 | 基准 |
| 185(iOS 常见) | 182 B | 563 | ~9x 快 |
| 512(Android 可请求) | 509 B | 202 | ~25x 快 |
五、权限与后台限制
BLE 权限在两端差异很大,配置不当会导致「扫描不到任何设备」。
Android:
<!-- android/app/src/main/AndroidManifest.xml -->
<!-- Android 12+ 的新权限模型 -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Android 11 及以下 -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
Android 12(API 31)起把蓝牙权限拆成 BLUETOOTH_SCAN 和 BLUETOOTH_CONNECT,需要运行时申请。声明 usesPermissionFlags="neverForLocation" 可以避免因蓝牙扫描被要求定位权限(前提是确实不用蓝牙推断位置)。
// 运行时申请权限(配合 permission_handler)
final status = await [
Permission.bluetoothScan,
Permission.bluetoothConnect,
].request();
iOS:
<!-- ios/Runner/Info.plist -->
<key>NSBluetoothAlwaysUsageDescription</key>
<string>需要蓝牙连接您的智能设备</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>需要蓝牙连接您的智能设备</string>
iOS 13+ 用 NSBluetoothAlwaysUsageDescription,NSBluetoothPeripheralUsageDescription 是 iOS 12 及以下的遗留键,两个都写上兼容性好。
后台限制是 iOS 上最头疼的问题:
- iOS 默认在 App 进入后台后暂停 BLE 操作,几分钟后断开连接。
- 要在后台保持连接,需在 Info.plist 声明
UIBackgroundModes包含bluetooth-central,并在 Xcode 的 Signing & Capabilities 里开启 Background Modes → Uses Bluetooth LE accessories。 - 即使声明了,后台的通知频率会被系统限制,且 App 被系统杀死后连接彻底断开。iOS 的
state restoration(状态恢复)能让系统在 App 被杀死后为特定事件重新唤醒 App,但配置复杂。
Android 后台限制相对宽松,但 Android 8+ 的后台执行限制、以及厂商的省电策略(如小米、华为的「自启动」白名单)会主动杀后台进程。工业场景建议引导用户把 App 加入电池优化白名单。
// 申请忽略电池优化(Android)
final status = await Permission.ignoreBatteryOptimizations.request();
六、连接状态机与断线重连
BLE 连接不稳定是常态:信号弱、外设休眠、系统回收,都会导致断开。硬编码「连上就一直用」的代码迟早出事。正确做法是用状态机管理连接生命周期:
disconnected → scanning → connecting → connected → discovering
↑ ↓
└──────────── reconnecting ←────────────── disconnected
用 flutter_bloc 或 Riverpod 实现这个状态机,核心是「断线后自动重连 + 指数退避」:
Future<void> connectWithRetry(BluetoothDevice device) async {
var attempt = 0;
while (attempt < 5) {
try {
await device.connect(timeout: const Duration(seconds: 10));
await device.discoverServices();
_resubscribeNotifications(); // 重连后必须重新订阅通知
return;
} catch (e) {
attempt++;
// 指数退避:1s, 2s, 4s, 8s, 16s
await Future.delayed(Duration(seconds: 1 << (attempt - 1)));
}
}
throw Exception('重连失败,已达最大重试次数');
}
重连后必须重新订阅通知——这是新手最常见的 bug:连接恢复了,但 onValueReceived 再也没有数据,因为 CCCD 订阅在断线时已被系统清除。重连流程里必须重新 setNotifyValue(true)。
其他实践要点:
- 主动断开要清理资源:
await device.disconnect()后取消所有订阅,避免内存泄漏。 - 单例管理连接:全局只维护一个
BluetoothDevice实例,避免多处连接同一设备导致状态混乱。 - 超时保护:每次
connect/read/write都要设超时,否则底层卡住时 Future 永不完成。
断开原因的排查清单:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 连接后几秒断开 | 连接参数不兼容 | 检查 connection interval |
| 后台几分钟断开 | iOS 后台模式未开启 | 声明 bluetooth-central |
| 传输中途断开 | 外设缓冲溢出 / 掉电 | 降低发送速率 |
| 偶发无法连接 | 上次连接未释放 | 先 disconnect 再连 |
七、性能与稳定性调优
| 问题 | 原因 | 对策 |
|---|---|---|
| 传输慢 | MTU 未协商、逐包等 ACK | 协商到 512、流水线发送 |
| 丢包 | 发送太快、外设缓冲溢出 | 加延时、应用层序号 + 重传 |
| 频繁断开 | 连接参数不佳、信号弱 | 请求更快的 connection interval |
| 扫描耗电 | 一直扫描不停 | 用 timeout、按 UUID 过滤 |
| 通知丢失 | 未处理重组、订阅被清除 | 按序号重组、重连后重订阅 |
连接参数(Connection Interval)影响功耗与延迟:间隔越短延迟越低但越耗电。iOS 不允许 App 设置连接参数,Android 部分机型支持。对实时性要求高的场景(如手柄),要在硬件侧优化而非 App 侧。
大文件传输(如固件 OTA 升级)建议用「请求-分片-确认」的协议,而不是单向狂发。外设每收到一片回一个 ACK,App 收到 ACK 再发下一片;丢包时重发该片。这样虽然慢一点,但可靠。
// OTA 传输:等 ACK 再发下一片
Future<void> sendFirmware(BluetoothCharacteristic char, Uint8List fw) async {
final chunk = device.mtuNow - 3;
for (var offset = 0; offset < fw.length; offset += chunk) {
final end = (offset + chunk).clamp(0, fw.length);
await char.write(fw.sublist(offset, end), withoutResponse: false);
// 等待外设回 ACK(超时重发)
await _waitForAck(timeout: const Duration(seconds: 2));
_reportProgress(end / fw.length);
}
}
传输过程中要上报进度、支持断点续传,并监控失败率——这些指标可以接入边缘与 IoT 可观测性体系做线上监控。失败率突然升高往往预示硬件批次问题或固件 bug。
如果 BLE 插件的原生能力不够(比如需要自定义的连接参数、特殊的广播包解析),可以通过 https://plumephp.com/flutter-platform-channels/ 自己写原生代码,或把 C 库(如某些厂商的私有协议栈)用 FFI 接进来。数据处理密集时(如实时解析传感器数据流),把解析放到 https://plumephp.com/flutter-isolates-concurrency/ 里,避免阻塞 UI 线程。
最后提一句测试策略:BLE 难以用真机做自动化测试,建议把「协议解析、分包重组、状态机转换」这些纯逻辑抽成不依赖插件的 Dart 类,用单元测试覆盖;连接层则用 mock 的 BluetoothDevice 做集成测试。这样即便换硬件或换插件,核心逻辑的测试仍然有效。
小结
BLE 开发的难点不在 API 调用,而在「理解协议 + 处理不确定性」。四条核心准则:先拿 GATT 表再写代码(UUID、Properties 决定能做什么);连接后必协商 MTU 再分包(默认 20 字节有效载荷,Android 可主动请求、iOS 自动协商);用状态机管理连接 + 指数退避重连(断线是常态,重连后必须重新订阅通知);两端权限配置齐全(Android 12+ 的 SCAN/CONNECT 权限、iOS 的 bluetooth-central 后台模式)。把这几条做扎实,剩下的就是和硬件工程师一起调连接参数与传输协议了。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。