10、BLE实战-心率监测器:解析心率服务、实时读取心率数据、绘制心率曲线
好,咱们终于到了最激动人心的实战环节。前面几章我们把BLE的理论、扫描、连接、通信都捋了一遍,现在该真刀真枪地干一票了。这一章,我带你做一个真正能用的小玩意儿——心率监测器。
你想想看,手机连上蓝牙心率带,实时显示你的心跳,还能画出曲线。是不是挺酷?其实背后的逻辑并不复杂。说白了,就是找到心率服务,订阅它的通知,然后不停地拿数据、画图。
10.1 心率服务(Heart Rate Service 0x180D)长什么样?
心率服务是蓝牙官方定义的标准服务之一。它的UUID是 0x180D。这个服务里包含几个特征值,我们最关心的是心率测量特征值(Heart Rate Measurement),UUID是 0x2A37。
这个特征值支持通知(Notify)。什么意思?就是心率带一测到心跳,就会主动往手机发数据,不用你轮询。嗯,这里要注意,不是所有设备都支持通知,但心率设备基本都支持。
我刚开始做这个项目时,以为连上服务就能直接读数据。结果发现,你得先开启通知,不然设备根本不会理你。这个坑我踩过,后面会细说。
心率测量特征值的数据格式是这样的:
| 字节 | 含义 |
|---|---|
| 第1字节 | 标志位(Flags),决定后面数据怎么解析 |
| 第2字节起 | 心率值(8位或16位,由标志位决定) |
| 后续字节 | 可选:能量消耗、RR间期等 |
标志位最低位(bit 0)决定心率值是8位还是16位:
- 0:心率值是8位,一个字节搞定
- 1:心率值是16位,两个字节,小端序
大部分心率带用8位就够了,毕竟正常人也就60-200。但有些专业设备会用到16位,咱们写代码时得兼容。
10.2 连接并发现服务
连接设备后,第一步是发现服务。代码很简单,但有个细节我提醒你:一定要等发现完成后再操作特征值。我见过有人没等回调就直接读数据,结果读了个寂寞。
// 连接成功后,在 onConnectionStateChange 回调里
@Override
public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
// 发现服务
gatt.discoverServices();
}
}
// 服务发现完成
@Override
public void onServicesDiscovered(BluetoothGatt gatt, int status) {
if (status == BluetoothGatt.GATT_SUCCESS) {
// 获取心率服务
BluetoothGattService heartRateService =
gatt.getService(UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb"));
if (heartRateService != null) {
// 获取心率测量特征值
BluetoothGattCharacteristic hrCharacteristic =
heartRateService.getCharacteristic(UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb"));
// 开启通知
enableNotification(gatt, hrCharacteristic);
}
}
}
我个人习惯把UUID定义成常量,方便复用。你想想看,如果每个地方都写一遍那串长长的UUID,万一写错一个字母,排查起来够你喝一壶的。
10.3 开启通知,接收实时数据
开启通知是BLE开发里最容易出错的一步。你需要做两件事:
- 设置特征值的通知开关为true
- 写入客户端特征配置描述符(CCCD)
我曾经漏掉第二步,结果折腾了一下午,数据就是不来。后来才发现,光设置通知开关没用,还得告诉设备“我同意接收通知”。
private void enableNotification(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) {
// 第一步:设置通知开关
boolean success = gatt.setCharacteristicNotification(characteristic, true);
if (!success) {
Log.e("HR", "设置通知开关失败");
return;
}
// 第二步:写入CCCD
BluetoothGattDescriptor descriptor =
characteristic.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"));
if (descriptor != null) {
descriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
gatt.writeDescriptor(descriptor);
}
}
这里有个小技巧:ENABLE_NOTIFICATION_VALUE 的值是 new byte[]{0x01, 0x00}。如果你要开启指示(Indicate),就用 ENABLE_INDICATION_VALUE,值是 new byte[]{0x02, 0x00}。别搞混了。
10.4 解析心率数据
数据来了之后,怎么解析?我们来看 onCharacteristicChanged 回调:
@Override
public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) {
byte[] data = characteristic.getValue();
if (data == null || data.length < 2) return;
// 解析标志位
int flags = data[0] & 0xFF;
int heartRate;
// 判断心率值是8位还是16位
if ((flags & 0x01) == 0) {
// 8位
heartRate = data[1] & 0xFF;
} else {
// 16位,小端序
heartRate = (data[2] & 0xFF) << 8 | (data[1] & 0xFF);
}
// 更新UI
runOnUiThread(() -> {
textViewHeartRate.setText(String.valueOf(heartRate));
// 添加到曲线数据
addDataPoint(heartRate);
});
}
注意 & 0xFF 这个操作。Java里的byte是有符号的,范围是-128到127。如果不做无符号转换,心率值超过127就会变成负数。嗯,这个坑我踩过,显示出来心率是-56,吓我一跳。
10.5 绘制心率曲线
数据拿到了,怎么展示?两种方案:
- Canvas自绘:灵活,但代码量大
- MPAndroidChart:省事,功能全
我个人建议,如果只是简单展示,用Canvas就够了。但如果你要做专业的图表,比如缩放、滑动、多轴,直接用MPAndroidChart,别自己造轮子。
方案一:Canvas自绘
自定义一个View,在 onDraw 里画折线。核心逻辑就是维护一个数据列表,每次新数据来了,重绘。
public class HeartRateView extends View {
private List<Integer> dataPoints = new ArrayList<>();
private Paint linePaint;
private Paint pointPaint;
public HeartRateView(Context context, AttributeSet attrs) {
super(context, attrs);
linePaint = new Paint();
linePaint.setColor(Color.RED);
linePaint.setStrokeWidth(3f);
linePaint.setStyle(Paint.Style.STROKE);
linePaint.setAntiAlias(true);
pointPaint = new Paint();
pointPaint.setColor(Color.RED);
pointPaint.setStyle(Paint.Style.FILL);
}
public void addDataPoint(int value) {
dataPoints.add(value);
// 只保留最近100个点
if (dataPoints.size() > 100) {
dataPoints.remove(0);
}
postInvalidate(); // 触发重绘
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (dataPoints.size() < 2) return;
float width = getWidth();
float height = getHeight();
float stepX = width / (dataPoints.size() - 1);
Path path = new Path();
path.moveTo(0, height - dataPoints.get(0));
for (int i = 1; i < dataPoints.size(); i++) {
float x = i * stepX;
float y = height - dataPoints.get(i);
path.lineTo(x, y);
}
canvas.drawPath(path, linePaint);
}
}
这里有个细节:坐标转换。屏幕的Y轴是向下的,但数据是向上的。所以要用 height - value 来翻转。另外,记得限制数据点数量,不然内存会爆。
方案二:MPAndroidChart
如果你用MPAndroidChart,代码会简洁很多。先加依赖:
implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0'
然后在布局里放一个 LineChart:
<com.github.mikephil.charting.charts.LineChart
android:id="@+id/lineChart"
android:layout_width="match_parent"
android:layout_height="200dp" />
初始化图表:
LineChart lineChart = findViewById(R.id.lineChart);
LineData lineData = new LineData();
lineChart.setData(lineData);
lineChart.setTouchEnabled(false); // 关闭触摸,省电
lineChart.getDescription().setEnabled(false); // 去掉描述
// 配置Y轴
YAxis leftAxis = lineChart.getAxisLeft();
leftAxis.setAxisMinimum(40f);
leftAxis.setAxisMaximum(220f);
lineChart.getAxisRight().setEnabled(false); // 隐藏右侧Y轴
添加数据点:
public void addDataPoint(int value) {
LineData lineData = lineChart.getData();
ILineDataSet dataSet = lineData.getDataSetByIndex(0);
if (dataSet == null) {
dataSet = createDataSet();
lineData.addDataSet(dataSet);
}
// 添加数据
dataSet.addEntry(new Entry(dataSet.getEntryCount(), value));
// 限制显示数量
dataSet.removeFirst(dataSet.getEntryCount() > 100 ? 1 : 0);
lineData.notifyDataChanged();
lineChart.notifyDataSetChanged();
lineChart.setVisibleXRangeMaximum(100);
lineChart.moveViewToX(dataSet.getEntryCount());
}
private ILineDataSet createDataSet() {
LineDataSet dataSet = new LineDataSet(null, "心率");
dataSet.setColor(Color.RED);
dataSet.setCircleColor(Color.RED);
dataSet.setLineWidth(2f);
dataSet.setCircleRadius(3f);
dataSet.setDrawValues(false); // 不显示数值
dataSet.setMode(LineDataSet.Mode.CUBIC_BEZIER); // 平滑曲线
return dataSet;
}
你看,用MPAndroidChart,几行代码就搞定了平滑曲线、自动缩放。但要注意,频繁更新图表会消耗性能。我建议每秒最多更新10次,别让手机发烫。
10.6 整体架构图
下面这张图展示了心率监测器的整体数据流:
这张图把整个流程串起来了。从设备发通知,到Gatt回调,再到解析、存储、显示。每一步都很清晰。
10.7 避坑指南
最后,我总结几个实战中容易踩的坑:
连接上服务后,一定要调用
setCharacteristicNotification 并写入CCCD。少一步,数据都来不了。
onCharacteristicChanged 在子线程回调。更新UI一定要切到主线程,不然会崩溃。
心率设备一般每秒发1-5次数据。如果画图时觉得太密,可以每2-3个点取一个,或者做平滑处理。
心率带可能会因为没电或距离远而断开。记得监听
onConnectionStateChange,断开后自动重连。
好了,这一章的内容就到这里。你跟着代码走一遍,应该能跑起来一个基本的心率监测器。如果遇到问题,别急,先看看是不是忘了开通知,或者数据解析错了。调试BLE设备,耐心最重要。
公众号:蓝海资料掘金营,微信deep3321