
简介本资源是一款基于Kotlin开发的Android端智能健康监测系统面向移动应用开发者、医疗信息化学习者及健康管理类毕业设计实践者解决个人生理数据实时采集、可视化分析与初步诊断支持等核心问题。压缩包共541个文件含108个Kotlin源码主业务逻辑与BLE通信模块、147个Java文件兼容组件与算法工具类、115个XML布局与资源定义、150个PNG图标与界面素材以及gradle构建配置、jar依赖库和so本地库等整体仅1.59MB轻量易导入结构清晰便于模块化学习与二次开发。已有45人下载学习适合中高级Android开发者深入理解蓝牙低功耗通信集成、心电信号波形实时绘制、QRS波检测等关键算法实现以及健康报告自动生成与医疗数据趋势分析等完整闭环功能。1. 为什么一个能实时画出心电波形的 Android App比“健康打卡”类应用更难落地这不是一个「加个蓝牙权限就能连手环」的玩具项目。标题里七个下划线分隔的技术点——实时心率监测、蓝牙BLE通信、心电波形绘制、心电诊断算法、健康报告生成、医疗数据分析、个人健康管理——共同指向一个真实临床级移动健康系统的最小可行闭环从传感器原始采样开始到屏幕上毫秒级刷新的波形图再到可被初级医护人员快速判读的结构化诊断建议最后沉淀为可回溯的个人健康趋势报告。它不依赖云端AI大模型兜底所有关键链路尤其是波形重建与节律分析必须在端侧完成它不能容忍300ms以上的端到端延迟否则波形会“跳帧”用户会误判为设备断连它更不能把“心电图”简化成一个绿色/红色状态灯——真正的ECG导联信号是微伏级、高噪声、强工频干扰的模拟量采样率动辄250Hz以上每秒产生近1KB原始数据。这个系统适合两类人一是正在做毕业设计或企业内部POC的Android开发者需要可验证、可调试、可演示的完整技术栈二是基层医疗信息化团队想快速验证便携式心电筛查工具在社区场景下的可用性边界。它不是替代医院心电图机而是解决“第一次异常信号谁来发现”的问题。2. 用Kotlin构建BLE通信层从扫描到稳定订阅绕开Android 12的后台限制2.1 为什么不能直接用BluetoothAdapter.getDefaultAdapter()——Android版本兼容性陷阱Android 12API 31起强制要求位置权限才能扫描BLE设备而Android 13API 33进一步限制后台应用发起BLE扫描。但心电监测必须持续运行——用户不可能每次测心电都手动开定位。常见做法是放弃后台扫描改用前台服务Notification Channel保活。我们不走“申请永久位置权限”这种高风险路径而是让BLE连接逻辑与UI生命周期解耦用Service承载连接管理用WorkManager处理离线数据同步。// HeartRateService.kt —— 继承自ForegroundService非IntentService class HeartRateService : Service() { private lateinit var bluetoothManager: BluetoothManager private lateinit var bluetoothAdapter: BluetoothAdapter private var gatt: BluetoothGatt? null private val connectionState MutableLiveDataInt() override fun onCreate() { super.onCreate() bluetoothManager getSystemService(BluetoothManager::class.java) bluetoothAdapter bluetoothManager.adapter // 创建前台通知渠道Android 8.0必需 createNotificationChannel() } private fun createNotificationChannel() { if (Build.VERSION.SDK_INT Build.VERSION_CODES.O) { val channel NotificationChannel( heart_rate_channel, 心率监测服务, NotificationManager.IMPORTANCE_LOW ) val manager getSystemService(NotificationManager::class.java) manager.createNotificationChannel(channel) } } // 启动时立即进入前台 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val notification buildForegroundNotification() startForeground(1, notification) return START_STICKY } private fun buildForegroundNotification(): Notification { return NotificationCompat.Builder(this, heart_rate_channel) .setContentTitle(心电监测中) .setContentText(正在接收实时心电信号) .setSmallIcon(R.drawable.ic_heart) .build() } }提示START_STICKY仅表示系统内存紧张时杀掉服务后尝试重启不保证连续性。真正保障连接的是“连接成功后不再主动断开”而非靠服务保活。这是血泪经验曾有项目因过度依赖前台服务在厂商定制ROM如某国产UI上被“智能省电”策略强制杀死最终改用AlarmManager每15分钟唤醒一次检查连接状态配合JobIntentService做轻量心跳。2.2 BLE连接三步法扫描→连接→启用通知每个环节都要设超时很多开发者卡在“能扫到设备但连不上”本质是没处理BLE协议栈的异步状态机。我们封装一个BleConnectionManager强制所有操作带超时// BleConnectionManager.kt class BleConnectionManager( private val context: Context, private val deviceAddress: String, private val callback: BleCallback ) { private var bluetoothAdapter: BluetoothAdapter? null private var gatt: BluetoothGatt? null private val timeoutHandler Handler(Looper.getMainLooper()) private var connectTimeoutRunnable: Runnable? null fun connect() { bluetoothAdapter BluetoothAdapter.getDefaultAdapter() if (!bluetoothAdapter?.isEnabled true) { callback.onConnectionFailed(蓝牙未开启) return } val device bluetoothAdapter?.getRemoteDevice(deviceAddress) if (device null) { callback.onConnectionFailed(设备地址无效: $deviceAddress) return } // 关键使用 TRANSPORT_LE 强制BLE避免双模设备走BR/EDR gatt device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE) // 设置10秒连接超时 connectTimeoutRunnable Runnable { gatt?.close() gatt null callback.onConnectionFailed(连接超时10s) } timeoutHandler.postDelayed(connectTimeoutRunnable!!, 10_000) } private val gattCallback object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { connectTimeoutRunnable?.let { timeoutHandler.removeCallbacks(it) } if (newState BluetoothProfile.STATE_CONNECTED) { callback.onConnected() gatt.discoverServices() // 连接成功后必须主动发现服务 } else if (newState BluetoothProfile.STATE_DISCONNECTED) { callback.onDisconnected() } else if (status ! BluetoothGatt.GATT_SUCCESS) { callback.onConnectionFailed(GATT状态错误: $status) } } override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { if (status BluetoothGatt.GATT_SUCCESS) { enableHeartRateNotification(gatt) // 启用特征值通知 } else { callback.onConnectionFailed(服务发现失败: $status) } } } private fun enableHeartRateNotification(gatt: BluetoothGatt) { val service gatt.getService(UUID.fromString(0000180D-0000-1000-8000-00805f9b34fb)) // Heart Rate Service val characteristic service?.getCharacteristic(UUID.fromString(00002A37-0000-1000-8000-00805f9b34fb)) // Heart Rate Measurement if (characteristic ! null) { gatt.setCharacteristicNotification(characteristic, true) // 写入Client Characteristic Configuration Descriptor (CCCD) 启用通知 val cccd characteristic.getDescriptor(UUID.fromString(00002902-0000-1000-8000-00805f9b34fb)) cccd?.value BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE gatt.writeDescriptor(cccd) } } }参数说明BluetoothDevice.TRANSPORT_LE显式指定BLE传输避免某些双模芯片如TI CC2640默认走经典蓝牙导致连接失败onServicesDiscovered回调内才调用setCharacteristicNotification这是BLE协议硬性要求服务未发现前写CCCD会返回GATT_FAILUREENABLE_NOTIFICATION_VALUE是字节数组[0x01, 0x00]不是布尔值——这是新手最常翻车的点直接cccd.value true会静默失败。2.3 心电原始数据解析从BLE包到浮点电压值的校准链标题中“心电波形绘制”依赖的不是心率值HR而是原始ECG采样点。主流医疗级手环/贴片如某国产ECG贴片通过专有UUID服务发送原始ADC值格式通常是[timestamp_low, timestamp_high, sample1, sample2, ..., sampleN]其中每个sample是2字节有符号整数int16。但原始ADC值不等于实际电压必须经过硬件校准// EcgDataParser.kt class EcgDataParser(private val vRef: Float 3.3f, private val gain: Float 12.0f) { /** * 将BLE收到的字节数组解析为毫伏级电压值 * param data BLE通知的原始字节数组 * param sampleRate 实际采样率Hz如250 * return ListPairLong, Float 每个元素为 (时间戳毫秒, 电压毫伏) */ fun parseEcgSamples(data: ByteArray, sampleRate: Int): ListPairLong, Float { val samples mutableListOfPairLong, Float() val timestamp ByteBuffer.wrap(data, 0, 4).order(ByteOrder.LITTLE_ENDIAN).getInt().toLong() // 跳过时间戳4字节从第5字节开始读ADC值 for (i in 4 until data.size step 2) { if (i 1 data.size) break val adcValue ByteBuffer.wrap(data, i, 2).order(ByteOrder.LITTLE_ENDIAN).short.toInt() // 核心校准公式Voltage(mV) (ADC_value / 2^12) * Vref(V) * Gain * 1000 // 假设ADC是12位0-4095实际需查设备Datasheet确认位数 val voltageMv (adcValue / 4095.0f) * vRef * gain * 1000.0f // 时间戳按采样率递推避免依赖设备时间戳因其可能不精准 val timeMs timestamp ((samples.size * 1000L) / sampleRate) samples.add(Pair(timeMs, voltageMv)) } return samples } }关键参数解释vRef 3.3f设备参考电压必须与硬件BOM一致常见值有1.2V、2.5V、3.3Vgain 12.0f前端运放增益由原理图中电阻比决定非软件可调4095.0f12位ADC最大值2^12 - 1若设备用10位ADC则改为1023时间戳处理设备端时间戳常有毫秒级漂移我们用“首包时间戳 样本序号 × 单样本间隔”生成严格等间隔时间轴这对后续FFT分析至关重要。3. 实时心电波形绘制用Canvas硬核渲染拒绝Chart库的性能黑洞3.1 为什么不用MPAndroidChart或AnyChart——帧率与内存的生死线MPAndroidChart在绘制2000点波形时单帧渲染耗时常超80ms远超16ms的60fps阈值且每帧创建大量Path、Paint对象触发GC导致波形“卡顿抖动”。而真实ECG监测要求稳定60fps更新尤其在运动状态下用户手臂微颤波形抖动会被误判为房颤。我们的方案是复用Bitmap Canvas.drawLines()纯CPU渲染零对象分配。// EcgWaveView.kt —— 继承自View class EcgWaveView JvmOverloads constructor( context: Context, attrs: AttributeSet? null, defStyleAttr: Int 0 ) : View(context, attrs, defStyleAttr) { private lateinit var bitmap: Bitmap private lateinit var canvas: Canvas private lateinit var paint: Paint private val points FloatArray(4000) // 预分配4000点2000个(x,y)对 private var pointCount 0 private var scrollOffset 0f private var lastTimestamp 0L init { setupPaint() setLayerType(LAYER_TYPE_HARDWARE, null) // 启用硬件加速 } private fun setupPaint() { paint Paint().apply { color ContextCompat.getColor(context, R.color.ecg_wave_color) strokeWidth 2f isAntiAlias true style Paint.Style.STROKE } } override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) bitmap Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) canvas Canvas(bitmap) // 清空背景为深色减少视觉疲劳 canvas.drawColor(ContextCompat.getColor(context, R.color.ecg_bg)) } /** * 外部调用此方法添加新采样点 * param voltageMv 电压毫伏值范围建议-2000~2000mV对应ECG典型±2mV * param timestamp 时间戳毫秒 */ fun addPoint(voltageMv: Float, timestamp: Long) { if (pointCount points.size / 2) { // 滚动丢弃最老的点整体左移 System.arraycopy(points, 2, points, 0, points.size - 2) pointCount-- } // 计算X坐标等间隔铺满宽度Y坐标电压映射到高度中心为0mV val x (pointCount * width / 200f).coerceAtMost(width.toFloat()) // 200f 每屏显示200ms数据 val y height / 2f - (voltageMv / 2000f * height / 2f) // 2000mV满幅 points[pointCount * 2] x points[pointCount * 2 1] y.coerceIn(0f, height.toFloat()) pointCount lastTimestamp timestamp invalidate() // 触发重绘 } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) if (pointCount 2) return // 复用bitmap只重绘变化区域增量更新 this.canvas.drawColor(ContextCompat.getColor(context, R.color.ecg_bg)) // 清屏 this.canvas.drawLines(points, 0, pointCount * 2, paint) // 一次性画所有线段 canvas.drawBitmap(bitmap, 0f, 0f, null) // 全屏拷贝到View } }注意drawLines()接受float[] points, int offset, int count, Paint paint其中points是[x0,y0,x1,y1,...]交错数组count是点对数量×2。这是性能关键——比循环调用drawLine()快5倍以上。3.2 波形平滑与基线漂移抑制在绘制前做轻量数字滤波原始ECG常含呼吸基线漂移0.1Hz以下和工频干扰50/60Hz直接绘制会导致波形“上下浮动”、“毛刺闪烁”。我们在addPoint()中插入一阶IIR高通滤波截止频率0.5Hz和5点中值滤波// 在EcgWaveView中添加滤波状态 private var lastFiltered 0f private var history FloatArray(5) { 0f } private var historyIndex 0 private fun filterEcg(raw: Float): Float { // 一阶高通滤波y[n] α * (y[n-1] x[n] - x[n-1]), α 0.995 (对应0.5Hz) val alpha 0.995f val y alpha * (lastFiltered raw - lastRaw) lastRaw raw lastFiltered y // 中值滤波取最近5个点排序取中位数 history[historyIndex] y historyIndex (historyIndex 1) % 5 val sorted history.sortedArray() return sorted[2] // 中位数 } // 修改addPoint方法 fun addPoint(voltageMv: Float, timestamp: Long) { val filtered filterEcg(voltageMv) // ... 后续坐标计算使用filtered而非voltageMv }参数选择依据alpha 0.995由α 1 - 2πfc*Ts计算fc0.5Hz,Ts4ms(250Hz采样)得α≈0.9955点中值滤波平衡去噪能力与相位延迟点数过多会导致QRS波群变形。4. 心电诊断算法落地从R波检测到节律分类的端侧轻量化实现4.1 R波检测为什么不用深度学习——资源与确定性的权衡标题中“心电诊断算法”常被误解为“跑个ResNet分类正常/异常”。但Android端GPU算力有限ResNet50推理需200ms无法满足实时波形叠加标注需求。我们采用Pan-Tompkins算法的Kotlin优化版纯CPU、无外部依赖、单次R波检测耗时1ms250Hz下。// PanTompkinsDetector.kt class PanTompkinsDetector( private val sampleRate: Int 250, private val windowSize: Int 200 // 移动窗口大小ms ) { private val integrationWindow (sampleRate * windowSize / 1000).coerceAtLeast(1) private val derivative FloatArray(200) private val squared FloatArray(200) private val integrated FloatArray(200) private var rPeaks mutableListOfInt() private var lastRPeak 0 /** * 检测R波峰值位置索引 * param ecgSamples 归一化后的ECG电压数组-1.0 ~ 1.0 * return ListInt R波在ecgSamples中的索引位置 */ fun detectRPeaks(ecgSamples: FloatArray): ListInt { rPeaks.clear() if (ecgSamples.size 200) return rPeaks // 步骤15点差分近似导数 for (i in 2 until ecgSamples.size - 2) { derivative[i] (ecgSamples[i 2] - ecgSamples[i - 2] 2 * (ecgSamples[i 1] - ecgSamples[i - 1])) / 8f } // 步骤2平方增强R波 for (i in derivative.indices) { squared[i] derivative[i].coerceAtLeast(0f) * derivative[i].coerceAtLeast(0f) } // 步骤3移动窗口积分低通 for (i in integrationWindow until squared.size) { var sum 0f for (j in i - integrationWindow 1..i) { sum squared[j] } integrated[i] sum / integrationWindow } // 步骤4自适应阈值检测动态调整 var threshold 0.05f var noiseLevel 0.01f for (i in 100 until integrated.size - 10) { if (integrated[i] threshold integrated[i] integrated[i - 1] integrated[i] integrated[i 1]) { // 确认是局部极大值 if (i - lastRPeak sampleRate / 2) { // 最小RR间期0.5s120bpm rPeaks.add(i) lastRPeak i // 更新阈值R波后提升阈值防止T波误检 threshold integrated[i] * 0.5f noiseLevel integrated[i] * 0.1f } } else if (integrated[i] noiseLevel) { // 噪声期降低阈值 threshold threshold * 0.99f noiseLevel noiseLevel * 0.995f } } return rPeaks } }算法要点差分核[-1,-2,0,2,1]比简单[1,-1]更抗噪平方操作使R波能量远高于P/T波自适应阈值避免固定阈值在运动伪影下失效lastRPeak约束确保RR间期合理过滤肌电干扰假峰。4.2 从R-R间期生成节律诊断窦性心律、窦性心动过速、室性早搏的规则引擎检测到R峰后计算相邻R峰时间差RR间期用规则引擎输出初步诊断// RhythmAnalyzer.kt class RhythmAnalyzer( private val sampleRate: Int 250, private val normalRrMinMs: Int 600, // 100bpm对应600ms private val normalRrMaxMs: Int 1000, // 60bpm对应1000ms private val pvcThresholdMs: Int 400 // PVC的RR间期400ms ) { private val rrIntervals mutableListOfLong() private var lastRPeakTime 0L fun analyzeRPeaks(rPeakIndices: ListInt, startTimeMs: Long): DiagnosisResult? { if (rPeakIndices.size 3) return null // 计算RR间期毫秒 rrIntervals.clear() for (i in 1 until rPeakIndices.size) { val msPerSample 1000.0 / sampleRate val intervalMs (rPeakIndices[i] - rPeakIndices[i - 1]) * msPerSample rrIntervals.add(intervalMs.toLong()) } // 统计指标 val avgRr rrIntervals.average().toLong() val stdRr sqrt(rrIntervals.map { it.toDouble() }.map { (it - avgRr) * (it - avgRr) }.average()) // 规则判断 return when { avgRr in normalRrMinMs..normalRrMaxMs stdRr 50 - DiagnosisResult(窦性心律, RR间期均值${avgRr}ms变异度低节律规整) avgRr normalRrMinMs - DiagnosisResult(窦性心动过速, 平均心率${60000 / avgRr}bpm建议休息后复查) rrIntervals.any { it pvcThresholdMs } - DiagnosisResult(疑似室性早搏(PVC), 检测到短联律间期RR${pvcThresholdMs}ms请结合临床判断) else - DiagnosisResult(节律异常, RR间期变异度过高标准差${stdRr.toInt()}ms可能存在房颤等不规则节律) } } } data class DiagnosisResult( val primary: String, val description: String )临床合理性验证normalRrMinMs600对应100bpm覆盖青少年静息上限pvcThresholdMs400对应150bpm符合PVC联律间期常500ms的特征标准差stdRr50是窦性心律的典型变异度健康成人SDNN常50ms。5. 健康报告生成与医疗数据分析PDF导出与趋势可视化5.1 用AndroidPdfWriter生成离线PDF报告不依赖网络不申请存储权限标题中“健康报告生成”需输出可打印、可存档的PDF。我们避开需要WRITE_EXTERNAL_STORAGE权限的方案用AndroidPdfWriter库纯Java无JNI将报告写入应用私有目录// HealthReportGenerator.kt class HealthReportGenerator(private val context: Context) { fun generateReport( ecgData: ListEcgSample, diagnosis: DiagnosisResult, sessionTime: Long ): File? { val pdf PdfWriter.getInstance( context.cacheDir.resolve(health_report_${System.currentTimeMillis()}.pdf) ) pdf.openDocument() // 添加标题 val titleFont Font(Font.FontFamily.HELVETICA, 16f, Font.BOLD) pdf.addParagraph(个人健康监测报告, titleFont) // 添加诊断摘要 val bodyFont Font(Font.FontFamily.HELVETICA, 12f, Font.NORMAL) pdf.addParagraph(【诊断结论】${diagnosis.primary}, bodyFont) pdf.addParagraph(【详细描述】${diagnosis.description}, bodyFont) // 添加波形图截取当前屏幕Bitmap val waveView getWaveViewFromActivity() // 从Activity获取当前波形View val bitmap waveView?.drawingCache ?: return null pdf.addImage(bitmap) // 添加统计表格 val table PdfPTable(2) table.widthPercentage 100f table.addCell(测量时间) table.addCell(DateUtils.formatDateTime(sessionTime)) table.addCell(总心拍数) table.addCell(${ecgData.size}) table.addCell(平均心率) table.addCell(${calculateAvgHeartRate(ecgData)} bpm) pdf.addTable(table) pdf.closeDocument() return pdf.file } private fun calculateAvgHeartRate(samples: ListEcgSample): Int { // 简单算法统计10秒内R波数 × 6 val tenSecSamples samples.takeIf { it.size 2500 } ?: samples return (tenSecSamples.size / 2500f * 60).toInt() } }注意PdfWriter库需在build.gradle中添加implementation com.github.barteksc:android-pdf-viewer:3.2.0-beta.1其PdfWriter类是轻量PDF生成器不依赖WebView或网络。5.2 医疗数据分析用Room持久化长期趋势支持SQLite FTS5全文检索“医疗数据分析”不是指训练模型而是让用户能查“上周三下午三点的心率最高是多少”。我们用RoomFTS5建立健康数据仓库// EcgEntity.kt Entity(tableName ecg_records) data class EcgRecord( PrimaryKey(autoGenerate true) val id: Long 0, val timestamp: Long, // 开始时间 val durationMs: Int, // 持续毫秒 val avgHeartRate: Int, val diagnosis: String, val rawEcgData: ByteArray // 压缩后的原始采样点zlib ) // EcgDao.kt Dao interface EcgDao { Insert(onConflict OnConflictStrategy.REPLACE) suspend fun insert(record: EcgRecord): Long // FTS5全文检索支持按诊断关键词搜索 Query(SELECT * FROM ecg_records_fts WHERE ecg_records_fts MATCH :query ORDER BY timestamp DESC LIMIT 20) suspend fun searchByDiagnosis(query: String): ListEcgRecord // 趋势查询按天聚合平均心率 Query(SELECT date(timestamp/1000, unixepoch, localtime) as day, AVG(avgHeartRate) as avg_hr FROM ecg_records GROUP BY day ORDER BY day DESC LIMIT 30) suspend fun getHeartRateTrend(): ListDayTrend } data class DayTrend( val day: String, val avg_hr: Double ) // 在Database中启用FTS5 Database( entities [EcgRecord::class], version 1, exportSchema false ) abstract class HealthDatabase : RoomDatabase() { abstract fun ecgDao(): EcgDao companion object { Volatile private var INSTANCE: HealthDatabase? null fun getDatabase(context: Context): HealthDatabase { return INSTANCE ?: synchronized(this) { val instance Room.databaseBuilder( context.applicationContext, HealthDatabase::class.java, health_database ) .addCallback(object : RoomDatabase.Callback() { override fun onCreate(db: SupportSQLiteDatabase) { super.onCreate(db) // 创建FTS5虚拟表 db.execSQL( CREATE VIRTUAL TABLE ecg_records_fts USING fts5( diagnosis, contentecg_records ) .trimIndent()) } }) .build() INSTANCE instance instance } } } }FTS5优势MATCH查询比LIKE %关键词%快10倍以上且支持词干提取如搜“心动过速”匹配“过速”contentecg_records将FTS5表与主表绑定插入主表时自动同步索引不需要额外维护索引语句Room迁移脚本极简。6. 避坑指南BLE心电开发中5个让你凌晨三点还在抓头发的真问题6.1 现象波形图左右颠倒R波变成向下的尖峰原因硬件ADC输入极性接反或设备固件将正负电压映射颠倒。例如真实R波是正向电压但设备发送的ADC值随电压升高而减小。解决在EcgDataParser.parseEcgSamples()中对voltageMv乘以-1。不要在硬件层改——这很可能是设备设计如此如某些单电源运放电路需与硬件团队确认Datasheet中“Output Polarity”字段。6.2 现象Android 12设备扫描不到任何BLE设备日志显示“App not allowed to scan”原因Manifest中漏声明uses-permission android:nameandroid.permission.BODY_SENSORS /心率服务必需或uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION /扫描必需且未在运行时请求。解决在MainActivity中onCreate()后立即检查并请求权限if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) { if (ActivityCompat.checkSelfPermission(this, Manifest.permission.BODY_SENSORS) ! PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.BODY_SENSORS), 101) } }6.3 现象连接成功后收不到通知onCharacteristicChanged()从不触发原因设备要求先写入CCC Descriptor0x2902但部分设备如某品牌ECG贴片的CCC Descriptor UUID不是标准00002902-0000-1000-8000-00805f9b34fb而是厂商自定义UUID。解决用nRF Connect App连接设备手动浏览所有Descriptor找到实际的CCC UUID替换代码中UUID.fromString(00002902-...)。6.4 现象波形绘制出现“阶梯状”锯齿不是平滑曲线原因EcgWaveView.addPoint()中X坐标计算用了整数除法width / 200f但200f是硬编码的“每屏200ms”当采样率非250Hz时时间轴压缩失真。解决将200f改为动态值screenDurationMs.toFloat()并在Activity中根据实际采样率设置waveView.screenDurationMs 200 // 默认200ms // 若设备支持500Hz则设为100ms以显示更多细节6.5 现象生成的PDF报告在某些PDF阅读器中显示空白图片原因PdfWriter.addImage()传入的Bitmap是ARGB_8888格式但部分阅读器只支持RGB_565。解决转换Bitmap配置val rgbBitmap Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.RGB_565) val canvas Canvas(rgbBitmap) canvas.drawBitmap(bitmap, 0f, 0f, null) pdf.addImage(rgbBitmap)7. 把“心电诊断”从功能点变成产品护城河一个我坚持了三年的习惯很多人做完R波检测就停了觉得“能标出R点就是诊断”。但真正的价值在诊断结果的可解释性与临床衔接。我给自己定的铁律是每一条诊断结论必须附带可验证的原始数据锚点。比如当报告说“窦性心动过速”PDF里不仅要写“平均心率112bpm”还要嵌入一个子图截取RR间期序列的直方图并标出计算该均值的10秒原始波形片段。这样当用户质疑“为什么说我心跳快”你可以立刻打开APP滑动到那个时间点放大波形指着两个R峰说“看这里间距只有534ms换算就是112bpm”。这个习惯逼我重构了整个数据流水线EcgRecord实体必须存原始采样点哪怕占空间RhythmAnalyzer必须返回ListRPeak而不仅是字符串结论HealthReportGenerator必须有能力从任意EcgRecord中抽取出指定毫秒范围的本文还有配套的精品资源点击获取