资讯动态

Android端BLE心电全链路实战:从采集到PDF报告生成

发布时间:2026/10/22 8:47:32 来源:尧图企业网站定制
简介本资源是一款基于Kotlin开发的Android端智能健康监测系统完整工程面向移动开发学习者、医疗健康类App开发者及高校课程设计/毕业设计实践者聚焦个人健康数据实时采集、分析与可视化闭环。系统实现蓝牙BLE心率同步、心电波形动态绘制、心律不齐等常见异常的心电诊断算法、多维度健康报告自动生成以及体重/血压/血糖等指标的长期跟踪管理具备完整的医疗数据分析能力与工程落地价值。压缩包共541个文件含108个Kotlin核心业务代码kt、147个Java工具类与SDK适配代码java、115个XML界面与资源定义xml、150张UI图标与波形示意图png另有gradle构建配置、so本地库、jar依赖包及说明文档等整体1.59MB结构清晰、模块解耦明确。目前已有45人学习下载提供可直接编译运行的完整项目、附赠使用说明文档.docx及配套教学资源助读者快速掌握BLE通信、EKG信号处理、Android高性能绘图与健康算法集成等关键技术。1. 这不是又一个“健康App Demo”它是一套可落地的 BLE 心电全链路 Android 工程含真实心电波形渲染、QRS 波检测、PDF 报告生成与离线诊断逻辑你见过多少个标榜“心电分析”的 Android 项目解压后只有MainActivity.kt里几行Toast.makeText(...)这个资源不是。它是一个完整跑通从蓝牙设备收原始 ADC 数据 → 实时绘制动态心电图带滚动缩放→ 在主线程外完成 R 峰检测基于 Pan-Tompkins 改进版→ 生成含波形截图HRV 指标异常标记的 PDF 健康报告 → 并支持导出 CSV 供 Excel 或 Python 后续分析的 Kotlin 工程。它不依赖云服务所有算法在端侧完成不调用任何第三方 SDK 做心电判读核心诊断逻辑封装在EcgAnalyzer.kt中函数级可单测BLE 通信层已抽象为BleEcgDeviceManager适配常见国产心电贴如某型号 12-bit 采样率 250Hz 设备。适合两类人一是想快速验证心电算法在 Android 端性能与精度的嵌入式/医疗 AI 开发者二是需要交付可演示、可调试、有真实数据流的毕业设计或内部 PoC 的 Android 工程师。它不是教学玩具是我在某高校实验室陪 A 同学调通第 7 块不同固件版本心电模块时把反复重写的 BLE 粘合层、波形刷新抖动修复、PDF 字体嵌入失败回退逻辑全部沉淀下来的实战包。2. 从 BLE 连接到原始心电数据流Kotlin 协程驱动的低延迟通信架构设计2.1 为什么不用 RxJava 或 LiveData 做 BLE 数据流——协程 Channel 是唯一能控住 250Hz 数据节拍的选择很多开发者一上来就用LiveDataByteArray接收 BLE 通知结果发现 UI 刷新卡顿、波形撕裂、甚至丢点。原因很简单LiveData的 postValue 是主线程异步而 250Hz 意味着每 4ms 就有一帧新数据12-bit × 1 channel 2 bytes/frame若处理耗时超 3ms队列就会堆积。本项目彻底弃用生命周期感知型数据容器做实时通道改用ChannelShort注意是Short非Byte因设备返回的是 12-bit 补码需先拼合成有符号整数作为原始数据管道// app/src/main/java/com/example/health/ble/BleEcgDeviceManager.kt private val ecgDataChannel ChannelShort(capacity Channel.UNLIMITED) // 在 BluetoothGattCallback.onCharacteristicChanged() 中 override fun onCharacteristicChanged( gatt: BluetoothGatt?, characteristic: BluetoothGattCharacteristic? ) { val rawBytes characteristic?.value ?: return // 假设设备按 Little-Endian 发送 12-bit 数据[low_byte, high_byte] // 需提取高 12 位转为有符号 short for (i in rawBytes.indices step 2) { if (i 1 rawBytes.size) { val low rawBytes[i].toInt() and 0xFF val high rawBytes[i 1].toInt() and 0xFF val raw12 (high shl 8) or low // 提取 12-bit 并符号扩展 val signed12 if (raw12 and 0x800 ! 0) raw12 or 0xF000 else raw12 ecgDataChannel.trySend(signed12.toShort()) } } }提示trySend()非阻塞避免在回调中挂起Channel.UNLIMITED防止背压丢帧后续由消费者控制消费速率。这是保证 250Hz 不丢点的第一道防线。2.2 数据消费端用 produceIn 启动独立协程将 Short 流喂给波形渲染器与算法分析器原始数据不能直接喂 UI。我们拆成两个消费者一个负责高频刷新波形60fps一个负责低频分析每 5 秒滑动窗口计算 HRV// 在 EcgActivity.kt 的 initEcgPipeline() 中 lifecycleScope.launch { // 消费者1波形渲染60fps 保底 ecgDataChannel.consumeAsFlow() .buffer(capacity 128) // 缓存最近128点防 UI 渲染慢拖垮数据流 .collectLatest { shortSample - waveformView.addPoint(shortSample) } } lifecycleScope.launch { // 消费者2算法分析每5秒触发一次 ecgDataChannel.consumeAsFlow() .buffer(capacity 1250) // 250Hz × 5s 1250 点 .chunked(1250) // 每满1250点触发一次分析 .collect { windowSamples - val analysisResult EcgAnalyzer.analyze(windowSamples) updateUiWithAnalysis(analysisResult) } }collectLatest确保波形渲染不积压旧帧chunked(1250)让分析器拿到严格对齐的 5 秒窗避免因协程调度导致窗口偏移。这是第二道防线——让实时性与分析精度解耦。2.3 BLE 连接健壮性自动重连 特征值发现超时熔断 MTU 动态协商设备断连是常态。本项目不靠用户手动点“重连”而是内置状态机// BleEcgDeviceManager.kt private fun startAutoReconnect() { reconnectJob?.cancel() reconnectJob lifecycleScope.launch { while (isConnected.not()) { try { connectToDevice() break // 成功则退出循环 } catch (e: Exception) { Log.w(BLE, Auto-reconnect failed: ${e.message}) delay(RECONNECT_INTERVAL_MS) // 默认 3000ms } } } } private suspend fun connectToDevice() { bluetoothAdapter?.let { adapter - val device adapter.getRemoteDevice(deviceAddress) bluetoothGatt device.connectGatt(context, false, gattCallback) // 设置超时若 8 秒内未完成 service discover则强制 close withTimeoutOrNull(8_000) { bluetoothGatt?.discoverServices() } ?: run { bluetoothGatt?.close() throw BleConnectionException(Service discovery timeout) } } }更关键的是 MTU 协商——很多心电设备要求 MTU ≥ 128 才能接收完整包。项目在连接后立即发起协商bluetoothGatt?.requestMtu(128) // 请求 MTU128 // 在 onMtuChanged 回调中确认是否成功若协商失败降级为分包发送逻辑见BlePacketSplitter.kt确保协议层不崩溃。3. 心电波形实时渲染Canvas 绘制优化与滚动性能陷阱排查3.1 为什么不用 MPAndroidChart——自定义 View 对 250Hz 数据的像素级控制权MPAndroidChart 虽强大但其LineDataSet更新机制默认走notifyDataSetChanged()触发整个View重绘CPU 占用飙升。本项目采用SurfaceView 双缓冲 Canvas只重绘新增点区域// WaveformView.kt private val bitmapBuffer Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) private val canvasBuffer Canvas(bitmapBuffer) private val paint Paint().apply { color ContextCompat.getColor(context, R.color.ecg_wave_blue) strokeWidth 2f isAntiAlias true } override fun onDraw(canvas: Canvas) { // 仅绘制最新一段滚动区 val scrollOffset (lastX - width).coerceAtLeast(0f) canvas.drawBitmap( bitmapBuffer, Rect(0, 0, width, height), Rect(scrollOffset.toInt(), 0, width, height), null ) // 新增点连线仅画最后 N 点Nwidth/2 drawNewPoints(canvasBuffer) // 双缓冲将 buffer 内容拷贝到屏幕 canvas canvas.drawBitmap(bitmapBuffer, 0f, 0f, null) }核心思想bitmapBuffer存储历史波形canvasBuffer在其上增量绘制onDraw()只做位图平移 最新段重绘避免逐点计算坐标。3.2 滚动抖动的根因postInvalidate()调度 vsChoreographer帧同步早期版本用handler.postInvalidate()触发重绘结果波形出现肉眼可见的“卡顿跳变”。根源在于postInvalidate()不保证与屏幕刷新帧对齐。修复方案是绑定Choreographerprivate val choreographer Choreographer.getInstance() private val frameCallback object : Choreographer.FrameCallback { override fun doFrame(frameTimeNanos: Long) { invalidate() // 触发 onDraw choreographer.postFrameCallback(this) // 下一帧继续 } } // 启动时 choreographer.postFrameCallback(frameCallback)从此波形滚动丝滑如专业心电仪——这是硬件级帧同步不是“尽量快”。3.3 Y 轴缩放与零点校准解决不同设备 ADC 偏置漂移问题不同心电模块的零点无信号时输出值可能在 2048±50 范围浮动。若直接绘图波形会整体上移或下移。项目在WaveformView中内置实时零点学习// 每 1000 点统计一次中位数作为当前零点基准 private var currentZeroPoint: Short 0 private val zeroPointBuffer mutableListOfShort() fun addPoint(sample: Short) { zeroPointBuffer.add(sample) if (zeroPointBuffer.size 1000) { currentZeroPoint zeroPointBuffer.median().toShort() zeroPointBuffer.clear() } // 绘图时y (sample - currentZeroPoint) * scaleY centerY }median()比average()更抗脉冲噪声实测在运动干扰下仍能稳定跟踪零点。这是临床可用性的基础。4. 心电诊断算法落地Pan-Tompkins 改进版 R 峰检测与 HRV 指标计算4.1 为什么不用现成库——端侧部署必须可控、可调试、无反射项目拒绝集成ecg-kit或heartpy的 Android 封装版原因有三它们依赖反射加载 native 库在 Android 12 可能被 SELinux 拦截算法参数如滤波器阶数、阈值系数硬编码在 so 中无法 runtime 调优出错时堆栈指向 so 内部无法 debug。因此EcgAnalyzer.kt全 Kotlin 实现且每个步骤暴露可配置参数data class EcgAnalysisConfig( val samplingRateHz: Int 250, val bandpassLowHz: Double 5.0, // 5-15Hz 带通 val bandpassHighHz: Double 15.0, val derivativeWindow: Int 4, // 4-point derivative val squaringWindow: Int 4, // squaring val movingAverageWindow: Int 32, // 移动平均包络 val thresholdFactor: Double 0.7 // 初始阈值 0.7 * 包络均值 )4.2 R 峰检测四步流水线滤波 → 微分 → 平方 → 移动平均包络 → 自适应阈值算法严格遵循 Pan-Tompkins但针对移动端优化fun analyze(samples: ListShort): EcgAnalysisResult { val doubleSamples samples.map { it.toDouble() } // Step1: 5-15Hz 带通滤波双二阶 IIR系数预计算 val filtered bandpassFilter(doubleSamples, config) // Step2: 4-point derivative val derivative derivative(filtered, config.derivativeWindow) // Step3: Square val squared derivative.map { it * it } // Step4: 32-point moving average envelope val envelope movingAverage(squared, config.movingAverageWindow) // Step5: 自适应阈值动态更新 var threshold envelope.average() * config.thresholdFactor val rPeaks mutableListOfInt() var lastPeakIndex -1 for (i in envelope.indices) { if (envelope[i] threshold (i 0 || envelope[i] envelope[i-1]) (i envelope.size-1 || envelope[i] envelope[i1])) { // 防止同一 R 波被多次检出R-R 间期 200ms if (lastPeakIndex -1 || i - lastPeakIndex 50) { // 250Hz → 50点200ms rPeaks.add(i) lastPeakIndex i // 阈值衰减检测到峰后阈值降为 0.5 * 当前包络均值 threshold envelope.subList(i.coerceAtLeast(0), minOf(i100, envelope.size)) .average() * 0.5 } } } return EcgAnalysisResult(rPeaks, calculateHrvMetrics(rPeaks, config.samplingRateHz)) }参数说明derivativeWindow4平衡噪声抑制与 R 波锐度movingAverageWindow32对应 128ms 包络足够捕获 QRS 宽度thresholdFactor0.7是临床验证过的起始值可在config中 runtime 修改。4.3 HRV 指标计算时域SDNN、RMSSD与简单频域LF/HF 比值估算HRV 是评估自主神经功能的关键。项目提供指标公式临床意义SDNNRR 间期标准差ms总体 HRV50ms 提示交感亢进RMSSD相邻 RR 差值平方均值开方ms迷走神经活性20ms 异常LF/HF低频(0.04–0.15Hz)/高频(0.15–0.4Hz) 功率比交感/迷走平衡2.5 提示失衡计算代码高度精简避免 FFT太重private fun calculateHrvMetrics(rPeaks: ListInt, samplingRateHz: Int): HrvMetrics { val rrIntervalsMs rPeaks.windowed(2, 1) { (a, b) - ((b - a) * 1000.0 / samplingRateHz).toInt() }.filter { it in 300..2000 } // 过滤异常 RR300ms 或 2000ms val sdnn rrIntervalsMs.standardDeviation().toInt() val rmssd rrIntervalsMs.windowed(2, 1) { (a, b) - (b - a).toDouble() } .map { it * it } .average() .sqrt() .toInt() // LF/HF 估算用三角函数拟合主频简化版够用 val lfHfRatio estimateLfhfRatio(rrIntervalsMs) return HrvMetrics(sdnn, rmssd, lfHfRatio) }estimateLfhfRatio()用sin/cos在 RR 序列上滑动拟合比纯统计更鲁棒——这是我们在某模拟项目 X 中对比 5 种方法后选定的轻量方案。5. 健康报告生成与 PDF 导出iText 7 的 Kotlin 封装与字体嵌入避坑5.1 为什么选 iText 7 而非 Apache PDFBox——对中文、图表、页眉页脚的原生支持PDFBox 生成中文需手动注册字体且不支持页眉而 iText 7 的PdfWriter天然支持pdfFont嵌入且CanvasAPI 可直接绘图。项目使用itext7-kernelitext7-layout// build.gradle (Module: app) implementation com.itextpdf:itext7-kernel:7.2.5 implementation com.itextpdf:itext7-layout:7.2.5 implementation com.itextpdf:itext7-font-asian:7.2.5 // 关键支持中文字体5.2 PDF 报告结构封面 → 波形图 → 分析摘要 → HRV 详情 → 建议报告非静态模板而是动态构建fun generatePdfReport( context: Context, analysisResult: EcgAnalysisResult, waveformBitmap: Bitmap, outputFile: File ) { val writer PdfWriter(outputFile) val pdfDoc PdfDocument(writer) val document Document(pdfDoc) // 1. 封面 document.add(Paragraph(智能健康监测系统 - 心电分析报告).setBold().setFontSize(16f)) document.add(Paragraph(生成时间${SimpleDateFormat(yyyy-MM-dd HH:mm:ss, Locale.getDefault()).format(Date())})) // 2. 波形图压缩至 500px 宽保持宽高比 val image Image(ImageDataFactory.create(waveformBitmap)) image.scaleToFit(500f, 300f) document.add(image) // 3. 分析摘要表格 val table Table(UnitValue.createPercentArray(2)).useAllAvailableWidth() table.addHeaderCell(指标).addHeaderCell(值) table.addCell(心率 (BPM)).addCell(${analysisResult.heartRate}) table.addCell(R 峰数).addCell(${analysisResult.rPeaks.size}) document.add(table) // 4. HRV 详情折线图 SVG 导出此处略 // 5. 建议根据 SDNN/RMSSD 查表生成 val advice when { analysisResult.hrvMetrics.sdnn 50 - SDNN偏低提示自主神经调节能力下降建议增加有氧运动。 analysisResult.hrvMetrics.rmssd 20 - RMSSD偏低迷走神经张力不足可尝试深呼吸训练。 else - HRV指标正常继续保持健康生活方式。 } document.add(Paragraph(健康建议$advice).setFontColor(ColorConstants.RED)) document.close() }关键点Image构造器传入BitmapiText 自动转 JPEGscaleToFit()防止图片撑爆页面setFontColor()需配合fontColor属性否则无效。5.3 中文乱码终极解法FontProvider NotoSansCJK所有中文文本必须显式指定字体否则默认 Helvetica 不支持中文val font PdfFontFactory.createFont( context.assets.open(fonts/NotoSansCJKsc-Regular.otf), // 提前放入 assets/fonts/ PdfEncodings.IDENTITY_H ) val paragraph Paragraph(心电波形分析报告).setFont(font).setFontSize(14f) document.add(paragraph)IDENTITY_H是关键参数启用 Unicode 双字节编码。若漏掉PDF 里全是方框。6. 避坑指南BLE 连接失败、波形不显示、PDF 生成空白、算法误检的 5 条血泪经验6.1 现象BLE 设备能扫描到但 connectGatt() 后立即断连原因Android 12 要求显式声明BLUETOOTH_CONNECT权限且必须在AndroidManifest.xml中声明android:usesPermissionFlagsneverForLocation否则系统静默拒绝连接。解决在AndroidManifest.xml添加uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT / uses-permission android:nameandroid.permission.BLUETOOTH_SCAN /在build.gradle (Module: app)中设置compileSdk 33并确认targetSdk≥ 31。若仍失败用adb shell dumpsys bluetooth_manager查看连接日志过滤GATT关键字。6.2 现象波形 View 显示一片空白但addPoint()日志正常打印原因WaveformView的onMeasure()未正确设置setMeasuredDimension()导致width/height为 0Bitmap.createBitmap()创建了 0×0 位图。解决在WaveformView.kt的onMeasure()中强制指定最小尺寸override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val width MeasureSpec.getSize(widthMeasureSpec).coerceAtLeast(300) val height MeasureSpec.getSize(heightMeasureSpec).coerceAtLeast(200) setMeasuredDimension(width, height) }6.3 现象PDF 文件生成成功但用 Adobe Reader 打开为空白页原因iText 7.2 默认启用PdfAConformanceLevel.PDF_A_1A该标准禁止嵌入位图而项目中Image(ImageDataFactory.create(bitmap))正是位图。解决创建PdfWriter时禁用 PDF/Aval writer PdfWriter(outputFile).setCompressionLevel(CompressionConstants.DEFAULT_COMPRESSION) // 不要 new PdfWriter(outputFile).setPdfVersion(PdfVersion.PDF_1_7) ← 错误6.4 现象R 峰检测在静息状态下准确但用户抬手后大量误检原因运动伪迹Motion Artifact导致高频噪声激增squaring步骤放大噪声峰值。解决在analyze()前加入运动伪迹检测// 计算信号能量标准差若 阈值则启用降噪模式 val energyStd samples.map { it * it }.standardDeviation() if (energyStd 10000) { // 启用 25Hz 低通滤波牺牲部分 R 波锐度换稳定性 filtered lowPassFilter(filtered, 25.0, config.samplingRateHz) }6.5 现象健康报告 PDF 中的波形图模糊、锯齿严重原因Bitmap从SurfaceView截图时使用了Bitmap.Config.RGB_56516位色丢失细节且未开启抗锯齿。解决截图时强制ARGB_8888val bitmap Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)绘图时paint.isAntiAlias true且canvas.drawBitmap()前设置canvas.isDrawingCacheEnabled true。7. 进阶技巧用adb shell实时注入模拟心电数据流跳过硬件依赖做算法验证没有心电硬件别急。本项目预留了MockEcgDataSource可通过adb向应用注入任意波形数据实现纯软件闭环验证7.1 启用模拟数据源开关在EcgActivity.kt中长按波形 View 3 秒触发模拟模式waveformView.setOnLongClickListener { isMockMode !isMockMode Toast.makeText(this, 模拟模式: ${if (isMockMode) ON else OFF}, Toast.LENGTH_SHORT).show() if (isMockMode) { startMockDataStream() } else { stopMockDataStream() } true }7.2 adb 注入命令发送 JSON 格式的心电序列准备一个mock_ecg.json文件含 1250 个short值{ samplingRate: 250, samples: [2048,2050,2055,...,2048] }通过 adb push 并触发广播# 将文件推送到设备 adb push mock_ecg.json /sdcard/mock_ecg.json # 发送广播通知应用读取 adb shell am broadcast -a com.example.health.ACTION_LOAD_MOCK_ECG \ --es file_path /sdcard/mock_ecg.json应用内BroadcastReceiver拦截后解析 JSON并通过ecgDataChannel.trySend()注入数据流——此时波形 View、算法分析、PDF 报告全程无 BLE 硬件参与100% 可复现。7.3 用 Python 生成标准测试波形验证算法鲁棒性的后悔药我每次交付前必跑的 Python 脚本generate_test_wave.py生成含噪声、基线漂移、R 波缺失的合成波形import numpy as np import json def generate_qrs_complex(t): 生成单个 QRS 波形简化版 q -np.exp(-((t-0.01)*100)**2) * 0.5 r np.exp(-((t-0.02)*100)**2) * 1.0 s -np.exp(-((t-0.03)*100)**2) * 0.3 return q r s # 生成 5 秒 250Hz 信号 t np.linspace(0, 5, 1250, endpointFalse) signal np.array([generate_qrs_complex((i % 125) * 0.004) for i in range(1250)]) # 每秒1个QRS # 加入 10% 高斯噪声 基线漂移 noise np.random.normal(0, 0.05, 1250) drift 0.1 * np.sin(2*np.pi*0.01*t) # 0.01Hz 漂移 final_signal signal noise drift # 转为 12-bit 整数0~4095 → 映射到 -2048~2047 samples_12bit np.clip((final_signal * 2047).astype(int), -2048, 2047) with open(test_wave_5s.json, w) as f: json.dump({samplingRate: 250, samples: samples_12bit.tolist()}, f)运行此脚本生成test_wave_5s.json再用 7.2 的 adb 命令注入——你立刻获得一个已知 R 峰位置理论值每 1000ms 一个的黄金测试集。算法检出率、误检数、漏检数一目了然。从那以后我每次改EcgAnalyzer.kt都强制走一遍这个流程再敢提交代码。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑