蓝牙 BLE 与外设集成

Flutter 蓝牙 BLE 开发实战:GATT 协议模型与 Service/Characteristic 概念、flutter_blue_plus 扫描连接与读写通知、MTU 与分包传输、iOS/Android 权限与后台限制、连接状态机与断线重连、大文件 OTA 传输与性能调优。

智能手环、体脂秤、蓝牙打印机、工业传感器——当 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 Access0x1800设备名、外观
Device Information0x180A厂商、固件版本
Battery Service0x180F电量
Heart Rate0x180D心率
厂商自定义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} 字节');
});

写操作的两个陷阱:

  1. withoutResponse 与 withResponse 的区别:前者快但无确认、连发太快会丢包;后者每包等 ACK、可靠但慢。连续发送时建议用 withResponse,或自己在应用层加序列号与重传。
  2. 单次写入长度受 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 B5120基准
185(iOS 常见)182 B563~9x 快
512(Android 可请求)509 B202~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 后台模式)。把这几条做扎实,剩下的就是和硬件工程师一起调连接参数与传输协议了。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「Flutter」更多文章

  1. 崩溃监控与线上可观测性
  2. 包体积与启动优化
  3. Golden 测试与视觉回归