蓝牙心率/血氧设备实战:Health Device Profile、数据格式解析、实时图表展示

好,咱们终于到了实战环节。前面讲了那么多理论,今天直接上手一个真实设备——蓝牙心率血氧仪。这类设备通常遵循 Health Device Profile(HDP),但更常见的是基于 BLE 的 Heart Rate Profile 和 Pulse Oximeter Profile。我个人习惯把这类设备叫做“健康三件套”:心率、血氧、还有体温。今天咱们重点搞定前两个。

一、Health Device Profile 到底是个啥?

说白了,HDP 就是蓝牙 SIG 定义的一套标准。它规定了健康设备怎么传数据、传什么数据、数据长什么样。你想想看,如果每个厂家都自己定义一套协议,那 App 开发者不得疯掉?

在 BLE 世界里,心率设备用的是 Heart Rate Service (0x180D),血氧设备用的是 Pulse Oximeter Service (0x1822)。这两个 Service 里都有对应的 Characteristic,用来上报测量数据。

核心要点:心率数据通常通过 Notification 实时上报,血氧数据也是。你只需要订阅对应的 Characteristic 的 Notification,就能源源不断地收到数据。

我记得第一次做这类项目时,以为血氧数据也是像心率一样一个字节搞定。结果发现血氧的格式复杂得多——它包含 SpO2 值、脉搏率、还有状态标志位。嗯,这里要注意。

二、数据格式解析——从字节流到可读数据

蓝牙传过来的数据是原始字节流。你得按照规范去解析。咱们先看心率的。

2.1 心率数据格式

心率 Characteristic 的 UUID 是 0x2A37。数据格式如下:

字节偏移 内容 说明
0 Flags 位0:心率格式(0=UINT8,1=UINT16)
位1-2:传感器接触状态
位3:能量消耗是否存在
位4:RR间隔是否存在
1 或 1-2 心率值 根据 Flags 决定是 1 字节还是 2 字节
后续 能量消耗(可选) UINT16,2字节
后续 RR间隔(可选) 每个 UINT16,单位 1/1024 秒

看代码更直观:

fun parseHeartRate(data: ByteArray): HeartRateData {
    val flags = data[0].toInt() and 0xFF
    val isUint16 = (flags and 0x01) != 0
    var index = 1

    // 解析心率值
    val heartRate = if (isUint16) {
        val value = (data[index].toInt() and 0xFF) or 
                    ((data[index + 1].toInt() and 0xFF) shl 8)
        index += 2
        value
    } else {
        val value = data[index].toInt() and 0xFF
        index += 1
        value
    }

    // 解析能量消耗(如果存在)
    var energyExpended: Int? = null
    if ((flags and 0x08) != 0) {
        energyExpended = (data[index].toInt() and 0xFF) or 
                         ((data[index + 1].toInt() and 0xFF) shl 8)
        index += 2
    }

    // 解析RR间隔(如果存在)
    val rrIntervals = mutableListOf<Float>()
    if ((flags and 0x10) != 0) {
        while (index + 1 < data.size) {
            val rrRaw = (data[index].toInt() and 0xFF) or 
                        ((data[index + 1].toInt() and 0xFF) shl 8)
            rrIntervals.add(rrRaw / 1024.0f)
            index += 2
        }
    }

    return HeartRateData(heartRate, energyExpended, rrIntervals)
}

避坑指南:我曾经遇到过一个设备,它的 Flags 位居然不按规范来——心率值明明是 UINT16,但 Flags 位却是 0。后来发现是设备固件 bug。我的建议是:解析时同时检查数据长度,如果 Flags 说 UINT8 但数据长度明显不够,就按 UINT16 再试一次。

2.2 血氧数据格式

血氧 Characteristic 的 UUID 是 0x2A5F。这个比心率复杂,因为它包含多个测量值。格式如下:

字节 内容 说明
0-1 Flags UINT16,各位标志含义复杂,见下方
2 SpO2 百分比,UINT8
3 脉搏率 UINT8,单位 bpm
4 脉搏幅度 可选,UINT16
后续 状态标志 可选,UINT16

Flags 的各位含义:

  • 位0:SpO2 是否有效
  • 位1:脉搏率是否有效
  • 位2:脉搏幅度是否存在
  • 位3:状态标志是否存在
  • 位4-7:保留
fun parseSpO2(data: ByteArray): SpO2Data {
    val flags = ((data[1].toInt() and 0xFF) shl 8) or 
                (data[0].toInt() and 0xFF)
    var index = 2

    // SpO2 值
    val spo2 = if ((flags and 0x01) != 0) {
        data[index++].toInt() and 0xFF
    } else {
        null
    }

    // 脉搏率
    val pulseRate = if ((flags and 0x02) != 0) {
        data[index++].toInt() and 0xFF
    } else {
        null
    }

    // 脉搏幅度(可选)
    var pulseAmplitude: Int? = null
    if ((flags and 0x04) != 0) {
        pulseAmplitude = (data[index].toInt() and 0xFF) or 
                         ((data[index + 1].toInt() and 0xFF) shl 8)
        index += 2
    }

    // 状态标志(可选)
    var status: Int? = null
    if ((flags and 0x08) != 0) {
        status = (data[index].toInt() and 0xFF) or 
                 ((data[index + 1].toInt() and 0xFF) shl 8)
    }

    return SpO2Data(spo2, pulseRate, pulseAmplitude, status)
}

注意:血氧数据中,SpO2 和脉搏率可能同时无效(比如设备正在测量中)。你的 UI 应该显示“测量中”而不是 0。我曾经见过一个 App 把 0 当作有效数据显示,结果用户看到自己血氧 0% 吓坏了。

三、实时图表展示——用 MPAndroidChart 还是自定义 View?

数据解析出来了,接下来就是展示。我个人习惯用 MPAndroidChart,它成熟、稳定、文档全。但如果你追求极致性能或者特殊效果,也可以自己画。

这里我用 MPAndroidChart 的 LineChart 来展示心率和血氧的实时曲线。

3.1 初始化图表

// 在 Fragment 或 Activity 中
private lateinit var chartHeartRate: LineChart
private lateinit var chartSpO2: LineChart

private fun initCharts() {
    // 心率图表
    chartHeartRate.apply {
        description.isEnabled = false
        setTouchEnabled(true)
        isDragEnabled = true
        setScaleEnabled(true)
        setPinchZoom(true)
        
        xAxis.apply {
            position = XAxis.XAxisPosition.BOTTOM
            setDrawGridLines(false)
            labelCount = 5
        }
        
        axisLeft.apply {
            axisMinimum = 40f
            axisMaximum = 200f
        }
        axisRight.isEnabled = false
        
        legend.isEnabled = false
    }

    // 血氧图表(类似设置,但 Y 轴范围 80-100)
    chartSpO2.apply {
        // ... 类似设置
        axisLeft.apply {
            axisMinimum = 80f
            axisMaximum = 100f
        }
    }
}

3.2 实时添加数据

当收到 BLE 通知时,把数据添加到图表中:

// 假设有一个数据队列,最多保留 100 个点
private val heartRateEntries = mutableListOf<Entry>()
private val spO2Entries = mutableListOf<Entry>()
private var xIndex = 0

fun onHeartRateReceived(value: Int) {
    // 添加到数据队列
    heartRateEntries.add(Entry(xIndex.toFloat(), value.toFloat()))
    if (heartRateEntries.size > 100) {
        heartRateEntries.removeAt(0)
    }
    
    // 更新图表
    val dataSet = LineDataSet(heartRateEntries, "心率").apply {
        color = Color.RED
        setCircleColor(Color.RED)
        lineWidth = 2f
        setDrawValues(false)
        mode = LineDataSet.Mode.CUBIC_BEZIER  // 平滑曲线
    }
    
    chartHeartRate.data = LineData(dataSet)
    chartHeartRate.notifyDataSetChanged()
    chartHeartRate.invalidate()
    
    xIndex++
}

fun onSpO2Received(value: Int) {
    // 类似逻辑,但颜色用蓝色
    spO2Entries.add(Entry(xIndex.toFloat(), value.toFloat()))
    // ... 更新图表
}

性能优化:如果数据更新频率很高(比如每秒 10 次),每次都重新设置 DataSet 会导致 UI 卡顿。我的做法是:用一个 Handler 每 200ms 批量更新一次图表,而不是每次收到数据都刷新。

四、整体架构图

下面这张图展示了从 BLE 连接到数据展示的完整流程。我把它画成了 SVG,方便你理解整个链路。

BLE 心率/血氧数据流架构 BLE 扫描 & 连接 找到设备,建立 GATT 连接 Service 发现 找到 0x180D / 0x1822 订阅 Notification 开启 0x2A37 / 0x2A5F 通知 数据接收 onCharacteristicChanged 回调 数据解析 按规范解析字节流 数据分发 发送到 ViewModel / UI 图表展示 LineChart 实时更新 数据存储(可选) Room / SQLite 保存历史记录

从图上你能看到,整个流程是单向的:连接 → 发现服务 → 订阅通知 → 接收数据 → 解析 → 分发 → 展示。每一步都有明确的职责。我在项目中经常看到有人把解析逻辑写在 UI 线程里,结果界面卡成 PPT。记住:解析数据要在后台线程做。

五、完整实战代码片段

最后,我贴一个完整的 BLE 回调处理片段。这是我在一个实际项目中用过的模板,稍微简化了一下:

override fun onCharacteristicChanged(
    gatt: BluetoothGatt,
    characteristic: BluetoothGattCharacteristic
) {
    val uuid = characteristic.uuid.toString()
    val data = characteristic.value ?: return

    when (uuid) {
        "00002a37-0000-1000-8000-00805f9b34fb" -> {
            // 心率数据
            val heartRateData = parseHeartRate(data)
            // 通过 LiveData 发送到 UI
            heartRateLiveData.postValue(heartRateData)
        }
        "00002a5f-0000-1000-8000-00805f9b34fb" -> {
            // 血氧数据
            val spo2Data = parseSpO2(data)
            spO2LiveData.postValue(spo2Data)
        }
    }
}

关键点:使用 postValue 而不是 setValue,因为 onCharacteristicChanged 是在 BLE 内部线程回调的,不是主线程。postValue 会自动切换到主线程。

好了,这一章的内容就到这里。你只要把上面的解析逻辑和图表展示结合起来,一个完整的心率血氧监测 App 就出来了。剩下的就是 UI 美化、数据记录、异常处理这些锦上添花的东西。


公众号:蓝海资料掘金营,微信deep3321