news 2026/9/11 7:48:26

Android心率监测系统开发:BLE通信、PPG信号处理与实时波形实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Android心率监测系统开发:BLE通信、PPG信号处理与实时波形实现

简介:一套面向Android毕业设计与课程设计的完整心率监测系统方案,覆盖安卓客户端、服务端与数据库三大模块。安卓端实现用户登录注册、通过手机摄像头实时测量心率、测试历史记录,并将结果上传至网站服务器;网站端支持管理员登录、查看受测者个人信息、心率测试结果及时间,同时为每次测试自动生成健康报告,辅助判断受测者心跳是否正常。资源共4个文件,包含2个RAR源码工程包、1个SQL数据库脚本和1个说明文本,压缩包整体约30.49MB,其中RAR包分别对应安卓工程与服务端工程,SQL脚本用于初始化数据表,文本文件提供部署说明。当前已有112人学习下载,适合作为毕业设计、课程设计或SSM+Android项目入门参考。获取后可同时获得安卓端与服务端完整源码、演示视频、数据库脚本及部署说明,便于快速搭建运行环境、理解项目结构并完成二次开发或论文撰写。

1. 从传感器到应用的 Android 心率监测系统,先想清楚这三件事

任何一个挂着“基于 Android”字样的心率监测系统,真正难的不是把 BPM 数字画在屏幕上,而是把“采集—计算—展示—存储”这条链路做到闭环。我见过不少应届生在简历里写“实现了心率监测 App”,实际只是调了系统 API 拿了个步数,或者用手机摄像头对着手指拍一段视频就宣称测出了心率,精度和采样率完全经不起推敲。这个标题的背后,是一个典型的软硬结合项目:硬件端是心率传感器(最常见的是光电式 PPG 传感器,比如 MAX30102),Android 端负责通过串口或 BLE 读取原始数据、做信号处理、计算心率值、绘制实时波形,最终落库并提供历史记录。它适合三类人:正在做毕业设计的学生、准备跳槽到健康类 App 方向的 Android 工程师、以及想在现有 App 里接入心率能力的移动端团队。

动手之前,必须先把三个决定项目走向的问题定下来:第一,传感器走什么通信协议,是 USB 串口还是 BLE 蓝牙;第二,心率算法放在哪一层,是传感器模组自己算好直接发 BPM,还是 Android 端拿到原始 PPG 信号自己做滤波和峰值检测;第三,Android 端的数据展示粒度,是只显示一个数字,还是需要实时波形图、历史趋势图以及 HRV 分析。这三个问题直接决定你要不要引入 BLE 开发栈、要不要处理 NDK 层面的信号处理代码、以及 UI 层要写多少图表逻辑。这篇文章就顺着“通信选型—数据采集—心率算法—Android 应用层实现—数据落库与图表展示”这条完整链路,把每一步的实现路径、关键参数和常见坑位拆开讲清楚。对于 5 年以上的 Android 开发者,文末的采样率对齐、滤波参数调优和 HRV 特征抽取部分,也值得花两分钟过一遍。

2. 硬件通信选型与 Android 端数据接入的两种主流方案

心率监测系统的第一步不是写代码,而是确定 Android 设备怎么和心率传感器通信。这个决定影响后续所有代码结构,如果在通信层选错了方案,后面写再多 UI 都是空中楼阁。

2.1 方案对比:BLE 蓝牙还是 USB 串口

市面上心率传感器模组的输出接口主要分两类:BLE(蓝牙低功耗)和 UART 串口。BLE 方案的代表是 Polar H10 胸带、各类手环心率模块,Android 端通过 BluetoothGatt 回调拿数据,优点是没有物理连接,用户体验好,缺点是 BLE 的 MTU 限制和数据分包机制要求你必须自己处理粘包、断包和字节序问题。USB 串口方案的代表是 MAX30102 搭配 CH340 转换芯片的开发板,Android 端通过 UsbManager 和 UsbSerial 库读数据,优点是数据流稳定、延迟低、非常适合调试阶段看原始波形,缺点是需要 OTG 线,而且 Android 的 USB 权限申请和热插拔处理比 BLE 繁琐得多。

两种方案都有真实项目在用:做医用级精度验证的实验室项目通常走 USB 串口,因为采样率和数据传输可靠性是第一优先级;做消费级健康 App 原型验证的则几乎全部走 BLE,因为真实用户不会愿意插着一根 OTG 线测心率。如果你是毕业设计或个人项目,我的建议是直接选 BLE——理由有三:BLE 开发经验在简历上是硬通货,Android 上 BLE 的调试工具链(nRF Connect、BLE Debugger)比较成熟,而且后续加血氧、体温等其他健康传感器时 BLE 的可扩展性远好于 USB。

// AndroidManifest.xml 中需要声明 BLE 相关权限(Android 12+ 还需要 BLUETOOTH_SCAN 和 BLUETOOTH_CONNECT) <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />

权限声明这部分,Android 12 是个明确的分水岭。在 API 31 之前,只需要BLUETOOTHBLUETOOTH_ADMIN两个普通权限;从 API 31 开始,Google 把蓝牙权限拆成了BLUETOOTH_SCANBLUETOOTH_CONNECT,而且这两个都是运行时权限,需要在代码里动态申请。很多新手在 Android 12 以上的设备上调试 BLE,第一步就卡在扫描不到设备上,原因就是只声明了旧权限,没有走动态授权流程。

2.2 用 Android BLE API 建立传感器连接的完整代码骨架

连接 BLE 心率传感器的常规路径是:扫描设备 → 连接 GATT → 发现服务与特征 → 开启通知 → 解析数据。下面这套代码是行业里最常用的骨架,直接改回调里的数据处理逻辑就能适配绝大多数心率传感器。

private var gatt: BluetoothGatt? = null private val bluetoothGattCallback = object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { if (newState == BluetoothProfile.STATE_CONNECTED) { // 连接成功后开始发现服务,注意这里必须在主线程调用 gatt.discoverServices() } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { // 常规处理:更新 UI、释放资源、可选自动重连策略 } } override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { if (status != BluetoothGatt.GATT_SUCCESS) return val service = gatt.getService(UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb")) val heartRateChar = service?.getCharacteristic(UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb")) // 开启通知,第二个参数 true 表示启用 CCCD 描述符 gatt.setCharacteristicNotification(heartRateChar, true) val descriptor = heartRateChar?.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")) descriptor?.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE gatt.writeDescriptor(descriptor) } override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { val flags = characteristic.getValue() if (flags.isNullOrEmpty()) return // 数据解析逻辑见 2.3 节 val heartRate = parseHeartRate(flags) runOnUiThread { /* 更新 UI */ } } } private fun parseHeartRate(data: ByteArray): Int { // BLE 心率规范:第一个字节的 bit0 标识心率格式是 UINT8 还是 UINT16 val flag = data[0].toInt() return if (flag and 0x01 == 0x01) { // UINT16 格式,低字节在前 (data[1].toInt() and 0xFF) or (data[2].toInt() shl 8) } else { data[1].toInt() and 0xFF } }

这段代码里有两个参数必须注意。第一个是0000180d这个服务 UUID,这是 Bluetooth SIG 标准定义的 Heart Rate Service(HRS),绝大多数心率设备都实现这个服务,其中00002a37是 Heart Rate Measurement 特征,每一次设备心跳都会触发一次onCharacteristicChanged回调。第二个参数是通知开关的方式:先调setCharacteristicNotification让 Android 系统知道该特征的 notify 要分发到回调,再手动写 CCCD 描述符的值,这一步很容易漏——漏了的话设备端不会推送数据,特征是静默的。另外建议在onCharacteristicChanged回调里用System.currentTimeMillis()记录时间戳,而不是依赖设备端自带的时间字段,因为大部分低端模组不提供精确时间同步。

2.3 数据分帧与采样时间戳对齐的必要性

BLE 传输层的不确定性是心率监测系统实现里最容易被低估的坑。传感器设备可能以 25Hz、50Hz 或 100Hz 的频率推送数据,但 BLE 的连接间隔(connection interval)一般在 7.5ms 到 4s 之间动态调整,这就意味着 Android 端收到数据的实际间隔是不均匀的。如果你在 UI 上直接把 BPM 值渲染成折线图,视觉上问题不大;但如果要做 HRV(心率变异性)分析,RR 间期的精度要求是毫秒级,数据到达时间的抖动会直接污染计算结果。

常规做法是:不要信任 BLE 回调的时间戳,而是在解析完数据后立刻用单调时钟(SystemClock.elapsedRealtimeNanos())打点,并对相邻两个采样点的时间差做一次合理性校验。比如设备标称 25Hz 采样率,理论间隔是 40ms,如果你检测到两个时间戳之间隔了 300ms,说明发生了数据包丢失或设备端降频,这时候要么丢弃这段数据,要么做插值补偿。下表是常见采样率对应的理论间隔与容差范围:

设备标称采样率理论间隔合法时间戳间隔范围处理策略
25 Hz40 ms30–60 ms超过 60ms 标记数据空洞
50 Hz20 ms15–35 ms超过 35ms 尝试插值补点
100 Hz10 ms5–20 ms超过 20ms 建议丢弃该窗口

这套时间戳对齐逻辑是很多实际项目从“能出数”走向“数据可信”必须跨过的一步。如果设备支持在数据帧里携带时间信息(少数高端医疗模组支持),就以设备时间为准;大多数情况下设备只推波形点,时间对齐的责任落在 Android 端。你可以把这部分做成一个独立的SampleScheduler类,输入是原始 PPG 数据,输出是带有合法时间戳的采样点列表,后续滤波和心率计算都基于这个列表进行。

3. 心率算法:从原始 PPG 信号到 BPM 值的三段式处理

心率传感器输出的原始数据分为两类:一类是设备端模组已经算好心率值,直接通过特征值推给你;另一类是只输出原始 PPG 波形数据(通常是光电容积脉搏波的 ADC 原始值),心率值需要 Android 端自己算。后者才是“设计与实现”这个标题真正要求的深度,这里展开讲。

3.1 为什么不能直接数波峰:运动伪迹与基线漂移的干扰逻辑

先建立一个直观认知:PPG 信号本质上是通过光电容积描记法测量血管容积变化得到的波形,心脏每次搏动会让毛细血管的血容量产生一个周期性的变化,反映在 ADC 值上就是一个脉冲峰值。理论上,数一个时间窗口内的波峰数量除以时间就是心率。但真实场景里信号远没有这么干净,主要干扰有三个:

第一是基线漂移,呼吸、身体轻微移动会导致传感器与皮肤接触压力变化,让整个波形上下浮动,浮动幅度甚至能超过真实的脉搏波峰值;第二是运动伪迹,走路、摆动手臂会让波形叠加一个低频大幅度的干扰信号,这个信号在频谱上往往和心率频段有重叠,单纯用阈值判断波峰会数出错误的结果;第三是高频噪声,环境光和传感器本身的电路噪声会叠加在信号上。这就是为什么任何心率算法都必须是三段式:先去噪,再定位特征点,最后算频域或统计值,直接数波峰的做法在真实设备上误差经常超过 20%。

3.2 滤波参数选择:哪些频段该保留,哪些该滤掉

在 Android 端处理 PPG 信号,最常见也是最推荐的方案是先做带通滤波,保留脉搏波信号的主要频段。静息状态下人体心率范围是 30 到 220 次/分钟,换算成频率大约是 0.5Hz 到 3.67Hz;剧烈运动后心率可以到 200 以上,对应约 3.3Hz。所以带通滤波器的截止频率一般设置为下限 0.5Hz、上限 4Hz,这个区间能把呼吸引起的低频漂移(通常低于 0.4Hz)和大部分高频噪声挡住。

实现滤波有两种路径:一是用 Java/Kotlin 在 CPU 上做滑动窗口均值或 FIR 滤波,二是用 Android NDK 写 C 代码。对毕业设计和大多数工程场景,FIR 带通滤波是精度和实现复杂度之间最好的平衡点。下面给出一个可直接运行的 Kotlin FIR 滤波器实现,阶数和系数用标准窗函数法生成,避免你直接去查 DSP 教科书里的推导过程。

class FIRFilter(private val coefs: DoubleArray) { private val buffer = ArrayDeque<Double>() fun process(sample: Double): Double { buffer.addFirst(sample) if (buffer.size > coefs.size) buffer.removeLast() // 有限冲激响应的叠加运算 var result = 0.0 var idx = 0 for (value in buffer) { result += coefs[idx++] * value } return result } } // 用窗函数法生成带通滤波器系数,采样率 100Hz,通带 0.5Hz–4Hz,阶数 128 fun generateBandpassCoefficients( sampleRate: Int, lowFreq: Double, highFreq: Double, taps: Int ): DoubleArray { val coefs = DoubleArray(taps) val fc1 = lowFreq / sampleRate val fc2 = highFreq / sampleRate for (n in 0 until taps) { val m = n - (taps - 1) / 2.0 if (m == 0.0) { coefs[n] = 2 * (fc2 - fc1) } else { // 理想带通滤波器的冲激响应 coefs[n] = 2 * fc2 * sinc(2 * fc2 * m) - 2 * fc1 * sinc(2 * fc1 * m) } // 汉明窗,降低旁瓣泄漏 val window = 0.54 - 0.46 * Math.cos(2 * Math.PI * n / (taps - 1)) coefs[n] *= window } return coefs } private fun sinc(x: Double): Double { return if (x == 0.0) 1.0 else Math.sin(Math.PI * x) / (Math.PI * x) }

这段代码的滤波效果取决于三个参数:采样率、通带边界、滤波器阶数,它们必须和实际传感器配置匹配。采样率如果是 50Hz,通带频率边界要同步等比缩放,否则滤波器实际效果和预期相差很远。阶数 128 在 100Hz 采样率下大约对应 1.28 秒的窗口长度,响应时间对实时显示稍微有点长,一般每秒能看到一次更新;如果你需要更快响应,把阶数降到 64,代价是过渡带变宽,靠近边界频段的噪声滤不干净。在实际工程里,我一般会把滤波这步放到后台线程里的采样缓冲队列里做,避免 UI 线程被浮点运算拖累掉帧。

3.3 用自适应阈值完成波峰检测与 RR 间期计算

滤波之后的信号虽然干净了,但幅度仍然会随时间缓慢变化——这是皮肤接触压力、传感器贴附位置的微小改动造成的,是物理层面不可避免的现象。因此波峰检测不能用固定阈值,行业内通行的做法是自适应阈值。核心逻辑是维护一个滑动窗口内的信号幅值估计,阈值设定为窗口最大值的 60% 到 70%,并结合一个最小间隔约束来防止把重搏波当成主波峰。

data class PeakResult(val peakIndex: Int, val peakValue: Double) class AdaptivePeakDetector( private val sampleRate: Int, private val windowSeconds: Double = 2.0 ) { private val windowSize = (sampleRate * windowSeconds).toInt() private val minPeakDistance = (sampleRate * 0.3).toInt() // 最短 300ms,对应 200 BPM 上限 private var lastPeakIndex = -minPeakDistance private var timestampIndex = 0 fun detect(processedSignal: DoubleArray): List<PeakResult> { val peaks = mutableListOf<PeakResult>() var currentWindowMax = 0.0 for (i in processedSignal.indices) { currentWindowMax = maxOf(currentWindowMax, processedSignal[i]) if (i >= windowSize) { // 清理窗口外的数据,保持最大值估计跟踪信号幅度变化 if (processedSignal[i - windowSize] == currentWindowMax) { currentWindowMax = processedSignal.sliceArray(i - windowSize + 1..i).max() } } val threshold = currentWindowMax * 0.6 val isLocalMax = i > 0 && processedSignal[i] > processedSignal[i - 1] && i < processedSignal.size - 1 && processedSignal[i] >= processedSignal[i + 1] // 必须同时满足超过阈值、是局部峰值、且距离上一个峰值足够远 if (isLocalMax && processedSignal[i] > threshold && (i - lastPeakIndex) >= minPeakDistance) { peaks.add(PeakResult(i, processedSignal[i])) lastPeakIndex = i } timestampIndex++ } return peaks } }

这个检测器的行为由三个关键参数控制:峰值阈值比例 0.6、最小峰值距离 300ms、窗口长度 2 秒。0.6 的含义是只接收幅度达到当前窗口最大值 60% 的波峰,这样在信号明显变形的情况下不会数错;300ms 最小距离保证不会把脉搏波主峰之后的重搏波误认为第二次心跳,对应 200 BPM 的极限心率,对绝大多数运动场景都够用;2 秒窗口让幅度估计能跟上信号缓慢变化的节奏。算完波峰之后,RR 间期就是相邻两个波峰对应的采样点时间戳之差,单位毫秒,它是后面计算瞬时心率和 HRV 指标的基础原料。瞬时 BPM 的公式是 60000 / RR 间期(毫秒),建议每次得到新 RR 间期时用滑动平均值做一下平滑,比如最近 5 个 RR 间期的平均,这样屏幕上数字不会跳来跳去。

4. Android 应用层实现:实时波形绘制、线程模型与数据持久化

底层数据通道和算法链路打通之后,Android 应用层要解决的是三个问题:怎么把采样点实时画出来不卡顿,线程模型怎么设计才不会丢数据或者 ANR,历史数据存哪里、怎么读回来回放。

4.1 用自定义 View 实现 60 FPS 的实时 PPG 波形图

实时波形图是心率监测 App 的核心交互界面,用户盯着它看,第一感知就是“流不流畅”。Android 里画实时数据曲线有几种做法:用SurfaceView配合子线程画布绘制、自定义ViewonDraw里画、或者引入 MPAndroidChart 这类封装好的图表库。帧率和功耗的平衡点是选型关键,我的常规选择是自定义 View,不引入第三方图表库。原因有三:数据刷新频率通常只有 25 到 100Hz,完全在自定义 View 的渲染能力范围内;自定义 View 可以完全控制绘制逻辑,想加网格线、参考线、峰值标注都很方便;不引入额外依赖意味着 APK 体积和初始化耗时都更可控。

下面给出一段可直接使用的实时波形 View 核心代码,重点在滑动窗口的数据管理方式与绘制策略的配合。

class WaveformView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : View(context, attrs) { private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#00E676") strokeWidth = 4f style = Paint.Style.STROKE } private val gridPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#33FFFFFF") strokeWidth = 1f } private val dataQueue = ArrayDeque<Float>() // 最多保存屏幕宽度对应的数据点数 private val maxPoints = 500 fun addDataPoint(value: Float) { dataQueue.addLast(value) if (dataQueue.size > maxPoints) dataQueue.removeFirst() postInvalidateOnAnimation() // 和 Choreographer 帧回调对齐,避免无效重绘 } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) // 绘制背景网格,方便用户观察信号的幅度变化 val stepX = width / 20f for (i in 0..20) canvas.drawLine(i * stepX, 0f, i * stepX, height.toFloat(), gridPaint) val stepY = height / 10f for (i in 0..10) canvas.drawLine(0f, i * stepY, width.toFloat(), i * stepY, gridPaint) if (dataQueue.size < 2) return var index = 0 val path = Path() val stepW = width.toFloat() / maxPoints for (value in dataQueue) { // 中心线对齐:信号范围假设为 -1 到 1,UI 高度范围是 0 到 height val y = height / 2f - value * height / 2.5f if (index == 0) path.moveTo(0f, y) else path.lineTo(index * stepW, y) index++ } canvas.drawPath(path, paint) } }

这段代码里有几个值得注意的细节。postInvalidateOnAnimation()是实时绘制的关键 API,它会把重绘请求挂到下一帧的 vsync 信号上,比直接调invalidate()更省电,在高频数据刷新场景下能明显减少无用绘制。波形 y 坐标的映射逻辑很直接:假设信号已经归一化到 -1 到 1 范围,乘以 height/2.5 是为了让波形在垂直方向保留一点上下边距,视觉效果更通透。maxPoints = 500是按屏幕宽度 1080 像素、每个采样点 2 像素推算出的合理上限,如果你的设备分辨率不一样,需要按实际宽度做调整,而不是硬编码。

4.2 生产者-消费者线程模型,避免 BLE 回调阻塞丢数据

BLE 的onCharacteristicChanged回调运行在 Binder 线程池里,不是主线程。如果在这类回调里直接做滤波、存库、刷新 UI,一旦其中一个环节耗时超过几十毫秒,后续数据包就会在系统层面堆积,表现就是波形卡顿、心率值跳动、甚至蓝牙连接被系统判断为无响应而断开。正确做法是生产者-消费者模型:BLE 回调只负责把原始数据扔进一个有界队列,后台一个单独线程消费队列,做滤波、波峰检测、心率计算,最后通过 Handler 把结果发到主线程更新 UI。

class HeartRateProcessor( private val onResult: (Int, Long) -> Unit // 回调心率值和对应时间戳 ) { private val rawQueue = ArrayBlockingQueue<ByteArray>(1024) private val filter = FIRFilter(generateBandpassCoefficients(100, 0.5, 4.0, 128)) private val peakDetector = AdaptivePeakDetector(100) private val currentWindow = mutableListOf<Double>() private val windowSize = 1000 // 10 秒数据窗口 private val processorThread = thread(start = true) { while (true) { val rawData = rawQueue.take() // 阻塞直到有可用数据 val heartRate = processOneSample(rawData) if (heartRate != null) { onResult(heartRate, SystemClock.elapsedRealtime()) } } } fun enqueue(data: ByteArray) { rawQueue.offer(data) // offer 不阻塞 BLE 回调线程 } private fun processOneSample(data: ByteArray): Int? { // 解析原始 PPG 值,这里假设数据帧格式为:第一个字节是状态,后四个字节是 ADC 值(小端) val ppg = ((data[2].toInt() and 0xFF) shl 8) or (data[1].toInt() and 0xFF) currentWindow.add(ppg.toDouble()) if (currentWindow.size > windowSize) { currentWindow.removeAt(0) // 窗口满时丢弃最旧数据 } // 每积累一个 R 波间期长度就返回一次瞬时心率 // 实际项目中可以简化:每次有新数据都做一次波峰检测,返回最近一次 RR 间期对应的 BPM return null } }

这里有几个工程细节值得展开。ArrayBlockingQueue的容量设为 1024,按 100Hz 采样率算大约可以缓冲 10 秒数据,这个余量可以应对短时间的 BLE 传输抖动;offer而不是put入队,保证 BLE 回调线程永远不会因为队列满而被阻塞,宁可丢弃极少数数据也不让回调卡死。处理器线程用while(true)死循环加take()阻塞的方式,这是 Java 并发里最标准的消费者写法,比whilesleep的方式省电且延迟低。windowSize = 1000表示 10 秒数据窗口,用于波峰检测的上下文参考,这个值不宜小于 5 秒,否则幅度自适应跟不上信号变化。

4.3 用 Room 落库保存心率历史与波形快照的 Schema 设计

持久化方案选 Room 是惯例,因为它和 LiveData/Flow 的配合、以及 SQLite 之上的类型安全封装,让代码量少很多。心率历史数据表的设计有一个需要提前想清楚的点:是只存 BPM 值,还是连原始波形一起存。如果只存 BPM,表结构会非常简单,但后续想做离线趋势分析、R 波形态回顾就无从下手;如果连波形一起存,数据量会大很多,需要分表或压缩策略。

下述表结构是折中方案,兼顾查询效率和回放能力。

CREATE TABLE heart_rate_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, start_time INTEGER NOT NULL, -- 记录开始时间(epoch millis) end_time INTEGER NOT NULL, -- 记录结束时间 avg_bpm INTEGER NOT NULL, -- 平均心率 min_bpm INTEGER NOT NULL, max_bpm INTEGER NOT NULL, duration_seconds INTEGER NOT NULL,-- 持续时间 rr_interval_values TEXT, -- JSON 数组,保存 RR 间期序列,用于 HRV 重算 sample_rate INTEGER NOT NULL -- 采样率,用于回放时的时间轴重建 ); CREATE INDEX idx_heart_rate_start_time ON heart_rate_records(start_time);

注意rr_interval_values字段的类型是 TEXT,里面存 JSON 数组。把 RR 间期序列序列化进单字段是刻意为之:如果你把每个 RR 间期拆成一行存,表会膨胀得很快,而且大多数查询(比如按天看平均 BPM、按周看静息心率趋势)根本不需要逐跳数据;只有用户点进某个具体记录要重新计算 HRV 时,才需要反序列化这一段 JSON。为了在代码里操作方便,对应的 Room Entity 里用TypeConverterList<Long>映射成 JSON。

class Converters { @TypeConverter fun fromLongList(value: List<Long>): String = JSONArray(value).toString() @TypeConverter fun toLongList(value: String): List<Long> { val array = JSONArray(value) return (0 until array.length()).map { array.getLong(it) } } }

这条路径的最后一个自由点是心率的单位精度。BPM 如果存成INTEGER,瞬时心率 71.6 会被截断成 71,在 HRV 相关的分析里这个误差会被放大,因为 HRV 的核心指标 SDNN(RR 间期标准差)依赖的是毫秒级精度。建议在计算层保留Double,只在数据库里以INTEGER存四舍五入后的展示值,而把精确的 RR 间期序列存进 JSON 字段,这样展示和分析两不误。

5. 实时波形与心率曲线联动的趋势页,以及 HRV 指标抽取技巧

图表展示层面要把“实时”和“历史”两种模式分开。实时模式已经在上文自定义 View 中覆盖,历史趋势页更建议用 MPAndroidChart 这种成熟图表库来减少工作量——历史数据的点数量级通常在几千到几万之间,MPAndroidChart 的LineChart组件提供了开箱即用的缩放、滑动、高亮十字线等功能,自己实现这些交互细节的性价比太低。坐标轴的赋值方式要注意一点:x 轴不要用数据库自增 ID,要用真实时间戳(epoch millis),这样一天看到 24 小时的刻度才能准确对齐。

// 历史心率趋势查询,按小时聚合,取窗口内平均值 SELECT (start_time / 3600000) * 3600000 AS hour_bucket, ROUND(AVG(avg_bpm), 0) AS avg_hour_bpm FROM heart_rate_records WHERE start_time BETWEEN ? AND ? GROUP BY hour_bucket ORDER BY hour_bucket ASC

这段 SQL 把时间戳向下取整到小时桶再做聚合,返回的结果可以直接喂给 MPAndroidChart 的LineDataSet。为什么取小时桶而不用原始记录?因为一次 10 分钟的心率记录可能有 6000 个点,24 小时下来 14 万数据点直接画在 LineChart 上,GPU 和内存压力都太大,而且视觉上密密麻麻根本看不出趋势;聚合到小时级之后,24 小时只有 24 个点,趋势一目了然。

HRV 指标抽取是这个方向最有进阶价值的部分。常规做法是从 RR 间期序列计算 SDNN 和 RMSSD,前者的公式是所有 RR 间期的标准差,反映整体变异性;后者是相邻 RR 间期差值的均方根,反映副交感神经活跃度。计算时遵从以下顺序:

  1. 从数据库读rr_interval_values字段,反序列化成List<Long>,单位毫秒。
  2. 剔除小于 300ms 或大于 2000ms 的异常值,这些通常是检测误判或传感器脱落产生的伪差。
  3. 相邻差值取绝对值,平方求和,取平均再开方,即得到 RMSSD 值。SDNN 就是standardDeviation(list)库函数一行的事。

这两个指标算出来之后,能从侧面反映用户当前的压力状态和恢复水平,很多健康 App 的心率变异性分析模块底层算法就是这两行统计逻辑。如果你想把 HRV 做实,建议把 RR 间期数组长度控制在 5 分钟以上,因为时域 HRV 指标的公认标准(HRV Task Force 指南)要求 5 分钟短时记录或 24 小时长时记录,低于这个时长算出的 SDNN 在临床上没有参考意义,但作为消费级 App 的参考指标是够用的。

// 心跳检测开关:放在 onPause 里关,onResume 里开 // 避免 App 退到后台时 BLE 回调还在跑,白白耗电还占着系统资源 override fun onPause() { super.onPause() processor.stop() // 停止消费者线程 gatt?.disconnect() // 断开 BLE } override fun onResume() { super.onResume() if (hasBluetoothPermission()) { connectSensor() // 重新连接并恢复数据流 } }

上面这段生命周期处理是一个容易踩坑的实际问题点:很多学生项目只写了 Activity 的onCreate里连接传感器,完全不处理onPause/onResume,用户一按 Home 键,蓝牙连接还挂着,处理器线程还在算,等用户切回来就会发现电量掉了一大截,甚至系统会弹出耗电警告。上述代码的 connect/disconnect 对称模式是行业惯例。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 7:47:17

ESP32蓝牙Beacon嵌入式测距实战:RSSI动态建模与精度优化

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/11 7:45:38

51单片机+ADS1110电子秤设计与实战

简介&#xff1a;本资源是一套完整的基于51单片机的电子秤设计开发包&#xff0c;面向嵌入式初学者、课程设计学生及单片机实践爱好者&#xff0c;解决重量检测系统从原理设计到仿真验证的全流程学习需求。资源共43个文件&#xff0c;涵盖Proteus仿真工程&#xff08;.pdsprj/.…

作者头像 李华
网站建设 2026/9/11 7:42:53

Claudian 响应慢怎么办:3 个高收益设置让它快回来

Claudian 响应慢怎么办&#xff1a;3 个高收益设置让它快回来 【免费下载链接】claudian An Obsidian plugin that embeds Claude Code/Codex as an AI collaborator in your vault 项目地址: https://gitcode.com/GitHub_Trending/cl/claudian 在 Obsidian 里用 Claudi…

作者头像 李华
网站建设 2026/9/11 7:39:28

Windows主机信息收集成果量化评估与可视化大屏实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/11 7:38:05

全国大学生成图大赛备考指南:从三维建模到工程图全解析

1. 先进成图大赛到底考什么——先看清这门赛事的真面目很多第一次接触“全国大学生先进成图技术与产品信息建模创新大赛”的同学&#xff0c;都会有个错觉&#xff1a;这不就是考软件操作吗&#xff1f;SolidWorks、UG、Creo用得溜不就行了&#xff1f;真不是这样。我带过几届参…

作者头像 李华