蓝牙心率/血氧设备实战: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,方便你理解整个链路。
从图上你能看到,整个流程是单向的:连接 → 发现服务 → 订阅通知 → 接收数据 → 解析 → 分发 → 展示。每一步都有明确的职责。我在项目中经常看到有人把解析逻辑写在 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