ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C# Windows桌面蓝牙GATT通信实战:从设备扫描到特征值读写

C# Windows桌面蓝牙GATT通信实战:从设备扫描到特征值读写

简介:面向C#开发者的蓝牙BLE通信项目资源,解决在Windows平台使用GATT协议与低功耗设备(如健康追踪器、智能家居设备等)进行数据交换的编码问题。资源为一套完整的WPF示例工程,演示BLE设备扫描、连接、GATT服务与特性查询、特征值读写以及ValueChanged通知订阅,尤其适合需要处理BLE服务发现与通知订阅的场景,供物联网调试与蓝牙开发入门者对照学习。压缩包共145个文件,以C#源文件(cs)、XAML/BAML界面资源、编译生成的exe/dll为主,另含resources资源文件、json配置与构建缓存,整体仅510KB,便于快速查看项目结构并直接运行体验。已有977人学习浏览。从中可获取可直接编译的工程源码、窗口布局与调试符号,对照示例代码可深入理解GATT协议中服务、特性、描述符的组织方式,掌握设备连接、数据读写、Notify通知注册等核心流程,为自行开发Windows平台BLE通信功能提供具体参考。

1. C# 蓝牙 GATT 通信:从 Windows 桌面直接和 BLE 外设打交道

如果你手里有一个 BLE 蓝牙外设——心率带、温湿度传感器、自制的 ESP32 采集节点,而你的业务系统偏偏跑在 Windows 桌面或工控机上,最常见的尴尬就是:厂商 SDK 全是 Android/iOS 的,桌面端只能靠串口或 USB 转接。实际上,Windows 10 之后的系统自带了完整的蓝牙 GATT 协议栈,用 C# 通过 WinRT API 就能直接扫描、连接、读写 BLE 设备,完全不需要额外装驱动或 USB 适配器。

这篇文章拆的是一个典型的 C# 蓝牙 GATT 通信工程。它做的事情很直接:用Windows.Devices.Bluetooth命名空间下的 API,完成设备发现、GATT 服务枚举、特征值读写和通知订阅,并且把这些底层操作封装成上层业务可以直接调用的接口。适合三类人看:一是桌面端要对接 BLE 传感器的嵌入式开发者,二是做医疗健康、工业采集类软件的同学,三是被厂商 SDK 绑架、想自己做一套跨平台协议层的人。先给出一个反直觉的结论:GATT 通信的难度不在蓝牙协议本身,而在 Windows 蓝牙栈的异步模型和生命周期管理——搞不定这两块,即使代码能跑通,换一台电脑或者多连几次设备就会翻车。

2. 技术选型与整体架构:为什么选择 WinRT 而不是第三方库

2.1 三条技术路线的取舍

在 C# 里做蓝牙 GATT 通信,业界常见的有三条路。

第一是 WinRT 原生 API,也就是Windows.Devices.Bluetooth系列。它的优势是系统自带、不需要额外包体,支持 UWP 和 .NET 6+ 的 Windows 桌面应用(通过 target framework 引用 Windows SDK);劣势是 API 风格偏异步、回调层级深,且部分功能只在 Windows 10 1809 以上版本可用。

第二是 32Feet.NET 这种开源库。它封装了 Windows 传统蓝牙 API(System.IO.Ports那套),对 RFCOMM 串口支持好,但 GATT 支持很弱,很多 BLE 设备的 128-bit UUID 解析不了。如果目标设备是经典蓝牙(如蓝牙音箱),选它没问题;做 BLE 传感器采集就不合适。

第三是写一个跨平台抽象层,底层分别调 WinRT 和 Android/iOS 原生 API。投入大,但适合产品线多、需要统一协议栈的团队。

这个工程选的是第一条路,WinRT 原生 API。理由很现实:在做工控和医疗采集的场景里,Windows 是主战场,少一个第三方依赖就少一层维护成本。但直接裸用 WinRT API 写业务代码会非常痛苦——每个 GATT 操作都是嵌套的异步调用,比如GetGattServicesAsync()返回的是GattServicesResult,里面还要再判断Status枚举。所以工程里做了一层封装。

2.2 工程目录结构与职责划分

整体结构按「底层 API 操作 → 设备抽象 → 业务应用」三层划分,典型的模块化思路:

BleGattDemo/ ├── BleCore/ // 底层封装,不感知业务 │ ├── BleDeviceDiscoverer.cs // 设备发现,负责扫描与过滤 │ ├── BleDeviceConnection.cs // 连接管理与生命周期 │ ├── GattServiceWrapper.cs // GATT 服务/特征值封装 │ └── BleDataModels.cs // 设备信息、特征值等模型 ├── BleServices/ // 业务层,面向具体设备类型 │ ├── HeartRateService.cs // 心率服务示例 │ ├── EnvironmentalService.cs // 环境传感器示例 │ └── IBlePeripheralService.cs // 服务接口抽象 ├── App/ // 演示应用(WPF) │ ├── MainWindow.xaml │ └── ViewModels/ └── BleGatt.sln

为什么把BleCore和BleServices分开?因为 GATT 协议本身是标准化的:所有 BLE 设备都有 Generic Access、Generic Attribute 服务,特征是Read、Write、Notify等属性也是固定的。但不同设备的数据格式完全不同——心率设备上报的 payload 和温湿度传感器完全不一样。所以底层只做「怎么收发」,上层才管「数据是什么意思」。

2.3 核心模型设计:用 IDisposable 管理设备生命周期

GATT 通信最忌讳的一件事是:设备对象用完了不释放,导致 Adapter 缓存占满,后续扫描不到设备。这个工程在模型层就做了约束。看一下BleDeviceConnection的设计:

public sealed class BleDeviceConnection : IDisposable { public BluetoothLEDevice Device { get; private set; } public string DeviceId { get; } public string DeviceName { get; } // GATT 服务缓存字典:避免重复获取服务 private readonly Dictionary<string, GattDeviceService> _serviceCache = new Dictionary<string, GattDeviceService>(); private bool _disposed; private readonly SemaphoreSlim _initLock = new SemaphoreSlim(1, 1); public BleDeviceConnection(BluetoothLEDevice device) { Device = device; DeviceId = device.DeviceId; DeviceName = device.Name; } public async Task<IReadOnlyList<GattDeviceService>> GetServicesAsync() { await _initLock.WaitAsync(); try { var result = await Device.GetGattServicesAsync(); if (result.Status != GattCommunicationStatus.Success) throw new InvalidOperationException($"获取服务失败: {result.Status}"); foreach (var svc in result.Services) _serviceCache[svc.Uuid.ToString()] = svc; return result.Services.ToList(); } finally { _initLock.Release(); } } public void Dispose() { if (_disposed) return; _disposed = true; foreach (var svc in _serviceCache.Values) svc.Dispose(); _serviceCache.Clear(); Device?.Dispose(); } }

参数说明:DeviceId是操作系统的设备唯一标识,重连时可以直接用这个 ID 实例化BluetoothLEDevice,不需要重新扫描。SemaphoreSlim是防止多个协程同时初始化服务列表造成崩溃——这是 WinRT 对象线程亲和性的坑,后面会详细讲。Dispose 里要把所有服务对象释放掉,否则 Windows 的 BLE 驱动不会真正断开连接,下次配对会失败。

从这一段能看出整个工程的风格:不追求花哨的异步编排,而是把生命周期管清楚。蓝牙 GATT 属于系统级资源,不同于普通的 HTTP 请求,连接对象、服务对象、特征值对象都需要用IDisposable模式管理。

3. 核心模块拆解:从设备扫描到特征值写读的完整链路

3.1 设备发现:Watcher 扫描与 RSSI 过滤

设备发现是整个流程里最容易出问题的环节。WinRT 提供了一个BluetoothLEAdvertisementWatcher类,可以持续监听周边的广播包。这个工程对它做了一层封装,关键点在过滤逻辑和线程调度:

public sealed class BleDeviceDiscoverer : IDisposable { private BluetoothLEAdvertisementWatcher _watcher; private readonly Dictionary<string, BleDeviceInfo> _foundDevices = new Dictionary<string, BleDeviceInfo>(); public event EventHandler<BleDeviceInfo> DeviceDiscovered; public event EventHandler<BleDeviceInfo> DeviceUpdated; public void StartScan(int scanTimeoutMs = 10000) { _watcher = new BluetoothLEAdvertisementWatcher { ScanningMode = BluetoothLEScanningMode.Active }; // 只接受信号强度在 -80dBm 以上的设备,过滤掉太远的杂散广播 _watcher.SignalStrengthFilter = new BluetoothSignalStrengthFilter { InRangeThresholdInDBm = -80, OutOfRangeThresholdInDBm = -90, OutOfRangeTimeout = TimeSpan.FromSeconds(2) }; _watcher.Received += OnAdvertisementReceived; _watcher.Start(); // 超时自动停止,避免 Watcher 一直挂着占用蓝牙适配器 Task.Delay(scanTimeoutMs).ContinueWith(_ => { if (_watcher?.Status == BluetoothLEAdvertisementWatcherStatus.Started) { _watcher.Stop(); _watcher.Received -= OnAdvertisementReceived; } }); } private void OnAdvertisementReceived(BluetoothLEAdvertisementWatcher sender, BluetoothLEAdvertisementReceivedEventArgs args) { // 过滤掉无广播名的设备(很多 OEM 设备不广播名称,只广播自定义数据) if (string.IsNullOrEmpty(args.Advertisement.LocalName)) return; var info = new BleDeviceInfo { DeviceId = args.BluetoothAddress.ToString("X12"), DeviceName = args.Advertisement.LocalName, Rssi = args.RawSignalStrengthInDBm }; if (_foundDevices.ContainsKey(info.DeviceId)) DeviceUpdated?.Invoke(this, info); else { _foundDevices[info.DeviceId] = info; DeviceDiscovered?.Invoke(this, info); } } public void Dispose() => _watcher?.Stop(); }

代码说明:BluetoothLEAdvertisementReceivedEventArgs里的BluetoothAddress是 64 位无符号整数,转成 12 位十六进制字符串就是设备的 MAC 地址。Active扫描模式会主动发送扫描请求,设备通常会被要求回应广播名称,所以这里能拿到LocalName;如果是Passive模式,只能被动收听,很多设备不会在广播包里放名称。SignalStrengthFilter不是必须的,但建议加上——办公室环境里广播设备太多了,不加过滤会扫出一堆蓝牙耳机、键盘,列表没法看。

有个细节可能踩坑:事件Received的回调不一定在 UI 线程上触发,如果直接把设备列表绑定到 ListView 会报跨线程异常。工程里的做法是DeviceDiscovered事件在 ViewModel 层用Dispatcher.BeginInvoke切回主线程。

3.2 连接与主服务发现:解析 GATT 服务层级

BLE 设备连接成功后,首要任务是拿到它的服务列表。一个服务(Service)下有多个特征值(Characteristic),特征值是真正承载数据的对象。这段代码展示了如何定位一个指定 UUID 的服务和特征:

public static class GattHelper { // 通过标准的 128-bit UUID 查找服务 public static async Task<GattDeviceService> FindServiceAsync( BluetoothLEDevice device, Guid serviceUuid) { var result = await device.GetGattServicesAsync(BluetoothCacheMode.Uncached); if (result.Status != GattCommunicationStatus.Success) return null; return result.Services.FirstOrDefault(s => s.Uuid == serviceUuid); } // 查找特征值,支持 16-bit 短 UUID 自动转换 public static async Task<GattCharacteristic> FindCharacteristicAsync( GattDeviceService service, ushort shortUuid) { var fullUuid = ConvertShortUuidToFull(shortUuid); var result = await service.GetCharacteristicsAsync(); if (result.Status != GattCommunicationStatus.Success) return null; return result.Characteristics.FirstOrDefault(c => c.Uuid == fullUuid); } private static Guid ConvertShortUuidToFull(ushort shortUuid) { // BLE 规定的底层基地址:0000xxxx-0000-1000-8000-00805F9B34FB return new Guid($"0000{shortUuid:X4}-0000-1000-8000-00805F9B34FB"); } }

看一眼参数:BluetoothCacheMode.Uncached很关键。系统会把之前获取过的服务列表缓存起来,但如果设备固件做过 OTA 升级,服务结构可能变了,用Cached拿到的还是旧服务列表,特征值读写就会返回DeviceUnreachable。开发调试阶段建议每次都走Uncached,等到稳定量产再换成Cached提升速度。

3.3 特征值读写与通知:理解 CCCD 的含义

BLE 特征值有四种主要的属性:读、写、通知、指示。工程里对读操作和通知订阅封装了两个核心方法:

public async Task<byte[]> ReadCharacteristicAsync(GattCharacteristic characteristic) { if (characteristic.CharacteristicProperties.HasFlag(GattCharacteristicProperties.Read)) { var result = await characteristic.ReadValueAsync(BluetoothCacheMode.Uncached); if (result.Status == GattCommunicationStatus.Success) { var data = new byte[result.Value.Length]; DataReader.FromBuffer(result.Value).ReadBytes(data); return data; } } return null; } public async Task<bool> SubscribeNotificationAsync( GattCharacteristic characteristic, Action<byte[]> onDataReceived) { // 关键是这一步:写入 CCCD(Client Characteristic Configuration Descriptor) // 通知的开启依赖这个描述符,值为 0x0001 表示开启通知,0x0002 表示开启指示 var cccd = characteristic.Descriptors.FirstOrDefault( d => d.Uuid == GattDescriptorUuids.ClientCharacteristicConfiguration); if (cccd == null) return false; var writer = new DataWriter(); writer.WriteBytes(new byte[] { 0x01, 0x00 }); // 0x0001 - 开启通知 var status = await cccd.WriteValueAsync(writer.DetachBuffer()); if (status != GattCommunicationStatus.Success) return false; characteristic.ValueChanged += (sender, args) => { var reader = DataReader.FromBuffer(args.Value); var data = new byte[args.Value.Length]; reader.ReadBytes(data); onDataReceived(data); }; return true; }

这段代码是 GATT 通信的核心。很多人第一次接 BLE 时,以为特征值本身有「订阅」属性,直接设ValueChanged就能收到数据——实际上不写入 CCCD 描述符,设备根本不会主动推数据。CCCD 是特征值下的一个描述符,它决定了这个特征是否启用通知模式。写 0x0001 是通知,写 0x0002 是指示(带确认的推送,适合可靠性要求高的场景,但吞吐量低)。

对于通知太频繁的场景,比如心率传感器每秒钟发 10 次以上,ValueChanged回调里千万不要做耗时的 UI 操作,否则回调队列会把 UI 线程堵死。工程实践里一般会先把数据压进队列,由后台线程统一处理。

4. 实战落地:对接一个温度传感器并解析数据

4.1 定义业务服务和协议解析

理论讲完,直接落到具体的业务封装。这个工程里有一个环境监测服务的示例,用的是 SIG 定义的标准 Environmental Sensing Service(0x181A),温度特征值 UUID 是 0x2A6E。看业务层如何组织代码:

public interface IBlePeripheralService : IDisposable { bool IsInitialized { get; } Task ConnectAsync(string deviceId); Task DisconnectAsync(); } public sealed class TemperatureService : IBlePeripheralService { private const ushort EnvironmentalSensingServiceUuid = 0x181A; private const ushort TemperatureCharacteristicUuid = 0x2A6E; private BleDeviceConnection _connection; private GattCharacteristic _temperatureChar; // 对外暴露的温度事件,单位是摄氏度 public event EventHandler<double> TemperatureUpdated; public async Task ConnectAsync(string deviceId) { // 从系统直接用设备 ID 连接,避免重新扫描 var device = await BluetoothLEDevice.FromIdAsync(deviceId); if (device == null) throw new InvalidOperationException("设备不可用"); _connection = new BleDeviceConnection(device); var service = await GattHelper.FindServiceAsync( _connection.Device, new Guid(EnvironmentalSensingServiceUuid.ToString())); if (service == null) throw new InvalidOperationException("设备不支持环境感知服务"); _temperatureChar = await GattHelper.FindCharacteristicAsync(service, TemperatureCharacteristicUuid); // 订阅通知前先读一次静态值,保证界面有初始数据 var initial = await ReadTemperatureAsync(); if (initial.HasValue) TemperatureUpdated?.Invoke(this, initial.Value); await _connection.Device.GetGattServicesAsync(); // 预热服务缓存 } public async Task<double?> ReadTemperatureAsync() { var data = await ReadCharacteristic(); if (data == null || data.Length < 2) return null; // 环境温度的标准格式:Int16,单位 0.01 摄氏度 var raw = BitConverter.ToInt16(data, 0); return raw / 100.0; } }

注意ConnectAsync里的一个隐性好处:从FromIdAsync直接连接,比扫描再连接快得多。扫描要等广播包窗口,通常是秒级;用设备 ID 直接连接是毫秒级。这也是推荐把DeviceId持久化保存的原因——很多设备的 MAC 地址是随机化的(隐私模式),但DeviceId在系统内是稳定的,即使蓝牙关了重开再扫描,ID 也基本保持一致。

4.2 数据解析的兼容性与字节序问题

BLE 协议规定数据默认是小端字节序。温度值用BitConverter.ToInt16(data, 0)读取时,Windows 平台本身就是小端,直接转没问题。但如果你的设备上报的是 IEEE 754 浮点数格式,或者自定义结构体,就要小心了。工程里写了一个通用的解析器:

public static class ByteParser { // 从字节数组中按偏移和长度读取值,自动处理小端/大端 public static T ReadLittleEndian<T>(byte[] buffer, int offset) where T : struct { if (typeof(T) == typeof(short)) { return (T)(object)BitConverter.ToInt16(buffer, offset); } if (typeof(T) == typeof(ushort)) { return (T)(object)BitConverter.ToUInt16(buffer, offset); } if (typeof(T) == typeof(int)) { return (T)(object)BitConverter.ToInt32(buffer, offset); } if (typeof(T) == typeof(float)) { // IEEE 754 浮点,需要先检查设备手势是左对齐还是右对齐 return (T)(object)BitConverter.ToSingle(buffer, offset); } throw new NotSupportedException($"不支持的数据类型: {typeof(T)}"); } // 从特征值通知中提取原始字节 public static byte[] FromBuffer(IBuffer buffer) { var reader = DataReader.FromBuffer(buffer); var bytes = new byte[buffer.Length]; reader.ReadBytes(bytes); return bytes; } }

这里要特别说明:BLE 特征值的数据格式完全由设备厂商定义,SIG 标准只是「建议」而不是「强制」。同样的温湿度传感器,有的用 16-bit 整数,有的用 4 字节浮点,甚至有的厂商会把温度放前两个字节、湿度放后两个字节拼成一个通知。所以业务解析层必须留给二次开发者足够的灵活性——你封装了ByteParser,但核心的TemperatureUpdated事件里还是允许调用方直接拿到原始字节数组,以免厂商协议不规范时无法排查。

4.3 多个特征值同时订阅的管理

实际项目里不会只读一个特征。比如一个环境监测站,可能是温度 + 湿度 + PM2.5 三个特征值同时上报。工程的做法是维护一个订阅管理器:

public sealed class GattSubscriptionManager { private readonly Dictionary<Guid, GattCharacteristic> _subscribedChars = new Dictionary<Guid, GattCharacteristic>(); private readonly HashSet<Guid> _pendingSubscribe = new HashSet<Guid>(); public async Task<bool> SubscribeAsync(GattCharacteristic characteristic, Action<byte[]> handler) { // 防止重复订阅:同一个特征值重复写 CCCD 会导致设备异常 if (_subscribedChars.ContainsKey(characteristic.Uuid)) return true; // 串行化订阅过程:某些设备不支持并发写描述符 if (!_pendingSubscribe.Add(characteristic.Uuid)) return false; var writer = new DataWriter(); writer.WriteBytes(new byte[] { 0x01, 0x00 }); var desc = characteristic.Descriptors.FirstOrDefault( d => d.Uuid == GattDescriptorUuids.ClientCharacteristicConfiguration); if (desc == null) return false; var status = await desc.WriteValueAsync(writer.DetachBuffer()); _pendingSubscribe.Remove(characteristic.Uuid); if (status != GattCommunicationStatus.Success) return false; characteristic.ValueChanged += (s, e) => { var data = ByteParser.FromBuffer(e.Value); handler(data); }; _subscribedChars[characteristic.Uuid] = characteristic; return true; } }

这个管理器的价值在断开重连场景:设备断线后重连,之前订阅过的特征值全部失效了,需要重新走一遍 CCCD 写入。如果工程里没有记录"已订阅列表",重连后就要重新扫描服务、找特征值、写 CCCD,麻烦且容易漏。这个字典结构就是重连恢复的依据。

5. 避坑与常见问题排查:Windows 蓝牙栈的五个经典翻车现场

5.1 扫描不到设备:广播类型与权限问题

现象:BluetoothLEAdvertisementWatcher启动后,Received事件一直不触发,或者只能扫到手机、耳机,扫不到自己的传感器。

原因分三类。第一,传感器工作在 Passive 广播模式,广播包间隔太长(比如 1000ms 以上),Active扫描也难捕捉。第二,Windows 的蓝牙权限没打开——桌面应用默认是「询问」状态,第一次调用 Watcher 时会弹权限窗,没点允许就直接没结果。第三,设备可能已经和 Windows 配对过了,系统走的是「已配对设备」通道,而广播扫描不会列出已配对设备。

解决:先到系统设置里删除该设备的配对记录,保证设备处于「未配对、纯广播」状态;然后检查Watcher.Status是否为Started;最后用手机上的 nRF Connect 软件验证设备是否在发广播——如果手机也扫不到,是设备端问题,不是 C# 的问题。

5.2 连接成功后立马断开:缺少服务释放

现象:第一次连接设备能成功,也能读到服务,但断开后第二次再连,GetGattServicesAsync返回Unreachable甚至直接丢异常。

原因:上一次连接后,BluetoothLEDevice对象没有 Dispose,服务对象GattDeviceService占住了系统的 BLE 连接句柄。Windows 对同时间连接的外设数量有限制(尤其 BT 4.0 适配器通常只支持 2-3 个活跃链接),不释放就导致新连接排队直到超时。

解决:在Dispose方法里显式释放每个GattDeviceService和BluetoothLEDevice,并在finally块中调用。我一般习惯在 ViewModel 的OnNavigatedFrom或者窗体Closed事件里统一走Dispose链路,不依赖垃圾回收。

5.3 拿到特征值但读不到数据:UUID 大小写与格式坑

现象:代码里用了完整的 128-bit UUID 字符串,比如"00002a6e-0000-1000-8000-00805f9b34fb",但FindCharacteristicAsync返回 null。

原因:UUID 在 .NETGuid里存储时,字节序是混合的——前三个字段小端,后两个字段大端。这意味着字符串表示2a6e在内部可能会被调整为6e2a,导致比较失败。

解决:工程里的ConvertShortUuidToFull用Guid.ToString("B")输出的字符串去比较,或者直接用new Guid(shortUuid) == characteristic.Uuid这样的方式做比较时,要确认两边的字节序一致。踩过一次之后,我干脆保存短 UUID(ushort),只在需要字符串时拼标准格式,不直接与Guid做字符串比较。

5.4 通知订阅成功但收不到推送

现象:CCCD 写入返回Success,ValueChanged也已挂上,但设备有数据更新时,回调不被触发。

原因:第一,某些设备要求先写特征值的「控制点」(Control Point),把传感器从待机模式切换到工作模式,才开始主动推送数据——CCCD 只是二道闸,前面还有一道开关。第二,设备推的是「指示」而不是「通知」,CCCD 需要写 0x0002 而不是 0x0001。第三,设备固件有 bug,会忽略 CCCD 写入,需要先断开重连一次才能生效。

解决:对照设备厂家的协议文档,确认数据推送是通知还是指示;如果是需要控制点的设备,先找到 GATT 服务里那个可写的 Characteristic,写入规定的启动值,再订阅 CCCD。没有文档时,用 nRF Connect 手动操作一遍——如果手机端也需要先写某个值才能收到推送,说明控制点机制存在,C# 里要复现同样的写值顺序。

5.5 WinRT 对象与线程的亲和性问题

现象:GetGattServicesAsync()没问题,但通过ValueChanged回调里访问GattCharacteristic对象的属性,偶尔会抛COMException (0x8001010E),含义是「在一个错误的线程上调用了方法」。

原因:WinRT 的GattCharacteristic对象内部绑定到创建它的线程上下文。如果第一次调用GetCharacteristicsAsync()是在后台线程,之后在另一线程触发ValueChanged,跨线程访问就可能触发这个异常。

解决:把GattCharacteristic的操作全部限制在同一个同步上下文里。最稳妥的做法是工程里所有 GATT 操作都走SemaphoreSlim串行化——先获取锁再调用 WinRT API,取完锁之后立刻把数据复制到业务模型,后续操作不再触碰 WinRT 对象。这条血泪经验基本解决了团队里大半的偶发崩溃问题。

6. 二次开发:把 GATT 通信抽成可复用的设备库

6.1 抽象一层「设备能力」,而不是抽象「设备型号」

很多人在做二次开发时,直接把TemperatureService这种类当作唯一入口,结果每接一个新设备就要复制粘贴改 UUID。更实际的做法是抽象「能力」——比如ITemperatureProvider、IHumidityProvider、IBatteryLevelProvider:

public interface ITemperatureProvider { Task<double?> ReadTemperatureAsync(); event EventHandler<double> TemperatureUpdated; } public interface IDeviceConnection { Task ConnectAsync(string deviceId); Task DisconnectAsync(); bool IsConnected { get; } }

然后定义两种设备——一种实现ITemperatureProvider,另一种实现ITemperatureProvider+IHumidityProvider。上层业务代码只依赖接口,不知道底层是 BLE 还是串口还是模拟数据。这带来的好处是:你在开发阶段可以写一个MockTemperatureService,让温湿度曲线界面不依赖真实硬件先行开发;然后等硬件到了,替换依赖注入的实现类即可。

接口拆分之后,原来的TemperatureService要做两处调整:ConnectAsync里不再抛「服务不存在」的异常,而是只订阅自己负责的那一部分特征值;多个服务类共享同一个BleDeviceConnection实例,避免一个设备被连接两次。

6.2 断线重连:用状态机管理连接生命周期

BLE 连接非常不稳定——设备走远、适配器休眠、Windows 更新驱动都会导致掉线。成熟的库里会有一个连接状态机:

public enum BleConnectionState { Disconnected, Connecting, Connected, Reconnecting } public sealed class BleConnectionManager { private BleConnectionState _state = BleConnectionState.Disconnected; private readonly Dictionary<string, IBlePeripheralService> _services; private CancellationTokenSource _reconnectCts; public async Task<bool> ReconnectAsync(string deviceId) { _state = BleConnectionState.Reconnecting; _reconnectCts?.Cancel(); var success = false; for (int attempt = 1; attempt <= 5; attempt++) { try { var device = await BluetoothLEDevice.FromIdAsync(deviceId); if (device == null) continue; // 重新走一遍服务发现 + 特征值订阅 foreach (var svc in _services.Values) { await svc.ConnectAsync(deviceId); } success = true; _state = BleConnectionState.Connected; break; } catch (Exception ex) { // 指数退避:1s, 2s, 4s, 8s, 16s await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt))); } } return success; } }

重连时有一个关键点:BluetoothLEDevice.FromIdAsync可能会返回同一个缓存实例,此时需要手动触发一次服务刷新,否则某些系统缓存会把之前的连接状态覆盖掉。工程里的ReconnectAsync在首次失败后会强制走BluetoothCacheMode.Uncached重新获取一遍服务列表。这个操作在 XP 时代的蓝牙库上不存在,但 WinRT 的缓存策略比较激进,不加这步重连成功率大概会掉 30%。

6.3 验证思路与收尾

二次开发完成后,验证不只看「能不能收到数据」,还要看三个工程指标:连续运行 72 小时不掉线、设备断电后 30 秒内自动重连、并发读三个特征值时无数据错乱。我的习惯是写一个压力测试页面:自动连上后持续 5 分钟高频读取特征值,同时记录每次读操作的耗时分布,如果出现单次读操作超过 2 秒,基本是底层服务没有释放干净。

还有一个小技巧:GATT 设备在 Windows 设备管理器里的「蓝牙 → 设备」节点,可以右键删除配对关系,这比在 C# 代码里折腾UnpairAsync快得多。每次调试遇到连接状态诡异,先删配对、重启蓝牙适配器,省掉一半的排查时间。希望这些经验对你的项目落地有帮助,祝早日调通第一台 BLE 设备的通知通道。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进