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开发里最容易出错的一步。你需要做两件事:

  1. 设置特征值的通知开关为true
  2. 写入客户端特征配置描述符(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 整体架构图

下面这张图展示了心率监测器的整体数据流:

心率监测器数据流架构 蓝牙心率设备 发送心率通知 BLE通知 BluetoothGatt onCharacteristicChanged 原始字节 数据解析器 解析标志位、心率值 心率数据列表 ArrayList<Integer> 数字显示 TextView 曲线显示 Canvas / MPAndroidChart 数据流方向:设备 → Gatt → 解析 → 存储 → 显示

这张图把整个流程串起来了。从设备发通知,到Gatt回调,再到解析、存储、显示。每一步都很清晰。

10.7 避坑指南

最后,我总结几个实战中容易踩的坑:

⚠️ 坑1:忘记开启通知
连接上服务后,一定要调用 setCharacteristicNotification 并写入CCCD。少一步,数据都来不了。
⚠️ 坑2:主线程更新UI
onCharacteristicChanged 在子线程回调。更新UI一定要切到主线程,不然会崩溃。
💡 技巧:数据采样率
心率设备一般每秒发1-5次数据。如果画图时觉得太密,可以每2-3个点取一个,或者做平滑处理。
⚠️ 坑3:设备断开重连
心率带可能会因为没电或距离远而断开。记得监听 onConnectionStateChange,断开后自动重连。

好了,这一章的内容就到这里。你跟着代码走一遍,应该能跑起来一个基本的心率监测器。如果遇到问题,别急,先看看是不是忘了开通知,或者数据解析错了。调试BLE设备,耐心最重要。


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