1. 为什么 Flutter 蓝牙开发值得单独拎出来聊
做移动端开发的人都有一个共识:蓝牙是那种“看起来简单、做起来想砸键盘”的模块。尤其是 BLE(低功耗蓝牙),协议栈层次多、平台差异大、连接状态不稳定,再加上 Android 和 iOS 在权限、后台策略、扫描机制上的各种“小脾气”,一个不小心就会掉进坑里爬不出来。
Flutter 生态里做 BLE 的插件不算少,但真正能在生产环境扛住考验的,flutter_blue_plus是绕不开的一个。它是flutter_blue的继任者,修掉了老版本一堆历史遗留问题,在连接管理、MTU 协商、多设备并发、平台兼容性上都做了大量改进。我前后用它做过智能穿戴、健康设备、车载配件几个项目,踩过的坑足够写一本小册子。
这篇内容适合三类人看:一是刚接触 Flutter 蓝牙、不知道该选哪个插件的新手;二是用过flutter_blue但被各种断连、超时折磨过的中级开发者;三是想搞清楚 BLE 底层连接过程、GATT 通信原理,不想只停留在“调 API”层面的进阶选手。我会从整体设计思路讲到核心 API 的实操细节,再到实际项目里的排查经验,尽量把“为什么这么做”讲透,而不是只丢一段能跑的代码。
2. flutter_blue_plus 的整体设计与选型逻辑
2.1 为什么不用 flutter_blue 而选 flutter_blue_plus
很多人第一次搜 Flutter 蓝牙插件,找到的还是flutter_blue。这个库确实经典,但它的维护在几年前基本停滞了,GitHub 上一堆 issue 没人回,尤其是 Android 12 之后的权限变更、iOS 后台连接、MTU 协商失败这些问题,老库基本处于“能用但随时炸”的状态。
flutter_blue_plus的出现就是为了解决这些遗留问题。它的核心改进我整理成了一张表,方便你直观对比:
| 对比维度 | flutter_blue | flutter_blue_plus |
|---|---|---|
| 维护状态 | 基本停更 | 持续活跃更新 |
| Android 12+ 权限 | 需手动适配,易崩 | 内置兼容处理 |
| MTU 协商 | 手动且不稳定 | 自动协商 + 可查询实际值 |
| 多设备连接 | 状态管理混乱 | 独立连接对象,互不干扰 |
| 连接超时控制 | 无原生支持 | 支持 timeout 参数 |
| 后台连接 | 支持差 | 平台策略明确 |
| 错误回调 | 信息模糊 | 错误码清晰可定位 |
选型的核心逻辑其实就一句话:BLE 开发最怕的不是功能做不出来,而是状态不可控。flutter_blue_plus把每个设备抽象成独立的BluetoothDevice对象,连接、断开、读写都是围绕这个对象操作,状态边界清晰,出问题时能快速定位是扫描阶段、连接阶段还是 GATT 通信阶段的问题。
2.2 BLE 协议栈的分层理解
要真正用好这个插件,得先搞清楚 BLE 协议栈的分层。很多人调 API 调不明白,根源是对底层模型没概念。
BLE 通信大致分这么几层:
- 物理层与链路层:负责射频、广播、建立连接,这部分 Flutter 层碰不到,由系统蓝牙栈处理。
- GATT 层:这是应用开发的主战场。GATT(通用属性配置文件)把数据组织成 Service(服务)和 Characteristic(特征值)的树状结构。
- ATT 层:GATT 的底层传输协议,负责读写属性的具体报文。
用生活化的类比:把一台 BLE 设备想象成一栋楼,Service 就是楼层,Characteristic 就是房间,UUID 就是门牌号。你要拿数据,得先找到楼层(Service UUID),再找到房间(Characteristic UUID),然后才能读或写。有些房间还带“门禁”,也就是权限属性(read/write/notify),没权限你进不去。
flutter_blue_plus暴露的 API 基本就是围绕这套模型设计的:discoverServices()找楼层,readCharacteristic()读房间,setNotifyValue()订阅房间的实时推送。
2.3 插件架构与平台通道机制
flutter_blue_plus本质是一个 MethodChannel 插件,Dart 层负责 API 封装和状态管理,原生层(Android 用BluetoothGatt,iOS 用CoreBluetooth)负责实际通信。理解这一点很关键,因为很多诡异问题的根源在原生层,而不是 Dart 层。
比如 Android 上扫描不到设备,可能是没申请BLUETOOTH_SCAN权限;iOS 上连接后立刻断开,可能是设备要求绑定但系统没弹配对框。这些问题在 Dart 层看日志是看不出来的,得结合原生日志排查。
插件在 Dart 层维护了一个设备缓存和连接状态机,每次原生层回调都会通过 EventChannel 推上来。所以你在 Dart 层看到的connectionState变化,其实是原生状态的一次映射。理解这个映射关系,排查问题时就能判断到底是原生没回调,还是 Dart 层处理逻辑有问题。
3. 核心功能拆解与实操要点
3.1 权限配置:最容易翻车的第一步
我见过太多人代码写得没问题,就是扫不到设备,最后发现是权限没配全。Android 和 iOS 的权限模型完全不同,必须分开处理。
Android 端,从 Android 12(API 31)开始,蓝牙权限被拆成了三个:
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />这里有个关键点:neverForLocation这个标志。如果你的应用确实不需要通过蓝牙推断位置,加上它可以让系统不把蓝牙扫描当成定位行为,避免申请定位权限。但如果你扫描的设备类型比较特殊,系统可能仍然要求定位权限,这时候就得老老实实加上ACCESS_FINE_LOCATION。
Android 12 以下则用老的BLUETOOTH和BLUETOOTH_ADMIN权限,同时定位权限是扫描的硬性前提。所以完整的权限声明要按版本区分:
<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" />iOS 端相对简单,在Info.plist里加两个描述:
<key>NSBluetoothAlwaysUsageDescription</key> <string>需要蓝牙权限以连接设备</string> <key>NSBluetoothPeripheralUsageDescription</key> <string>需要蓝牙权限以连接设备</string>注意:iOS 的描述文案不能随便写,审核时如果发现描述和实际用途不符会被拒。我一般写“用于连接智能设备进行数据同步”,既准确又安全。
3.2 扫描设备:过滤策略决定效率
扫描是 BLE 开发的第一步,也是最耗电的一步。flutter_blue_plus的扫描 API 设计得比较灵活:
FlutterBluePlus.startScan( withServices: [Guid("0000ffe0-0000-1000-8000-00805f9b34fb")], timeout: Duration(seconds: 15), ); FlutterBluePlus.scanResults.listen((results) { for (ScanResult r in results) { print('${r.device.platformName} - ${r.rssi}'); } });这里有几个实操要点值得展开:
第一,能用withServices过滤就别全量扫描。全量扫描会返回周围所有 BLE 设备,包括一堆没有名字的、信号极弱的,处理起来很麻烦。如果你知道目标设备的 Service UUID,直接过滤,扫描效率和准确率都会大幅提升。
第二,timeout必须设。不设超时的扫描会一直跑,耗电不说,在 Android 上还可能被系统限制。我一般设 10 到 15 秒,够发现设备了。
第三,RSSI 值可以用来做距离粗估。RSSI 是信号强度,单位 dBm,越接近 0 越强。常见参考值:-50 以内很近,-70 左右中等距离,-90 以上基本快断了。但要注意 RSSI 波动很大,不能用来精确测距,只能做趋势判断。
第四,扫描结果要去重。同一个设备可能在多次回调里重复出现,我一般用device.remoteId做 key 去重,只保留最新的一条。
3.3 建立连接:超时与重连机制
连接是 BLE 最容易出问题的环节。flutter_blue_plus的连接 API 支持超时参数:
try { await device.connect(timeout: Duration(seconds: 10)); } catch (e) { print('连接失败: $e'); }为什么一定要设超时?因为 BLE 连接在某些情况下会“卡住”——设备在广播但拒绝连接,或者信号弱到握手失败,这时候如果不设超时,connect()会一直挂着,UI 就卡死了。
连接成功后,建议立刻监听连接状态变化:
device.connectionState.listen((state) { if (state == BluetoothConnectionState.disconnected) { // 触发重连逻辑 } });重连策略我一般这么设计:首次断开后等 1 秒重连,失败则等 2 秒,再失败等 4 秒,指数退避,最多重试 5 次。这样既能应对偶发的信号抖动,又不会在设备真的关机时无限重试耗电。
实操心得:Android 上有个坑,设备断开后系统可能还缓存着 GATT 连接,导致重连时拿到的是旧连接。解决办法是在断开回调里调用
device.disconnect()清理,或者干脆换一个BluetoothDevice对象重新连。
3.4 服务发现与 GATT 结构解析
连接成功后第一件事是发现服务:
List<BluetoothService> services = await device.discoverServices(); for (var service in services) { print('Service: ${service.uuid}'); for (var c in service.characteristics) { print(' Characteristic: ${c.uuid}'); print(' Properties: ${c.properties}'); } }discoverServices()返回的是完整的 GATT 树。这里的关键是看懂 Characteristic 的 properties,它决定了你能对这个特征值做什么操作:
| Property | 含义 | 对应操作 |
|---|---|---|
| read | 可读 | readCharacteristic |
| write | 可写(有响应) | writeCharacteristic |
| writeWithoutResponse | 可写(无响应) | writeCharacteristic |
| notify | 支持通知 | setNotifyValue |
| indicate | 支持指示 | setNotifyValue |
notify 和 indicate 的区别:notify 是设备主动推数据,不要求手机确认;indicate 要求手机收到后回一个确认。indicate 更可靠但更慢,一般传感器数据用 notify,关键指令用 indicate。
注意:有些设备的 Service UUID 是 16 位短 UUID,比如
ffe0,但插件返回的是 128 位完整格式0000ffe0-0000-1000-8000-00805f9b34fb。做匹配时要注意格式统一,我一般写个工具函数把短 UUID 补全。
3.5 数据读写与通知订阅
读数据:
List<int> value = await characteristic.read();写数据:
await characteristic.write([0x01, 0x02], withoutResponse: false);订阅通知:
await characteristic.setNotifyValue(true); characteristic.onValueReceived.listen((value) { print('收到数据: $value'); });写数据有个大坑:MTU 限制。BLE 单次传输的数据量受 MTU(最大传输单元)限制,默认 MTU 是 23 字节,减去 3 字节的 ATT 头,实际能传 20 字节。超过这个长度就得分包。
flutter_blue_plus支持 MTU 协商:
int mtu = await device.requestMtu(512);但要注意,MTU 协商不是你想要多少就给多少,最终值取决于设备和系统的支持。Android 上一般能协商到 512,iOS 上系统会自动管理,通常能到 185 左右。协商后要查询实际值:
int actualMtu = device.mtuNow;分包发送的逻辑我一般这么写:
Future<void> writeLargeData(BluetoothCharacteristic c, List<int> data) async { int mtu = c.device.mtuNow - 3; for (int i = 0; i < data.length; i += mtu) { int end = (i + mtu < data.length) ? i + mtu : data.length; await c.write(data.sublist(i, end), withoutResponse: false); await Future.delayed(Duration(milliseconds: 20)); } }那个 20 毫秒的延迟很关键。连续快速写入会导致设备缓冲区溢出,数据丢失。加个小延迟能让设备喘口气。
4. 完整实操流程与关键环节实现
4.1 从零搭建一个 BLE 连接 Demo
我把整个流程串一遍,你可以直接照着搭。
第一步,初始化与权限检查:
Future<bool> checkPermissions() async { if (Platform.isAndroid) { Map<Permission, PermissionStatus> statuses = await [ Permission.bluetoothScan, Permission.bluetoothConnect, Permission.location, ].request(); return statuses.values.every((s) => s.isGranted); } return true; }第二步,扫描并展示设备列表:
List<ScanResult> _results = []; void startScan() { FlutterBluePlus.startScan(timeout: Duration(seconds: 15)); FlutterBluePlus.scanResults.listen((results) { setState(() { _results = results; }); }); }第三步,连接并发现服务:
Future<void> connectToDevice(BluetoothDevice device) async { await device.connect(timeout: Duration(seconds: 10)); List<BluetoothService> services = await device.discoverServices(); // 找到目标 Service 和 Characteristic }第四步,订阅通知并处理数据:
await targetChar.setNotifyValue(true); targetChar.onValueReceived.listen((data) { // 解析数据 });4.2 数据解析:从字节到业务含义
BLE 传的都是原始字节,怎么解析成业务数据是另一门学问。常见的有几种格式:
单字节标志位:比如[0x01]表示开,[0x00]表示关。
多字节数值:注意字节序。BLE 一般用小端序(Little Endian),比如温度值[0x64, 0x00]表示 100。
int parseTemperature(List<int> data) { return data[0] | (data[1] << 8); }浮点数:有些设备用 IEEE 754 格式传浮点,需要用ByteData转换:
double parseFloat(List<int> data) { var bytes = Uint8List.fromList(data); var buffer = ByteData.view(bytes.buffer); return buffer.getFloat32(0, Endian.little); }字符串:直接 UTF-8 解码:
String parseString(List<int> data) { return utf8.decode(data); }实操心得:解析前一定要确认设备的字节序和数据类型。我遇到过一个设备温度值用大端序传,按小端解析出来是 25600 度,排查了半天才发现是字节序搞反了。建议拿到新设备先用调试助手抓原始数据,确认格式再写解析代码。
4.3 多设备并发连接管理
实际项目里经常要同时连多个设备,比如一个 App 管多个传感器。flutter_blue_plus支持多设备连接,但要注意几点:
每个设备独立管理状态。不要用一个全局变量存连接状态,而是给每个设备维护一个状态对象:
class DeviceManager { final BluetoothDevice device; BluetoothConnectionState state = BluetoothConnectionState.disconnected; List<BluetoothService> services = []; DeviceManager(this.device); Future<void> connect() async { await device.connect(timeout: Duration(seconds: 10)); services = await device.discoverServices(); } }并发连接要控制节奏。同时发起 5 个连接请求,系统蓝牙栈可能处理不过来。我一般用队列串行连接,或者最多同时连 2 到 3 个。
断开要彻底。多设备场景下,App 退出或页面销毁时要把所有连接都断掉,否则残留的连接会占用系统资源,下次连接可能失败。
4.4 后台连接与保活策略
后台连接是 BLE 开发里最复杂的部分,因为 Android 和 iOS 的策略完全不同。
Android 端,需要用前台服务(Foreground Service)来保活。在AndroidManifest.xml里声明服务,并在代码里启动:
<service android:name=".BluetoothService" android:foregroundServiceType="connectedDevice" />前台服务会显示一个常驻通知,这是 Android 的硬性要求,用户能看到你的 App 在后台运行。
iOS 端,需要在Info.plist里声明后台模式:
<key>UIBackgroundModes</key> <array> <string>bluetooth-central</string> </array>但 iOS 的后台连接有严格限制:App 被系统回收后,只有在特定事件(如设备发来通知)时才会被唤醒,且唤醒时间有限。所以 iOS 上做后台数据同步,要设计成“事件驱动”模式,而不是轮询。
注意:后台连接不是所有场景都需要。如果你的 App 只是前台使用,别加后台权限,否则审核时会被问“为什么需要后台蓝牙”,解释不清楚就麻烦了。
5. 常见问题与排查技巧实录
5.1 扫描不到设备怎么办
这是最高频的问题,我按排查顺序列一下:
| 排查项 | 检查方法 | 常见原因 |
|---|---|---|
| 权限 | 打印权限状态 | Android 12+ 没申请 SCAN 权限 |
| 定位服务 | 检查系统定位开关 | Android 扫描依赖定位服务 |
| 蓝牙开关 | 检查适配器状态 | 蓝牙没开或异常 |
| 设备广播 | 用调试助手验证 | 设备没在广播或广播间隔太长 |
| UUID 过滤 | 去掉过滤全量扫描 | 过滤 UUID 写错了 |
| 扫描时长 | 延长 timeout | 设备广播间隔长,短时间扫不到 |
我遇到最多的是权限问题和UUID 过滤写错。尤其是 UUID,短格式和长格式不匹配,过滤条件永远命中不了。
5.2 连接后立刻断开
这个问题的原因比较隐蔽,常见的有几种:
设备要求绑定。有些设备连接后需要系统弹配对框,如果 App 没处理配对流程,设备会主动断开。解决办法是监听bondState变化,或者用device.createBond()主动触发配对。
GATT 缓存问题。Android 会缓存设备的 GATT 服务,如果设备固件更新了服务结构,缓存会导致连接异常。解决办法是在连接前调用device.disconnect()清理,或者在开发者选项里手动清除蓝牙缓存。
MTU 协商失败。有些设备不支持大 MTU,协商时直接断开。解决办法是先不协商 MTU,用默认值连接,连上后再尝试协商。
5.3 数据写入失败或丢失
写入失败一般有几个原因:
特征值不支持写。检查properties里有没有write或writeWithoutResponse。
数据超过 MTU。分包处理,或者协商更大的 MTU。
写入太频繁。加延迟,或者用队列串行写入。
设备缓冲区满。这种情况在连续写入时常见,解决办法是写入后等待设备的响应,或者加足够的延迟。
实操心得:我一般会在写入后加一个重试机制。如果写入抛异常,等 100 毫秒重试一次,最多重试 3 次。这样能应对偶发的写入失败,比直接报错给用户体验好得多。
5.4 iOS 和 Android 的差异坑
两个平台的 BLE 行为差异很大,我整理了几个典型的:
扫描回调频率。iOS 对同一设备的扫描回调有节流,不会像 Android 那样频繁回调。所以 iOS 上做 RSSI 实时监测,数据点会比 Android 少。
连接参数。iOS 不允许 App 设置连接间隔等参数,由系统统一管理。Android 可以通过原生 API 设置,但flutter_blue_plus没暴露这个能力。
后台行为。iOS 后台连接限制严格,Android 相对宽松但需要前台服务。
UUID 格式。iOS 返回的 UUID 是大写,Android 是小写。做字符串比较时要注意统一大小写。
5.5 常见问题速查表
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| 扫描无结果 | 权限/定位/UUID | 逐项排查权限和过滤条件 |
| 连接超时 | 信号弱/设备拒绝 | 靠近设备,检查设备状态 |
| 连接后断开 | 绑定/GATT缓存 | 处理配对,清理缓存 |
| 读数据为空 | 特征值不可读 | 检查 properties |
| 写数据失败 | MTU/权限/频率 | 分包,检查权限,加延迟 |
| 通知收不到 | 未订阅/特征值不支持 | 检查 setNotifyValue 和 properties |
| 后台断连 | 保活策略 | Android 前台服务,iOS 后台模式 |
6. 性能优化与稳定性提升的实战经验
6.1 扫描优化:省电与效率的平衡
BLE 扫描是耗电大户,优化方向有几个:
按需扫描。不要一直开着扫描,用户进入设备列表页才扫,离开就停。
用过滤减少回调。withServices和withRemoteIds都能减少无关设备的回调。
控制扫描时长。15 秒足够发现大部分设备,没必要一直扫。
扫描间隔。如果需要持续扫描,可以扫 10 秒停 5 秒,循环进行,比一直扫省电。
6.2 连接稳定性:重连与心跳
连接稳定性是 BLE 应用的生命线。我的经验是重连机制 + 心跳检测双管齐下。
重连用指数退避,前面说过了。心跳检测则是定期读一个特征值,或者依赖设备的 notify 数据。如果超过一定时间没收到任何数据,就主动断开重连。
Timer.periodic(Duration(seconds: 30), (timer) { if (lastDataTime.difference(DateTime.now()).inSeconds > 60) { // 超过 60 秒没数据,触发重连 reconnect(); } });6.3 内存与资源管理
BLE 连接是系统资源,用完必须释放。几个要点:
页面销毁时断开连接。在dispose()里调用device.disconnect()。
取消订阅。onValueReceived的 StreamSubscription 要 cancel,否则会内存泄漏。
清理扫描。stopScan()要调用,否则扫描会一直跑。
单例管理。我一般用一个全局的BluetoothManager单例来管理所有连接,避免多处创建导致状态混乱。
7. 一些踩坑后的个人体会
做 BLE 开发这几年,最大的感受是:文档和 API 只能解决 60% 的问题,剩下 40% 全靠踩坑和调试。每个设备厂商的实现都有差异,同一个协议在不同设备上表现可能完全不同。
我现在拿到一个新设备,第一件事不是写代码,而是用通用的 BLE 调试助手把设备的服务、特征值、读写权限、通知行为全部摸一遍,确认清楚了再动手。这一步花的时间,远比后面调试省下来的多。
另外,日志一定要打全。连接状态变化、数据收发、错误回调,全部打日志。BLE 的问题往往是偶发的,没有日志根本没法复现和定位。我一般会在 Debug 模式下把日志输出到控制台,Release 模式下写到本地文件,方便用户反馈问题时导出。
最后分享一个小技巧:如果遇到特别诡异的连接问题,试试重启手机蓝牙或者重启设备。BLE 协议栈在某些情况下会进入异常状态,重启是最简单有效的恢复手段。虽然听起来很“土”,但实测下来能解决相当一部分玄学问题。