Android实现心电图(附带源码)

一、项目介绍

1. 背景与意义

在医疗健康、健身监测、智能穿戴等领域,心电图(ECG, Electrocardiogram)是一项重要的生理信号,用以反映人体心脏的电活动。传统的心电设备体积庞大、成本高昂,而移动端的智能心电监测方案,以其便携、低成本、实时等优势,越来越受到关注。本项目旨在用 Android 平台实现一个实时 ECG 波形采集与显示的端到端方案,包含从信号采集数据预处理波形渲染等完整流程,帮助开发者快速搭建移动心电监测应用。

2. 功能目标

  1. 实时采集:支持蓝牙(BLE)或 USB 串口协议,从外部心电传感器获取实时数据。

  2. 数据预处理:带通滤波、基线漂移消除、去噪平滑,提升显示效果与后续分析准确度。

  3. 波形渲染:高性能自定义 ECGView,支持流畅滚动、网格背景、触摸缩放。

  4. 心率计算:基于 R 峰检测算法,实时计算并显示心率(BPM)。

  5. 数据存储与导出:记录时序数据,支持保存为 CSV 或二进制格式。

  6. 界面与交互:支持开始/停止、快进回放、截屏、放大细节查看等交互操作。

  7. 跨屏幕适配:兼容不同分辨率、横竖屏自动布局。


二、相关基础知识

  1. ECG 信号特征

    • 正常 ECG 波形由 P 波、QRS 复合波、T 波等组成,采样率一般在 250–1000 Hz。

  2. 信号采集接口

    • BLE:使用 GATT 协议订阅心电数据特征;

    • USB 串口:通过 UsbManager + UsbSerial 库读取外设串口数据;

    • 模拟数据:在无硬件时,通过算法生成仿真 ECG 波形。

  3. 数字滤波

    • 带通滤波:如 Butterworth 实现 0.5–40 Hz 带通;

    • 数字滤波器:可用 Apache Commons Math 或自定义 IIR/FFT 实现。

  4. 波形绘制

    • 自定义 ViewSurfaceView:在 onDraw(Canvas) 中用 Path 绘制折线;

    • 双缓冲SurfaceView + Canvas.lockCanvas() 保证 60 FPS。

  5. 心率检测

    • 峰值检测:基于导数/阈值法或 Pan–Tompkins 算法;

    • BPM 计算:统计单位时间内 R 峰个数。

  6. 多线程与性能

    • 使用后台线程或 HandlerThread 处理数据接收与预处理,UI 线程仅负责渲染;

    • 限制渲染频率(如 30 FPS)避免过度绘制。


三、实现思路与架构

+-----------------------------------------------+
| Android App                                   |
|                                               |
|  +------------+   +---------------+           |
|  | BLE Manager|-->| Data Processor|--+        |
|  +------------+   +---------------+  |        |
|  | USB Manager|                     |        |
|  +------------+                     |        |
|                                    v        |
|                           +----------------+ |
|                           |   ECGView      | |
|                           | (Custom View)  | |
|                           +----------------+ |
|                                   |            |
|                                   v            |
|                           +----------------+   |
|                           | HeartRateCalc  |   |
|                           +----------------+   |
|                                   |            |
|                           +----------------+   |
|                           | Data Storage   |   |
|                           +----------------+   |
+-----------------------------------------------+
  1. BLE/USB Manager:负责底层通信,持续推送原始采样数据。

  2. Data Processor:一维滤波、基线校正、数据队列维护。

  3. ECGView:自定义 SurfaceView,接收数据队列,绘制实时滚动波形与背景网格。

  4. HeartRateCalc:在 Data Processor 内部并行运行,基于峰值检测算法计算心率。

  5. 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)
    }
  }
}

六、代码解读

  1. 主界面与交互

    • MainActivity 负责权限申请、数据源(BLE/USB)与处理器、以及 UI 按钮开启/停止采集;

  2. 数据源

    • BLEECGDataSourceUSBECGDataSource 通过回调将原始电压值推送给处理器;

  3. 数据处理

    • ECGDataProcessor 在后台线程中取样,应用带通滤波,推送到 outputQueue,并在并行峰值检测中计算心率;

  4. 波形呈现

    • ECGView 使用 SurfaceView,在独立绘制线程中以 60 FPS 读取队列并绘制滚动折线;

    • 背景网格增强对比,xStep 控制时间轴缩放;

  5. 性能考量

    • 数据处理与绘制分离,多线程解耦

    • 使用 BlockingQueue 保证线程安全


七、性能与优化

  1. 滤波器优化:使用高效 IIR 或 FFT 实现带通滤波,避免过度耗时。

  2. 绘制合批:可预先计算网格,只在尺寸变化时重绘。

  3. 帧率调节:根据数据刷新速率动态调整 Thread.sleep(),平衡 CPU 与流畅度。

  4. 内存管理:注意队列大小与处理速度匹配,防止内存爆增或数据丢失。


八、扩展思路

  • 手势缩放:为 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_parentECGView 内部根据 widthheight 自动计算网格与波形。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值