← 返回蜂巢洞察

Flutter中的低功耗蓝牙技术:开发者手册

大多数Flutter教程都只涉及到网络调用和REST API。但一旦你需要与物理设备进行交互——比如心率监测器、智能灯泡、健身追踪器、工业传感器,或者你自己定制的硬件设备——你就不得不离开HTTP这个“舒适的环境”,转而使用蓝牙低功耗技术。 本指南会教你如何在Flutter中正确且全面地实现这些功能。 移动设备上的蓝牙功能其实相当复杂。Android和iOS之间的权限设置有所不同,即使是同一款Android系统的不同版本,权限要求也会存在差异。蓝牙连接的生命周期包含许多状态,服务与特征的数据模型也会让新手感到困惑,而字节级的数据编码方式几乎会让每个人在初次尝试时遇到麻烦。 flutter_bl

大多数Flutter教程都只涉及到网络调用和REST API。但一旦你需要与物理设备进行交互——比如心率监测器、智能灯泡、健身追踪器、工业传感器,或者你自己定制的硬件设备——你就不得不离开HTTP这个“舒适的环境”,转而使用蓝牙低功耗技术。

本指南会教你如何在Flutter中正确且全面地实现这些功能。

移动设备上的蓝牙功能其实相当复杂。Android和iOS之间的权限设置有所不同,即使是同一款Android系统的不同版本,权限要求也会存在差异。蓝牙连接的生命周期包含许多状态,服务与特征的数据模型也会让新手感到困惑,而字节级的数据编码方式几乎会让每个人在初次尝试时遇到麻烦。

flutter_blue_plus这个包能够掩盖大部分与平台相关的复杂性,同时让你能够完全控制扫描设备、建立连接以及交换数据的过程。

这本手册的设计初衷就是帮助读者系统地了解如何使用蓝牙低功耗技术。它涵盖了蓝牙的工作原理、针对Android和iOS平台的配置方法、设备扫描与广告数据的解析过程、连接过程中的MTU协商机制、服务发现机制、数据的读写操作、通知功能的实现方式、配对与绑定流程、后台操作的注意事项、错误处理方法,以及适用于生产环境的服务架构设计,还包括测试与调试技巧以及性能优化方法。

此外,书中的每一段代码都配有详细的解释,因此你可以根据自己的实际需求对这些代码进行修改,以便应用于自己的硬件设备上。

目录

先决条件

您需要安装Flutter SDK(版本3.0或更高),并且需要对Dart、StatefulWidgetFuture以及Stream API有充分的了解,因为BLE技术中的几乎所有功能都是基于数据流来实现的。

您还需要一台真实的Android或iOS设备,因为BLE技术在模拟器上是无法正常工作的——这些设备并不具备蓝牙功能。

最后,您需要一个能够与BLE设备进行通信的终端设备。便宜的心率监测手环、像Nordic nRF52这样的BLE开发板、ESP32芯片,甚至是一台运行了BLE外围设备模拟应用程序的第二部手机,都可以满足这一需求。

建议您也在另一部手机上安装免费的nRF Connect应用程序,因为它是进行BLE开发时最实用的调试工具。

蓝牙经典版与蓝牙低功耗版

蓝牙技术分为两种不兼容的版本,许多开发者最初会犯将它们混淆的错误。

蓝牙经典版(也称为BR/EDR,即基本速率/增强数据速率协议)是一种较老的技术,具有较高的带宽,可用于向耳机传输音频、进行文件传输或模拟串行端口功能。

蓝牙低功耗版是随着蓝牙4.0技术推出的全新协议,它专为发送少量数据以及极低的能耗而设计。使用蓝牙低功耗技术的传感器只需一块电池就能持续运行数月甚至数年,而蓝牙经典版则无法实现这一点。

这两种协议之间是无法互相通信的。仅支持蓝牙经典版的设备无法通过BLE API进行控制,反之亦然。不过,许多现代芯片都具备双模式功能,因此可以同时支持这两种协议。

flutter_blue_plus包专门用于处理蓝牙低功耗相关功能。如果您需要使用蓝牙经典版来建立与Arduino之间的串行连接,那么就需要使用flutter_bluetooth_serial等其他包。

本文所介绍的内容全部都与BLE技术有关,而现代绝大多数物联网设备及可穿戴设备实际上都在使用BLE技术。

对于开发者来说,这两种蓝牙版本在实际应用中的主要区别在于数据传输方式。蓝牙经典版提供的是类似套接字的数据流结构,而蓝牙低功耗版则提供了一个结构化数据库,您需要按字段逐个进行读写操作。这种差异会直接影响整个API的设计,因此在编写代码之前了解这些区别是非常重要的。

BLE数据模型:GATT、服务与特性

BLE数据是通过通用属性协议(GATT)来组织的。GATT建立在属性协议(ATT)的基础上,但您通常不会直接使用ATT。关键在于,外围设备会暴露出一个层次化的数据库结构,而您的手机则会读取或写入其中的数据。

外围设备(例如心率监测器)
└── 服务:心率监测(UUID 0x180D)
    ├── 特性:心率测量数据(0x2A37)[可通知]
    │   └── 描述符:客户端特性配置信息(0x2902)
    ├── 特性:身体传感器位置信息(0x2A38)[仅可读取]
    └── 特性:心率控制点设置(0x2A39)[可写入]
└── 服务:电池状态(UUID 0x180F)
    └── 特性:电池电量信息(0x2A19)[可读取、可通知]
上图展示了典型外设的GATT结构。在最顶层,设备会提供一项或多项服务,每项服务都通过一个UUID来标识,这些服务会将相关的功能组合在一起,例如心率服务和电池服务。 在每项服务内部,还包含了具体的特征信息——这些其实就是您与之交互的实际数据端点。每个特征都有一个UUID,以及一组用方括号括起来的属性,这些属性说明了该特征支持哪些操作。 某些特征还会包含描述符,这些描述符是附加在特征上的元数据。其中最重要的描述符是“客户端特征配置描述符”(CCCD,UUID为0x2902),它决定了通知功能是否能够被启用。 在编写BLE代码时,您需要按照这种结构进行操作:先发现设备提供的服务,找到所需的具体特征,然后再对这些特征进行读写操作或订阅相关数据。 UUID有两种长度。蓝牙技术联盟定义的标准功能通常使用16位的短UUID,这类UUID由4个十六进制数字表示,例如代表心率服务的UUID为0x180D。实际上,这些短UUID是符合特定格式的128位UUID的缩写形式。 而那些实现自定义功能的设备则会使用完整的128位UUID,这类UUID通常以长字符串的形式表示,例如Nordic UART服务所使用的UUID为6e400001-b5a3-f393-e0a9-e50e24dcca9e。在自行开发硬件时,您需要为各种服务和特征生成随机的128位UUID,以确保它们不会与其他设备的UUID发生冲突。

角色、广告机制与连接生命周期

BLE定义了两种容易混淆的角色组合。第一种角色组合与连接过程相关:中心设备负责扫描并发起连接请求,通常是手机;而外设则负责发送广告信息并接受连接请求,通常是传感器或可穿戴设备。 第二种角色组合描述了连接建立后的数据流动方式:GATT客户端会向GATT服务器请求数据,而数据通常存储在服务器端,即外设中。 在本文的示例中,您的Flutter应用程序扮演的是GATT客户端和中心设备的角色,而硬件设备则作为外设和GATT服务器存在。这种安排是常见的,不过两种角色的角色也可以互换,某个设备完全有可能同时承担这两种角色。 在任何连接建立之前,外设会首先发送广告包。广告包包含少量数据,在旧版本格式下最大长度为31字节,它用于宣告设备的存在,并可以包含设备的名称、提供的服务UUID、制造商特定的信息以及传输功率等级等信息。中心设备通过监听这些广告包来发现周围的可连接设备。 当您决定建立连接时,两台设备会进行协商,确定诸如连接间隔时间这样的参数——连接间隔时间决定了它们交换数据包的频率。较短的间隔时间可以降低延迟,但会增加功耗;而较长的间隔时间虽然能节省电池电量,却会导致延迟增加。

在连接成功后,中央设备会执行服务发现操作,以获取外围设备的GATT结构信息,只有这样它才能进行读写操作或订阅相关服务。当任意一方超出通信范围或主动断开连接时,链接就会中断,所有已发现的服务对象都会失效,此时你需要重新连接并重新执行服务发现流程才能继续使用该功能。

理解这一完整的生命周期过程(广告发布、扫描设备、建立连接、发现服务信息、进行数据交互以及断开连接),是编写任何与蓝牙相关的代码时必须掌握的基本概念。

选择Flutter蓝牙插件

在Flutter中,有许多用于处理BLE功能的插件,选择合适的插件能够避免后续出现各种问题。本文推荐使用flutter_blue_plus这个插件,它是原本被弃用的flutter_blue插件的社区维护版本,目前仍然得到活跃的开发与更新。该插件支持Android、iOS和macOS系统,提供了基于流式的简洁API接口,并涵盖了包括MTU协商、设备配对以及连接优先级设置在内的所有核心功能。

另一个值得考虑的选项是飞利浦开发的flutter_reactive_ble插件,这个插件的设计更加偏向于反应式编程模式,要求开发者为每个操作分别创建相应的数据流。如果你所在的团队已经习惯使用反应式编程方式,那么这个插件也是一个不错的选择。

还有universal_ble这个插件,它为Windows/Linux系统提供了支持,并提供了一个统一的API接口。因此,如果你的目标用户群是桌面应用或浏览器用户,这个插件会非常有用。

如果你需要使用经典蓝牙协议而不是BLE协议,那么应该选择flutter_bluetooth_serial插件,因为其他BLE插件都不支持这种协议。

对于大多数同时针对Android和iOS平台、并且需要作为中央设备与外围设备进行连接的项目来说,flutter_blue_plus无疑是最佳选择。该插件成熟度较高,相关文档也很齐全,而且拥有庞大的开发者社区。即使不同插件的方法名称有所不同,但它们所基于的BLE技术框架是相同的,因此本文介绍的概念同样适用于这些插件。

项目设置

首先创建一个新的Flutter项目,然后添加所需的插件。其中必不可少的两个插件是flutter_blue_plus(用于处理BLE功能)和permission_handler(用于在Android平台上优雅地请求运行时权限)。

flutter create ble_demo
cd ble_demo
flutter pub add flutter_blue_plus
flutter pub add permission_handler

这些命令会生成一个空项目框架,随后会将这两个插件添加到你的pubspec.yaml文件中,系统会自动执行flutter pub get命令来下载最新的依赖版本。使用flutter pub add而不是手动编辑pubspec.yaml文件,可以确保你安装的是兼容的最新版本,同时也能避免在YAML文件中出现格式错误。配置完成后,打开pubspec.yaml文件,确认这两个插件确实被添加到了dependencies部分中,并且版本信息也是正确的。

无论你在项目的哪个位置需要使用这些插件,只需通过一行代码即可导入它们。该插件通过顶级的FlutterBluePlus类以及BluetoothDeviceBluetoothServiceBluetoothCharacteristic类型,提供了所有必要的功能接口。

import 'dart:async';
import 'dart:io' show Platform;
import 'packageflutter_blue_plus/flutter_blue_plus.dart';

这个导入语句块引入了三项在后续开发中会经常用到的内容。导入`dart.async`模块后,你可以使用`StreamSubscription`和`Future`这两个类,因为所有的BLE操作都离不开它们;导入`dart:io`模块后,你可以获取`Platform`对象,这样就可以根据不同的平台环境来编写针对Android或iOS的代码了,而`show Platform`这条语句则有助于避免不必要的导入;最后一行代码用于导入该插件本身。将这些导入语句放在所有与BLE相关的文件的顶部,可以避免因为某些类型在作用域之外而导致的错误。

配置Android权限

对于Android平台来说,权限设置相对更为复杂,因为从Android 12(API级别31)开始,蓝牙相关的权限规定发生了显著变化。

在Android 11及更早的版本中,进行BLE扫描需要位置权限,因为搜索附近的设备实际上可能会暴露用户的位置信息。但从Android 12开始,系统提供了专门的蓝牙权限选项,因此你可以选择不使用位置权限。不过,为了确保你的应用能够在用户的各种设备上正常运行,你仍然需要声明所有相关的权限。

打开`android/app/src/main/AndroidManifest.xml`文件,在``标签内、``标签之前添加以下内容:

<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" /> <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" /> <uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />

前三个权限是针对Android 12及更高版本的。`BLUETOOTH_SCAN`权限允许你的应用发现附近的设备,而`neverForLocation`标志则告诉系统你不会利用BLE功能来获取用户的位置信息,因此在这类设备上你可以完全跳过请求位置权限的步骤。

要连接设备并与其交换数据,就必须使用`BLUETOOTH_CONNECT`权限;如果你的应用只是作为从设备进行广播操作,那么`BLUETOOTH_ADVERTISE`权限就不是必需的,对于纯中央控制型应用来说,可以忽略这个权限。

接下来的三个权限是针对Android 11及更低版本的:`BLUETOOTH`和`BLUETOOTH_ADMIN`是传统的蓝牙权限,而在这些版本中,`ACCESS_FINE_LOCATION`权限也是进行扫描操作所必需的。通过设置`maxSdkVersion="30"`,可以让这些权限仅适用于Android 11及更早的版本,从而避免在新设备上不必要的权限请求。最后的`uses-feature`语句声明了你的应用需要蓝牙硬件支持,而将`required="true"`设置为可以防止Play Store将这个应用推荐给没有相应硬件的设备。

需要注意的一点是:如果你设置了neverForLocation,但你的应用程序实际上确实使用了BLE技术来确定位置信息(例如基于信标的室内定位功能),那么你必须取消这个设置并申请位置权限;否则,Android系统会忽略你通过扫描获取的位置数据。对于那些只需要与已知设备进行交互的情况,可以保持这个设置的启用状态。

你还需要指定应用程序所支持的最低SDK版本。打开android/app/build.gradle文件,确认minSdkVersion的值至少为21,因为BLE相关API要求使用这一版本。

android {
    defaultConfig {
        minSdkVersion 21
        targetSdkVersion 34
    }
}

这段代码用于指定你的应用程序支持的Android版本范围。minSdkVersion 21对应的是Android 5.0,这是flutter_blue_plus框架中首次支持BLE功能的版本;而设置targetSdkVersion 34则告诉系统,你的应用程序是按照较新的Android行为规范进行测试的,这对于提交到Play Store来说是必要的,同时也能确保你的应用程序使用的是Android 12版本的权限模型,而不是旧的基于位置的权限机制。

配置iOS应用的权限与后台运行模式

在iOS系统中,权限管理的流程相对简单,但App Store的审核要求却更为严格。代码中不能在运行时动态申请权限,你必须为应用程序的功能提供明确的描述;否则,一旦应用程序尝试使用蓝牙功能,就会立即崩溃。打开ios/Runner/Info.plist文件,在顶层的标签内添加以下键值对。

<key>NSBluetoothAlwaysUsageDescription本应用程序使用蓝牙技术与您的设备建立连接并进行通信。
<key>NSBluetoothPeripheralUsageDescription
本应用程序使用蓝牙技术与您的设备建立连接并进行通信。

这两个键值对决定了iOS系统在用户首次使用蓝牙功能时会在权限提示界面显示哪些文字。NSBluetoothAlwaysUsageDescription是iOS 13及更高版本所使用的键值对,而NSBluetoothPeripheralUsageDescription则适用于更早的版本。

你需要编写一段清晰明了的描述,解释为什么你的应用程序需要使用蓝牙功能,并说明这一功能能为用户带来什么好处。因为苹果会在审核过程中拒绝那些描述含糊不清或完全没有合理依据的应用程序。在iOS系统中,系统会在用户首次尝试使用蓝牙功能时自动显示权限提示界面,因此在这个平台上你不需要手动调用permission_handler方法。

如果你的应用程序需要在后台继续使用蓝牙功能,例如在屏幕关闭的情况下仍然接收心率监测数据,那么你也必须声明相应的后台运行模式。请将以下内容添加到相同的Info.plist文件中:

<key>UIBackgroundModes

    bluetooth-central

这个数组使得bluetooth-central背景模式成为可能,这样即使在用户离开应用程序界面后,您的应用仍可以继续扫描并与其他外设进行通信。如果没有这个数组,当应用程序退出前台时,iOS系统会暂停其蓝牙相关操作。

只有当您确实需要在后台执行某些操作时,才应该声明使用这个数组。因为苹果会在审核过程中严格检查这些背景模式功能,对于那些没有正当理由就申请使用这些功能的应用,他们会予以拒绝。如果您的应用程序在后台也充当外设的角色,那么还需要将bluetooth-peripheral添加到数组中。

检查蓝牙适配器状态

在开始扫描之前,请先确认设备确实支持蓝牙功能并且已开启该功能。flutter_blue_plus提供了一个用于获取适配器状态的流,因此即使在应用程序运行过程中,您也能实时响应用户在系统设置中切换蓝牙开关的操作。

Future initBluetooth() async {
  if (await FlutterBluePlus.isSupported == false) {
    print('此设备不支持蓝牙功能');
    return;
  }

  FlutterBluePlus.adapterState.listen((BluetoothAdapterState state) {
    print('适配器状态:$state');
    if (state == BluetoothAdapterState.on) {
      // 可以开始扫描了
    } else if (state == BluetoothAdapterState.off) {
      // 需要提示用户开启蓝牙功能
    }
  });

  if (Platform.isAndroid) {
    await FlutterBluePlus.turnOn();
  }
}

这个函数首先会检查FlutterBluePlus.isSupported,如果设备不支持蓝牙功能,这个方法会返回false,这样应用程序就可以优雅地退出而不会崩溃。随后,它会订阅FlutterBluePlus.adapterState这个流,每当蓝牙状态发生变化时,这个流就会发送新的BluetoothAdapterState值,因此即使用户在扫描过程中关闭了蓝牙功能,您的应用也能保持同步。

BluetoothAdapterState.on表示可以开始扫描了,而off则表示需要提示用户重新开启蓝牙功能。仅在Android系统上,FlutterBluePlus(turnOn())方法会通过显示标准的启用对话框来请求系统开启蓝牙功能;而在iOS系统中,苹果并没有提供用于程序化开启蓝牙功能的API,因此这个方法只有在确认设备支持蓝牙功能后才会被调用,在iOS系统中,您需要引导用户手动进入设置界面来开启蓝牙。

您也可以在不订阅该流的情况下,一次性获取当前的蓝牙状态信息。这种做法在需要做出决策的时候非常有用,而不是用于持续监控蓝牙状态。

BluetoothAdapterState current = FlutterBluePlus.adapterStateNow;
if (current != BluetoothAdapterState.on) {
  print('蓝牙功能尚未开启,当前状态:$current');
  return;
}

这个方法会获取FlutterBluePlus(adapterStateNow,也就是在调用该方法的瞬间适配器状态的快照。如果发现蓝牙功能未开启,就会立即停止后续的操作。在开始扫描或建立连接之前,使用这种检查方式可以避免执行那些肯定会失败的操作。

对于那些需要实时反映蓝牙设备状态的用户界面组件,可以使用前面代码片段中提供的数据流;而对于工作流程中偶尔需要的操作,则可以使用这个一次性获取权限的函数。

请求运行时权限

在Android 6.0及更高版本中,仅仅在清单文件中声明权限是不够的。对于某些敏感权限,还必须在运行时向用户申请,具体需要申请的权限列表会因Android版本的不同而有所差异。

`permission_handler`包使得这一过程变得简单明了,并且能够自动处理不同版本之间的差异。

import 'package:permission_handler/permission_handler.dart';

Future requestBlePermissions() async {
  if (!Platform.isAndroid) {
    return true;
  }

  final statuses = await [
    Permission.bluetoothScan,
    PermissionBluetoothConnect,
    Permission.location,
  ].request();

  final granted = statuses.values.every((status) => status.isGranted);

  if (!granted) {
    final permanentlyDenied = statuses.values.any(
      (status) => status.isPermanentlyDenied,
    );
    if (permanently Denied) {
      await openAppSettings();
    }
  }

  return granted;
}

在iOS系统中,这个函数会立即返回`true`,因为操作系统会通过`Info.plist`文件来处理蓝牙权限的请求,而无需用户编写任何代码。

在Android系统中,这个函数会通过将这三个权限作为列表传递给`.request()`方法,在一个系统对话框中向用户申请这些权限。其中`BluetoothScan`和`BluetoothConnect`权限对应于Android 12版本中的权限设置,而`location`权限则适用于那些仍需要将蓝牙扫描功能与位置信息关联起来的旧版本设备。该插件会自动忽略那些与当前操作系统版本无关的权限请求。调用这个函数后,会得到一个映射表,其中列出了每个权限的获取结果以及其最终的状态;`.every()`方法用于确认所有权限是否都已被成功授予。

如果有任何权限被永久性地拒绝授予,也就是说用户选择了“不再询问”,那么代码就会通过`openAppSettings()`函数打开应用程序的设置页面,让用户手动允许这些权限的使用。因为在这种情况下,系统不会再再次提示用户授权。

在首次进行蓝牙扫描之前,请先调用这个函数一次;如果它返回`false`,则应立即停止扫描操作。

搜索附近的设备

在权限问题得到解决之后,就可以开始搜索附近的可连接设备了。扫描过程会返回一系列扫描结果,每个结果都代表一个可连接的设备,其中包含了该设备的标识信息、信号强度以及它发布的广告数据。

final List _scanResults = [];
StreamSubscription>? _scanSubscription;

Future startScan() async {
  _scanResults.clear();

  _scanSubscription = FlutterBluePlus.onScanResults.listen(
    (results) {
      for (ScanResult r in results) {
        print(${r.device.remoteId}: "${r.advertisementData.advName}" '
            'rssi: ${r.rssi}`);
      }
      _scanResults
        ..clear()
        ..addAll(results);
    },
    onError: (e) => print('扫描错误:$e'),
  );

  FlutterBluePlus.cancelWhenScanComplete(_scanSubscription!);

  await FlutterBluePlus.startScan(
    timeout: const Duration(seconds: 15),
    androidUsesFineLocation: false,
  );
}

Future stopScan() async {
  await FlutterBluePlus.stopScan();
  await _scanSubscription?.cancel();
}

startScan函数首先会清除之前执行扫描所获得的结果,然后订阅FlutterBluePlus.onScanResults事件;每当有新的设备信息被检测到时,该事件就会发送当前发现的设备列表。

在监听器中,每个ScanResult对象都会提供设备的remoteId(一个稳定的标识符)、通过advertisementData.advName获取的设备名称,以及rssi值(以分贝为单位表示信号强度,数值越接近0表示信号越强,因此-40表示信号较强,而-95则表示信号较弱)。

onError回调函数可以捕获扫描过程中出现的错误,比如在扫描过程中权限被取消等情况。FlutterBluePlus.cancelWhenScanComplete会将订阅的生命周期与扫描操作绑定在一起,这样当超时发生时,系统就会自动清理相关资源。扫描操作本身是通过FlutterBluePlus.startScan启动的;其中timeout参数用于设置扫描在15秒后自动停止,从而节省电池电量;而androidUsesFineLocation: false这个选项则与你在应用程序清单中设置的neverForLocation标志保持一致。stopScan函数会提前终止无线电信号的发送,并取消相关的订阅操作,这样就不会有资源被持续占用。

如果你只对某种特定类型的设备感兴趣,就可以对扫描操作进行过滤,让操作系统忽略其他所有设备。这种方式比在Dart代码中先进行全范围扫描然后再进行过滤要高效得多,也更加可靠;尤其是在信号环境较为复杂的情况下,这种过滤机制的效果会更加显著。

await FlutterBluePlus.startScan(
  withServices: [Guid('180D')],
  withNames: ['MySensor'],
  withKeywords: ['Sensor'],
  timeout: const Duration(seconds: 15),
);

这个调用通过多种方式限制了扫描范围:withServices参数只让那些会广播指定服务UUID的外设被纳入扫描范围,在这里指定的UUID是用于检测心率的设备,而Guid类则用于封装这个UUID字符串。withNames参数会匹配那些其广告名称与列表中某个字符串完全相同的设备,而withKeywords参数则会匹配那些名称中包含特定子字符串的设备。

在平台层面进行过滤意味着你的结果流中只会包含相关的设备信息,这样一来,在有大量蓝牙设备同时广播信号的环境中,就能有效减少不必要的干扰。你可以将这些过滤条件组合使用,只有同时满足所有条件的设备才会被显示出来。

如果你想了解当前是否正在执行扫描操作,就可以监听FlutterBluePlus.isScanning事件;这个事件会返回一个布尔值,当扫描开始时会返回true,而当扫描结束时则会返回false,无论是因为超时还是因为调用了stopScan()函数才停止扫描的。将扫描按钮的标签和图标与这个事件绑定起来,可以让用户界面更准确地反映设备的实际状态,而不是根据你上次设置的指令来显示信息。

FlutterBluePlus.isScanning.listen((scanning) {
  print('Scanning: $scanning');
});

解析广告数据

每个扫描结果中附带的广告信息不仅仅包含设备名称和RSSI值,通常还会在连接之前就提供有关该设备主要用途的详细信息。正确解读这些信息能够帮助你准确识别并筛选目标设备。

void inspectAdvertisement(ScanResult r) {
  final adv = r.advertisementData;

  print('名称:${adv.advName}`);
  print('是否可连接:${adv.connectable}`);
  print('发射功率:${adv.txPowerLevel}};
  print('服务UUID列表:${adv.serviceUuids}');

  adv.manufacturerData.forEach((companyId, bytes) {
    print('制造商 $companyId:$bytes');
  });

  adv.serviceData.forEach((uuid, bytes) {
    print('服务数据 $uuid:$bytes';
  });
}

这个函数用于解析advertisementData对象。其中,advName表示设备公布的本地名称,但由于许多外设为了节省有限的31字节广告传输空间而省略了这个字段,因此该值通常为空。connectable用于判断该设备是否支持连接功能,因为有些设备虽然会发布广告信息,但实际上并不支持连接。

txPowerLevel表示设备声称自身的发射功率,你可以通过将其与rssi值进行比较来大致估算设备与你的距离。serviceUuids列出了该设备提供的服务类型,这些信息有助于判断设备的用途。manufacturerData是一个映射关系,它将公司标识符与原始字节数据关联起来。像苹果的iBeacon这样的设备就是通过这种方式将自定义数据包含在广告信息中的,你需要根据设备制造商规定的格式来解码这些字节。serviceData同样是将服务UUID与对应的字节数据关联起来的,许多传感器都会利用这种机制在不需要建立连接的情况下广播自身检测结果。

提前读取这些字段的信息,就能在花费时间和消耗电池进行连接之前,先识别并筛选目标设备。

连接设备

选定了目标设备后,就可以开始连接它了。不过连接过程可能会出现失败或中断的情况,因此在进行连接操作之前,一定要添加错误处理机制,并持续监控连接状态。

Future〈void〉 connectToDevice(BluetoothDevice device) async {
  final subscription = device.connectionState.listen((state) {
    print('连接状态:$state');
    if (state == BluetoothConnectionState.disconnected) {
      print('已断开连接,原因代码:${device.disconnectReason?.code}, '
          '描述:${devicedisconnectReason?.description}`);
    }
  });

  device.cancelWhenDisconnected(subscription, delayed: true, next: true);

  try {
    await device.connect(
      timeout: const Duration(seconds: 15),
      autoConnect: false,
      mtu: null,
    );
    print('已连接到 ${device.platformName}`);
  } catch (e) {
    print('连接失败:$e');
  }
}

这个函数首先会订阅设备发送的connectionState信息流,这样你就能随时了解自己当前是处于连接状态还是断开连接的状态。当连接中断时,该函数还会记录下device.disconnectReason中提供的数值代码及人类可读的描述信息,这些信息对于诊断设备为何会突然失去连接功能来说非常有用。

device.cancelWhenDisconnected 将该订阅与连接关联起来,这样在连接断开时就能进行适当的清理;而 delayed: true 则能确保该订阅在连接完全断开之前仍然保持活跃状态,从而能够捕获到最后的断开事件。

连接过程本身是在 try/catch 结构中进行的:如果设备在 15 秒内没有做出响应,timeout 会自动放弃尝试;autoConnect: false 则会让系统立即尝试建立连接,而不会等待设备重新出现才进行连接;而传递 mtu: null 可以跳过自动的 MTU 协商过程,这样你就可以之后自行控制 MTU 的大小。如果连接过程中出现异常,catch 块会报告错误信息,而不会导致程序崩溃。在调用 connect 方法之前,一定要先设置状态监听器,否则可能会错过一些重要的状态变化。

在建立连接之前,务必先停止扫描操作。在许多 Android 设备上,同时进行扫描和连接操作会加重无线电设备的负担,从而导致连接频繁中断。因此应该先调用 stopScan(),然后再尝试连接。你还可以使用 device.isConnected 方法来检查是否已经建立了连接,这个方法会同步返回一个布尔值,从而避免不必要的多次连接尝试。

当你不再需要与某个设备进行通信时,一定要及时断开连接,以释放相应的连接资源。因为手机同时支持的 BLE 连接数量是有限的。

Future disconnectFromDevice(BluetoothDevice device) async {
  await device.disconnect();
  print('已与 ${device.platformName} 断开连接');
}

这个方法会调用 devicedisconnect,从而断开 GATT 连接并释放相关资源。等待这个操作完成后再继续执行后续代码是非常重要的,特别是当你打算立即重新连接或连接到其他设备时。

如果未能正确地断开连接,就很容易导致“已达到最大连接数”这样的错误出现,因为未断开的连接会不断累积起来。

协商 MTU 大小

MTU(最大传输单元)是指一个 BLE 数据包中能够容纳的最大数据量。默认情况下,MTU 的大小为 23 字节,其中 3 字节用于协议开销,因此实际可用于数据传输的字节数为 20 字节。如果需要传输更大的数据量,就应在连接成功后立即请求调整 MTU 大小。

Future negotiateMtu(BluetoothDevice device) async {
  if (Platform.isAndroid) {
    int mtu = await device.requestMtu(512);
    print('协商后的 MTU 大小为:$mtu');
  } else {
    int mtu = await device.mtu.first;
    print('iOS 系统自动协商出的 MTU 大小为:$mtu');
  }
}

在 Android 平台上,device.requestMtu(512) 方法会向外围设备请求 512 字节的 MTU 大小,这是 BLE 规范允许的最大值。该方法会返回双方最终协商一致的 MTU 值,因为外围设备也可能同意较小的 MTU 大小。如果使用较大的数据量进行传输,数据就可以一次性发送完毕,而不会被分割成 20 字节的小段进行传输,这样就能显著提高传输效率。

在iOS系统中,无需手动设置MTU值,因为苹果会在连接时自动协商确定MTU数值。因此,代码只需通过`device.mtu`流并使用`.first`方法读取当前的MTU值即可。计算最大安全数据传输量时,应始终将协商得到的MTU值减去3字节的ATT开销;切勿假设外围设备会按照你的请求来发送完整的数据。 你也可以订阅`mtu`流,以便在MTU值发生变化时及时得到通知。有些通信协议栈会在连接过程中随时检查MTU值的变化。 以下是使用Dart语言编写的示例代码: ```dart device.mtu.listen((mtu) { print('当前的MTU值为:$mtu,可用的数据传输量为:${mtu - 3}字节'); }); ``` 这段代码会监听`device.mtu`流。该流会在连接期间随时更新MTU值,因此通过这个流可以获取到实时的MTU信息。计算可用数据传输量时,需要从MTU值中减去3字节的ATT开销,这样才能确保考虑到固定的ATT头部信息。将数据分块发送的逻辑绑定到这个实时更新的流上,而不是依赖于之前缓存过的数值,这样即使MTU值在初次连接后发生了变化,你的数据发送操作也能保持正确性。 ## 发现设备的服务和特性 仅凭一次连接是无法获取设备的任何信息的。你必须先发现设备提供的服务,才能进一步了解其特性。这个过程需要构建GATT树结构,并且每次重新连接时都需要重新执行这一操作,因为之前的对象信息会失效。 以下是使用Dart语言编写的示例代码: ```dart Future discoverServices( BluetoothDevice device, Guid serviceUuid, Guid characteristicUuid, ) async { List services = await device.discoverServices(); for (BluetoothService service in services) { print('服务名称:${service.uuid}`); for (BluetoothCharacteristic c in service.characteristics) { print(' 特性名称:${c.uuid} ' '(可读取:${c.properties.read}, ' '可写入:${c.properties.write}, ' '可通知:${c.properties.notify})'); } } // 如果找到了指定的服务和特性,就返回该对象;否则返回null for (BluetoothService service in services) { if (service.uuid == characteristicUuid) { return c; } } return null; } ``` 这个函数会调用`device.discoverServices`方法,向设备请求其完整的GATT树结构,并在查询完成后返回相应的服务列表。第一组循环会列出所有的服务及其特性信息,这在开发过程中非常有用,可以帮助你了解设备的结构;第二组循环则会根据传入的serviceUuid和characteristicUuid来查找特定的服务和特性,如果找到了就返回对应的`BluetoothCharacteristic`对象,否则返回null。 将查询到的特性对象缓存起来,可以在后续的读写操作中重复使用这些信息,而无需每次都重新查询GATT树结构。建议在连接设备后立即执行一次服务发现操作,将需要的信息缓存下来;而在任何重新连接的情况下,都需要再次执行这个操作。

了解特征属性

每个特征都会通过其properties对象来说明它支持哪些操作,如果尝试执行不支持的操作,就会引发错误。事先检查这些属性,正是让应用程序变得稳定运行的关键所在——这样的程序才能在遇到意外的硬件环境时依然正常运行,而不会崩溃。

void printProperties(BluetoothCharacteristic c) {
  final p = c.properties;
  print('read: ${p.read}`);
  print('write: ${p.write}`);
  print('writeWithoutResponse: ${p.writeWithoutResponse}`);
  print('notify: ${p.notify}`);
  print('indicate: ${p.indicate}`);
  print('broadcast: ${p.broadcast}");
  print('authenticatedSignedWrites: ${p.authenticatedSigned Writes'));
}

这个函数会输出所有特征属性的详细信息。read表示可以按需读取该属性的值;write表示设备会确认接收到的写入操作;而writeWithoutResponse则是一种快速且不会引起设备确认的写入方式。

notifyindicate这两种方法都会让设备主动向应用程序发送更新信息,但不同之处在于:indicate要求中央设备必须对每一条更新信息进行确认,而notify则不需要;因此indicate更为可靠,但处理速度也会更慢。

broadcast表示该属性的值可以被包含在广告数据包中;authenticatedSignedWrites则表示该特征支持需要建立连接关系才能进行的加密写入操作。

在采取任何行动之前先查看这些属性信息,可以帮助你选择正确的方法,并避开设备不支持的那些操作。当你的应用程序需要与来自不同厂商的硬件设备进行交互时,这一点尤为重要——因为这些设备的逻辑功能可能相同,但所支持的属性设置却可能有所不同。

从特征中读取数据

读取数据意味着可以根据需要获取某个特征的当前值。返回的数据总是以字节列表的形式出现,你需要根据该设备的规格说明来解读这些字节。

Future<List<int>>> readCharacteristic(BluetoothCharacteristic c) async {
  if (!c.properties.read) {
    print('这个特征无法被读取');
    return [];
  }

  List<int>> value = await c.read();
  print('原始字节数据: $value');
  return value;
}

该函数首先会通过检查c.properties.read来确认是否可以读取某个特征的数据;如果不允许进行这种操作,就会返回一个空列表。随后它会调用c.read方法,该方法会返回一个List<int>类型的列表,其中每个元素都是0到255之间的字节。

由于BLE在数据传输层并不区分数据类型,因此你收到的是原始字节数据,必须根据设备的技术规格说明书自行对这些数据进行解码。我们将在后面的编码部分详细介绍这一过程。返回原始字节数据的目的,就是让调用者能够自行决定如何解读这些数据。在任何情况下,都务必先确认该特征是否支持被读取——如果尝试读取一个无法被读取的特征,就会引发FlutterBluePlusException错误。

将数据写入特性值

写入操作实际上是向外设发送字节数据,通过这种方式可以发送命令、更改设置或向自定义硬件传输数据。

存在两种写入模式,选择合适的模式对于确保数据的可靠性和传输速度至关重要。

Future writeCharacteristic(
  BluetoothCharacteristic c,
  List data,
) async {
  if (c.properties.write) {
    await c.write(data, withoutResponse: false);
    print('带有响应的写入操作已完成');
  } else if (c.properties.writeWithoutResponse) {
    await c.write(data, withoutResponse: true);
    print('无响应的写入操作已完成');
  } else {
    print('该特性值无法被写入');
  }
}

此函数会检查相关属性来确定具体的写入方式。如果该特性值支持write方法,就会使用带有响应的写入模式,此时需要传入withoutResponse: false参数,这样外设会在接收到数据后给出确认反馈,只有在收到确认信号后,await操作才会完成。这种方式的可靠性较高,但传输速度较慢,因为需要等待往返通信的过程。

如果该特性值支持writeWithoutResponse方法,就会采用直接发送数据而不等待响应的方式,此时传入withoutResponse: true参数。这种方式传输速度较快,非常适合高吞吐量的数据流传输,但无法保证数据一定能被成功送达。

如果这两种属性都不存在,说明该特性值无法被写入,函数也会相应地给出提示。data参数是一个包含字节数据的List对象,因此如果要发送一个由两个字节组成的命令,就可以传入[0x01, 0xFF]这样的数组。

当需要传输的数据量超过了MTU的最大允许长度时,应该将数据分成若干份,每份的大小为协商确定的MTU大小减去一些开销字节,然后依次进行写入操作。

Future writeLongData(
  BluetoothCharacteristic c,
  List data,
  int mtu,
) async {
  final chunkSize = mtu - 3;
  for (var i = 0; i < data.length; i += chunkSize) {
    final end = (i + chunkSize < data.length) ? i + chunkSize : data.length;
    final chunk = data.sublist(i, end);
    await c.write(chunk, withoutResponse: false);
  }
  print('已分${chunkSize}大小的几份数据,共发送了${data.length}字节');
}

此函数会将较大的数据量拆分成MTU大小为单位的若干部分。它首先计算出chunkSize的值,即协商确定的MTU大小减去3字节的ATT开销,然后按照这个大小来分割数据并依次进行写入操作。

在每次写入操作中,都会计算出数据的结束索引,从而确保不会超出数据列表的边界。使用sublist方法提取出相应的数据部分后进行写入。由于这里采用了带有响应的写入模式(withoutResponse: false),因此每个写入操作都会等待外设的确认信号后再开始下一个操作,这样就能有效防止外设缓冲区被溢出。

如果你的外设有自己专门的数据重组协议,那么应该按照该协议来执行写入操作,因为有些设备要求在每份数据中包含长度字段或序列号等信息。

订阅通知与数据更新

正是由于这种通知机制,蓝牙技术才能具备高效性。你无需反复查询某个特性值,只需进行一次订阅,当外围设备的数据发生变化时,它就会自动将新数值推送给你的设备。这样一来,像心率、温度或加速度传感器的数据就能以最低的能耗持续传送到你的设备上。

StreamSubscription>? _valueSubscription;

Future subscribe(BluetoothCharacteristic c) async {
  if (!c.properties.notify && !c.properties.indicate) {
    print('该特性不支持通知功能');
    return;
  }

  _valueSubscription = c.onValueReceived.listen((value) {
    print('收到更新值: $value');
  });

  c.device.cancelWhenDisconnected(_valueSubscription!);

  await c.setNotifyValue(true);
}

Future unsubscribe(BluetoothCharacteristic c) async {
  await c.setNotifyValue(false);
  await _valueSubscription?.cancel();
}

subscribe函数首先会确认该特性是否支持notifyindicate功能,这两种功能都属于服务器主动发起的数据更新机制。随后,它会通过c.onValueReceived监听器来接收外围设备发送的更新数据;同时,使用cancelWhenDisconnected确保在连接断开时能够及时停止数据接收。最后,该函数会调用setNotifyValue(true),从而让外围设备开始发送数据。需要注意的是,如果某个特性仅支持indicate功能,插件会自动选择使用这种机制。

操作顺序非常重要:必须先设置数据监听器,然后再启用通知功能,这样才能确保不会错过最初传来的更新数据。unsubscribe函数则会通过调用setNotifyValue(false)来停止数据接收,并取消相关的订阅以释放系统资源。一旦不再需要这些数据,就必须立即取消订阅,因为继续保留通知机制会消耗双方设备的电量。

使用描述符

描述符是附加在特性上的元数据。当你调用setNotifyValue函数时,插件会自动处理相关的通知描述符;但有些设备也会提供自定义描述符,你需要直接对这些描述符进行读写操作,比如读取设备的可读说明或有效值范围等信息。

Future exploreDescriptors(BluetoothCharacteristic c) async {
  for (BluetoothDescriptor d in c.descriptors) {
    print('描述符: ${d.uuid}`);
    List value = await d.read();
    print('  值: $value');
  }
}

Future writeDescriptor(BluetoothDescriptor d, List data) async {
  await d.write(data);
  print('描述符已写入');
}
exploreDescriptors函数会遍历c.descriptors——这个包含与特征一同被检测到的描述符的列表,并使用d.read方法读取每个描述符的值。该方法返回的字节数据,与读取特征数据时得到的是相同的格式。

writeDescriptor函数通过d.write将字节发送到描述符中。大多数应用程序都不会直接操作描述符,因为setNotifyValue会负责管理这些重要的描述符;但如果你使用的硬件使用了自定义的描述符——比如用于存储人类可读标签的“特征用户描述”描述符(0x2901),那么你就需要通过这种方式来访问它。

应将描述符中的数据视为原始字节,并根据相关规范对其进行解码,就像处理其他特征数据一样。

字节数据的编码与解码

BLE协议传输的是不包含类型信息的原始字节,因此编码和解码过程很容易出错。你必须了解每种特征的字节布局,包括每个字段的大小、整数是否带符号以及字节顺序。

在BLE协议中,最常用的字节顺序是小端序,即最低有效位字节会先被发送;但无论如何都应对照设备的文档进行验证。

import 'dart:typed_data';

int readUint8(List bytes, int offset) => bytes[offset];

int readUint16LE(List bytes, int offset) {
  return bytes[offset] | (bytes[offset + 1] << 8);
}

int readUint32LE(List bytes, int offset) {
  return bytesoffset] |
      (bytes[offset + 1] << 8) |
      (bytes[offset + 2] << 16) |
      (bytes[offset + 3] << 24);
}

int readInt16LE(List bytes, int offset) {
  final data = ByteData.sublistView(Uint8List.fromList(bytes));
  return data.getInt16(offset, Endian.little);
}

double readFloat32LE(List bytes, int offset) {
  final data = ByteData.sublistView(Uint8List.fromList(bytes));
  return data.getFloat32offset, Endian.little);
}

这些辅助函数涵盖了你最常遇到的数据类型。readUint8简单地将一个字节作为无符号整数返回;readUint16LE通过将低字节放在前面,并将高字节左移8位,然后使用按位或运算,将两个字节组合成一个16位的无符号数值;readUint32LE则将这个原理扩展到四个字节,分别进行8位、16位和24位的位移操作。

对于带符号的整数和浮点数来说,手动处理字节很容易出错,因此readInt16LEreadFloat32LE会先将字节封装到ByteData对象中,然后使用其getInt16getFloat32方法,并指定Endian.little参数,这样就能正确处理符号扩展和IEEE 754浮点数的解码工作。对于任何复杂的数据类型来说,使用ByteData都是推荐的做法,因为这种方法既准确又便于理解。

在将数据编码后发送时,需要按照相反的步骤进行操作,而ByteData依然是最合适的工具。

List encodeCommand(int commandId, int value) {
  final data = ByteData(5);
  data.setUint8(0, commandId);
  data.setUint32(1, value, Endian.little);
  return data.buffer.asUint8List();
}

这个函数用于构建一个长度为5字节的命令数据包。它首先分配一个长度为5字节的`ByteData`缓冲区,然后使用`setUint8`方法将命令标识符写入该缓冲区的偏移量为0的位置;接着使用`setUint32`方法以小端序格式将一个32位数值写入偏移量为1的位置。最后,通过`buffer.asUint8List()`方法将该缓冲区转换为`Uint8List`类型的数据结构,而`characteristic.write`方法正是期望接收这种类型的数据的。

使用`ByteData`来构建数据包能够确保偏移量的准确性以及字节序的正确性,从而有效避免那些在手工组装字节列表时容易出现的错误。

int parseHeartRate(List bytes) {
  final flags = bytes[0];
  final is16Bit = (flags & 0x01) != 0;
  if (is16Bit) {
    return readUint16LE(bytes, 1);
  } else {
    return readUint8(bytes, 1);
  }
}

这个函数遵循标准的心率测量数据格式。数据包的第一个字节用于携带标志信息,其中最低位用来指示后续的心率数值是8位还是16位。代码通过与该位进行按位与操作来判断这一信息:如果该位被设置为1,那么心率数值就是从偏移量1开始读取的两个字节组成的小端序整数;否则,心率数值就是一个位于偏移量1处的单个字节。

这种“先标志位再数据 payload”的结构在标准的BLE特性中非常常见,因此识别这种格式能够节省解析时间。这也说明了一个道理:如果没有相关规范,就无法正确解码BLE数据——因为同一特性会根据不同的标志位来改变数据的结构。

配对、绑定与加密

有些BLE特性要求使用加密连接,而访问这些特性时就会触发配对过程。配对是指两台设备互相交换密钥的过程,而绑定则是将这些密钥保存下来,这样以后在连接时就可以自动进行加密,而无需再次进行配对操作。

许多安全设备都是按照这种机制工作的,了解这一流程有助于避免遇到“认证失败”这类令人困惑的错误。

Future bondDevice(BluetoothDevice device) async { if (Platform.isAndroid) { print('当前绑定状态: ${await device.bondState.first}`); await device.createBond(); print('绑定成功'); } } Future removeBondIfNeeded(BluetoothDevice device) async { if (Platform.isAndroid) { await device.removeBond(); print('解除绑定'); } }

在插件中,这些API仅适用于Android系统,因为iOS系统能够透明地处理设备配对过程:在iOS上,当你第一次访问某个加密特性时,配对会自动启动,系统会自行管理相关密钥,无需你编写任何代码。

实际上,最简洁的跨平台解决方案通常是让设备在读取或写入加密数据时自动完成配对过程,然后让每个操作系统分别显示自己的配对提示。只有在对配对有特殊要求的情况下,才使用createBond函数。

需要注意的一点是:在Android系统上,有时需要在发现加密服务之前先完成设备配对;而在其他设备上,则可以按需进行配对。如果你的设备发现结果中没有显示相关的加密特性,可以先尝试进行配对,然后再重新搜索。

由于不同制造商的设备在配对机制上存在差异,因此请务必在你目标使用的硬件上进行测试,而不能假设某种配对流程在所有设备上都适用。

读取信号强度与设置连接优先级

连接成功后,你仍然可以查看当前的信号强度,并调整连接的功耗设置。这些功能有助于实现接近感应功能,以及在保证数据传输速度的同时延长电池寿命。

Future readLiveRssi(BluetoothDevice device) async {
  int rssi = await device.readRssi();
  print('当前信号强度:$rssi dBm');
}

Future setHighThroughput(BluetoothDevice device) async {
  if (Platform.isAndroid) {
    await device.requestConnectionPriority(
      connectionPriorityRequest: ConnectionPriority.high,
    );
    print('已请求高优先级连接');
  }
}

readLiveRssi函数会调用device.readRssi方法,该方法会返回当前活跃连接的信号强度值,单位为dBm。这个数值与扫描结果中显示的RSSI不同,因为它反映的是实时连接状态,而非设备广播信息中的数据。通过定期检测这一参数,你可以实现诸如“将手机拿得更近些”这样的接近感应功能。

setHighThroughput函数会使用ConnectionPriority.high值调用device.requestConnectionPriority方法,这样可以让Android系统缩短连接间隔,从而增加数据传输频率,提高传输速度,但这样会消耗更多电池电量。其他选项包括balanced(适用于常规使用场景)和lowPower(适用于更新频率较低的场景,以延长电池寿命)。

这种设置功能仅适用于Android系统,因为iOS系统会根据外设的配置自动调整连接间隔。在进行大文件传输时,可以暂时设置高优先级,传输完成后再恢复为常规设置,以避免过度消耗设备的电量。

处理断开与重新连接

蓝牙连接本质上是不稳定的。设备可能会超出信号范围、电池电量耗尽,或者无线电信号会中断。因此,生产环境中的应用程序必须能够优雅地处理断开连接的情况,并智能地重新建立连接,而不能假设连接会一直保持正常状态。

int _retryCount = 0;
const int _maxRetries = 5;

void setupAutoReconnect(BluetoothDevice device) {
  device.connectionState.listen((state) async {
    if (state == BluetoothConnectionState.connected) {
      _retryCount = 0;
      await device.discoverServices();
    } else if (state == BluetoothConnectionState.disconnected) {
      print('已断开连接:${devicedisconnectReason?.description}`);
      await _attemptReconnect(device);
    }
  });
}

Future _attemptReconnect(BluetoothDevice device) async {
  while (_retryCount < _maxRetries && !device.isConnected) {
    _retryCount++;
    final backoff = Duration(seconds: 1 << _retryCount);
    print('正在尝试重新连接,当前尝试次数为 $_retryCount,等待时间为 ${backoff.inSeconds} 秒');
    await Future.delayed(backoff);
    try {
      await device.connect(timeout: const Durationseconds: 15));
      print('重新连接成功');
      return;
    } catch (e) {
      print('重新连接失败:$e');
    }
  }
  if (!device.isConnected) {
    print('经过 $_maxRetries 次尝试后,放弃重新连接');
  }
}

setupAutoReconnect函数会监听连接状态的变化,并对两种状态变化做出相应的处理。当设备处于连接状态时,该函数会重置重试计数器并重新检测可用的服务;因为任何一次断开连接后,之前的服务对象都会变得无效,所以这样做是必要的。当设备处于断开连接状态时,该函数会记录断开连接的原因,并调用重新连接函数。

_attemptReconnect函数采用了指数级延迟重试策略:它最多会尝试_maxRetries次,每次尝试的等待时间都会比上一次长,具体的等待时间为1 << _retryCount秒,因此对应的等待时间分别为2秒、4秒、8秒、16秒和32秒。采用这种延迟重试策略是很有必要的,因为如果连续不断地尝试重新连接,不仅会浪费电池电量,而且很少能够成功;而通过间隔一段时间再尝试,就可以给设备足够的时间重新进入可连接范围。

每次尝试重新连接的操作都被包裹在try/catch块中,这样一旦重试失败,系统就会安排下一次尝试,而不会直接导致程序异常终止。当设备重新连接到网络,或者所有的重试机会都被用完时,这个循环就会结束。

在Android系统中,你也可以将autoConnect: true参数传递给connect方法,这样系统就会负责重新连接操作,每当设备再次出现时,系统会在后台自动尝试重新建立连接。不过,这种方式会导致初始连接速度变慢。

在后台运行蓝牙功能

当你的应用程序处于后台状态时,要想让BLE连接保持活跃状态,就需要根据不同的平台采取相应的措施。在iOS系统中,你可以通过之前设置过的后台模式来实现这一目标;而在Android系统中,则需要使用前台服务,这样才能确保系统不会关闭与蓝牙相关的进程。

在iOS系统中,一旦你在Info.plist文件中加入了bluetooth-central后台模式,系统就会自动保持你的连接状态活跃,并且即使应用程序被暂停,也会向它发送通知,从而让应用程序能够在短时间内恢复运行并处理这些通知。

关于Dart方面,已经没有更多需要补充的内容了。不过你需要知道的是,iOS系统会严格限制后台扫描的功能:在后台进行扫描时无法使用某些过滤条件,扫描的频率也会被降低,同时还需要用户明确指定相关服务的UUID;因此,在iOS系统中,如果不使用任何过滤条件进行后台扫描,是根本找不到任何结果的。在Android系统中,你必须运行一个具有持久通知功能的前台服务,这样系统才会将你的蓝牙相关操作视为用户可见的操作,并且不会在“Doze模式”下暂停这些操作。你可以使用像`flutter_foreground_task`这样的包来实现这一目标,只需将其配置为与设备连接相关的服务类型即可。 以下是相应的Dart代码示例: ```dart import 'package:flutterforeground_task/flutter_foreground_task.dart'; Future startBleForegroundService() async { FlutterForegroundTask.init( androidNotificationOptions: AndroidNotificationOptions( channelId: 'ble_service', channelName: 'BLE Connection', channelDescription: '用于维持蓝牙连接', ), iosNotificationOptions: const IOSNotificationOptions(), foregroundTaskOptions: ForegroundTaskOptions( eventAction: ForegroundTaskEventAction.repeat(5000), autoRunOnBoot: false, allowWakeLock: true, ), ); await FlutterForegroundTask.startService( notificationTitle: 'BLE连接中', notificationText: '已连接到您的设备', ); } ``` 这个函数用于初始化并启动一个前台服务。`androidNotificationOptions`定义了Android系统所需的持久通知渠道,包括该渠道的ID、可见名称以及描述信息,这些信息会显示在系统的通知设置中。 `foregroundTaskOptions`用于控制服务的行为:`eventAction.repeat(5000)`会每隔5秒触发一次回调函数,从而确保能够定期执行维护操作;`autoRunOnBoot: false`可以防止服务在系统重启后自动启动;而`allowWakeLock: true`则能阻止CPU进入睡眠状态,从而保证蓝牙相关的回调功能能够可靠地执行。 调用`startService`后,系统会显示相应的通知,并将你的应用程序置于前台优先级状态,这样才能确保蓝牙连接持续有效。此外,你还需要在应用的清单文件中声明`FOREGROUND_SERVICE`和`FOREGROUND_SERVICE_CONNECTED_DEVICE`权限,并将服务类型设置为`connectedDevice`,因为从Android 14版本开始,操作系统会强制要求服务类型必须与其实际执行的操作相匹配。 当不再需要该服务时,可以使用`FlutterForegroundTask.stopService()`来停止它,因为持续存在的通知会打扰用户。 ## 错误处理 BLE操作可能会以多种方式出现故障,而这个插件会将这些故障表现为`FlutterBluePlusException`异常,并提供相应的错误代码供你查看。捕获并解析这些异常信息,就可以将那些看似复杂的崩溃问题转化为可以解决的状态。 以下是另一个示例代码: ```dart Future> safeRead(BluetoothCharacteristic c) async { try { return await c.read(); } on FlutterBluePlusException catch (e) { print('BLE错误:函数=${e.function}, 错误代码=${e.code}, 描述=${e.description}`); if (e.code == 6) { print('设备已断开连接'); } return []; } on PlatformException catch (e) { print('平台错误:${e.message}`); return []; } catch (e) { print('发生未知错误:$e'); return []; } } ```

这个函数通过分层异常处理机制来封装所有的BLE读写操作。第一个catch块用于处理插件自身抛出的FlutterBluePlusException异常,这种异常会包含function(发生故障的具体操作)、code(底层平台提供的错误代码)以及description(可读的错误信息)。通过检查特定的错误代码,例如代码6表示设备已断开连接,就可以采取相应的恢复措施。

第二个catch块用于处理可能由平台通道本身引发的PlatformException异常,而最后一个通用的catch块则可以作为应对任何未预见情况的保障。虽然每个异常处理分支都会返回一个空列表,这样可以使调用者代码更加简洁,但在实际应用中,你可能会选择重新抛出特定类型的异常或更新用户界面状态。

核心要点在于:所有的BLE操作都可能引发异常,因此应该使用try/catch机制来封装这些操作,这样才能防止异常破坏整个应用程序的结构。

生产环境下的BLE服务架构

如果将多个BLE操作分散分配到不同的组件中,那么维护起来会变得非常困难。一个更合理的结构是将所有的蓝牙相关逻辑集中在一个服务类中,这个服务类会提供状态流供用户界面和状态管理层使用。这样的设计可以让各个组件无需了解具体的BLE细节,同时也便于对相关逻辑进行测试。

enum BleConnectionStatus { disconnected, scanning, connecting, connected }

class BleService {
  BluetoothDevice? _device;
  BluetoothCharacteristic? _dataCharacteristic;

  final _statusController =
      StreamController.broadcast();
  final _dataController = StreamController get status => _statusController.stream;
  Stream> get data => _dataController.stream;

  final Guid serviceUuid = Guid('180D');
  final Guid characteristicUuid = Guid('2A37');

  Future scanAndConnect() async {
    _statusController.add(BleConnectionStatus.scanning);

    await FlutterBluePlus.startScan(
      withServices: [serviceUuid],
      timeout: const Duration(seconds: 15),
    );

    final results = await FlutterBluePlus.onScanResults.first;
    if (results.isEmpty) {
      _statusController.add(BleConnectionStatus.disconnected);
      return;
    }

    await FlutterBluePlus.stopScan();
    await _connect(results.first.device);
  }

  Future _connect(BluetoothDevice device) async {
    _device = device;
    _statusController.add(BleConnectionStatus.connecting);

    device.connectionState.listen((state) {
      if (state == BluetoothConnectionState.connected) {
        _statusController.add(BleConnectionStatusconnected);
      } else if (state == BluetoothConnectionState.disconnected) {
        _statusController.add(BleConnectionStatus.disconnected);
      }
    });

    await device.connect(timeout: const Duration(seconds: 15));
    await _setupCharacteristic();
  }

  Future _setupCharacteristic() async {
    final services = await _device!.discoverServices();
    for (final service in services) {
      if (service.uuid == serviceUuid) {
        for (final c in service.characteristics) {
          if (c.uuid == characteristicUuid) {
            _dataCharacteristic = c;
            c.onValueReceived.listen(_dataController.add);
            await c.setNotifyValue(true);
          }
        }
      }
    }
  }

  Future send(List bytes) async {
    await _dataCharacteristic?.write(bytes);
  }

  Future dispose() async {
    await _device?.disconnect();
    await _statusController.close();
    await _dataController.close();
  }
}

这项服务通过一个简洁的接口封装了整个BLE工作流程。它定义了一个BleConnectionStatus枚举类型,以便以直观、适合用户界面的方式显示连接状态,并提供了两个广播流:status用于传递连接状态的变更信息,data用于传输特征值数据。同时,系统还提供了相应的广播控制机制,使得多个监听器可以同时订阅这些数据。

scanAndConnect方法负责完成整个连接流程:它会首先发布扫描状态,开始进行过滤扫描,等待获取第一批扫描结果,然后停止扫描,并与第一个匹配到的设备建立连接;如果未找到任何设备,则会发布“未连接”的状态。

私有的_connect方法会设置一个监听器,将BLE设备的状态映射到BleConnectionStatus枚举类型中,随后完成连接过程并配置相关特征参数。_setupCharacteristic方法用于发现设备提供的服务、定位目标特征值,将其onValueReceived信号转发到对应的服务数据控制模块,并启用通知功能。send方法用于将数据写入缓存中的特征值字段,而dispose方法则会断开连接并关闭相关控制组件,以防止资源泄漏。

由于所有数据都是通过简单的枚举类型和字节列表进行传输的,因此用户界面根本不需要直接操作BluetoothDevice对象。这样一来,相关的UI组件就会变得非常简单易用,整个系统的逻辑也更加清晰,便于维护和替换。

构建用户界面

一旦相关服务准备就绪,用户界面就只需要响应这些数据流即可。下面是一个示例:这个扫描器界面就是利用上述服务实现的。

import 'package:flutter/material.dart';

class BleHomePage extends StatefulWidget {
  final BleService service;
  const BleHomePage({super.key, required this.service});

  @override
  State createState() => _BleHomePageState();
}

class _BleHomePageState extends State {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('BLE演示')),
      body: Column(
        children: [
          StreamBuilder( 
            stream: widget.service.status,
            initialData: BleConnectionStatus.disconnected,
            builder: (context, snapshot) {
              return ListTile(
                leading: const IconIcons.bluetooth),
                title: Text('状态:${snapshot.data?.name}'),
              );
            },
          ),
          Expanded(
            child: StreamBuilder>>(
              stream: widget.service.data,
              builder: (context, snapshot) {
                if (!snapshot.hasData) {
                  return const Center(child: Text('尚未获取到数据'));
                }
                final hr = snapshot.data!.length > 1 ? snapshot.data![1] : 0;
                return Center(
                  child: Text('$hr bpm'),
                      style: const TextStyle(fontSize: 48)),
                );
              },
            ),
          ),
        ],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: widget.service.scanAndConnect,
        child: const IconIcons.search),
      ),
    );
  }
}

这个小部件会使用一个BleService,并将其所有的用户界面元素完全绑定到该服务的数据流上。第一个StreamBuilder会监听status数据流,并将当前的连接状态以列表图标的形式显示出来;通过设置initialData,可以在第一个事件到达之前就让图标显示出相应的内容。

第二个StreamBuilder被包裹在Expanded组件中,它负责监听data数据流,并将接收到的数据显示出来;它会将心率数据中字节索引为1的那个数值解析出来,并以较大的文字形式显示出来;如果没有接收到任何数据,就会显示一个占位符。

那个浮动动作按钮会直接调用service.scanAndConnect方法,因此整个交互过程实际上只涉及一次方法调用而已。由于这个小部件不包含任何BLE相关的对象或连接逻辑,因此可以使用一个模拟服务来对其进行测试——这种模拟服务会向相同的数据流中发送预设好的数据;即使更换底层的BLE设备,也不会影响到这个小部件的功能。

对于规模较大的应用程序来说,应该将相关服务封装在ProviderRiverpod providerBloc等组件中,这样就可以通过这些组件来注入服务对象,而无需手动传递它们。

测试与调试

由于BLE技术依赖于物理硬件及无线通信条件,因此对其进行测试会遇到一些困难,但有一些方法可以帮助我们有效地进行测试。

最有用的工具是Nordic Semiconductor公司开发的nRF Connect应用程序,该应用在Android和iOS平台上都是免费提供的。使用它,我们可以扫描设备、建立连接、浏览设备的GATT结构树,手动读取或写入设备的各种数据,并记录下所有发送或接收的数据包。

在针对新设备编写任何Dart代码之前,首先应该使用nRF Connect连接到该设备,然后记下该设备提供的服务名称及其特征值的UUID、这些特征的属性信息,以及每个数据值的字节编码格式。这样就可以避免出现猜测的情况,也能帮助我们判断问题是出在代码中还是硬件上。

如果要对自己的逻辑进行单元测试,应该将那些纯粹的功能模块单独提取出来进行测试。前面提到的那些用于字节编码和解码的辅助函数其实只是普通的Dart代码,并不需要任何插件,因此可以直接在没有设备的情况下对它们进行测试。

import 'packageflutter_test/flutter_test.dart';

void main() {
  test('readUint16LE能正确解析小端字节序', () {
    expect(readUint16LE([0x34, 0x12], 0), equals(0x1234));
  });

  test('parseHeartRate能正确处理8位格式的数据', () {
    expect(parseHeartRate([0x00, 72]), equals(72));
  });

  test('parseHeartRate能正确处理16位格式的数据', () {
    expect.parseHeartRate([0x01, 0x2C, 0x01]), equals(300));
  });
}

这些测试都是在没有蓝牙硬件的情况下进行的。第一个测试验证了readUint16LE函数能否正确地将字节序列0x34, 0x12解析为数值0x1234,从而证明了该函数能够正确处理小端字节序;后两个测试则分别验证了parseHeartRate函数能否正确解析8位格式和16位格式的数据——其中8位格式的数据是0x01, 0x2C,表示数值72;16位格式的数据也是0x01, 0x2C,表示数值300。

<因为你在设计这项服务时,就将BLE技术可能带来的副作用与数据解析过程分开了,所以所有那些复杂的解析逻辑都被纳入了在持续集成环境中运行的快速、确定性测试中,从而得到了有效的检测。>

对于BLE相关的开发工作来说,实际可行的方法是先在真实硬件上进行手动测试,同时利用像BleService这样的抽象层。在进行小部件测试时,你可以用虚假的实现来替代这些抽象层,这样就可以将预设好的数据发送到UI所使用的相同数据流中。

在调试实时连接时,建议启用插件的详细日志记录功能,这样就能看到所有的操作及其结果。

FlutterBluePlus.setLogLevel(LogLevelverbose, color: true);

这样就可以将插件的日志记录级别设置为verbose,这样所有的扫描结果、连接事件以及读写操作和通知信息都会被打印到控制台上。而color: true这个选项可以让输出内容更易于阅读。

在排查连接或数据相关的问题时,启用这种详细日志记录功能可以清楚地知道程序中的哪些步骤出现了问题——例如是否真的进行了写入操作,或者服务发现功能是否返回了预期的信息。在产品发布之前,建议将日志记录级别设置回LogLevel.noneLogLevel.error,因为详细的日志记录会产生大量的输出信息,而且还可能会泄露有关连接设备的敏感信息。

性能与电池优化

BLE技术的设计初衷就是为了降低功耗,但如果不小心编写了低效的代码,就会破坏这一目标。其中最耗电的操作就是扫描,因此千万不要让扫描操作持续进行下去。在调用startScan方法时,一定要设置timeout参数;同时要通过服务UUID来过滤扫描请求,这样就可以减少设备的无线通信模块被频繁唤醒的次数。一旦找到了目标设备,就应该立即停止扫描操作。如果让扫描任务在后台持续运行,那么应用程序的电池寿命肯定会受到严重影响。

连接间隔也是影响功耗的重要因素。较短的连接间隔可以实现快速、高效率的数据传输,但也会使设备的无线通信模块一直处于工作状态;而较长的连接间隔虽然能降低功耗,但却会导致延迟增加。在进行固件更新或大量数据传输等操作时,可以使用requestConnectionPriority(ConnectionPriority.high)来设置较高的连接优先级;而在日常的监控任务中,则应该使用balancedlowPower优先级。请根据应用程序的实际数据传输需求来调整连接间隔,而不是一味地追求高吞吐量。

尽量将多个操作合并在一起执行。每次进行读写操作或接收通知时,设备的无线通信模块都会被唤醒一次,因此将几个小量的数据合并成一个较大的数据包进行传输,或者一次性读取整个数据块而不是分别读取各个字段,都可以节省功耗和时间。如果外设支持的话,建议优先使用通知机制而不是轮询机制,因为只有当数据真正发生变化时,通知机制才会发送信号;而轮询机制则会不断地消耗电量来检查是否有新的数据。

最后,在完成操作后一定要及时断开连接,而不要让闲置的连接持续保持开启状态。因为即使没有数据传输,维持连接也会消耗电量,而且手机也会对同时支持的连接数量进行限制。释放一个连接接口,就可以为下一个连接请求腾出空间。

常见误区

最常见的错误就是在模拟器上进行测试。无论是Android模拟器还是iOS模拟器,它们都不具备蓝牙功能,因此你在扫描操作中根本看不到任何结果。务必在真实设备上进行测试,如果条件允许的话,最好同时在旧版和新版Android设备上进行测试,这样才能及时发现Android 11和Android 12之间的权限差异,因为那些只出现在某一版本设备上的问题很容易被忽略。

第二个常见问题是人们会忘记:扫描结果中往往包含空名称。许多外设为了节省有限的31字节通信空间,不会在广告数据中填写自己的名称,因此依赖advName来进行识别是行不通的。应该通过服务UUID进行过滤,或者使用稳定的remoteId来进行匹配,而将设备名称仅作为用于显示的附加信息来处理。

第三个需要注意的问题是忽视连接的状态变化过程。开发者通常只进行一次连接操作,完成数据读取后就认为连接会一直保持正常状态,但实际上并非如此。因此必须始终关注connectionState的变化,妥善处理断开连接的情况,并在每次重新连接后重新发现可用的服务。因为旧的 serviço和特性对象会变得无效,导致后续的数据读取操作失败或出现异常。

与此相关的是,当不再需要某些数据流订阅时,务必及时取消这些订阅。否则,每当小程序重新构建时,就会导致监听器被重复创建,进而使每个通知消息都被多次处理,造成不必要的麻烦。

第四个需要注意的陷阱与MTU有关。如果你的数据写入操作在达到20字节时就自动被截断,那很可能是因为你忘记协商更大的MTU值了,或者你发送的数据超出了约定的大小限制。请确保发送的数据长度始终在协商好的MTU值减去3字节的开销范围内。另外需要注意的是,在iOS系统中,MTU值的协商是系统自动完成的,而在Android系统中则需要通过API进行手动配置。

第五个问题是字节序混淆:如果设备使用的是小端字节序,而你却假设它是大端字节序;或者将带有符号的值当作无符号值来处理,都会导致得到错误的结果。因此,无论在什么情况下,都应根据规格要求验证数据的格式,并通过单元测试来确保解析逻辑的正确性。

最后需要强调的是,在Android系统中,千万不要同时进行扫描和连接操作,因为这样会导致连接频繁中断,而且这种问题非常难以复现。正确的做法应该是先完成扫描操作,然后再建立连接。

总结

在Flutter框架中,使用蓝牙低功耗功能其实只需要遵循一套固定的步骤:为每个平台配置相应的权限,确认适配器已开启状态,扫描可用的外设并查看它们的广告信息,连接到目标设备上,如果需要传输较大的数据量,则协商合适的MTU值;接着发现该设备提供的服务及特性信息,最后根据这些特性的允许范围进行数据的读写或订阅操作。

flutter_blue_plus包通过流式接口直接实现了上述所有步骤。一旦你掌握了GATT架构中服务、特性和描述符之间的层次关系,这个API就不会再显得那么复杂了,反而会让你觉得它就像是一个封装了明确定义的协议的外壳而已。

那些经常让人犯错的地方往往并不是最简单的路径。比如不同Android版本之间在权限设置上的差异、设备名称为空所带来的问题、需要通过重试机制来恢复的不稳定连接、依赖于设备具体规格的字节级编码规则,以及会自动截断数据的MTU限制等等。针对这些问题,你需要有意识地采取相应的处理措施。可以将所有的相关逻辑封装在一个服务类中,确保提供清晰、稳定的数据流接口,并通过单元测试来验证解析逻辑的正确性。这样一来,你的蓝牙应用就会运行得更加稳定可靠了。

从这里开始,后续该采取哪些步骤取决于你的具体目标。如果你要开发的设备是用于与心率监测仪、温度计或血糖仪等标准设备进行交互,那么请查阅蓝牙SIG发布的GATT规范,因为这些规范明确规定了你所需要使用的UUID值以及数据字节的排列格式。

如果你在开发自定义硬件设备,就需要自行生成128位的UUID值,并详细记录每个功能对应的 数据字节格式,这样才能确保你的固件与应用程序能够正确地交互。

为了保证系统的稳定性,建议使用Provider或Riverpod等工具来实施适当的状态管理机制;只有在实际需要的情况下才启动后台操作;同时,要充分利用nRF Connect框架来验证硬件的正常工作状态,而不要轻易将问题归咎于代码本身。

掌握了本文中介绍的基础知识,你就可以通过Flutter应用程序与几乎任何BLE外围设备进行交互了,从而开发出可靠的应用程序。

相关文章

技术实践

大规模产品实验:Airbnb、Netflix、Lyft和Uber是如何针对基于大语言模型的AI功能进行因果分析的

对于基于大语言模型的AI功能而言,因果推断已不再是理论上的概念。Airbnb、Netflix、Lyft和Uber都发布了详细的工程博客文章,详细说明了他们是如何衡量产品变更对用户行为的因果影响的。 他们所使用的技术方法(如差异分析法、回归不连续性分析以及双重稳健估计等)都是标准工具。 值得关注的是,这些团队是如何在大规模应用中运用这些技术的:在哪些情况下这些方法在实际操作中会失效,他们又是如何通过补充措施来确保估算结果的可靠性,以及他们是如何将这些数据与实际的产品决策联系起来的。 如果你正在开发基于大语言模型的功能,并且是根据用户点赞率和会话时长来做出产品决策的,那么这些文章一定会改变你对测量

阅读全文
技术实践

人工智能评估工程:从零开始构建一款可用于生产环境的大型语言模型评估平台【完整使用手册】

一个令人印象深刻的演示与一个值得信赖的系统之间的差距,其实是通过各种评估来衡量的。 我想先讲一个目前正在数百个工程团队中发生的真实案例。 有一个团队为法律研究开发了一个RAG应用程序。他们用40个精心挑选的问题对该程序进行了测试,结果看起来很不错,于是便向合作方展示了这个系统。合作方对它印象深刻,随后便决定将其正式投入使用。 然而在系统投入生产三周后,一名法律助理发现其中一个答案错误地引用了某项法规。工程团队查看了相关数据,发现“准确性得分”为0.91,这个数值看起来是正常的;他们还检查了答案的相关性,结果也符合标准。 但他们忽略了一个重要的指标:即“上下文完整性”。这个指标用于判断系统是否检

阅读全文
技术实践

在大型语言模型应用中,当你的两个模型都出现错误时,如何通过双重鲁棒性估计方法来进行产品测试与优化?

六个月前,你们推出的这款人工智能产品开始采用“用户主动选择参与”的模式。你们进行了倾向性分析,并考虑了用户的参与程度以及查询的准确性等因素,最终发现任务完成率确实提高了8个百分点。这一数据被纳入了季度业务报告中,大家都对此感到满意。 然而,严谨的数据科学家难免会提出一些棘手的问题:你们有多确定这个倾向性模型已经考虑到了所有可能影响结果的因素?如果遗漏了某些因素,那么逻辑回归模型得出的选择概率就会不准确;又或者,如果结果回归模型的设定本身就有误——因为任务完成率与查询准确性之间的关系并非线性的,线性模型根本无法准确反映这一关系——那又会怎样呢? 你们有两个模型,但并不确定哪个是正确的,而这两个模

阅读全文
技术实践

Flutter前端系统设计:在人工智能时代,如何像资深工程师一样思考

系统设计长期以来一直被视为后端领域的问题。 如果你问一群Flutter工程师“系统设计到底意味着什么”,他们中的大多数人会提到服务器架构:负载均衡器、数据库以及微服务。 但如果你让他们设计一个分布式缓存系统或画出一个消息队列的示意图,他们会犹豫不决。而当你要求他们为社交Feed应用开发Flutter客户端时,他们就会立刻打开新文件开始编写组件代码。 这种差距确实存在,不过正在迅速缩小。 随着Flutter应用程序变得越来越复杂——它们具备了实时功能、离线支持、多平台兼容性,同时还包含需要维护的人工智能生成代码——在编写任何一个组件之前所做出的架构决策,其重要性已经与后端架构相当了。 在那些以产

阅读全文