
去年年底我想给工作室做一套运动监测可视化工具手边正好有一只华为手环就想着把实时心率直接接到UWP程序里。结果一查官方资料就懵了华为运动健康 SDK 只给 Android/iOSWindows 平台完全没有现成接口。折腾了几天之后我绕开了所有官方通道直接在 UWP 里通过蓝牙低功耗BLE连接手环读取标准心率服务成功拿到了实时心率数据流还顺便导出了 CSV 方便二次分析。这篇文章就把这套方案的完整链路和一些非文档里能查到的坑都写出来给同样想做华为手环数据接入 Windows 应用的朋友一个可以直接抄作业的参考。整条链路的技术核心并不复杂UWP 天然支持 BLE而绝大多数智能手环都实现了蓝牙 SIG 的标准心率服务Heart Rate Service服务 UUID 0x180D心率测量特征 0x2A37。我们需要做的就是让 UWP 应用作为 BLE 中心设备扫描到手环、建立连接、订阅心率特征的通知然后解析心率数据。这个过程不依赖华为私有协议所以理论上也对荣耀手环和其他支持标准心率服务的设备通用。1. 需求分析与方案选型为什么不走官方 API而是直接啃 BLE1.1 华为手环健康数据的三种获取路径在做技术选型之前必须把可用的数据通路看一遍。当时我研究了三种思路华为运动健康 App 手动导出文件。这是最直观的方式App 里能导出 CSV 或者 Excel 格式的历史运动记录但问题也很明显一是导出动作需要人工操作完全谈不上实时二是导出的数据通常是整段运动记录而不是逐秒的实时心率流三是导出格式在不同版本 App 里变过好几次用脚本自动处理非常脆弱。华为开放平台 Health Kit / Fitness Kit REST API。这是比较“正规”的方式应用通过 OAuth 授权之后可以拉取用户的健康数据。文档很全但有两个痛点第一申请接入需要企业认证或相应审核流程个人开发者想快速跑通要花不少时间第二云侧 API 返回的心率数据并不是“实时流”它是设备同步到云端之后的数据延迟往往在几分钟甚至更久对实时监测场景不够用。UWP 直接连接手环硬件抓数据。手环本身就是 BLE 外设只要它暴露了标准心率服务Windows 应用就能直接读。这条路虽然要处理 GATT 协议细节但胜在本地直连、延迟低、不需要申请任何 API 权限个人项目完全可以接受。1.2 为什么“标准心率服务”这条路径能走通很多人一听华为手环第一反应是“私有协议Windows 肯定连不上”。实际上这里有个认知死角华为手环虽然和手机 App 通信时会用私有加密协议但它作为标准 BLE 设备同样实现了蓝牙 SIG 定义的标准 GATT 服务。尤其是心率这一类基础健康数据几乎所有的穿戴设备都会套用 0x180D 这个标准服务因为它要兼容第三方健身设备和 App。标准心率服务的核心结构很简单项目UUID属性说明Heart Rate Service0x180D主服务包含心率测量特征Heart Rate Measurement0x2A37Notify/Indicate实时推送心率数据Body Sensor Location0x2A38Read传感器佩戴位置可选心率测量特征通过通知方式主动推送数据数据包格式也是标准化的第一个字节是标志位用于说明心率值的字节长度和是否包含其他附加数据。这就意味着只要拿到特征值的通知回调我们按标准协议解析就能得到 BPM完全不需要逆向华为的私有协议。1.3 方案的前提条件和风险预期当然直接连 BLE 不等于没有任何限制。这套方案有两个前提需要心里有数手环必须处于“可以被连接”的状态。如果手环正连着手机尤其华为运动健康 App 在后台占用连接电脑端往往连不上或者一连接就被踢掉。实测解决办法是先关闭手机蓝牙或者在手环设置里主动断开与手机的连接。手环固件必须暴露标准心率服务。在我的华为手环 6 和手环 7 上这个服务是存在的。但不同固件下有些设备可能在非运动模式下不会持续推送心率可能需要先手动开启一次测心率或者进入运动模式数据流才会稳定。一句话总结官方 API 适合做合规的、有授权的健康数据产品但如果你只是想在本地 Windows 应用里实时拿心率BLE 直连会是成本最低、实时性最好的路线。2. UWP 工程准备蓝牙权限、设备扫描与连接2.1 修改 Package.appxmanifest 声明蓝牙能力UWP 项目对蓝牙访问有严格的权限控制。第一步就是打开Package.appxmanifest在 Capabilities 选项卡里勾选“蓝牙”或者直接编辑 XML在Capabilities节点下加上Capabilities DeviceCapability Namebluetooth / /Capabilities这一步漏掉的话运行时调用蓝牙 API 会直接抛AccessDeniedException而且表现很隐蔽有时不是启动时就报错而是等到扫描或连接某个方法时才异常。建议在动手写代码前就把权限加上。另外建议把项目目标版本设为 Windows 10 1809 或更高因为低版本对 BLE 的 GATT 客户端支持不够完善缓存刷新和通知事件的行为会和现代版本不一致。2.2 用 BluetoothLEAdvertisementWatcher 实现设备扫描UWP 里做 BLE 扫描的核心类是BluetoothLEAdvertisementWatcher。它和传统蓝牙的DeviceWatcher不一样是基于广播包的扫描更快、信息也更丰富。我的实现思路是创建 watcher设置ScanningMode BluetoothLEScanningMode.Active主动扫描可以获得更完整的广播包数据。订阅Received事件在事件参数里取设备地址、名称和 RSSI信号强度。名称过滤用advertisement.LocalName华为手环通常叫 “HUAWEI Band 6-XXXX” 或者 “HUAWEI Band 7-XXXX”荣耀手环则是 “Honor Band ...”。为了避免遗漏我把名称等于空但 MAC 前缀匹配的设备也加到了候选列表。下面是扫描代码的关键片段private BluetoothLEAdvertisementWatcher _watcher; private void StartScan() { _watcher new BluetoothLEAdvertisementWatcher { ScanningMode BluetoothLEScanningMode.Active }; _watcher.Received OnAdvertisementReceived; _watcher.Start(); } private void OnAdvertisementReceived(BluetoothLEAdvertisementWatcher sender, BluetoothLEAdvertisementReceivedEventArgs args) { var localName args.Advertisement.LocalName; var address args.BluetoothAddress; if (string.IsNullOrEmpty(localName)) return; if (localName.Contains(HUAWEI Band) || localName.Contains(Honor Band)) { // 这里用 ConcurrentDictionary 去重避免同一设备多次回调 devices.TryAdd(address, localName); } }注意args.BluetoothAddress是一个ulong实际上就是设备的 MAC 地址可以直接传给后面的连接 API。2.3 建立连接与获取 GATT 服务表扫描到设备之后通过BluetoothLEDevice.FromBluetoothAddressAsync(address)拿到设备实例然后调用GetGattServicesAsync获取服务列表。这里有第一个坑UWP 的 GATT 默认会走缓存如果之前连接过可能返回过期的服务列表导致 0x180D 服务找不到。所以获取服务时务必显式指定BluetoothCacheMode.Uncachedvar device await BluetoothLEDevice.FromBluetoothAddressAsync(address); var servicesResult await device.GetGattServicesAsync(BluetoothCacheMode.Uncached); if (servicesResult.Status ! GattCommunicationStatus.Success) { // 处理失败常见原因是设备被占用或已断开 return; } foreach (var service in servicesResult.Services) { if (service.Uuid GattServiceUuids.HeartRate) // 0x180D { // 找到心率服务 } }这里GattServiceUuids.HeartRate是 UWP 内置常量省去手写 UUID 的麻烦。找到服务后继续取特征值也是同样的套路var characteristicsResult await service.GetCharacteristicsAsync(BluetoothCacheMode.Uncached); foreach (var characteristic in characteristicsResult.Characteristics) { if (characteristic.Uuid GattCharacteristicUuids.HeartRateMeasurement) // 0x2A37 { // 拿到心率测量特征 } }整个连接流程的核心就是“扫描 → 连接 → 找服务 → 找特征”。每一步都有对应的状态码建议关键节点加日志。你后面调试断线问题时会发现这些日志比什么调试器都管用。3. 实时心率流订阅从特征通知到 CSV 落盘3.1 开启 CCCD 通知让手环主动推数据GATT 特征有两种数据获取方式主动读和订阅通知。心率测量特征属于典型的高频推送场景必须订阅通知否则你只能一次读一个瞬时值拿不到连续数据。订阅通知的完整步骤如下为特征的ValueChanged事件挂接回调。调用特征的WriteClientCharacteristicConfigurationDescriptorAsync参数传GattClientCharacteristicConfigurationDescriptorValue.Notify。等待返回值确认是否成功。顺序不能反。如果你先写 CCCD 再挂事件可能会漏掉开头的几个数据包如果只挂事件不写 CCCD则一个回调都收不到。代码如下characteristic.ValueChanged OnHeartRateChanged; var status await characteristic.WriteClientCharacteristicConfigurationDescriptorAsync( GattClientCharacteristicConfigurationDescriptorValue.Notify); if (status GattCommunicationStatus.Success) { // 订阅成功开始接收数据 }这里还有个小细节华为手环在连接后并不会立刻开始推送心率数据。如果你的程序订阅成功了一直等不到回调大概率是手环没在测量状态。解决办法是先在手环上手动点一次“心率”测量或者直接开一个“户外跑步”运动模式数据流就会稳定下来。3.2 解析 Heart Rate Measurement 数据包收到ValueChanged后characteristic.Value是一个byte[]格式完全遵循蓝牙规范。解析逻辑如下第一个字节flags决定心率值的字节数。flags 的第 0 位bit 0为 0说明心率值是一个 uint8取第二个字节即可如果为 1说明心率值是 uint16需要按小端序取第二和第三个字节。心率值单位是 bpm次/分钟。如果 flags 的第 4 位为 1说明后面还带着一个或多个 RR 间期值每个也是 uint16单位是 1/1024 秒可以用来做 HRV 分析但对于纯实时显示可以先忽略。private void OnHeartRateChanged(GattCharacteristic sender, GattValueChangedEventArgs args) { var data args.CharacteristicValue.ToArray(); if (data.Length 2) return; var flags data[0]; int heartRate; if ((flags 0x01) 0) { heartRate data[1]; } else { heartRate BitConverter.ToUInt16(data, 1); } // 回调运行在线程池线程更新 UI 要切回 UI 线程 DispatcherQueue.TryEnqueue(() { HeartRateText.Text ${heartRate} bpm; }); }GattValueChangedEventArgs.CharacteristicValue是IBuffer先用DataReader.FromBuffer或者args.CharacteristicValue.ToArray()转成字节数组。我在实测中发现华为手环 7 多数情况下返回的是单字节心率值也就是 flags 的 bit0 为 0但如果设备处于“连续测量”模式偶尔也会返回两字节的样式所以解析逻辑还是要完整。3.3 给每一条数据打上时间戳并写入 CSV实时显示只是第一步“导出”才是关键。我的做法是开一个后台写入队列把收到的心率数据带着时间戳写入 CSV方便后面导入 Excel 或者做数据分析。CSV 格式越简单越好我用三列timestamp,heart_rate_bpm,device 2025-01-06 14:23:01.123,72,HUAWEI Band 7 2025-01-06 14:23:02.145,73,HUAWEI Band 7需要注意一点ValueChanged回调频率可能很高如果每来一条数据就File.AppendAllLines一次磁盘 IO 会成为瓶颈。尤其长时间佩戴监测的话数据量会很大。我的做法是使用Channel或ConcurrentQueue做缓冲每隔 5 秒批量刷盘一次private readonly ChannelHeartRateRecord _channel Channel.CreateUnboundedHeartRateRecord(); private void OnHeartRateChanged(GattCharacteristic sender, GattValueChangedEventArgs args) { var record new HeartRateRecord { Timestamp DateTime.Now, HeartRate heartRate, Device _deviceName }; _channel.Writer.TryWrite(record); } private async Task FlushLoop() { await using var writer new StreamWriter(_csvPath, append: true); while (await _channel.Reader.WaitToReadAsync()) { while (_channel.Reader.TryRead(out var record)) { await writer.WriteLineAsync(${record.Timestamp:yyyy-MM-dd HH:mm:ss.fff},{record.HeartRate},{record.Device}); } await writer.FlushAsync(); } }这样既保证了实时性又不会因为频繁小数据写入把磁盘搞炸。实测连续记录 2 小时生成的 CSV 文件不到 1MB数据量完全可控。4. 实测中的坑与解决方案这条链路远比想象中“娇气”4.1 “明明扫描到了连接却一直失败”的典型原因最常见的问题是手环已经被手机占用。华为手环同一时间基本只会保持一个 BLE 中心连接。你的手机蓝牙开着华为运动健康 App 在后台常驻那么电脑端扫描到手环后FromBluetoothAddressAsync也许能成功但GetGattServicesAsync会返回Unreachable或者直接超时。排查顺序建议如下关闭手机蓝牙或者至少退出华为运动健康 App。在手环主界面向下拉出控制中心找到“设置”里的“恢复出厂”或者“断开连接”入口主动让手环释放连接。关闭电脑蓝牙再重新打开清空本机缓存。如果手环支持 NFC也不影响 BLE 连接不用专门关。4.2 GATT 服务列表里找不到 0x180D之前提过服务缓存是最主要的坑。但是还有一个更迷惑的原因手环当前没在“测量”状态它不会广播完整的 GATT 服务表甚至某些服务会被隐藏。华为手环的固件在这方面做过省电优化——如果长时间没有交互一些 GATT 服务不会暴露。解决办法是先在上面手动点击几次心率测量或者直接启动“锻炼”模式。如果仍然不行就尝试重启手环然后再扫描连接。另外GetGattServicesAsync建议加一个重试循环for (var i 0; i 3; i) { var servicesResult await device.GetGattServicesAsync(BluetoothCacheMode.Uncached); if (servicesResult.Status GattCommunicationStatus.Success servicesResult.Services.Any(s s.Uuid GattServiceUuids.HeartRate)) { // success break; } await Task.Delay(500); }我之前就遇到过固件启动后前两次GetGattServicesAsync返回的服务列表不完整第三次才稳定的情况。4.3 订阅成功但收不到任何通知订阅成功WriteClientCharacteristicConfigurationDescriptorAsync返回 Success不代表数据就会源源不断。常见诱因有三个手环没开启连续心率监测。华为手环的“连续心率”开关在 App 里如果关闭手环只在手动测量时推送数据。解决办法是在华为运动健康 App 里打开“连续测量心率”开关或者直接让手环进入运动模式。事件挂晚了。有些固件在连接后很短时间内就会开始推数据如果你先写 CCCD 再挂事件可能会错过第一批包。建议先把ValueChanged挂好再写 CCCD。CCCD 写入成功后没有立即调用事件回调。UWP 里偶尔会出现订阅返回 Success但事件一直没有触发的情况。这时候不需要重新连接只需要再调一次WriteClientCharacteristicConfigurationDescriptorAsync或者读一次特征值触发固件重新推送。4.4 长时间运行后连接断开如何自动重连手环非常容易在无操作几分钟后进入休眠状态导致蓝牙连接断开。这跟电脑无关是手环的省电策略。应用要做的不是防止它断开而是在断开后自动重连。BluetoothLEDevice提供了ConnectionStatusChanged事件可以在连接状态变为Disconnected时重启扫描流程。但要注意事件回调里不要直接执行异步连接最好用一个状态机避免频繁重试导致蓝牙堆栈卡死。我写的简化逻辑是device.ConnectionStatusChanged OnConnectionStatusChanged; private async void OnConnectionStatusChanged(BluetoothLEDevice sender, object args) { if (sender.ConnectionStatus BluetoothConnectionStatus.Disconnected) { await Task.Delay(3000); // 等一下避免手环还在休眠 await Reconnect(); } }重连前最好执行一次“断开设备”操作然后重新走“扫描 → 连接 → 找服务 → 订阅”流程。实测下来重连成功率很高但要留意如果用户重新打开了手机蓝牙手环很可能被手机又抢回去这时你需要在应用里加一个人工重连按钮而不是无限循环自动重试。4.5 UI 线程与数据线程的纠缠UWP 的事件回调很多时候运行在线程池线程无法直接更新 XAML 控件。我一开始直接抛出了一个RPC_E_WRONG_THREAD异常后来用DispatcherQueue.TryEnqueue才解决。如果你的项目还在用旧版CoreDispatcher也可以用await Dispatcher.RunAsync。建议把所有 UI 更新都收敛到一个方法里不要在多个事件回调里交叉更新控件否则很容易出现界面卡顿。5. 方案延伸如果 BLE 直连不稳定还有哪些路可走5.1 用 Android/iOS 手机做中转网关如果你对实时性要求没那么高或者手环固件一直没有暴露标准心率服务可以考虑用手机做中转。具体做法是手机安装华为运动健康 App同时使用华为官方 SDK 写一个小型 App读取实时心率。手机 App 通过 WebSocket 或 TCP Socket把心率数据转发到局域网内的 UWP 程序。UWP 只负责接收和展示。这个方案的优点是稳定因为官方 SDK 对自家手环的支持肯定最好缺点是要维护两个客户端链路复杂而且延迟取决于局域网质量一般在几十毫秒内对多数场景足够。5.2 用华为 Health Kit REST API 拉取历史数据如果你的业务场景能接受分钟级延迟甚至只需要历史数据那直接调用华为 Health Kit REST API 更省心。申请到接口权限后通过 OAuth 拿到用户 token再按时间范围拉取心率记录。但我不建议拿它来替代“实时心率导出”。华为健康云服务的同步时机不确定有时要等手环和手机同步完成之后数据才出现在云端而手环和手机的同步频率并不高所以云侧 API 的数据天然有滞后。更麻烦的是Health Kit 的接口字段在申请审核时有很多限制个人项目不一定能批下来。5.3 最终选型建议从我这段实践来看如果你只追求在 Windows UWP 里拿到华为手环的实时心率BLE 直连标准心率服务是一切的优先解。它不依赖任何云服务、不需要申请接口权限、没有数据延迟代码量也就几百行。如果后续你的需求升级成了“必须持续采集 7x24 小时且不能中断”那我建议不要硬撑 BLE直接买一块能把心率广播到第三方设备的手环或者干脆切换成 Android 采集端 网络转发架构。专业的事情交给专业的链路BLE 直连方案的初衷是快速验证不是做产品级容灾。另外如果是想把手环的历史运动记录导出成文件再去尝试逆向手环上的文件系统或者分析 App 的私有格式性价比就很低了。直接用官方 App 的导出功能或者 Health Kit 会更稳妥。我在这个项目里踩过最大的坑就是没有先检查手环是否支持标准心率服务而是花了一整天查华为私有协议的资料。后来冷静下来抓了一下 GATT 服务表发现 0x180D 就明晃晃地摆在那儿。很多朋友看到“华为”两个字就容易接受一个预设“它肯定走的是私有协议”。但只要你想清楚“手环本质上是个 BLE 外设”思路一下就打开了。如果你也在做类似的 Windows 健康数据接入先打开蓝牙调试工具看一眼服务表往往能省下好几个晚上的折腾时间。