一、项目介绍
1. 背景与意义
在医疗健康、健身监测、智能穿戴等领域,心电图(ECG, Electrocardiogram)是一项重要的生理信号,用以反映人体心脏的电活动。传统的心电设备体积庞大、成本高昂,而移动端的智能心电监测方案,以其便携、低成本、实时等优势,越来越受到关注。本项目旨在用 Android 平台实现一个实时 ECG 波形采集与显示的端到端方案,包含从信号采集、数据预处理到波形渲染等完整流程,帮助开发者快速搭建移动心电监测应用。
2. 功能目标
-
实时采集:支持蓝牙(BLE)或 USB 串口协议,从外部心电传感器获取实时数据。
-
数据预处理:带通滤波、基线漂移消除、去噪平滑,提升显示效果与后续分析准确度。
-
波形渲染:高性能自定义
ECGView
,支持流畅滚动、网格背景、触摸缩放。 -
心率计算:基于 R 峰检测算法,实时计算并显示心率(BPM)。
-
数据存储与导出:记录时序数据,支持保存为 CSV 或二进制格式。
-
界面与交互:支持开始/停止、快进回放、截屏、放大细节查看等交互操作。
-
跨屏幕适配:兼容不同分辨率、横竖屏自动布局。
二、相关基础知识
-
ECG 信号特征
-
正常 ECG 波形由 P 波、QRS 复合波、T 波等组成,采样率一般在 250–1000 Hz。
-
-
信号采集接口
-
BLE:使用 GATT 协议订阅心电数据特征;
-
USB 串口:通过
UsbManager
+UsbSerial
库读取外设串口数据; -
模拟数据:在无硬件时,通过算法生成仿真 ECG 波形。
-
-
数字滤波
-
带通滤波:如
Butterworth
实现 0.5–40 Hz 带通; -
数字滤波器:可用
Apache Commons Math
或自定义 IIR/FFT 实现。
-
-
波形绘制
-
自定义
View
或SurfaceView
:在onDraw(Canvas)
中用Path
绘制折线; -
双缓冲:
SurfaceView
+Canvas.lockCanvas()
保证 60 FPS。
-
-
心率检测
-
峰值检测:基于导数/阈值法或
Pan–Tompkins
算法; -
BPM 计算:统计单位时间内 R 峰个数。
-
-
多线程与性能
-
使用后台线程或
HandlerThread
处理数据接收与预处理,UI 线程仅负责渲染; -
限制渲染频率(如 30 FPS)避免过度绘制。
-
三、实现思路与架构
+-----------------------------------------------+
| Android App |
| |
| +------------+ +---------------+ |
| | BLE Manager|-->| Data Processor|--+ |
| +------------+ +---------------+ | |
| | USB Manager| | |
| +------------+ | |
| v |
| +----------------+ |
| | ECGView | |
| | (Custom View) | |
| +----------------+ |
| | |
| v |
| +----------------+ |
| | HeartRateCalc | |
| +----------------+ |
| | |
| +----------------+ |
| | Data Storage | |
| +----------------+ |
+-----------------------------------------------+
-
BLE/USB Manager:负责底层通信,持续推送原始采样数据。
-
Data Processor:一维滤波、基线校正、数据队列维护。
-
ECGView:自定义
SurfaceView
,接收数据队列,绘制实时滚动波形与背景网格。 -
HeartRateCalc:在
Data Processor
内部并行运行,基于峰值检测算法计算心率。 -
Data Storage:将原始与处理后数据写入本地文件、数据库或通过网络上传。
四、环境与依赖
// app/build.gradle
plugins {
id 'com.android.application'
id 'kotlin-android'
}
android {
compileSdk 34
defaultConfig {
applicationId "com.example.ecgapp"
minSdk 21
targetSdk 34
}
buildFeatures { viewBinding true }
}
dependencies {
implementation "androidx.appcompat:appcompat:1.6.1"
implementation "androidx.core:core-ktx:1.10.1"
// BLE
implementation "no.nordicsemi.android:ble:2.5.0"
// USB 串口
implementation "com.hoho:android-usb-serial:3.4.6"
// 数学库(可选)
implementation "org.apache.commons:commons-math3:3.6.1"
}
五、整合代码
// =======================================================
// 文件:AndroidManifest.xml
// 描述:权限声明
// =======================================================
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.ecgapp">
<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-feature android:name="android.hardware.usb.host"/>
<application android:theme="@style/Theme.ECGApp">
<activity android:name=".MainActivity"
android:exported="true">
<intent-filter>…</intent-filter>
</activity>
</application>
</manifest>
// =======================================================
// 文件:res/layout/activity_main.xml
// 描述:主界面布局:ECGView + 控制按钮
// =======================================================
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_height="match_parent">
<com.example.ecgapp.ui.ECGView
android:id="@+id/ecgView"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
<LinearLayout
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:background="#80000000" android:padding="8dp">
<Button android:id="@+id/btnStart" android:text="开始采集"/>
<Button android:id="@+id/btnStop" android:text="停止采集"
android:layout_marginStart="16dp"/>
<TextView android:id="@+id/tvHeartRate" android:text="HR: --"
android:textColor="#FFEB3B" android:textSize="18sp"
android:layout_marginStart="24dp"/>
</LinearLayout>
</FrameLayout>
// =======================================================
// 文件:MainActivity.kt
// 描述:主逻辑,管理 BLE/USB, 数据处理与 UI 交互
// =======================================================
package com.example.ecgapp
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager
import android.os.*
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import com.example.ecgapp.databinding.ActivityMainBinding
import com.example.ecgapp.ecg.*
class MainActivity : AppCompatActivity() {
private lateinit var b: ActivityMainBinding
private lateinit var processor: ECGDataProcessor
private lateinit var dataSource: ECGDataSource
override fun onCreate(s: Bundle?) {
super.onCreate(s)
b = ActivityMainBinding.inflate(layoutInflater)
setContentView(b.root)
// 申请权限
ActivityCompat.requestPermissions(this, arrayOf(
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.RECORD_AUDIO,
Manifest.permission.WRITE_EXTERNAL_STORAGE
), 1)
// 初始化数据处理器与 UI 绑定
processor = ECGDataProcessor()
b.ecgView.setDataSource(processor.outputQueue)
// 心率回调
processor.onHeartRateCalculated = { hr ->
runOnUiThread { b.tvHeartRate.text = "HR: $hr BPM" }
}
// 数据源选择:BLE or USB
dataSource = BLEECGDataSource(this, processor::onNewSample)
// 或者: USBECGDataSource(this, processor::onNewSample)
b.btnStart.setOnClickListener {
processor.start()
dataSource.start()
b.btnStart.isEnabled = false
b.btnStop.isEnabled = true
}
b.btnStop.setOnClickListener {
dataSource.stop()
processor.stop()
b.btnStart.isEnabled = true
b.btnStop.isEnabled = false
}
}
}
// =======================================================
// 文件:ecg/ECGDataSource.kt
// 描述:数据源接口与 BLE/USB 实现
// =======================================================
package com.example.ecgapp.ecg
typealias SampleCallback = (Double) -> Unit
interface ECGDataSource {
fun start()
fun stop()
}
// BLE 实现(伪代码)
class BLEECGDataSource(
private val activity: Activity,
private val onSample: SampleCallback
) : ECGDataSource {
override fun start() {
// 扫描、连接,订阅特征,解析回调并调用 onSample(value)
}
override fun stop() {
// 断开
}
}
// USB 串口实现(伪代码)
class USBECGDataSource(
private val activity: Activity,
private val onSample: SampleCallback
) : ECGDataSource {
override fun start() {
// 获取 UsbManager, 查找设备, 打开串口, 启动线程读取并解析样本
}
override fun stop() {
// 关闭串口
}
}
// =======================================================
// 文件:ecg/ECGDataProcessor.kt
// 描述:滤波器、基线校正、心率计算与数据队列
// =======================================================
package com.example.ecgapp.ecg
import java.util.concurrent.ArrayBlockingQueue
import java.util.concurrent.BlockingQueue
import org.apache.commons.math3.filter.* // 可选
class ECGDataProcessor {
// 输出给 UI 的点队列
val outputQueue: BlockingQueue<Double> = ArrayBlockingQueue(2000)
// 心率回调
var onHeartRateCalculated: ((Int) -> Unit)? = null
// 内部缓存与滤波器
private val rawBuffer = mutableListOf<Double>()
// 示例:一阶高通 + 带通滤波伪代码
private val filter = CustomBandpassFilter(0.5, 40.0, 250.0)
private var running = false
private lateinit var worker: Thread
fun onNewSample(sample: Double) {
if (!running) return
rawBuffer += sample
}
fun start() {
running = true
worker = Thread {
val rrIntervals = mutableListOf<Long>()
var lastPeakTime = System.currentTimeMillis()
while (running) {
if (rawBuffer.isNotEmpty()) {
val raw = rawBuffer.removeAt(0)
val filtered = filter.filter(raw)
outputQueue.put(filtered)
// 峰值检测(简单阈值示例)
if (filtered > 0.8 && isPeak(filtered)) {
val now = System.currentTimeMillis()
val interval = now - lastPeakTime
lastPeakTime = now
if (interval in 300..2000) {
rrIntervals += interval
if (rrIntervals.size >= 5) {
val avg = rrIntervals.average()
onHeartRateCalculated?.invoke((60_000/avg).toInt())
rrIntervals.clear()
}
}
}
} else Thread.sleep(2)
}
}
worker.start()
}
fun stop() {
running = false
worker.join()
}
private fun isPeak(value: Double): Boolean {
// 简化:真实实现需邻域比较
return true
}
}
// 自定义带通滤波(伪代码)
class CustomBandpassFilter(f1: Double, f2: Double, fs: Double) {
fun filter(x: Double): Double {
// 实际可用 IIR 或 FIR 实现
return x
}
}
// =======================================================
// 文件:ui/ECGView.kt
// 描述:自定义 SurfaceView 实时绘制 ECG 波形
// =======================================================
package com.example.ecgapp.ui
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.SurfaceHolder
import android.view.SurfaceView
import java.util.concurrent.BlockingQueue
class ECGView @JvmOverloads constructor(
ctx: Context, attrs: AttributeSet? = null
) : SurfaceView(ctx, attrs), SurfaceHolder.Callback {
private lateinit var drawThread: Thread
private var running = false
private lateinit var dataQueue: BlockingQueue<Double>
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.GREEN; strokeWidth = 2f; style = Paint.Style.STROKE
}
private val gridPaint = Paint().apply {
color = Color.DKGRAY; strokeWidth = 1f
}
fun setDataSource(queue: BlockingQueue<Double>) {
dataQueue = queue
holder.addCallback(this)
}
override fun surfaceCreated(holder: SurfaceHolder) {
running = true
drawThread = Thread { drawLoop(holder) }
drawThread.start()
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
running = false
drawThread.join()
}
override fun surfaceChanged(h: SurfaceHolder, f: Int, w: Int, h2: Int) = Unit
private fun drawLoop(holder: SurfaceHolder) {
val path = Path()
var x = 0f
val width = width.toFloat()
val height = height.toFloat()
val midY = height/2
val xStep = 4f // 控制滚动速度
var prevY = midY
while (running) {
val canvas = holder.lockCanvas() ?: continue
// 清屏与绘制网格
canvas.drawColor(Color.BLACK)
drawGrid(canvas, width, height)
// 读取一点数据
val sample = dataQueue.poll() ?: 0.0
val y = midY - (sample * midY).toFloat()
if (x == 0f) path.moveTo(0f, prevY) else path.lineTo(x, y)
prevY = y
x += xStep
if (x > width) { x = 0f; path.reset(); path.moveTo(0f, prevY) }
// 绘制路径
canvas.drawPath(path, paint)
holder.unlockCanvasAndPost(canvas)
Thread.sleep(16) // ~60 FPS
}
}
private fun drawGrid(c: Canvas, w: Float, h: Float) {
val stepX = w/10; val stepY = h/10
for (i in 0..10) {
c.drawLine(i*stepX, 0f, i*stepX, h, gridPaint)
c.drawLine(0f, i*stepY, w, i*stepY, gridPaint)
}
}
}
六、代码解读
-
主界面与交互
-
MainActivity
负责权限申请、数据源(BLE/USB)与处理器、以及 UI 按钮开启/停止采集;
-
-
数据源
-
BLEECGDataSource
/USBECGDataSource
通过回调将原始电压值推送给处理器;
-
-
数据处理
-
ECGDataProcessor
在后台线程中取样,应用带通滤波,推送到outputQueue
,并在并行峰值检测中计算心率;
-
-
波形呈现
-
ECGView
使用SurfaceView
,在独立绘制线程中以 60 FPS 读取队列并绘制滚动折线; -
背景网格增强对比,
xStep
控制时间轴缩放;
-
-
性能考量
-
数据处理与绘制分离,多线程解耦
-
使用
BlockingQueue
保证线程安全
-
七、性能与优化
-
滤波器优化:使用高效 IIR 或 FFT 实现带通滤波,避免过度耗时。
-
绘制合批:可预先计算网格,只在尺寸变化时重绘。
-
帧率调节:根据数据刷新速率动态调整
Thread.sleep()
,平衡 CPU 与流畅度。 -
内存管理:注意队列大小与处理速度匹配,防止内存爆增或数据丢失。
八、扩展思路
-
手势缩放:为
ECGView
添加双指缩放,调整时基(xStep
)和振幅。 -
回放功能:在停止采集后,保存
outputQueue
到文件,再次加载到ECGView
中进行历史回放。 -
多导联展示:横向或分屏布局显示多条波形(I、II、III 导联)。
-
实时云同步:将数据推送至远程服务器,实现远程监护。
九、FAQ
Q1:信号噪声较大如何处理?
A:可增加 50 Hz 工频陷波,或使用小波去噪技术。
Q2:BLE 数据丢包怎么补偿?
A:在数据处理层插值或线性插补,保持波形连续。
Q3:为什么波形有抖动?
A:检查滤波器参数与采样率匹配,并确保绘制循环稳定。
Q4:如何在 UI 线程安全地更新心率?
A:processor.onHeartRateCalculated
回调中使用 runOnUiThread
。
Q5:横屏时如何适配布局?
A:在布局文件中使用 match_parent
,ECGView
内部根据 width
、height
自动计算网格与波形。