Flutter 实现 BLE 设备 WiFi 配网流程实践

Flutter 实现 BLE 设备 WiFi 配网完整实践:扫描、连接、获取 WiFi、发送凭证与结果处理

在 IoT 设备开发中,很多设备首次使用时并没有网络能力,需要通过手机 App 将 WiFi 信息发送给设备。常见的配网方式有:

text 复制代码
SmartConfig
SoftAP
二维码配网
BLE 蓝牙配网

其中 BLE 配网的体验相对比较好:用户不需要手动切换手机 WiFi,也不需要扫描二维码,只需要打开蓝牙,App 扫描到设备后,选择 WiFi 并输入密码即可完成配网。

本文记录一次基于 Flutter + flutter_blue_plus 实现 BLE 设备 WiFi 配网的完整过程。

整体流程如下:

text 复制代码
扫描设备
  ↓
连接设备
  ↓
发现服务和特征值
  ↓
订阅通知
  ↓
获取设备扫描到的 WiFi 列表
  ↓
选择 WiFi 并输入密码
  ↓
分包发送 SSID 和密码
  ↓
等待设备返回配网结果
  ↓
展示成功或失败状态

说明:本文中的设备名称、UUID、WiFi 名称、设备 ID、密码等敏感信息均已脱敏处理。


一、业务背景

设备首次启动时,通常还没有连接互联网。

这时 App 需要通过 BLE 与设备建立临时通信通道,把用户选择的 WiFi 信息发送给设备。

设备收到 SSID 和密码后,会自己连接路由器。

连接成功或失败后,再通过 BLE notify 把结果返回给 App。

这个过程看起来简单,但实际开发中容易遇到很多细节问题,例如:

text 复制代码
扫描不到设备
设备广播名大小写不一致
Service UUID 返回短格式
notify 开启顺序不对
WiFi 列表数据被丢弃
WiFi 列表分包不完整
重试时重复订阅 notify 导致设备行为异常
FlutterBluePlus.isScanning 初始值导致扫描提前结束
配网结果返回较慢,App 提前超时

本文重点记录这些实际踩过的坑和对应处理方式。


二、BLE 协议设计

本次 Demo 中,设备端暴露了一个 BLE Service,并通过多个 Characteristic 完成配网流程。

脱敏后的协议如下:

类型 示例 UUID 作用
Service xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 设备配网服务
Notify Characteristic xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxx01 接收配网结果
Write SSID Characteristic xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxx02 写入 WiFi 名称
Write Password Characteristic xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxx03 写入 WiFi 密码
WiFi List Characteristic xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxx04 接收 WiFi 列表

其中:

text 复制代码
notify_result:用于接收配网结果
write_ssid:用于写入 WiFi 名称
write_password:用于写入 WiFi 密码
notify_wifi_list:用于接收设备扫描到的 WiFi 列表

需要注意:

实际开发中,有些 BLE 库或者设备端返回的 UUID 不一定是完整 UUID,可能是短 UUID。

所以匹配服务和特征值时,最好同时兼容完整 UUID 和短 UUID。


三、整体页面状态设计

App 页面可以按照配网流程设计成 5 个状态:

dart 复制代码
enum _Step {
  scan,
  connect,
  wifi,
  provisioning,
  done,
}

分别对应:

状态 含义
scan 扫描设备
connect 连接设备
wifi 展示 WiFi 列表
provisioning 正在配网
done 配网完成

这样 UI 和业务流程可以保持一致,页面切换也更清晰。


四、扫描设备

扫描阶段主要做三件事:

text 复制代码
1. 开启 BLE 扫描
2. 根据设备名称前缀过滤目标设备
3. 根据 RSSI 过滤过远设备
4. 对扫描结果去重

1. 设备名大小写问题

实际踩过一个坑:设备广播名可能不是预期的大写格式。

例如你预期设备名是:

text 复制代码
DEVICE_XXX

但实际扫描出来可能是:

text 复制代码
Device_xxx
device_xxx

如果直接大小写敏感匹配,就会导致设备明明在广播,但 App 扫不到。

所以过滤设备名时建议统一转成小写:

dart 复制代码
final name = advName.toLowerCase();

if (!name.startsWith(_devicePrefix.toLowerCase())) {
  continue;
}

2. isScanning 初始值问题

FlutterBluePlus 的 isScanning 初始值通常就是 false

如果直接这样写:

dart 复制代码
await FlutterBluePlus.startScan(timeout: const Duration(seconds: 10));
await FlutterBluePlus.isScanning.where((value) => !value).first;

可能会出现问题:

text 复制代码
isScanning 初始值就是 false
  ↓
where((value) => !value).first 立刻命中
  ↓
代码认为扫描已经结束
  ↓
实际上扫描还没真正开始

所以更稳妥的写法是:

dart 复制代码
await FlutterBluePlus.startScan(timeout: const Duration(seconds: 10));

await FlutterBluePlus.isScanning.where((value) => value).first;
await FlutterBluePlus.isScanning.where((value) => !value).first;

也就是:

text 复制代码
先等扫描状态变成 true
再等扫描状态变成 false

这个问题很隐蔽,因为代码看起来是对的,但实际表现可能是"点击扫描后立刻结束"。


五、连接设备并发现服务

扫描到目标设备后,用户点击设备列表项,App 开始连接设备。

核心流程:

dart 复制代码
await device.connect(timeout: const Duration(seconds: 15));

final services = await device.discoverServices();

_service = services.firstWhere(
  (service) {
    final uuid = service.uuid.str.toLowerCase();

    return uuid == _serviceUuid.toLowerCase() ||
        uuid == _serviceShortUuid.toLowerCase();
  },
  orElse: () => throw Exception('未找到目标服务'),
);

这里的关键点是:

text 复制代码
不能只匹配完整 Service UUID
还要兼容短 UUID

因为设备返回的 UUID 可能是:

text 复制代码
xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

也可能是:

text 复制代码
xxxx

如果只判断完整 UUID,就可能找不到服务。


六、获取 WiFi 列表

获取 WiFi 列表是整个 BLE 配网流程中最容易踩坑的部分。

很多文章会简单写成:

text 复制代码
订阅 WiFi notify
等待设备推送 WiFi 列表

但实际实现里要区分两种情况:

text 复制代码
首次获取 WiFi
重试获取 WiFi

1. 首次获取 WiFi

首次连接设备后,notify 还没有开启。

此时流程是:

text 复制代码
订阅配网结果 notify
  ↓
开启配网结果 notify
  ↓
订阅 WiFi 列表 notify
  ↓
开启 WiFi 列表 notify
  ↓
设备主动推送 WiFi 列表

也就是说,不只是订阅 WiFi 列表通道,还需要先订阅配网结果通道。

原因是:有些设备端协议可能要求两个 notify 都开启后,才会开始推送完整 WiFi 列表。

示例:

dart 复制代码
_notifySub = notifyChar.onValueReceived.listen((chunk) {
  if (chunk.isNotEmpty) {
    _addLog('配网结果通道收到 ${chunk.length} 字节');
  }
});

await notifyChar.setNotifyValue(true);

_wifiSub = wifiChar.onValueReceived.listen((chunk) {
  if (chunk.isEmpty) {
    return;
  }

  _wifiBuffer.addAll(chunk);
});

await wifiChar.setNotifyValue(true);

_wifiNotifyEnabled = true;

2. 重试获取 WiFi

如果用户点击"重试获取 WiFi 列表",这时 notify 已经开启过了。

这个时候不能每次都重新订阅 notify,否则部分设备可能会出现异常行为。

重试时更合适的方式是:

dart 复制代码
await wifiChar.read();

也就是:

text 复制代码
首次:开启 notify,等待设备主动推送
重试:notify 已经开启,通过 read 触发设备重新扫描或重新推送

代码里可以用一个状态来区分:

dart 复制代码
bool _wifiNotifyEnabled = false;

七、不要用 broadcast stream 缓存 BLE 数据

Flutter 里很多人习惯用 StreamController.broadcast() 转发 BLE 数据。

但是 broadcast stream 有一个坑:

text 复制代码
如果没有 listener,事件不会被缓存,会直接丢弃

在 BLE 配网场景里,设备可能在 notify 开启后很快推送数据。

如果此时业务层 listener 还没建立,数据就会丢掉。

结果就是:

text 复制代码
设备已经推送 WiFi 列表
  ↓
App 没接住
  ↓
页面一直显示未获取到 WiFi

更稳妥的做法是:BLE 数据一到,就直接写入 List<int> buffer。

dart 复制代码
final List<int> _wifiBuffer = [];

_wifiSub = wifiChar.onValueReceived.listen((chunk) {
  if (chunk.isEmpty) {
    return;
  }

  _wifiBuffer.addAll(chunk);
});

这种方式更简单,也更稳定。


八、WiFi 列表解析

设备端推送的 WiFi 列表通常是 JSON 数组,例如:

json 复制代码
[
  {
    "ssid": "WiFi_A",
    "rssi": -45
  },
  {
    "ssid": "WiFi_B",
    "rssi": -60
  }
]

但是实际 BLE notify 可能会分包,也可能会出现多个 JSON 数组拼接:

json 复制代码
[{"ssid":"WiFi_A","rssi":-45}]
[{"ssid":"WiFi_A","rssi":-45},{"ssid":"WiFi_B","rssi":-60}]

如果直接对完整字符串 jsonDecode,会解析失败。

一个简单处理方式是:

text 复制代码
从字符串里匹配所有 JSON 数组
取最长的那个
认为它是最完整的数据

示例代码:

dart 复制代码
final matches = RegExp(
  r'\[(?:[^\[\]]*)\]',
  dotAll: true,
).allMatches(str);

if (matches.isEmpty) {
  return [];
}

final best = matches.reduce(
  (a, b) => a.group(0)!.length >= b.group(0)!.length ? a : b,
);

final list = jsonDecode(best.group(0)!) as List;

如果是正式生产协议,建议设备端增加更明确的数据结构,例如:

json 复制代码
{
  "type": "wifi_list",
  "seq": 1,
  "end": true,
  "data": []
}

这样 App 端可以更准确判断数据是否接收完整。


九、发送 SSID 和密码

用户选择 WiFi 并输入密码后,App 需要把 SSID 和密码分别写入对应 Characteristic。

因为 BLE 单次写入长度有限,所以需要分包。

示例:

dart 复制代码
Future<void> _writeChunked(String charUuid, String value) async {
  final char = _findChar(charUuid);

  if (char == null) {
    throw Exception('未找到特征值');
  }

  final bytes = utf8.encode(value);

  for (var i = 0; i < bytes.length; i += _chunkSize) {
    final end = (i + _chunkSize).clamp(0, bytes.length);

    await char.write(
      bytes.sublist(i, end),
      withoutResponse: false,
    );

    if (end < bytes.length) {
      await Future<void>.delayed(const Duration(milliseconds: 100));
    }
  }
}

这里有一个非常重要的点:

text 复制代码
分包必须基于 UTF-8 字节数组
不能基于字符串长度

因为 SSID 或密码可能包含:

text 复制代码
中文
emoji
特殊符号

如果直接按字符串长度切割,可能会把一个 UTF-8 字符截断,导致设备端解码失败。


十、等待配网结果

发送完 SSID 和密码后,App 监听配网结果 Characteristic。

设备端可能返回:

text 复制代码
Connected!

也可能返回:

json 复制代码
{"result":"ok"}

失败时可能返回:

json 复制代码
{"result":"fail","reason":"auth_failed"}

App 需要统一解析这些结果。

另外,配网结果不能超时太短。

设备连接 WiFi 可能需要较长时间,例如:

text 复制代码
路由器响应慢
设备信号一般
DHCP 分配慢
设备内部有重试逻辑

实际经验中,设备可能十几秒后才返回结果,所以 Demo 中使用了 30 秒超时:

dart 复制代码
final result = await completer.future.timeout(
  const Duration(seconds: 30),
  onTimeout: () => 'timeout',
);

如果超时时间太短,可能会出现:

text 复制代码
设备其实成功了
但 App 已经提前显示失败

这类问题非常影响用户体验。


十一、完整代码

下面是完整脱敏版 Demo 代码。

注意:UUID、设备名前缀均为脱敏示例值,实际使用时需要替换为自己的设备协议。

dart 复制代码
/// BLE 设备配网 Demo
///
/// 演示通过蓝牙 BLE 对 IoT 设备进行 WiFi 配网的完整流程:
/// 扫描设备 → 连接 → 获取 WiFi 列表 → 发送凭证 → 接收配网结果
///
/// 依赖:
/// flutter_blue_plus: ^1.x
///
/// Android 权限参考:
/// BLUETOOTH_SCAN
/// BLUETOOTH_CONNECT
/// ACCESS_FINE_LOCATION
///
/// 注意:
/// 以下 UUID、设备名前缀均已脱敏,实际项目中请替换为自己的协议值。

import 'dart:async';
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';

void main() => runApp(const BleProvisioningApp());

class BleProvisioningApp extends StatelessWidget {
  const BleProvisioningApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'BLE 配网 Demo',
      theme: ThemeData(colorSchemeSeed: Colors.blue),
      home: const ProvisioningPage(),
    );
  }
}

// ─── 常量 ───────────────────────────────────────────────────────────────────

/// 设备名称前缀过滤,大小写不敏感
///
/// 实际项目中可以是你的设备广播名前缀。
/// 这里已脱敏。
const _devicePrefix = 'device_xxx';

/// Service UUID
///
/// 这里使用脱敏示例值。
/// 注意:实际设备可能返回完整 UUID,也可能返回短 UUID,所以需要同时兼容。
const _serviceUuid = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx';
const _serviceShortUuid = 'xxxx';

/// Characteristic UUID
///
/// 以下均为脱敏示例值。
const _charNotify = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxx01'; // 接收配网结果
const _charSsid = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxx02'; // 写入 SSID
const _charPass = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxx03'; // 写入密码
const _charWifi = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxx04'; // 接收 WiFi 列表

/// BLE 单包写入大小
///
/// 实际项目中需要结合 MTU、设备端协议调整。
const _chunkSize = 18;

// ─── 页面状态枚举 ────────────────────────────────────────────────────────────

enum _Step {
  scan,
  connect,
  wifi,
  provisioning,
  done,
}

// ─── 主页面 ──────────────────────────────────────────────────────────────────

class ProvisioningPage extends StatefulWidget {
  const ProvisioningPage({super.key});

  @override
  State<ProvisioningPage> createState() => _ProvisioningPageState();
}

class _ProvisioningPageState extends State<ProvisioningPage> {
  _Step _step = _Step.scan;

  String _log = '';

  // 扫描
  final List<ScanResult> _devices = [];
  bool _isScanning = false;

  // 连接
  BluetoothDevice? _device;
  BluetoothService? _service;

  // WiFi 列表
  final List<Map<String, dynamic>> _wifiList = [];
  final List<int> _wifiBuffer = [];

  bool _isLoadingWifi = false;

  /// 是否已经开启过 WiFi notify
  ///
  /// 首次获取 WiFi:开启 notify 后,设备主动推送。
  /// 重试获取 WiFi:notify 已经开启,使用 read 触发设备重新扫描。
  bool _wifiNotifyEnabled = false;

  StreamSubscription<List<int>>? _wifiSub;
  StreamSubscription<List<int>>? _notifySub;

  // 配网
  String _ssid = '';
  String _password = '';
  String _result = '';
  bool _isProvisioning = false;

  // ─── 日志 ──────────────────────────────────────────────────────────────────

  void _addLog(String msg) {
    setState(() {
      _log = '$msg\n$_log';
    });

    debugPrint('[BLE Demo] $msg');
  }

  // ─── STEP 1:扫描设备 ──────────────────────────────────────────────────────

  Future<void> _startScan() async {
    setState(() {
      _devices.clear();
      _isScanning = true;
    });

    _addLog('开始扫描设备...');

    final sub = FlutterBluePlus.onScanResults.listen((results) {
      for (final result in results) {
        final advName = result.advertisementData.advName;

        if (advName.isEmpty) {
          continue;
        }

        /// 设备名大小写兼容
        ///
        /// 实际踩坑:
        /// 设备广播名可能是 Device_Xxx、DEVICE_XXX、device_xxx。
        /// 如果直接大小写敏感匹配,可能导致扫不到设备。
        final name = advName.toLowerCase();

        if (!name.startsWith(_devicePrefix.toLowerCase())) {
          continue;
        }

        /// 过滤过弱信号
        if (result.rssi < -90) {
          continue;
        }

        /// 扫描结果去重
        final exists = _devices.any(
          (item) => item.device.remoteId == result.device.remoteId,
        );

        if (!exists) {
          setState(() {
            _devices.add(result);
          });

          _addLog('发现设备:$advName,RSSI=${result.rssi}');
        }
      }
    });

    try {
      await FlutterBluePlus.startScan(
        timeout: const Duration(seconds: 10),
      );

      /// 关键点:
      /// FlutterBluePlus.isScanning 初始值通常就是 false。
      /// 如果直接等待 false,会立刻命中,导致扫描还没真正开始就结束。
      ///
      /// 正确做法:
      /// 先等 true,再等 false。
      await FlutterBluePlus.isScanning.where((value) => value).first;
      await FlutterBluePlus.isScanning.where((value) => !value).first;
    } catch (e) {
      _addLog('扫描异常:$e');
    } finally {
      await sub.cancel();

      if (mounted) {
        setState(() {
          _isScanning = false;
        });
      }

      _addLog('扫描结束,共发现 ${_devices.length} 个设备');
    }
  }

  // ─── STEP 2:连接设备 ──────────────────────────────────────────────────────

  Future<void> _connect(BluetoothDevice device) async {
    _addLog('开始连接设备...');

    setState(() {
      _step = _Step.connect;
    });

    try {
      await device.connect(
        timeout: const Duration(seconds: 15),
      );

      _device = device;

      _addLog('连接成功,开始发现服务...');

      final services = await device.discoverServices();

      /// 兼容完整 UUID 和短 UUID。
      _service = services.firstWhere(
        (service) {
          final uuid = service.uuid.str.toLowerCase();

          return uuid == _serviceUuid.toLowerCase() ||
              uuid == _serviceShortUuid.toLowerCase();
        },
        orElse: () => throw Exception('未找到目标服务'),
      );

      _addLog('找到目标服务,开始获取 WiFi 列表...');

      setState(() {
        _step = _Step.wifi;
      });

      await _loadWifiList();
    } catch (e) {
      _addLog('连接失败:$e');

      setState(() {
        _step = _Step.scan;
      });
    }
  }

  // ─── STEP 3:获取 WiFi 列表 ────────────────────────────────────────────────

  Future<void> _loadWifiList() async {
    setState(() {
      _isLoadingWifi = true;
      _wifiList.clear();
      _wifiBuffer.clear();
    });

    final wifiChar = _findChar(_charWifi);
    final notifyChar = _findChar(_charNotify);

    if (wifiChar == null || notifyChar == null) {
      _addLog('未找到 WiFi 或配网结果特征值');

      setState(() {
        _isLoadingWifi = false;
      });

      return;
    }

    try {
      if (!_wifiNotifyEnabled) {
        /// 首次获取 WiFi 列表
        ///
        /// 关键点 1:
        /// 先订阅配网结果 notify,再订阅 WiFi 列表 notify。
        ///
        /// 有些设备端协议要求两个 notify 都开启后,
        /// 才会推送完整 WiFi 列表。
        _notifySub = notifyChar.onValueReceived.listen((chunk) {
          /// 这里不一定需要处理数据。
          /// 但保持订阅可以满足设备端协议要求。
          if (chunk.isNotEmpty) {
            _addLog('配网结果通道收到 ${chunk.length} 字节');
          }
        });

        await notifyChar.setNotifyValue(true);

        /// 关键点 2:
        /// 不建议用 StreamController.broadcast() 转发 BLE 数据。
        /// broadcast stream 没有 listener 时会直接丢数据。
        ///
        /// 更稳妥的方式是:BLE 数据一到,就直接进入 List<int> buffer。
        _wifiSub = wifiChar.onValueReceived.listen((chunk) {
          if (chunk.isEmpty) {
            return;
          }

          _wifiBuffer.addAll(chunk);

          _addLog(
            'WiFi 通道收到 ${chunk.length} 字节,累计 ${_wifiBuffer.length} 字节',
          );
        });

        await wifiChar.setNotifyValue(true);

        _wifiNotifyEnabled = true;

        _addLog('首次获取:通知已开启,等待设备主动推送 WiFi 列表...');
      } else {
        /// 重试获取 WiFi 列表
        ///
        /// 关键点:
        /// notify 已经开启过了,不能每次都重新订阅。
        /// 重试时通过 read 触发设备重新扫描或重新推送。
        _addLog('重试获取:通知已开启,通过 read 触发设备重新扫描...');

        try {
          await wifiChar.read();
        } catch (e) {
          _addLog('read 触发失败,可忽略:$e');
        }
      }

      /// 等待设备推送 WiFi 数据。
      ///
      /// 这里最多等待 20 秒。
      /// 如果提前解析到足够的 WiFi,也可以提前结束。
      for (var i = 0; i < 20; i++) {
        await Future<void>.delayed(const Duration(seconds: 1));

        if (_wifiBuffer.isEmpty) {
          continue;
        }

        final list = _parseWifiList(_wifiBuffer);

        if (list.length >= 5) {
          _addLog('已获取 ${list.length} 个 WiFi,提前结束');

          setState(() {
            _wifiList.addAll(list);
            _isLoadingWifi = false;
          });

          return;
        }
      }

      final list = _parseWifiList(_wifiBuffer);

      _addLog('WiFi 获取结束,共解析到 ${list.length} 个 WiFi');

      setState(() {
        _wifiList.addAll(list);
        _isLoadingWifi = false;
      });
    } catch (e) {
      _addLog('获取 WiFi 列表异常:$e');

      setState(() {
        _isLoadingWifi = false;
      });
    }
  }

  // ─── WiFi JSON 解析 ───────────────────────────────────────────────────────

  List<Map<String, dynamic>> _parseWifiList(List<int> buffer) {
    if (buffer.isEmpty) {
      return [];
    }

    try {
      final str = utf8.decode(buffer);

      /// 设备可能多次推送 JSON 数组。
      ///
      /// 例如:
      /// [{"ssid":"WiFi_A","rssi":-45}]
      /// [{"ssid":"WiFi_A","rssi":-45},{"ssid":"WiFi_B","rssi":-60}]
      ///
      /// 直接 jsonDecode 会失败。
      /// 这里取最长的 JSON 数组,认为它是最完整的数据。
      final matches = RegExp(
        r'\[(?:[^\[\]]*)\]',
        dotAll: true,
      ).allMatches(str);

      if (matches.isEmpty) {
        return [];
      }

      final best = matches.reduce(
        (a, b) => a.group(0)!.length >= b.group(0)!.length ? a : b,
      );

      final list = jsonDecode(best.group(0)!) as List;

      return list
          .cast<Map<String, dynamic>>()
          .where((item) {
            final ssid = item['ssid'] as String?;
            return ssid != null && ssid.isNotEmpty;
          })
          .toList();
    } catch (e) {
      _addLog('WiFi 列表解析失败:$e');
      return [];
    }
  }

  // ─── STEP 4:发送配网凭证 ──────────────────────────────────────────────────

  Future<void> _sendCredentials() async {
    if (_ssid.isEmpty || _password.isEmpty) {
      return;
    }

    setState(() {
      _step = _Step.provisioning;
      _isProvisioning = true;
      _result = '';
    });

    _addLog('开始发送 WiFi 凭证,SSID 已脱敏');

    try {
      /// 写入 SSID
      await _writeChunked(_charSsid, _ssid);

      _addLog('SSID 发送完成,开始发送密码...');

      /// 写入密码
      await _writeChunked(_charPass, _password);

      _addLog('密码发送完成,等待设备返回配网结果,超时时间 30 秒...');

      final notifyChar = _findChar(_charNotify);

      if (notifyChar == null) {
        throw Exception('未找到配网结果特征值');
      }

      final resultBuffer = <int>[];
      final completer = Completer<String>();

      final sub = notifyChar.onValueReceived.listen((chunk) {
        if (chunk.isEmpty) {
          return;
        }

        resultBuffer.addAll(chunk);

        final str = utf8.decode(resultBuffer).trim();

        /// 设备可能返回:
        /// Connected!
        /// {"result":"ok"}
        /// {"result":"fail","reason":"auth_failed"}
        if ((str.startsWith('{') && str.endsWith('}')) ||
            str == 'Connected!' ||
            str.contains('success')) {
          if (!completer.isCompleted) {
            completer.complete(str);
          }
        }
      });

      /// 关键点:
      /// 配网结果不能超时太短。
      /// 设备连接 WiFi 可能需要十几秒,所以这里设置为 30 秒。
      final result = await completer.future.timeout(
        const Duration(seconds: 30),
        onTimeout: () => 'timeout',
      );

      await sub.cancel();

      _addLog('收到配网结果:$result');

      setState(() {
        _result = _parseResult(result);
        _isProvisioning = false;
        _step = _Step.done;
      });
    } catch (e) {
      _addLog('配网异常:$e');

      setState(() {
        _result = '配网失败:$e';
        _isProvisioning = false;
        _step = _Step.done;
      });
    }
  }

  // ─── 配网结果解析 ─────────────────────────────────────────────────────────

  String _parseResult(String raw) {
    if (raw == 'timeout') {
      return '⏱ 配网超时';
    }

    if (raw == 'Connected!' || raw.contains('success')) {
      return '✅ 配网成功';
    }

    try {
      final json = jsonDecode(raw) as Map<String, dynamic>;

      if (json['result'] == 'ok') {
        return '✅ 配网成功';
      }

      final reason = json['reason'] ?? 'unknown';

      const reasonMap = {
        'auth_failed': '密码错误',
        'ap_not_found': '未找到 WiFi',
        'assoc_timeout': '路由器拒绝',
        'dhcp_failed': 'DHCP 失败',
        'timeout': '连接超时',
      };

      return '❌ 配网失败:${reasonMap[reason] ?? reason}';
    } catch (_) {
      return '❌ 未知结果';
    }
  }

  // ─── 分包写入 ─────────────────────────────────────────────────────────────

  Future<void> _writeChunked(String charUuid, String value) async {
    final char = _findChar(charUuid);

    if (char == null) {
      throw Exception('未找到特征值');
    }

    /// 关键点:
    /// 分包必须基于 UTF-8 字节。
    /// 不能直接按字符串长度截取,否则中文、emoji 可能被截断。
    final bytes = utf8.encode(value);

    for (var i = 0; i < bytes.length; i += _chunkSize) {
      final end = (i + _chunkSize).clamp(0, bytes.length);

      final chunk = bytes.sublist(i, end);

      await char.write(
        chunk,
        withoutResponse: false,
      );

      if (end < bytes.length) {
        await Future<void>.delayed(
          const Duration(milliseconds: 100),
        );
      }
    }
  }

  // ─── 查找特征值 ───────────────────────────────────────────────────────────

  BluetoothCharacteristic? _findChar(String uuid) {
    if (_service == null) {
      return null;
    }

    /// 从完整 UUID 中提取短 UUID。
    ///
    /// 示例:
    /// xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxx01
    ///
    /// 实际项目中请根据自己的 UUID 结构调整。
    String shortUuid = '';

    try {
      shortUuid = uuid.replaceAll('-', '').substring(4, 8).toLowerCase();
    } catch (_) {
      shortUuid = '';
    }

    try {
      return _service!.characteristics.firstWhere(
        (char) {
          final charUuid = char.uuid.str.toLowerCase();

          return charUuid == uuid.toLowerCase() ||
              charUuid == shortUuid;
        },
      );
    } catch (_) {
      return null;
    }
  }

  // ─── 断开连接 ─────────────────────────────────────────────────────────────

  Future<void> _disconnect() async {
    await _wifiSub?.cancel();
    await _notifySub?.cancel();

    try {
      await _device?.disconnect();
    } catch (_) {}

    setState(() {
      _device = null;
      _service = null;

      _wifiList.clear();
      _wifiBuffer.clear();

      _wifiNotifyEnabled = false;

      _ssid = '';
      _password = '';
      _result = '';

      _step = _Step.scan;
    });

    _addLog('已断开连接');
  }

  @override
  void dispose() {
    _wifiSub?.cancel();
    _notifySub?.cancel();

    try {
      _device?.disconnect();
    } catch (_) {}

    super.dispose();
  }

  // ─── UI ───────────────────────────────────────────────────────────────────

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('BLE 配网 Demo'),
        actions: [
          if (_device != null)
            IconButton(
              icon: const Icon(Icons.bluetooth_disabled),
              tooltip: '断开连接',
              onPressed: _disconnect,
            ),
        ],
      ),
      body: Column(
        children: [
          _buildStepIndicator(),
          Expanded(
            child: switch (_step) {
              _Step.scan => _buildScanView(),
              _Step.connect => _buildLoadingView('正在连接设备...'),
              _Step.wifi => _buildWifiView(),
              _Step.provisioning => _buildLoadingView('正在配网...'),
              _Step.done => _buildDoneView(),
            },
          ),
          _buildLogView(),
        ],
      ),
    );
  }

  Widget _buildStepIndicator() {
    final steps = ['扫描', '连接', 'WiFi', '配网', '完成'];
    final current = _step.index;

    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 12),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: List.generate(steps.length, (index) {
          final done = index < current;
          final active = index == current;

          return Row(
            children: [
              CircleAvatar(
                radius: 14,
                backgroundColor: done
                    ? Colors.green
                    : active
                        ? Colors.blue
                        : Colors.grey.shade300,
                child: Text(
                  done ? '✓' : '${index + 1}',
                  style: TextStyle(
                    fontSize: 12,
                    color: done || active ? Colors.white : Colors.grey,
                  ),
                ),
              ),
              if (index < steps.length - 1)
                Container(
                  width: 24,
                  height: 2,
                  color: done ? Colors.green : Colors.grey.shade300,
                ),
            ],
          );
        }),
      ),
    );
  }

  Widget _buildScanView() {
    return Column(
      children: [
        Padding(
          padding: const EdgeInsets.all(16),
          child: ElevatedButton.icon(
            onPressed: _isScanning ? null : _startScan,
            icon: _isScanning
                ? const SizedBox(
                    width: 16,
                    height: 16,
                    child: CircularProgressIndicator(strokeWidth: 2),
                  )
                : const Icon(Icons.bluetooth_searching),
            label: Text(_isScanning ? '扫描中...' : '开始扫描'),
          ),
        ),
        Expanded(
          child: _devices.isEmpty
              ? const Center(
                  child: Text('未发现设备,点击开始扫描'),
                )
              : ListView.builder(
                  itemCount: _devices.length,
                  itemBuilder: (_, index) {
                    final result = _devices[index];
                    final name = result.advertisementData.advName;

                    return ListTile(
                      leading: const Icon(Icons.bluetooth),
                      title: Text(name.isEmpty ? '未知设备' : name),
                      subtitle: const Text('设备 ID 已脱敏'),
                      trailing: Text('${result.rssi} dBm'),
                      onTap: () => _connect(result.device),
                    );
                  },
                ),
        ),
      ],
    );
  }

  Widget _buildWifiView() {
    if (_isLoadingWifi) {
      return const Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            CircularProgressIndicator(),
            SizedBox(height: 16),
            Text('正在获取 WiFi 列表...'),
          ],
        ),
      );
    }

    if (_wifiList.isEmpty) {
      return Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            const Text('未获取到 WiFi 列表'),
            const SizedBox(height: 16),
            ElevatedButton(
              onPressed: _loadWifiList,
              child: const Text('重试'),
            ),
          ],
        ),
      );
    }

    return ListView.builder(
      itemCount: _wifiList.length,
      itemBuilder: (_, index) {
        final wifi = _wifiList[index];

        final ssid = wifi['ssid'] as String? ?? '';
        final rssi = wifi['rssi'] as int? ?? -100;

        return ListTile(
          leading: Icon(
            rssi >= -50
                ? Icons.wifi
                : rssi >= -70
                    ? Icons.wifi_2_bar
                    : Icons.wifi_1_bar,
          ),
          title: Text(ssid),
          trailing: Text('$rssi dBm'),
          onTap: () => _showPasswordDialog(ssid),
        );
      },
    );
  }

  void _showPasswordDialog(String ssid) {
    final controller = TextEditingController();

    showDialog<void>(
      context: context,
      builder: (_) {
        return AlertDialog(
          title: Text('连接 "$ssid"'),
          content: TextField(
            controller: controller,
            obscureText: true,
            decoration: const InputDecoration(
              labelText: 'WiFi 密码',
            ),
          ),
          actions: [
            TextButton(
              onPressed: () {
                Navigator.pop(context);
              },
              child: const Text('取消'),
            ),
            ElevatedButton(
              onPressed: () {
                Navigator.pop(context);

                setState(() {
                  _ssid = ssid;
                  _password = controller.text;
                });

                _sendCredentials();
              },
              child: const Text('连接'),
            ),
          ],
        );
      },
    );
  }

  Widget _buildLoadingView(String message) {
    return Center(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          const CircularProgressIndicator(),
          const SizedBox(height: 16),
          Text(message),
        ],
      ),
    );
  }

  Widget _buildDoneView() {
    final success = _result.startsWith('✅');

    return Center(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Icon(
            success ? Icons.check_circle : Icons.error,
            size: 64,
            color: success ? Colors.green : Colors.red,
          ),
          const SizedBox(height: 16),
          Text(
            _result,
            style: const TextStyle(fontSize: 18),
          ),
          const SizedBox(height: 24),
          ElevatedButton(
            onPressed: _disconnect,
            child: const Text('重新配网'),
          ),
        ],
      ),
    );
  }

  Widget _buildLogView() {
    return Container(
      height: 120,
      width: double.infinity,
      color: Colors.black87,
      padding: const EdgeInsets.all(8),
      child: SingleChildScrollView(
        reverse: true,
        child: Text(
          _log,
          style: const TextStyle(
            color: Colors.green,
            fontSize: 11,
          ),
        ),
      ),
    );
  }
}

十二、关键踩坑点总结

1. 设备广播名大小写不一致

设备名可能是:

text 复制代码
Device_xxx
DEVICE_XXX
device_xxx

所以扫描过滤时要统一转小写:

dart 复制代码
final name = advName.toLowerCase();

2. UUID 可能是长格式,也可能是短格式

有些设备返回完整 UUID:

text 复制代码
xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

有些设备返回短 UUID:

text 复制代码
xxxx

所以服务和特征值匹配时,两种都要兼容。


3. isScanning 初始值是 false

不能直接等:

dart 复制代码
where((value) => !value).first

否则可能立刻命中。

正确做法:

dart 复制代码
await FlutterBluePlus.isScanning.where((value) => value).first;
await FlutterBluePlus.isScanning.where((value) => !value).first;

4. ffe1 和 ffe4 都要订阅

实际设备可能要求:

text 复制代码
配网结果 notify 已开启
WiFi 列表 notify 已开启

两个条件都满足后,才会推送完整 WiFi 列表。

所以首次获取 WiFi 时,不能只订阅 WiFi 列表通道。


5. 首次和重试获取 WiFi 的方式不同

首次:

text 复制代码
开启 notify
等待设备主动推送

重试:

text 复制代码
notify 已经开启
通过 read 触发设备重新扫描

不要每次重试都重新订阅 notify。


6. broadcast stream 会丢数据

StreamController.broadcast() 没有 listener 时不会缓存数据。

BLE notify 数据到达很快时,如果 listener 还没建立,数据就会丢。

更稳妥的方式:

dart 复制代码
final buffer = <int>[];

sub = char.onValueReceived.listen((chunk) {
  buffer.addAll(chunk);
});

7. WiFi 列表可能是分包数据

不能假设一次 notify 就能收到完整 JSON。

需要用 buffer 累积:

dart 复制代码
_wifiBuffer.addAll(chunk);

然后再统一解析。


8. WiFi JSON 可能多次拼接

设备可能连续推送多个 JSON 数组。

可以临时取最长的 JSON 数组作为最完整结果。

正式项目中更推荐设备端增加:

json 复制代码
{
  "type": "wifi_list",
  "seq": 1,
  "end": true,
  "data": []
}

9. SSID 和密码分包必须按 UTF-8 字节

不能按字符串长度截取。

正确做法:

dart 复制代码
final bytes = utf8.encode(value);

因为 SSID 可能包含中文、emoji 或特殊字符。


10. 配网结果超时不要太短

设备连接 WiFi 可能需要十几秒。

建议至少给到 30 秒:

dart 复制代码
timeout: const Duration(seconds: 30)

否则可能出现:

text 复制代码
设备其实已经成功
App 却提前显示失败

十三、正式项目优化建议

如果要把 Demo 用到正式项目中,建议继续补充这些能力:

text 复制代码
1. Android / iOS 蓝牙权限检查
2. 蓝牙未开启时的用户引导
3. 定位权限处理
4. 设备连接状态监听
5. BLE 断线自动恢复
6. 配网过程防重复点击
7. WiFi 密码格式校验
8. 设备端错误码完整映射
9. 配网埋点和失败原因统计
10. Debug 日志只在开发环境展示
11. 敏感日志脱敏
12. 设备 ID、WiFi 密码不落日志
13. WiFi 列表协议增加 seq/end 字段
14. App 退出页面时主动释放 BLE 连接

十四、脱敏说明

本文对以下敏感信息进行了脱敏:

原始内容类型 脱敏方式
设备名称前缀 替换为 device_xxx
Service UUID 替换为 xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Characteristic UUID 替换为 xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxx01
WiFi 名称 示例中使用 WiFi_AWiFi_B
设备 ID / MAC 不展示真实值
WiFi 密码 不展示
业务品牌名 泛化为"IoT 设备"或"设备"
日志内容 去除真实设备 ID 和真实 SSID

十五、总结

BLE 配网主流程并不复杂:

text 复制代码
扫描
连接
发现服务
订阅通知
获取 WiFi 列表
发送 SSID 和密码
等待配网结果

但真正影响稳定性的,是这些细节:

text 复制代码
设备名大小写
长短 UUID 兼容
notify 开启顺序
首次和重试触发方式不同
BLE 数据分包
broadcast stream 丢数据
isScanning 初始值问题
配网结果超时

这类问题如果不处理,Demo 可能偶尔能跑通,但真实用户使用时就会出现各种不稳定。

所以 BLE 配网开发不能只验证"正常路径能成功",还要重点处理:

text 复制代码
扫不到设备
连接失败
找不到服务
收不到 WiFi 列表
WiFi 列表不完整
密码写入失败
设备成功但 App 超时
设备失败但 App 无法识别原因

把这些边界场景处理好,BLE 配网体验才会真正稳定。

相关推荐
片酷2 小时前
【Isaacsim&Isaaclab】安装教程
linux·开发语言·python
Magic@2 小时前
Redis学习[1] ——基本概念和数据类型
linux·开发语言·数据库·c++·redis·学习
黑不溜秋的2 小时前
C++ STL reduce 用法
开发语言·c++
倾听一世,繁花盛开2 小时前
Java语言程序设计——篇十三(1)
java·开发语言·ide·eclipse
大腕先生2 小时前
通用分页超详细介绍(附带源代码解析&页面展示效果)
xml·java·linux·服务器·开发语言·前端·idea
AIKZX2 小时前
西门子博途 TIA Portal v18 中文版图文安装教程(超级详细)附下载链接
开发语言·c#·编辑器·idea
RunsenLIu2 小时前
019 | backtrader回测布林带突破策略
开发语言·python
A_aspectJ2 小时前
如何抓住Java开发岗的市场红利?从需求端反推学习路径
java·开发语言·职场和发展
睿智的海鸥2 小时前
Markdown 语法大全详解
开发语言·前端·javascript·css·html