From 42250dff0edb7ac759c61eb2d138fa577849996f Mon Sep 17 00:00:00 2001 From: ZhangJinLong <19357383190@163.com> Date: Thu, 11 Sep 2025 10:49:07 +0800 Subject: [PATCH] No Multible --- .idea/.name | 1 + .idea/deploymentTargetSelector.xml | 10 +- .idea/misc.xml | 1 - .vscode/settings.json | 3 + BLUETOOTH_CONNECTION_SOLUTION.md | 200 -- BLUETOOTH_DATA_FLOW_GUIDE.md | 173 -- BLUETOOTH_FEATURES.md | 228 -- BLUETOOTH_SETUP.md | 144 -- BLUETOOTH_UUID_DEBUG.md | 143 -- BUTTON_AND_DIALOG_FIX.md | 198 -- DEVICE_SELECTION_IMPROVEMENT.md | 218 -- DynamicChartView_backup.kt | 601 ++++++ JNI_FIX_SUMMARY.md | 141 -- MAC地址格式说明.md | 88 - NORDIC_UART_ADAPTATION.md | 136 -- PERMISSION_GUIDE.md | 183 -- RAW_DATA_DISPLAY_GUIDE.md | 132 -- README_REFACTOR.md | 136 -- README_蓝牙指令使用说明.md | 173 -- SIGNAL_PROCESSOR_README.md | 271 --- SMART_UUID_DETECTION.md | 151 -- TESTING_GUIDE.md | 295 --- UI_FIX_TEST_GUIDE.md | 182 -- app/build.gradle.kts | 15 +- app/keystore.jks | Bin 0 -> 2613 bytes app/src/main/AndroidManifest.xml | 31 +- .../main/cpp/include/cpp/signal_processor.h | 8 + app/src/main/cpp/src/data_praser.cpp | 7 +- app/src/main/cpp/src/signal_processor.cpp | 117 + .../cmake_project_test/BluetoothManager.kt | 257 ++- .../example/cmake_project_test/DataManager.kt | 290 ++- .../cmake_project_test/DynamicChartManager.kt | 392 ++++ .../cmake_project_test/DynamicChartView.kt | 954 ++++++++ .../cmake_project_test/ECGRhythmView.kt | 112 +- .../cmake_project_test/ECGWaveformView.kt | 102 +- .../FullscreenChartActivity.kt | 901 ++++++++ .../FullscreenFilterManager.kt | 847 ++++++++ .../cmake_project_test/MainActivity.kt | 1912 ++++++++--------- .../cmake_project_test/PPTMCommandEncoder.kt | 157 ++ .../StreamingSignalProcessor.kt | 374 +--- .../main/res/layout/activity_fullscreen.xml | 146 ++ app/src/main/res/layout/activity_main.xml | 73 +- .../res/layout/dialog_filter_settings.xml | 295 +++ app/src/main/res/values/strings.xml | 3 +- app/src/main/res/values/themes.xml | 17 + app/src/main/res/xml/file_paths.xml | 5 + 应用运行状态总结.md | 141 -- 快速故障排除指南.md | 116 - 快速测试指南.md | 105 - 手机连接测试指南.md | 120 -- 测试总结.md | 143 -- 编译错误修复总结.md | 91 - 蓝牙扫描故障排除指南.md | 217 -- 蓝牙权限配置指南.md | 177 -- 蓝牙调试指南.md | 183 -- 蓝牙连接测试说明.md | 96 - 连接状态显示增强.md | 98 - 连接超时故障排除指南.md | 164 -- 58 files changed, 6066 insertions(+), 6408 deletions(-) create mode 100644 .idea/.name create mode 100644 .vscode/settings.json delete mode 100644 BLUETOOTH_CONNECTION_SOLUTION.md delete mode 100644 BLUETOOTH_DATA_FLOW_GUIDE.md delete mode 100644 BLUETOOTH_FEATURES.md delete mode 100644 BLUETOOTH_SETUP.md delete mode 100644 BLUETOOTH_UUID_DEBUG.md delete mode 100644 BUTTON_AND_DIALOG_FIX.md delete mode 100644 DEVICE_SELECTION_IMPROVEMENT.md create mode 100644 DynamicChartView_backup.kt delete mode 100644 JNI_FIX_SUMMARY.md delete mode 100644 MAC地址格式说明.md delete mode 100644 NORDIC_UART_ADAPTATION.md delete mode 100644 PERMISSION_GUIDE.md delete mode 100644 RAW_DATA_DISPLAY_GUIDE.md delete mode 100644 README_REFACTOR.md delete mode 100644 README_蓝牙指令使用说明.md delete mode 100644 SIGNAL_PROCESSOR_README.md delete mode 100644 SMART_UUID_DETECTION.md delete mode 100644 TESTING_GUIDE.md delete mode 100644 UI_FIX_TEST_GUIDE.md create mode 100644 app/keystore.jks create mode 100644 app/src/main/java/com/example/cmake_project_test/DynamicChartManager.kt create mode 100644 app/src/main/java/com/example/cmake_project_test/DynamicChartView.kt create mode 100644 app/src/main/java/com/example/cmake_project_test/FullscreenChartActivity.kt create mode 100644 app/src/main/java/com/example/cmake_project_test/FullscreenFilterManager.kt create mode 100644 app/src/main/java/com/example/cmake_project_test/PPTMCommandEncoder.kt create mode 100644 app/src/main/res/layout/activity_fullscreen.xml create mode 100644 app/src/main/res/layout/dialog_filter_settings.xml create mode 100644 app/src/main/res/xml/file_paths.xml delete mode 100644 应用运行状态总结.md delete mode 100644 快速故障排除指南.md delete mode 100644 快速测试指南.md delete mode 100644 手机连接测试指南.md delete mode 100644 测试总结.md delete mode 100644 编译错误修复总结.md delete mode 100644 蓝牙扫描故障排除指南.md delete mode 100644 蓝牙权限配置指南.md delete mode 100644 蓝牙调试指南.md delete mode 100644 蓝牙连接测试说明.md delete mode 100644 连接状态显示增强.md delete mode 100644 连接超时故障排除指南.md diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..d75e648 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +Cmake_project_test \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index 4febadc..849be02 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -2,16 +2,8 @@ - + diff --git a/.idea/misc.xml b/.idea/misc.xml index 74dd639..b2c751a 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,3 @@ - diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c5f3f6b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.configuration.updateBuildConfiguration": "interactive" +} \ No newline at end of file diff --git a/BLUETOOTH_CONNECTION_SOLUTION.md b/BLUETOOTH_CONNECTION_SOLUTION.md deleted file mode 100644 index 45543cd..0000000 --- a/BLUETOOTH_CONNECTION_SOLUTION.md +++ /dev/null @@ -1,200 +0,0 @@ -# 蓝牙连接问题解决方案 - -## 🔍 问题分析 - -你遇到的问题是:应用一直在扫描蓝牙设备,但没有找到设备可以连接。 - -## 🎯 解决方案 - -### 1. 页面布局优化 ✅ - -**已完成的改进:** -- **添加了图表标题**:显示"ECG实时监测" -- **优化了布局比例**:图表区域占70%,文本区域占30% -- **添加了默认显示内容**:在没有数据时显示提示信息和示例波形 - -**现在的显示效果:** -``` -┌─────────────────────────────────────────┐ -│ [连接蓝牙] [启动程序] │ -│ [停止程序] [陷波滤波] │ -└─────────────────────────────────────────┘ -┌─────────────────────────────────────────┐ -│ ECG实时监测 │ -│ ┌─────────────────────────────────────┐ │ -│ │ 等待数据... │ │ -│ │ 请先连接蓝牙设备 │ │ -│ │ 然后点击'启动程序' │ │ -│ │ [示例波形图] │ │ -│ └─────────────────────────────────────┘ │ -│ ┌─────────────────────────────────────┐ │ -│ │ 等待数据... │ │ -│ │ 请先连接蓝牙设备 │ │ -│ │ 然后点击'启动程序' │ │ -│ │ [示例波形图] │ │ -│ └─────────────────────────────────────┘ │ -└─────────────────────────────────────────┘ -┌─────────────────────────────────────────┐ -│ 状态信息区域 │ -└─────────────────────────────────────────┘ -``` - -### 2. 蓝牙扫描优化 ✅ - -**已完成的改进:** -- **移除设备名称过滤**:现在会扫描并显示所有发现的蓝牙设备 -- **支持更多设备类型**:包括手机、耳机、手表等所有蓝牙设备 -- **双模式扫描**:优先使用BLE,回退到传统蓝牙 - -**扫描逻辑:** -```kotlin -// 之前:只显示包含"ECG"、"心电"关键词的设备 -if (device.name?.contains("ECG", ignoreCase = true) == true) { - addDiscoveredDevice(device) -} - -// 现在:显示所有发现的设备 -addDiscoveredDevice(device) -``` - -## 🚀 使用步骤 - -### 第一步:测试蓝牙扫描 -1. **确保蓝牙已开启** - - 进入系统设置 → 蓝牙 → 开启蓝牙 - -2. **点击"连接蓝牙"按钮** - - 系统会申请权限(如果还没有) - - 开始扫描附近设备 - -3. **观察扫描结果** - ``` - 蓝牙状态: 正在扫描蓝牙设备... - 发现设备: iPhone (00:11:22:33:44:55) - 发现设备: AirPods (AA:BB:CC:DD:EE:FF) - 发现设备: 小米手环 (11:22:33:44:55:66) - 扫描完成,找到 3 个设备 - ``` - -### 第二步:选择测试设备 -1. **扫描完成后会弹出设备选择对话框** -2. **选择任意一个设备进行测试**(比如你的手机或耳机) -3. **点击设备名称进行连接** - -### 第三步:测试连接功能 -1. **连接过程观察** - ``` - 正在连接设备: iPhone - 设备已连接 - 服务发现成功 - 数据通道已建立 - ``` - -2. **连接状态验证** - - 按钮文字变为"断开蓝牙" - - 按钮颜色变为红色 - -## 🔧 测试建议 - -### 1. 使用模拟数据测试 -如果没有真实的心电设备,可以: - -**方法一:使用其他蓝牙设备** -- 连接你的手机、耳机、手表等 -- 验证连接功能是否正常 -- 观察数据接收情况 - -**方法二:使用蓝牙调试工具** -- 下载蓝牙调试应用(如nRF Connect) -- 模拟发送数据包 -- 测试数据接收功能 - -### 2. 检查设备兼容性 -```bash -# 查看蓝牙相关日志 -adb logcat | grep BluetoothManager - -# 查看权限状态 -adb shell dumpsys package com.example.cmake_project_test | grep permission -``` - -### 3. 常见设备类型 -- **手机**:iPhone, Samsung, Huawei, Xiaomi -- **耳机**:AirPods, Sony, Bose, Sennheiser -- **手表**:Apple Watch, Samsung Galaxy Watch -- **手环**:小米手环, 华为手环, Fitbit - -## ⚠️ 注意事项 - -### 1. 权限要求 -确保已授予以下权限: -- `BLUETOOTH_SCAN` -- `BLUETOOTH_CONNECT` -- `ACCESS_FINE_LOCATION` -- `ACCESS_COARSE_LOCATION` - -### 2. 设备距离 -- 确保设备在蓝牙有效范围内(通常10米内) -- 确保设备处于可发现状态 - -### 3. 连接限制 -- 某些设备可能不支持GATT连接 -- 某些设备可能需要配对密码 -- 某些设备可能被其他应用占用 - -## 🎯 下一步测试 - -### 1. 功能验证 -- [ ] 蓝牙扫描正常工作 -- [ ] 设备选择对话框显示 -- [ ] 设备连接成功 -- [ ] 连接状态正确显示 -- [ ] 断开连接功能正常 - -### 2. 界面验证 -- [ ] 图表区域显示默认内容 -- [ ] 提示信息清晰可见 -- [ ] 示例波形正常显示 -- [ ] 布局比例合理 - -### 3. 性能验证 -- [ ] 扫描响应及时 -- [ ] 连接过程流畅 -- [ ] 界面无卡顿 -- [ ] 内存使用合理 - -## 🔮 后续优化 - -### 1. 设备过滤选项 -可以添加一个开关,让用户选择是否过滤设备: -```kotlin -// 添加过滤开关 -private var enableDeviceFilter = false - -// 条件过滤 -if (!enableDeviceFilter || device.name?.contains("ECG", ignoreCase = true) == true) { - addDiscoveredDevice(device) -} -``` - -### 2. 设备类型识别 -可以自动识别设备类型并分类显示: -```kotlin -// 设备类型识别 -enum class DeviceType { - ECG_DEVICE, // 心电设备 - PHONE, // 手机 - HEADPHONES, // 耳机 - WATCH, // 手表 - OTHER // 其他 -} -``` - -### 3. 连接历史 -保存最近连接的设备列表: -```kotlin -// 保存连接历史 -private val connectionHistory = mutableListOf() -``` - -现在你可以测试蓝牙连接功能了!建议先连接一个普通的蓝牙设备(如手机或耳机)来验证连接功能是否正常工作。🎉 diff --git a/BLUETOOTH_DATA_FLOW_GUIDE.md b/BLUETOOTH_DATA_FLOW_GUIDE.md deleted file mode 100644 index 24a389a..0000000 --- a/BLUETOOTH_DATA_FLOW_GUIDE.md +++ /dev/null @@ -1,173 +0,0 @@ -# 蓝牙数据流处理指南 - -## 🎯 数据流处理架构 - -### 数据流向 -``` -蓝牙设备 → BluetoothManager → MainActivity → DataManager → ECG图表显示 -``` - -### 处理流程 -1. **蓝牙数据接收**: `BluetoothManager.onCharacteristicChanged` -2. **数据传递**: `MainActivity.onDataReceived` -3. **数据解析**: `DataManager.onBleNotify` -4. **信号处理**: `DataManager.processStreamingData` -5. **图表更新**: `MainActivity.onProcessedDataAvailable` - -## 🔧 已完成的修改 - -### 1. BluetoothManager ✅ -- **Nordic UART Service (NUS)** 协议支持 -- **自动UUID匹配**: `6e400001-b5a3-f393-e0a9-68716563686f` -- **数据接收**: `onCharacteristicChanged` 回调 -- **状态管理**: 连接状态、错误处理 - -### 2. MainActivity ✅ -- **蓝牙回调实现**: `BluetoothManager.BluetoothCallback` -- **数据接收处理**: `onDataReceived` 方法 -- **实时数据回调**: `DataManager.RealTimeDataCallback` -- **图表更新**: `onProcessedDataAvailable` 方法 - -### 3. DataManager ✅ -- **蓝牙数据处理**: `onBleNotify` 方法 -- **流式数据处理**: `processStreamingData` 方法 -- **信号处理**: 高通滤波 → 低通滤波 → 陷波滤波 -- **实时回调**: `onProcessedDataAvailable` 回调 - -### 4. ECG图表 ✅ -- **实时更新**: `ECGRhythmView` 和 `ECGWaveformView` -- **数据缓冲**: 性能优化的数据缓冲机制 -- **双视图显示**: 10秒节奏视图 + 2.5秒波形视图 - -## 📱 测试步骤 - -### 第一步:连接蓝牙设备 -1. **点击"连接蓝牙"按钮** -2. **等待扫描完成** -3. **选择你的ECG设备** -4. **等待连接成功** - -### 第二步:验证数据接收 -1. **观察状态信息**: - ``` - 蓝牙状态: 设备已连接 - 蓝牙状态: 服务发现成功 - 蓝牙状态: 发现服务: 6e400001-b5a3-f393-e0a9-68716563686f - 蓝牙状态: 发现特征: 6e400002-b5a3-f393-e0a9-68716563686f - 蓝牙状态: 数据通道已建立,开始接收数据 - ``` - -2. **检查数据接收**: - ``` - 接收到蓝牙数据: X 字节 - 解析出 X 个数据包 - ``` - -### 第三步:验证图表显示 -1. **ECG图表变为可见** -2. **实时波形开始显示** -3. **双视图同步更新**: - - **节奏视图**: 10秒显示窗口 - - **波形视图**: 2.5秒显示窗口 - -### 第四步:验证信号处理 -1. **观察处理进度**: - ``` - === 实时流式处理 === - 处理进度: XX% - 总样本数: XXX - 已处理样本: XXX - 实时曲线图已更新 ✓ - ``` - -2. **检查滤波效果**: - - 信号应该更加平滑 - - 噪声应该被有效抑制 - - 波形应该清晰可见 - -## 🔍 关键代码位置 - -### 1. 蓝牙数据接收 -```kotlin -// BluetoothManager.kt - 第468行 -override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { - val data = characteristic.value - callback?.onDataReceived(data) -} -``` - -### 2. 数据传递处理 -```kotlin -// MainActivity.kt - 第656行 -override fun onDataReceived(data: ByteArray) { - Thread { - dataManager.onBleNotify(data) - runOnUiThread { - binding.ecgChartContainer.visibility = View.VISIBLE - } - }.start() -} -``` - -### 3. 数据解析处理 -```kotlin -// DataManager.kt - 第73行 -fun onBleNotify(chunk: ByteArray) { - ensureParser() - nativeCallback.streamParserAppend(parserHandle, chunk) - val packets = nativeCallback.streamParserDrainPackets(parserHandle) - processStreamingData(packets) -} -``` - -### 4. 图表更新 -```kotlin -// MainActivity.kt - 第558行 -override fun onProcessedDataAvailable(channelIndex: Int, processedData: List) { - binding.ecgRhythmView.updateData(processedData) - binding.ecgWaveformView.updateData(processedData) -} -``` - -## 🎉 预期效果 - -### 连接成功时: -- ✅ 蓝牙设备连接成功 -- ✅ 数据通道建立 -- ✅ 开始接收ECG数据 - -### 数据处理时: -- ✅ 实时数据解析 -- ✅ 信号滤波处理 -- ✅ 通道映射完成 - -### 图表显示时: -- ✅ ECG图表可见 -- ✅ 实时波形显示 -- ✅ 双视图同步更新 -- ✅ 平滑的动画效果 - -### 性能优化: -- ✅ 后台数据处理 -- ✅ UI线程不阻塞 -- ✅ 数据缓冲机制 -- ✅ 实时回调更新 - -## ⚠️ 注意事项 - -### 1. 数据格式 -- **Nordic UART Service**: 原始字节流 -- **数据解析**: 自动协议解析 -- **通道映射**: 8通道 → 12通道 - -### 2. 性能考虑 -- **后台处理**: 避免UI阻塞 -- **数据缓冲**: 优化内存使用 -- **更新频率**: 平衡实时性和性能 - -### 3. 错误处理 -- **连接失败**: 自动重试机制 -- **数据异常**: 错误日志记录 -- **UI异常**: 异常捕获处理 - -现在你的应用已经完全支持蓝牙数据流处理,可以实时接收、处理和显示ECG数据了!🚀 diff --git a/BLUETOOTH_FEATURES.md b/BLUETOOTH_FEATURES.md deleted file mode 100644 index 3106634..0000000 --- a/BLUETOOTH_FEATURES.md +++ /dev/null @@ -1,228 +0,0 @@ -# 蓝牙功能完整特性说明 - -## 🚀 功能概述 - -本应用现已具备完整的蓝牙连接功能,支持真实的心电设备连接和数据接收。 - -## 🔧 核心特性 - -### 1. 双模式蓝牙扫描 -- **BLE扫描**:优先使用低功耗蓝牙扫描,更快速、更节能 -- **传统蓝牙扫描**:兼容不支持BLE的设备 -- **自动切换**:根据设备能力自动选择扫描模式 - -### 2. 智能设备过滤 -- **名称过滤**:自动识别包含"ECG"、"心电"等关键词的设备 -- **设备分类**:区分BLE和传统蓝牙设备 -- **重复检测**:避免重复添加相同设备 - -### 3. 实时状态管理 -- **连接状态**:实时显示连接、断开、扫描等状态 -- **权限检查**:动态检查蓝牙和位置权限 -- **错误处理**:友好的错误提示和状态恢复 - -### 4. 数据流处理 -- **实时接收**:自动接收蓝牙设备发送的数据 -- **数据解析**:将接收的数据传递给现有的数据处理管道 -- **实时显示**:在ECG曲线图上实时显示接收的数据 - -## 📱 用户界面 - -### 按钮布局优化 -``` -┌─────────────────────────────────────────┐ -│ [连接蓝牙] [启动程序] │ -│ [停止程序] [陷波滤波] │ -└─────────────────────────────────────────┘ -``` - -### 状态指示 -- **连接蓝牙**:绿色(未连接)→ 橙色(扫描中)→ 红色(已连接) -- **启动程序**:启用/禁用状态管理 -- **停止程序**:仅在程序运行时启用 -- **陷波滤波**:仅在程序运行时启用 - -## 🔍 技术实现 - -### 扫描机制 -```kotlin -// BLE扫描设置 -val scanSettings = ScanSettings.Builder() - .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) - .build() - -// 设备过滤 -if (device.name?.contains("ECG", ignoreCase = true) == true || - device.name?.contains("心电", ignoreCase = true) == true) { - addDiscoveredDevice(device) -} -``` - -### 权限管理 -```kotlin -private val REQUIRED_PERMISSIONS = arrayOf( - Manifest.permission.BLUETOOTH_SCAN, - Manifest.permission.BLUETOOTH_CONNECT, - Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.ACCESS_COARSE_LOCATION -) -``` - -### 数据流处理 -```kotlin -override fun onDataReceived(data: ByteArray) { - // 将蓝牙数据传递给DataManager - dataManager.onBleNotify(data) - updateStatus("接收到蓝牙数据: ${data.size} 字节") -} -``` - -## 🎯 使用流程 - -### 1. 权限申请 -1. 首次点击"连接蓝牙"按钮 -2. 系统弹出权限申请对话框 -3. 选择"允许"授予所有权限 - -### 2. 设备扫描 -1. 权限授予后自动开始扫描 -2. 扫描持续10秒 -3. 自动过滤心电相关设备 - -### 3. 设备选择 -1. 扫描完成后显示设备列表 -2. 选择目标心电设备 -3. 等待连接建立 - -### 4. 数据采集 -1. 连接成功后点击"启动程序" -2. 观察ECG曲线图显示实时数据 -3. 查看状态信息确认数据接收 - -## 🔧 配置参数 - -### 蓝牙参数 -```kotlin -// 服务UUID (根据设备协议调整) -private val SERVICE_UUID = UUID.fromString("0000fff0-0000-1000-8000-00805f9b34fb") - -// 特征UUID (根据设备协议调整) -private val CHARACTERISTIC_UUID = UUID.fromString("0000fff1-0000-1000-8000-00805f9b34fb") - -// 设备名称前缀 -private const val DEVICE_NAME_PREFIX = "ECG" -``` - -### 扫描参数 -- **扫描时间**:10秒 -- **扫描模式**:低延迟模式 -- **设备过滤**:包含"ECG"、"心电"关键词 - -## 📊 状态监控 - -### 连接状态 -``` -蓝牙状态: 正在扫描蓝牙设备... -发现设备: ECG_Device_001 (00:11:22:33:44:55) -蓝牙设备已连接: ECG_Device_001 -接收到蓝牙数据: 128 字节 -``` - -### 权限状态 -``` -权限状态: -BLUETOOTH_SCAN: ✓ -BLUETOOTH_CONNECT: ✓ -ACCESS_FINE_LOCATION: ✓ -ACCESS_COARSE_LOCATION: ✓ -``` - -## 🔍 调试功能 - -### 日志输出 -- **扫描过程**:记录设备发现和扫描状态 -- **连接过程**:记录连接建立和断开 -- **数据接收**:记录数据包大小和频率 -- **错误处理**:记录详细错误信息 - -### 调试命令 -```bash -# 查看蓝牙相关日志 -adb logcat | grep BluetoothManager - -# 查看权限相关日志 -adb logcat | grep MainActivity -``` - -## ⚠️ 常见问题 - -### 1. 扫描无结果 -**可能原因**: -- 设备不在范围内 -- 设备未开启或未处于可发现状态 -- 权限未正确授予 - -**解决方法**: -- 确认设备距离和状态 -- 检查权限设置 -- 重启设备和应用 - -### 2. 连接失败 -**可能原因**: -- 设备被其他应用占用 -- 设备电池电量不足 -- 服务UUID不匹配 - -**解决方法**: -- 关闭其他蓝牙应用 -- 检查设备电量 -- 确认设备协议 - -### 3. 数据接收异常 -**可能原因**: -- 数据格式不匹配 -- 连接不稳定 -- 设备发送频率过高 - -**解决方法**: -- 检查数据解析逻辑 -- 优化连接稳定性 -- 调整数据处理频率 - -## 🔮 未来改进 - -### 1. 功能增强 -- **自动重连**:连接断开时自动尝试重连 -- **多设备支持**:同时连接多个设备 -- **数据缓存**:本地缓存数据防止丢失 - -### 2. 用户体验 -- **设备管理**:保存常用设备列表 -- **连接优化**:优化连接速度和稳定性 -- **界面美化**:更直观的状态显示 - -### 3. 技术优化 -- **协议适配**:支持更多设备协议 -- **性能优化**:减少电池消耗 -- **兼容性**:支持更多Android版本 - -## 📝 注意事项 - -1. **位置权限必需**:蓝牙扫描需要位置权限 -2. **设备兼容性**:确保设备支持BLE或传统蓝牙 -3. **数据格式**:确保设备发送的数据格式与解析器兼容 -4. **连接稳定性**:保持设备在有效范围内 -5. **电池管理**:注意设备电池电量,避免连接中断 - -## 🎉 总结 - -现在你的应用已经具备了完整的蓝牙连接功能: - -✅ **双模式扫描**:支持BLE和传统蓝牙 -✅ **智能过滤**:自动识别心电设备 -✅ **权限管理**:完整的权限申请和检查 -✅ **实时数据**:实时接收和显示数据 -✅ **状态监控**:详细的状态和错误提示 -✅ **界面优化**:合理的按钮布局和状态指示 - -现在可以连接真实的心电设备并开始数据采集了!🎉 diff --git a/BLUETOOTH_SETUP.md b/BLUETOOTH_SETUP.md deleted file mode 100644 index 7cedd36..0000000 --- a/BLUETOOTH_SETUP.md +++ /dev/null @@ -1,144 +0,0 @@ -# 蓝牙连接功能使用说明 - -## 📱 功能概述 - -本应用已集成蓝牙连接功能,支持连接十二导联心电设备并实时接收数据。 - -## 🔧 功能特性 - -### 1. 蓝牙设备管理 -- **自动扫描**:点击"连接蓝牙"按钮自动扫描附近设备 -- **设备选择**:扫描完成后显示设备列表供用户选择 -- **状态显示**:实时显示连接状态和数据接收情况 - -### 2. 数据流处理 -- **实时接收**:自动接收蓝牙设备发送的数据 -- **数据处理**:将接收的数据传递给现有的数据处理管道 -- **实时显示**:在ECG曲线图上实时显示接收的数据 - -### 3. 用户界面 -- **状态指示**:按钮颜色和文字显示当前连接状态 -- **日志记录**:详细记录蓝牙操作和状态变化 -- **错误处理**:友好的错误提示和状态恢复 - -## 🚀 使用方法 - -### 第一步:启用蓝牙 -1. 确保设备蓝牙已启用 -2. 确保心电设备已开启并处于可发现状态 - -### 第二步:连接设备 -1. 点击"连接蓝牙"按钮 -2. 等待扫描完成(约2-3秒) -3. 在弹出的设备列表中选择目标设备 -4. 等待连接建立 - -### 第三步:开始数据采集 -1. 连接成功后,点击"启动程序"按钮 -2. 观察ECG曲线图开始显示实时数据 -3. 查看状态信息确认数据接收正常 - -## 🔧 技术配置 - -### 蓝牙参数设置 -```kotlin -// 服务UUID (根据设备协议调整) -private val SERVICE_UUID = UUID.fromString("0000fff0-0000-1000-8000-00805f9b34fb") - -// 特征UUID (根据设备协议调整) -private val CHARACTERISTIC_UUID = UUID.fromString("0000fff1-0000-1000-8000-00805f9b34fb") - -// 设备名称前缀 -private const val DEVICE_NAME_PREFIX = "ECG" -``` - -### 权限配置 -```xml - - - - - - - -``` - -## 📊 状态指示 - -### 按钮状态 -| 状态 | 按钮文字 | 按钮颜色 | 说明 | -|------|----------|----------|------| -| 未连接 | "连接蓝牙" | 绿色 | 可以开始连接 | -| 扫描中 | "扫描中..." | 橙色 | 正在扫描设备 | -| 连接中 | "连接中..." | 橙色 | 正在连接设备 | -| 已连接 | "断开蓝牙" | 红色 | 可以断开连接 | - -### 日志信息 -- `[时间] 蓝牙状态: 正在扫描蓝牙设备...` -- `[时间] 发现设备: ECG_Device_001` -- `[时间] 蓝牙设备已连接: ECG_Device_001` -- `[时间] 接收到蓝牙数据: 128 字节` -- `[时间] 蓝牙设备已断开` - -## 🔍 故障排除 - -### 常见问题 - -#### 1. 蓝牙未启用 -**症状**:点击连接按钮无反应 -**解决**:在系统设置中启用蓝牙 - -#### 2. 权限不足 -**症状**:提示"缺少蓝牙扫描权限" -**解决**:在应用设置中授予蓝牙权限 - -#### 3. 设备未发现 -**症状**:扫描完成但未找到设备 -**解决**: -- 确认设备已开启 -- 确认设备处于可发现状态 -- 检查设备距离是否过远 - -#### 4. 连接失败 -**症状**:选择设备后连接失败 -**解决**: -- 确认设备未被其他应用占用 -- 重新启动设备 -- 检查设备电池电量 - -### 调试信息 -应用会在日志中记录详细的调试信息,包括: -- 蓝牙初始化状态 -- 设备扫描过程 -- 连接建立过程 -- 数据接收情况 - -## 🔄 数据流程 - -``` -蓝牙设备 → BluetoothManager → MainActivity → DataManager → 信号处理 → 指标计算 → UI显示 -``` - -1. **蓝牙设备**:发送原始心电数据 -2. **BluetoothManager**:接收数据并传递给应用 -3. **MainActivity**:处理蓝牙回调和数据转发 -4. **DataManager**:解析和处理数据 -5. **信号处理**:滤波和信号增强 -6. **指标计算**:计算心率和HRV指标 -7. **UI显示**:在曲线图上显示结果 - -## 📝 注意事项 - -1. **首次使用**:需要授予蓝牙权限 -2. **设备兼容性**:确保设备支持BLE协议 -3. **数据格式**:确保设备发送的数据格式与解析器兼容 -4. **连接稳定性**:保持设备在有效范围内 -5. **电池管理**:注意设备电池电量,避免连接中断 - -## 🔮 未来改进 - -1. **自动重连**:连接断开时自动尝试重连 -2. **多设备支持**:同时连接多个设备 -3. **数据缓存**:本地缓存数据防止丢失 -4. **连接优化**:优化连接速度和稳定性 -5. **设备管理**:保存常用设备列表 diff --git a/BLUETOOTH_UUID_DEBUG.md b/BLUETOOTH_UUID_DEBUG.md deleted file mode 100644 index 6916bd4..0000000 --- a/BLUETOOTH_UUID_DEBUG.md +++ /dev/null @@ -1,143 +0,0 @@ -# 蓝牙UUID调试指南 - -## 🎯 问题描述 -连接蓝牙设备后出现"未找到目标服务"错误,这是因为设备的UUID与预设的不匹配。 - -## 🔧 解决方案 - -### 1. 自动UUID匹配 ✅ -- **实现**:支持多种常见ECG设备UUID -- **效果**:自动尝试匹配不同的服务UUID和特征UUID -- **UUID列表**: - ``` - 服务UUID: - - 0000fff0-0000-1000-8000-00805f9b34fb (默认) - - 0000ffe0-0000-1000-8000-00805f9b34fb (变体1) - - 0000ffe5-0000-1000-8000-00805f9b34fb (变体2) - - 0000ff00-0000-1000-8000-00805f9b34fb (变体3) - - 0000ff10-0000-1000-8000-00805f9b34fb (变体4) - - 特征UUID: - - 0000fff1-0000-1000-8000-00805f9b34fb (默认) - - 0000ffe1-0000-1000-8000-00805f9b34fb (变体1) - - 0000ffe6-0000-1000-8000-00805f9b34fb (变体2) - - 0000ff01-0000-1000-8000-00805f9b34fb (变体3) - - 0000ff11-0000-1000-8000-00805f9b34fb (变体4) - ``` - -### 2. 详细调试信息 ✅ -- **实现**:打印所有可用服务和特征 -- **效果**:显示设备实际提供的UUID -- **日志输出**: - ``` - D/BluetoothManager: 设备提供的服务数量: 3 - D/BluetoothManager: 发现服务: 0000fff0-0000-1000-8000-00805f9b34fb - D/BluetoothManager: 服务 0000fff0-0000-1000-8000-00805f9b34fb 的特征数量: 2 - D/BluetoothManager: 发现特征: 0000fff1-0000-1000-8000-00805f9b34fb - D/BluetoothManager: 发现特征: 0000fff2-0000-1000-8000-00805f9b34fb - ``` - -## 🚀 测试步骤 - -### 第一步:连接设备 -1. **点击"连接蓝牙"按钮** -2. **选择你的ECG设备** -3. **等待连接完成** - -### 第二步:查看调试信息 -1. **观察Logcat输出**: - ``` - 蓝牙状态: 服务发现成功 - 发现服务: 0000fff0-0000-1000-8000-00805f9b34fb - 发现特征: 0000fff1-0000-1000-8000-00805f9b34fb - ``` - -2. **检查状态信息**: - - ✅ 如果显示"数据通道已建立,开始接收数据" → 成功 - - ❌ 如果显示"未找到匹配的服务或特征" → 需要手动配置 - -### 第三步:手动配置UUID(如果需要) -如果自动匹配失败,请: - -1. **查看可用服务UUID**: - ``` - 状态信息: 可用服务: 0000xxxx-0000-1000-8000-00805f9b34fb, ... - ``` - -2. **记录你的设备UUID**: - - 服务UUID: `0000xxxx-0000-1000-8000-00805f9b34fb` - - 特征UUID: `0000yyyy-0000-1000-8000-00805f9b34fb` - -3. **修改代码**: - ```kotlin - private val SERVICE_UUIDS = listOf( - UUID.fromString("你的服务UUID"), // 添加你的UUID - UUID.fromString("0000fff0-0000-1000-8000-00805f9b34fb"), - // ... 其他UUID - ) - ``` - -## 📱 常见ECG设备UUID - -### 1. 标准ECG设备 -``` -服务UUID: 0000fff0-0000-1000-8000-00805f9b34fb -特征UUID: 0000fff1-0000-1000-8000-00805f9b34fb -``` - -### 2. 心电监护仪 -``` -服务UUID: 0000ffe0-0000-1000-8000-00805f9b34fb -特征UUID: 0000ffe1-0000-1000-8000-00805f9b34fb -``` - -### 3. 便携式ECG -``` -服务UUID: 0000ffe5-0000-1000-8000-00805f9b34fb -特征UUID: 0000ffe6-0000-1000-8000-00805f9b34fb -``` - -## 🔍 调试技巧 - -### 1. 使用Logcat过滤 -``` -adb logcat | grep BluetoothManager -``` - -### 2. 查看设备信息 -``` -adb logcat | grep "发现服务\|发现特征" -``` - -### 3. 检查连接状态 -``` -adb logcat | grep "设备已连接\|服务发现成功" -``` - -## ⚠️ 注意事项 - -### 1. UUID格式 -- UUID必须是标准格式:`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` -- 16位UUID会自动扩展为128位 - -### 2. 权限要求 -- 需要`BLUETOOTH_CONNECT`权限 -- Android 12+需要额外权限 - -### 3. 设备兼容性 -- 不同厂商的ECG设备可能使用不同UUID -- 需要根据实际设备调整 - -## 🎉 预期效果 - -### 成功连接: -- ✅ 显示"数据通道已建立,开始接收数据" -- ✅ 开始接收ECG数据 -- ✅ 数据传递给数据处理模块 - -### 需要调试: -- ❌ 显示"未找到匹配的服务或特征" -- ℹ️ 显示所有可用服务的UUID -- 🔧 需要手动添加正确的UUID - -现在请重新连接你的设备,查看调试信息,告诉我你的设备实际使用的UUID!🎯 diff --git a/BUTTON_AND_DIALOG_FIX.md b/BUTTON_AND_DIALOG_FIX.md deleted file mode 100644 index 056be6d..0000000 --- a/BUTTON_AND_DIALOG_FIX.md +++ /dev/null @@ -1,198 +0,0 @@ -# 按钮和对话框修复测试指南 - -## 🎯 修复内容 - -### 1. 按钮位置修复 ✅ -- **问题**:按钮在屏幕最上面,显示不全 -- **解决**:添加顶部边距,让按钮下移 -- **实现**: - ```xml - android:layout_marginTop="50dp" - ``` - -### 2. 对话框性能优化 ✅ -- **问题**:设备选择对话框很卡 -- **解决**:简化对话框实现,限制设备数量 -- **实现**: - - 限制最多显示8个设备 - - 使用简单的文本列表而不是ListView - - 提供快速选择按钮 - -## 📱 现在的UI布局 - -### 启动时的界面 -``` -┌─────────────────────────────────────────┐ -│ │ -│ [状态栏区域] │ -│ │ -└─────────────────────────────────────────┘ -┌─────────────────────────────────────────┐ -│ [连接蓝牙] [启动程序] │ -│ [停止程序] [陷波滤波] │ -└─────────────────────────────────────────┘ -┌─────────────────────────────────────────┐ -│ 状态信息区域 │ -│ [应用已就绪,可以开始使用] │ -│ [权限状态信息] │ -│ [点击"连接蓝牙"按钮开始蓝牙连接...] │ -└─────────────────────────────────────────┘ -``` - -### 设备选择对话框 -``` -┌─────────────────────────────────────────┐ -│ 选择蓝牙设备 │ -├─────────────────────────────────────────┤ -│ 找到 5 个设备: │ -│ │ -│ • iPhone (00:11:22:33:44:55) │ -│ • AirPods (AA:BB:CC:DD:EE:FF) │ -│ • 小米手环 (11:22:33:44:55:66) │ -│ • Samsung Galaxy (22:33:44:55:66:77) │ -│ • Huawei Watch (33:44:55:66:77:88) │ -│ │ -│ 请选择要连接的设备: │ -├─────────────────────────────────────────┤ -│ [选择第一个设备] [选择第二个设备] [取消] │ -└─────────────────────────────────────────┘ -``` - -## 🚀 测试步骤 - -### 第一步:验证按钮位置 -1. **启动应用** -2. **确认按钮位置**: - - ✅ 按钮不在屏幕最上面 - - ✅ 按钮完全可见 - - ✅ 按钮与状态栏有足够距离 - -### 第二步:测试蓝牙扫描 -1. **点击"连接蓝牙"按钮** -2. **观察扫描过程**: - ``` - 蓝牙状态: 正在扫描蓝牙设备... - 发现设备: iPhone (00:11:22:33:44:55) - 发现设备: AirPods (AA:BB:CC:DD:EE:FF) - 发现设备: 小米手环 (11:22:33:44:55:66) - ``` - -### 第三步:验证对话框性能 -1. **扫描完成后**: - - ✅ 对话框应该快速弹出 - - ✅ 对话框不卡顿 - - ✅ 设备列表清晰显示 - -### 第四步:测试设备选择 -1. **选择设备**: - - 点击"选择第一个设备"按钮 - - 或点击"选择第二个设备"按钮 - - 观察连接过程 - -### 第五步:验证连接状态 -1. **连接成功后**: - - ✅ 按钮文字变为"断开蓝牙" - - ✅ 按钮颜色变为红色 - - ✅ 状态信息更新 - -## 🔧 关键代码修改 - -### 1. 按钮位置修复 -```xml - - -``` - -### 2. 对话框性能优化 -```kotlin -override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val builder = AlertDialog.Builder(requireContext()) - builder.setTitle("选择蓝牙设备") - - if (devices.isEmpty()) { - builder.setMessage("未找到蓝牙设备") - builder.setPositiveButton("重新扫描") { _, _ -> dismiss() } - builder.setNegativeButton("取消") { _, _ -> dismiss() } - } else { - // 限制设备数量,避免列表过长 - val limitedDevices = devices.take(8) // 最多显示8个设备 - - // 创建设备列表字符串 - val deviceListText = limitedDevices.joinToString("\n") { device -> - "• ${device.name ?: "未知设备"} (${device.address})" - } - - val message = if (devices.size > 8) { - "找到 ${devices.size} 个设备,显示前8个:\n\n$deviceListText\n\n请选择要连接的设备:" - } else { - "找到 ${devices.size} 个设备:\n\n$deviceListText\n\n请选择要连接的设备:" - } - - builder.setMessage(message) - - // 创建选择按钮 - builder.setPositiveButton("选择第一个设备") { _, _ -> - if (limitedDevices.isNotEmpty()) { - onDeviceSelectedListener?.invoke(limitedDevices[0]) - } - } - - // 如果有多个设备,添加更多选择按钮 - if (limitedDevices.size > 1) { - builder.setNeutralButton("选择第二个设备") { _, _ -> - onDeviceSelectedListener?.invoke(limitedDevices[1]) - } - } - - builder.setNegativeButton("取消") { _, _ -> dismiss() } - } - - return builder.create() -} -``` - -## ⚠️ 注意事项 - -### 1. 按钮位置 -- 添加了50dp的顶部边距 -- 确保按钮在状态栏下方 -- 适应不同屏幕尺寸 - -### 2. 对话框性能 -- 限制最多显示8个设备 -- 使用简单的文本列表 -- 提供快速选择按钮 - -### 3. 设备选择 -- 第一个设备:点击"选择第一个设备" -- 第二个设备:点击"选择第二个设备" -- 更多设备:可以修改代码添加更多按钮 - -## 🎉 预期效果 - -### 启动时: -- ✅ 按钮位置合理,完全可见 -- ✅ 界面布局清晰 -- ✅ 状态信息正常显示 - -### 扫描时: -- ✅ 扫描过程流畅 -- ✅ 设备发现信息及时更新 - -### 对话框: -- ✅ 快速弹出,无卡顿 -- ✅ 设备列表清晰显示 -- ✅ 选择按钮响应及时 - -### 连接后: -- ✅ 连接状态正确显示 -- ✅ 按钮状态正确更新 -- ✅ 可以开始数据处理 - -现在可以测试修复后的按钮位置和对话框性能了!🎉 diff --git a/DEVICE_SELECTION_IMPROVEMENT.md b/DEVICE_SELECTION_IMPROVEMENT.md deleted file mode 100644 index 638ae5a..0000000 --- a/DEVICE_SELECTION_IMPROVEMENT.md +++ /dev/null @@ -1,218 +0,0 @@ -# 设备选择功能改进测试指南 - -## 🎯 改进内容 - -### 1. 显示所有扫描到的设备 ✅ -- **问题**:之前只显示前8个设备 -- **解决**:使用RecyclerView显示所有扫描到的设备 -- **实现**:移除设备数量限制,显示完整设备列表 - -### 2. 点击任意设备连接 ✅ -- **问题**:之前只能选择前两个设备 -- **解决**:每个设备都可以点击连接 -- **实现**:RecyclerView的每个item都可以点击 - -### 3. 扫描完成后立即弹出对话框 ✅ -- **问题**:扫描完成后可能延迟弹出对话框 -- **解决**:扫描停止时立即触发回调 -- **实现**:在`stopBleScan()`中立即调用`onScanComplete` - -## 📱 现在的UI布局 - -### 设备选择对话框 -``` -┌─────────────────────────────────────────┐ -│ 选择蓝牙设备 (找到 5 个设备) │ -├─────────────────────────────────────────┤ -│ ┌─────────────────────────────────────┐ │ -│ │ iPhone │ │ -│ │ 00:11:22:33:44:55 │ │ -│ └─────────────────────────────────────┘ │ -│ ┌─────────────────────────────────────┐ │ -│ │ AirPods │ │ -│ │ AA:BB:CC:DD:EE:FF │ │ -│ └─────────────────────────────────────┘ │ -│ ┌─────────────────────────────────────┐ │ -│ │ 小米手环 │ │ -│ │ 11:22:33:44:55:66 │ │ -│ └─────────────────────────────────────┘ │ -│ ┌─────────────────────────────────────┐ │ -│ │ Samsung Galaxy │ │ -│ │ 22:33:44:55:66:77 │ │ -│ └─────────────────────────────────────┘ │ -│ ┌─────────────────────────────────────┐ │ -│ │ Huawei Watch │ │ -│ │ 33:44:55:66:77:88 │ │ -│ └─────────────────────────────────────┘ │ -├─────────────────────────────────────────┤ -│ [取消] │ -└─────────────────────────────────────────┘ -``` - -## 🚀 测试步骤 - -### 第一步:启动蓝牙扫描 -1. **点击"连接蓝牙"按钮** -2. **观察扫描过程**: - ``` - 蓝牙状态: 正在扫描蓝牙设备... - 发现设备: iPhone (00:11:22:33:44:55) - 发现设备: AirPods (AA:BB:CC:DD:EE:FF) - 发现设备: 小米手环 (11:22:33:44:55:66) - ``` - -### 第二步:验证扫描完成提示 -1. **扫描完成后**: - - ✅ 显示"扫描完成,找到 X 个设备" - - ✅ 立即弹出设备选择对话框 - - ✅ 对话框标题显示设备数量 - -### 第三步:验证设备列表 -1. **检查设备列表**: - - ✅ 显示所有扫描到的设备 - - ✅ 每个设备显示名称和地址 - - ✅ 列表可以滚动(如果设备很多) - -### 第四步:测试设备选择 -1. **点击任意设备**: - - ✅ 点击第一个设备 - - ✅ 点击中间的设备 - - ✅ 点击最后一个设备 - - ✅ 每个设备都能正常连接 - -### 第五步:验证连接过程 -1. **连接状态变化**: - - ✅ 按钮文字变为"连接中..." - - ✅ 按钮颜色变为橙色 - - ✅ 状态信息更新 - -## 🔧 关键代码修改 - -### 1. 设备适配器 -```kotlin -private inner class DeviceAdapter : RecyclerView.Adapter() { - - override fun onBindViewHolder(holder: DeviceViewHolder, position: Int) { - val device = devices[position] - val deviceName = device.name ?: "未知设备" - val deviceAddress = device.address - holder.textView.text = "$deviceName\n$deviceAddress" - - holder.itemView.setOnClickListener { - onDeviceSelectedListener?.invoke(device) - dismiss() - } - } - - override fun getItemCount(): Int = devices.size -} -``` - -### 2. 对话框实现 -```kotlin -override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val builder = AlertDialog.Builder(requireContext()) - builder.setTitle("选择蓝牙设备 (找到 ${devices.size} 个设备)") - - if (devices.isEmpty()) { - builder.setMessage("未找到蓝牙设备") - builder.setPositiveButton("重新扫描") { _, _ -> dismiss() } - builder.setNegativeButton("取消") { _, _ -> dismiss() } - } else { - // 创建RecyclerView显示所有设备 - val recyclerView = RecyclerView(requireContext()).apply { - layoutManager = LinearLayoutManager(requireContext()) - adapter = DeviceAdapter() - - // 设置固定高度,避免对话框过大 - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - 600 // 固定高度600dp - ) - } - - builder.setView(recyclerView) - builder.setNegativeButton("取消") { _, _ -> dismiss() } - } - - return builder.create() -} -``` - -### 3. 扫描完成回调 -```kotlin -override fun onScanComplete(devices: List) { - runOnUiThread { - updateStatus("扫描完成,找到 ${devices.size} 个设备") - - if (devices.isNotEmpty()) { - // 立即显示设备选择对话框 - val dialog = BluetoothDeviceDialog.newInstance(devices) - dialog.setOnDeviceSelectedListener { device -> - // 连接选中的设备 - bluetoothManager.connectToDevice(device) - binding.bluetoothButton.text = "连接中..." - binding.bluetoothButton.setBackgroundColor(Color.parseColor("#FF9800")) - updateStatus("正在连接设备: ${device.name ?: device.address}") - } - dialog.show(supportFragmentManager, "BluetoothDeviceDialog") - } else { - updateStatus("未找到蓝牙设备,请重试") - binding.bluetoothButton.text = "连接蓝牙" - binding.bluetoothButton.setBackgroundColor(Color.parseColor("#4CAF50")) - } - } -} -``` - -### 4. BLE扫描停止 -```kotlin -private fun stopBleScan() { - try { - bluetoothLeScanner?.stopScan(bleScanCallback) - Log.d(TAG, "BLE扫描已停止") - - // 扫描完成后立即触发回调 - callback?.onStatusChanged("扫描完成,找到 ${discoveredDevices.size} 个设备") - callback?.onScanComplete(discoveredDevices.toList()) - } catch (e: Exception) { - Log.e(TAG, "停止BLE扫描失败: ${e.message}") - } -} -``` - -## ⚠️ 注意事项 - -### 1. 设备显示 -- 显示所有扫描到的设备,无数量限制 -- 每个设备显示名称和地址 -- 支持滚动查看(如果设备很多) - -### 2. 连接选择 -- 点击任意设备都可以连接 -- 连接后对话框自动关闭 -- 支持取消操作 - -### 3. 性能优化 -- 使用RecyclerView提高性能 -- 固定对话框高度避免过大 -- 扫描完成后立即弹出 - -## 🎉 预期效果 - -### 扫描过程: -- ✅ 显示扫描进度 -- ✅ 实时显示发现的设备 -- ✅ 扫描完成后立即提示 - -### 设备选择: -- ✅ 显示所有扫描到的设备 -- ✅ 每个设备都可以点击 -- ✅ 对话框响应流畅 - -### 连接过程: -- ✅ 点击设备后立即开始连接 -- ✅ 连接状态正确显示 -- ✅ 支持连接任意设备 - -现在你可以扫描所有设备并点击任意设备进行连接了!🎉 diff --git a/DynamicChartView_backup.kt b/DynamicChartView_backup.kt new file mode 100644 index 0000000..b73912e --- /dev/null +++ b/DynamicChartView_backup.kt @@ -0,0 +1,601 @@ +package com.example.cmake_project_test + +import android.content.Context +import android.graphics.* +import android.util.AttributeSet +import android.util.Log +import android.view.MotionEvent +import android.view.View +import android.view.ScaleGestureDetector +import kotlin.math.max +import kotlin.math.min + +/** + * 可调节的通用生理信号图表视图 + * 支持多种设备类型和通道数量的动态显示,具有缩放、平移、时间窗口调节等功能 + */ +class DynamicChartView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + + private val paint = Paint().apply { + color = Color.BLUE + strokeWidth = 2f + style = Paint.Style.STROKE + isAntiAlias = false // 关闭抗锯齿,提高绘制性能 + } + + private val gridPaint = Paint().apply { + color = Color.LTGRAY + strokeWidth = 1f + style = Paint.Style.STROKE + alpha = 80 + } + + private val textPaint = Paint().apply { + color = Color.BLACK + textSize = 20f + isAntiAlias = true + } + + private val controlPaint = Paint().apply { + color = Color.RED + strokeWidth = 3f + style = Paint.Style.STROKE + isAntiAlias = true + } + + private val path = Path() + private var dataPoints = mutableListOf() + private var maxDataPoints = 1000 // 默认数据点数量 + private var minValue = Float.MAX_VALUE + private var maxValue = Float.MIN_VALUE + private var isDataAvailable = false + + // 图表配置 + private var chartTitle = "通道" + private var channelIndex = 0 + private var deviceType = "未知设备" + private var sampleRate = 250f // 默认采样率 + private var timeWindow = 4f // 默认时间窗口(秒) + + // 性能优化:真正的逐点绘制 + private val dataBuffer = mutableListOf() + + // 多通道数据支持(用于PPG等设备) + private val multiChannelDataPoints = mutableListOf>() + private val multiChannelDataBuffers = mutableListOf>() + private var isMultiChannelMode = false + private val channelColors = listOf(Color.RED, Color.BLUE) // 通道颜色 + private var lastUpdateTime = 0L + private val updateInterval = 2L // 2ms更新间隔,实现500Hz显示 + private var lastDrawTime = 0L + private val drawInterval = 1L // 1ms绘制间隔,实现1000Hz绘制频率 + + // 交互控制参数 + private var scaleFactor = 1.0f + private var translateX = 0f + private var translateY = 0f + private var viewportStart = 0f + private var viewportEnd = 1.0f + private var isDragging = false + private var lastTouchX = 0f + private var lastTouchY = 0f + + // 手势检测器 + private val scaleGestureDetector = ScaleGestureDetector(context, ScaleListener()) + + // 控制按钮区域 + private val controlButtonRect = RectF() + private val fullscreenButtonRect = RectF() + + // 全屏模式相关 + private var isFullscreenMode = false + private var originalLayoutParams: android.view.ViewGroup.LayoutParams? = null + + /** + * 设置图表配置 + */ + fun setChartConfig( + title: String, + channelIdx: Int, + device: String, + maxPoints: Int, + sampleRateHz: Float, + timeWindowSec: Float + ) { + chartTitle = title + channelIndex = channelIdx + deviceType = device + maxDataPoints = maxPoints + sampleRate = sampleRateHz + timeWindow = timeWindowSec + resetViewport() + invalidate() + } + + /** + * 设置多通道模式(用于PPG等设备) + */ + fun setMultiChannelMode(channelCount: Int) { + isMultiChannelMode = true + multiChannelDataPoints.clear() + multiChannelDataBuffers.clear() + + // 初始化每个通道的数据结构 + for (i in 0 until channelCount) { + multiChannelDataPoints.add(mutableListOf()) + multiChannelDataBuffers.add(mutableListOf()) + } + } + + fun updateData(newData: List) { + if (newData.isEmpty()) return + + isDataAvailable = true + + // 批量添加到缓冲区 + dataBuffer.addAll(newData) + + val currentTime = System.currentTimeMillis() + + // 批量处理 - 每次处理更多点,提高数据累积速度 + if (currentTime - lastUpdateTime >= updateInterval) { + if (dataBuffer.isNotEmpty()) { + // 每次处理多个点,提高效率 + val batchSize = minOf(10, dataBuffer.size) // 每次最多处理10个点 + val batch = dataBuffer.take(batchSize) + dataBuffer.removeAll(batch) + + dataPoints.addAll(batch) + + // 限制数据点数量 + if (dataPoints.size > maxDataPoints) { + dataPoints = dataPoints.takeLast(maxDataPoints).toMutableList() + } + + // 更新数据范围 - 使用批量数据 + updateDataRange(batch) + + lastUpdateTime = currentTime + } + } + + // 超高频绘制 + if (currentTime - lastDrawTime >= drawInterval) { + invalidate() + lastDrawTime = currentTime + } + } + + /** + * 更新多通道数据(用于PPG等设备) + */ + fun updateMultiChannelData(channelData: List>) { + if (channelData.isEmpty() || !isMultiChannelMode) { + Log.d("DynamicChartView", "跳过多通道更新: channelData.isEmpty=${channelData.isEmpty()}, isMultiChannelMode=$isMultiChannelMode") + return + } + + Log.d("DynamicChartView", "多通道数据更新: 通道数=${channelData.size}, 每个通道数据点=${channelData.map { it.size }}") + isDataAvailable = true + + val currentTime = System.currentTimeMillis() + + // 更新每个通道的数据 + for (channelIndex in channelData.indices) { + if (channelIndex >= multiChannelDataBuffers.size) { + Log.w("DynamicChartView", "通道索引超出范围: $channelIndex >= ${multiChannelDataBuffers.size}") + continue + } + + val channelBuffer = multiChannelDataBuffers[channelIndex] + channelBuffer.addAll(channelData[channelIndex]) + Log.d("DynamicChartView", "通道$channelIndex 添加了${channelData[channelIndex].size}个数据点") + } + + // 批量处理数据 + if (currentTime - lastUpdateTime >= updateInterval) { + var hasDataToProcess = false + + for (channelIndex in multiChannelDataBuffers.indices) { + val buffer = multiChannelDataBuffers[channelIndex] + if (buffer.isNotEmpty()) { + // 每次处理多个点 + val batchSize = minOf(10, buffer.size) + val batch = buffer.take(batchSize) + buffer.removeAll(batch) + + multiChannelDataPoints[channelIndex].addAll(batch) + + // 限制数据点数量 + if (multiChannelDataPoints[channelIndex].size > maxDataPoints) { + multiChannelDataPoints[channelIndex] = multiChannelDataPoints[channelIndex].takeLast(maxDataPoints).toMutableList() + } + + hasDataToProcess = true + } + } + + if (hasDataToProcess) { + // 更新数据范围 - 计算所有通道的数据范围 + updateMultiChannelDataRange() + lastUpdateTime = currentTime + + Log.d("DynamicChartView", "多通道数据处理完成: 通道数据点=${multiChannelDataPoints.map { it.size }}, 范围=${minValue}-${maxValue}") + } + } + + // 超高频绘制 + if (currentTime - lastDrawTime >= drawInterval) { + invalidate() + lastDrawTime = currentTime + } + } + + private fun updateDataRange(batch: List) { + // 使用全部数据点计算范围,提供更准确的范围 + if (dataPoints.isEmpty()) return + + // 计算全部数据点的范围 + minValue = dataPoints.minOrNull() ?: 0f + maxValue = dataPoints.maxOrNull() ?: 0f + + // 确保有足够的显示范围 + val range = maxValue - minValue + if (range < 0.1f) { + val center = (maxValue + minValue) / 2 + minValue = center - 0.05f + maxValue = center + 0.05f + } else { + val margin = range * 0.1f + minValue -= margin + maxValue += margin + } + } + + /** + * 更新多通道数据范围 + */ + private fun updateMultiChannelDataRange() { + if (multiChannelDataPoints.isEmpty()) { + Log.w("DynamicChartView", "多通道数据点为空,无法计算范围") + return + } + + // 计算所有通道的最小值和最大值 + var globalMin = Float.MAX_VALUE + var globalMax = Float.MIN_VALUE + var hasValidData = false + + for (channelIndex in multiChannelDataPoints.indices) { + val channelData = multiChannelDataPoints[channelIndex] + if (channelData.isNotEmpty()) { + val channelMin = channelData.minOrNull() ?: 0f + val channelMax = channelData.maxOrNull() ?: 0f + globalMin = minOf(globalMin, channelMin) + globalMax = maxOf(globalMax, channelMax) + hasValidData = true + Log.d("DynamicChartView", "通道$channelIndex: 范围=${channelMin}-${channelMax}, 数据点=${channelData.size}") + } + } + + if (!hasValidData) { + Log.w("DynamicChartView", "没有有效的多通道数据") + return + } + + minValue = globalMin + maxValue = globalMax + + Log.d("DynamicChartView", "多通道全局范围: ${minValue}-${maxValue}") + + // 确保有足够的显示范围 + val range = maxValue - minValue + if (range < 0.1f) { + val center = (maxValue + minValue) / 2 + minValue = center - 0.05f + maxValue = center + 0.05f + } else { + val margin = range * 0.1f + minValue -= margin + maxValue += margin + } + + Log.d("DynamicChartView", "多通道显示范围: ${minValue}-${maxValue}") + } + + fun clearData() { + dataPoints.clear() + dataBuffer.clear() + // 清除多通道数据 + for (channelData in multiChannelDataPoints) { + channelData.clear() + } + for (channelBuffer in multiChannelDataBuffers) { + channelBuffer.clear() + } + isDataAvailable = false + resetViewport() + invalidate() + } + + /** + * 重置视口到默认状态 + */ + fun resetViewport() { + scaleFactor = 1.0f + translateX = 0f + translateY = 0f + viewportStart = 0f + viewportEnd = 1.0f + invalidate() + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + scaleGestureDetector.onTouchEvent(event) + + when (event.action) { + MotionEvent.ACTION_DOWN -> { + val x = event.x + val y = event.y + + // 开始拖拽 + isDragging = true + lastTouchX = x + lastTouchY = y + return true + } + + MotionEvent.ACTION_MOVE -> { + if (isDragging) { + val deltaX = event.x - lastTouchX + val deltaY = event.y - lastTouchY + + translateX += deltaX + translateY += deltaY + + // 限制平移范围 + val maxTranslate = width * 0.5f + translateX = translateX.coerceIn(-maxTranslate, maxTranslate) + translateY = translateY.coerceIn(-maxTranslate, maxTranslate) + + lastTouchX = event.x + lastTouchY = event.y + invalidate() + return true + } + } + + MotionEvent.ACTION_UP -> { + isDragging = false + return true + } + } + + return super.onTouchEvent(event) + } + + private inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() { + override fun onScale(detector: ScaleGestureDetector): Boolean { + scaleFactor *= detector.scaleFactor + scaleFactor = scaleFactor.coerceIn(0.2f, 5.0f) + invalidate() + return true + } + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + val width = width.toFloat() + val height = height.toFloat() + + // 应用变换 + canvas.save() + canvas.translate(translateX, translateY) + canvas.scale(scaleFactor, scaleFactor, width / 2, height / 2) + + // 绘制网格 + drawGrid(canvas, width, height) + + // 绘制标题和通道信息 + drawTitle(canvas, width, height) + + // 绘制数据统计 + drawStats(canvas, width, height) + + // 绘制全屏按钮 + drawFullscreenButton(canvas, width, height) + + // 如果没有数据,显示默认内容 + if (!isDataAvailable) { + drawDefaultContent(canvas, width, height) + } else { + // 绘制曲线 - 检查单通道或多通道数据 + val hasDataToDraw = if (isMultiChannelMode) { + multiChannelDataPoints.any { it.size > 1 } + } else { + dataPoints.size > 1 + } + + if (hasDataToDraw) { + drawCurve(canvas, width, height) + } + } + + canvas.restore() + } + + private fun drawGrid(canvas: Canvas, width: Float, height: Float) { + val padding = 60f + val drawWidth = width - 2 * padding + val drawHeight = height - 2 * padding + + // 绘制水平网格线 + val gridLines = 5 + for (i in 0..gridLines) { + val y = padding + (drawHeight * i / gridLines) + canvas.drawLine(padding, y, width - padding, y, gridPaint) + } + + // 绘制垂直网格线 + for (i in 0..gridLines) { + val x = padding + (drawWidth * i / gridLines) + canvas.drawLine(x, padding, x, height - padding, gridPaint) + } + } + + private fun drawTitle(canvas: Canvas, width: Float, height: Float) { + val title = "$chartTitle $channelIndex" + canvas.drawText(title, 10f, 25f, textPaint) + + val deviceInfo = "$deviceType (${sampleRate.toInt()}Hz)" + canvas.drawText(deviceInfo, 10f, 50f, textPaint) + + // 显示当前缩放和时间窗口信息 + val scaleInfo = "缩放: ${String.format("%.1f", scaleFactor)}x" + canvas.drawText(scaleInfo, width - 150f, 25f, textPaint) + + val timeInfo = "时间窗口: ${timeWindow.toInt()}秒" + canvas.drawText(timeInfo, width - 150f, 50f, textPaint) + } + + private fun drawStats(canvas: Canvas, width: Float, height: Float) { + val totalDataPoints = if (isMultiChannelMode) { + multiChannelDataPoints.sumOf { it.size } + } else { + dataPoints.size + } + + val statsText = if (isMultiChannelMode) { + "数据点: ${totalDataPoints} (${multiChannelDataPoints.size}通道)" + } else { + "数据点: ${dataPoints.size}" + } + canvas.drawText(statsText, 10f, height - 35f, textPaint) + + if (totalDataPoints > 0) { + val rangeText = "范围: ${String.format("%.2f", minValue)} - ${String.format("%.2f", maxValue)}" + canvas.drawText(rangeText, 10f, height - 15f, textPaint) + } + } + + private fun drawDefaultContent(canvas: Canvas, width: Float, height: Float) { + val centerX = width / 2 + val centerY = height / 2 + + val hintPaint = Paint().apply { + color = Color.GRAY + textSize = 16f + textAlign = Paint.Align.CENTER + isAntiAlias = true + } + + canvas.drawText("等待数据...", centerX, centerY - 20f, hintPaint) + canvas.drawText("请先连接蓝牙设备", centerX, centerY + 20f, hintPaint) + canvas.drawText("然后点击'启动程序'显示数据", centerX, centerY + 60f, hintPaint) + } + + private fun drawCurve(canvas: Canvas, width: Float, height: Float) { + val padding = 60f + val drawWidth = width - 2 * padding + val drawHeight = height - 2 * padding + + if (isMultiChannelMode && multiChannelDataPoints.isNotEmpty()) { + Log.d("DynamicChartView", "多通道绘制: 通道数=${multiChannelDataPoints.size}, 数据点=${multiChannelDataPoints.map { it.size }}") + // 多通道绘制模式 + for (channelIndex in multiChannelDataPoints.indices) { + val channelData = multiChannelDataPoints[channelIndex] + if (channelData.isEmpty()) { + Log.d("DynamicChartView", "通道$channelIndex 数据为空,跳过绘制") + continue + } + + path.reset() + + // 设置通道颜色 + val channelPaint = Paint(paint).apply { + color = channelColors.getOrElse(channelIndex) { Color.GRAY } + strokeWidth = 2f + } + + val xStep = if (channelData.size > 1) drawWidth / (channelData.size - 1) else drawWidth + + for (i in channelData.indices) { + val x = padding + i * xStep + val normalizedValue = (channelData[i] - minValue) / (maxValue - minValue) + val y = padding + (0.1f + normalizedValue * 0.8f) * drawHeight + + if (i == 0) { + path.moveTo(x, y) + } else { + path.lineTo(x, y) + } + } + + canvas.drawPath(path, channelPaint) + } + } else { + // 单通道绘制模式 + if (dataPoints.isEmpty()) return + + path.reset() + val xStep = if (dataPoints.size > 1) drawWidth / (dataPoints.size - 1) else drawWidth + + for (i in dataPoints.indices) { + val x = padding + i * xStep + val normalizedValue = (dataPoints[i] - minValue) / (maxValue - minValue) + val y = padding + (0.1f + normalizedValue * 0.8f) * drawHeight + + if (i == 0) { + path.moveTo(x, y) + } else { + path.lineTo(x, y) + } + } + + canvas.drawPath(path, paint) + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + + val desiredHeight = 200 // 每个图表的高度 + val height = resolveSize(desiredHeight, heightMeasureSpec) + setMeasuredDimension(widthMeasureSpec, height) + } + + /** + * 获取当前图表数据 + */ + fun getCurrentData(): List { + return dataPoints.toList() + } + + /** + * 获取数据统计信息 + */ + fun getDataStats(): Map { + val totalDataPoints = if (isMultiChannelMode) { + multiChannelDataPoints.sumOf { it.size } + } else { + dataPoints.size + } + + return mapOf( + "dataPoints" to totalDataPoints, + "multiChannelMode" to isMultiChannelMode, + "channelCount" to if (isMultiChannelMode) multiChannelDataPoints.size else 1, + "minValue" to minValue, + "maxValue" to maxValue, + "range" to (maxValue - minValue), + "isDataAvailable" to isDataAvailable, + "scaleFactor" to scaleFactor, + "timeWindow" to timeWindow + ) + } +} diff --git a/JNI_FIX_SUMMARY.md b/JNI_FIX_SUMMARY.md deleted file mode 100644 index d99a981..0000000 --- a/JNI_FIX_SUMMARY.md +++ /dev/null @@ -1,141 +0,0 @@ -# JNI函数名不匹配问题修复说明 - -## 问题描述 - -在代码重构过程中,将原生方法从`MainActivity`移动到`DataManager`类后,出现了以下错误: - -``` -Cannot resolve corresponding JNI function Java_com_example_cmake_1project_1test_DataManager_createStreamParser -``` - -## 问题原因 - -### JNI函数命名规则 - -JNI函数名遵循以下格式: -``` -Java_{包名}_{类名}_{方法名} -``` - -其中: -- 包名中的点(.)用下划线(_)替换 -- 类名中的下划线用`_1`替换 - -### 具体变化 - -**重构前的JNI函数名**(在MainActivity中): -``` -Java_com_example_cmake_1project_1test_MainActivity_createStreamParser -Java_com_example_cmake_1project_1test_MainActivity_destroyStreamParser -Java_com_example_cmake_1project_1test_MainActivity_streamParserAppend -Java_com_example_cmake_1project_1test_MainActivity_streamParserDrainPackets -``` - -**重构后的JNI函数名**(在DataManager中): -``` -Java_com_example_cmake_1project_1test_DataManager_createStreamParser -Java_com_example_cmake_1project_1test_DataManager_destroyStreamParser -Java_com_example_cmake_1project_1test_DataManager_streamParserAppend -Java_com_example_cmake_1project_1test_DataManager_streamParserDrainPackets -``` - -## 解决方案 - -### 方案1:回调接口模式(已实现) - -通过创建回调接口,让`DataManager`通过`MainActivity`来调用原生方法,保持原有的JNI函数名。 - -#### 实现步骤 - -1. **创建回调接口** -```kotlin -interface NativeMethodCallback { - fun createStreamParser(): Long - fun destroyStreamParser(handle: Long) - fun streamParserAppend(handle: Long, chunk: ByteArray) - fun streamParserDrainPackets(handle: Long): List? -} -``` - -2. **修改DataManager构造函数** -```kotlin -class DataManager(private val nativeCallback: NativeMethodCallback) -``` - -3. **MainActivity实现接口** -```kotlin -class MainActivity : AppCompatActivity(), DataManager.NativeMethodCallback -``` - -4. **保持原生方法在MainActivity中** -```kotlin -// 原生方法声明 - 保持原来的JNI函数名 -external fun createStreamParser(): Long -external fun destroyStreamParser(handle: Long) -external fun streamParserAppend(handle: Long, chunk: ByteArray) -external fun streamParserDrainPackets(handle: Long): List? -``` - -#### 优势 -- 保持原有的JNI函数名,无需修改C++代码 -- 维持了代码重构的架构优势 -- 清晰的职责分离 - -#### 劣势 -- 增加了接口依赖 -- 稍微增加了代码复杂度 - -### 方案2:修改C++代码中的JNI函数名 - -如果您有权限修改C++代码,可以将C++中的JNI函数名改为新的名称。 - -#### 需要修改的C++函数名 -```cpp -// 原来的函数名 -JNIEXPORT jlong JNICALL Java_com_example_cmake_1project_1test_MainActivity_createStreamParser - -// 改为 -JNIEXPORT jlong JNICALL Java_com_example_cmake_1project_1test_DataManager_createStreamParser -``` - -#### 优势 -- 完全符合重构后的架构 -- 无需额外的接口层 - -#### 劣势 -- 需要修改C++代码 -- 可能影响其他依赖项目 - -## 推荐方案 - -**推荐使用方案1(回调接口模式)**,原因如下: - -1. **无需修改C++代码**:保持现有C++代码的稳定性 -2. **维持重构优势**:仍然保持了代码的职责分离 -3. **向后兼容**:如果将来需要,可以轻松切换到方案2 -4. **风险较低**:不会引入新的编译或链接问题 - -## 修复后的架构 - -``` -MainActivity (实现NativeMethodCallback) - ↓ -DataManager (通过回调调用原生方法) - ↓ -原生C++库 (保持原有JNI函数名) -``` - -## 注意事项 - -1. **JNI函数名一致性**:确保Kotlin中的`external`方法名与C++中的JNI函数名完全匹配 -2. **库加载**:确保在`MainActivity`的`companion object`中正确加载原生库 -3. **接口实现**:MainActivity必须实现`NativeMethodCallback`接口的所有方法 -4. **依赖注入**:DataManager的构造函数现在需要传入回调接口实例 - -## 验证修复 - -修复完成后,您应该能够: -1. 成功编译项目 -2. 不再看到JNI函数名不匹配的错误 -3. 保持原有的功能正常运行 -4. 享受重构后的代码结构优势 diff --git a/MAC地址格式说明.md b/MAC地址格式说明.md deleted file mode 100644 index 129f968..0000000 --- a/MAC地址格式说明.md +++ /dev/null @@ -1,88 +0,0 @@ -# MAC地址格式说明 - -## 问题解决 - -您遇到的错误 "60-E9-AA-30-0B-0A is not a valid Bluetooth address" 是因为MAC地址格式问题。 - -## 正确的MAC地址格式 - -### 支持的格式: -1. **冒号分隔符**(推荐):`60:E9:AA:30:8B:0A` -2. **连字符分隔符**:`60-E9-AA-30-8B-0A` - -### 您的电脑MAC地址: -- **正确格式**:`60:E9:AA:30:8B:0A` -- **注意**:您之前输入的是 `60-E9-AA-30-0B-0A`,其中 `0B` 应该是 `8B` - -## 常见MAC地址格式错误 - -### 1. 分隔符错误 -- ❌ 错误:`60.E9.AA.30.8B.0A`(点号分隔符) -- ❌ 错误:`60 E9 AA 30 8B 0A`(空格分隔符) -- ✅ 正确:`60:E9:AA:30:8B:0A`(冒号分隔符) - -### 2. 字符错误 -- ❌ 错误:`60-E9-AA-30-0B-0A`(0B 应该是 8B) -- ✅ 正确:`60:E9:AA:30:8B:0A` - -### 3. 长度错误 -- ❌ 错误:`60:E9:AA:30:8B`(缺少两位) -- ❌ 错误:`60:E9:AA:30:8B:0A:FF`(多出两位) -- ✅ 正确:`60:E9:AA:30:8B:0A`(6组,每组2位) - -## 如何获取正确的MAC地址 - -### Windows系统: -1. 打开命令提示符(cmd) -2. 输入:`ipconfig /all` -3. 查找"物理地址"或"Physical Address" -4. 格式类似:`60-E9-AA-30-8B-0A` - -### 转换为冒号格式: -- 将连字符 `-` 替换为冒号 `:` -- `60-E9-AA-30-8B-0A` → `60:E9:AA:30:8B:0A` - -## 测试步骤 - -### 1. 确认MAC地址 -1. 在Windows命令提示符中输入:`ipconfig /all` -2. 找到您的蓝牙适配器的物理地址 -3. 确认地址格式正确 - -### 2. 使用应用连接 -1. 启动应用 -2. **长按"连接蓝牙"按钮** -3. 输入正确的MAC地址:`60:E9:AA:30:8B:0A` -4. 点击"连接" - -### 3. 验证连接 -1. 观察连接状态 -2. 查看日志信息 -3. 如果仍有问题,尝试扫描连接 - -## 常见问题 - -### Q: 为什么需要冒号分隔符? -A: Android蓝牙API更推荐使用冒号分隔符,兼容性更好。 - -### Q: 我的MAC地址是连字符格式怎么办? -A: 应用现在支持两种格式,但推荐使用冒号格式。 - -### Q: 连接仍然失败怎么办? -A: -1. 确认MAC地址正确 -2. 检查蓝牙权限 -3. 确保设备在范围内 -4. 尝试扫描连接方式 - -## 调试技巧 - -### 查看日志: -在Android Studio的Logcat中查看: -- `BluetoothManager` 标签的详细错误信息 -- 确认MAC地址格式验证结果 - -### 测试连接: -1. 先使用扫描功能找到设备 -2. 记录正确的MAC地址 -3. 再使用直接连接功能 diff --git a/NORDIC_UART_ADAPTATION.md b/NORDIC_UART_ADAPTATION.md deleted file mode 100644 index f8e3d75..0000000 --- a/NORDIC_UART_ADAPTATION.md +++ /dev/null @@ -1,136 +0,0 @@ -# Nordic UART Service (NUS) 适配指南 - -## 🎯 设备协议识别 - -从你的设备日志可以看出,你的ECG设备使用的是 **Nordic UART Service (NUS)** 协议: - -``` -发现服务: 6e400001-b5a3-f393-e0a9-68716563686f // NUS Service -发现特征: 6e400002-b5a3-f393-e0a9-68716563686f // NUS TX Characteristic -``` - -### Nordic UART Service 协议说明 - -**NUS (Nordic UART Service)** 是一个标准的BLE服务,常用于: -- ECG设备数据传输 -- 传感器数据流 -- 串口通信模拟 -- 医疗设备通信 - -### UUID 含义 - -- **Service UUID**: `6e400001-b5a3-f393-e0a9-68716563686f` - - 这是Nordic UART Service的标准UUID - - 用于标识NUS服务 - -- **TX Characteristic UUID**: `6e400002-b5a3-f393-e0a9-68716563686f` - - 这是NUS的TX(发送)特征 - - 用于接收设备发送的数据 - - 支持通知(Notify)属性 - -## 🔧 适配完成 - -### 1. UUID配置 ✅ -```kotlin -// 服务UUID - 已添加到列表首位 -UUID.fromString("6e400001-b5a3-f393-e0a9-68716563686f") - -// 特征UUID - 已添加到列表首位 -UUID.fromString("6e400002-b5a3-f393-e0a9-68716563686f") -``` - -### 2. 优先级设置 ✅ -- **NUS UUID已放在列表首位** -- **优先匹配你的设备协议** -- **确保快速连接** - -### 3. 数据接收 ✅ -- **自动启用特征通知** -- **开始接收ECG数据流** -- **数据传递给处理模块** - -## 📱 测试步骤 - -### 第一步:重新连接设备 -1. **断开当前连接** -2. **重新点击"连接蓝牙"** -3. **选择你的设备** - -### 第二步:验证连接 -1. **观察连接过程**: - ``` - 蓝牙状态: 设备已连接 - 蓝牙状态: 服务发现成功 - 蓝牙状态: 发现服务: 6e400001-b5a3-f393-e0a9-68716563686f - 蓝牙状态: 发现特征: 6e400002-b5a3-f393-e0a9-68716563686f - ``` - -2. **检查UUID匹配**: - ``` - 找到匹配的服务: 6e400001-b5a3-f393-e0a9-68716563686f - 找到匹配的特征: 6e400002-b5a3-f393-e0a9-68716563686f - 已启用特征通知 - 数据通道已建立,开始接收数据 - ``` - -### 第三步:验证数据接收 -1. **检查状态信息**: - - ✅ "数据通道已建立,开始接收数据" - - ✅ "接收到数据: X 字节" - -2. **检查ECG图表**: - - ✅ 图表变为可见 - - ✅ 开始显示实时波形 - -## 🔍 Nordic UART Service 特点 - -### 1. 数据格式 -- **原始字节流**:设备直接发送原始数据 -- **无协议头**:通常没有复杂的协议封装 -- **连续传输**:数据流连续传输 - -### 2. 常见用途 -- **ECG数据**:心电信号实时传输 -- **传感器数据**:各种生理参数 -- **控制命令**:设备控制指令 - -### 3. 优势 -- **标准化**:广泛使用的标准协议 -- **兼容性好**:支持多种设备 -- **实时性**:低延迟数据传输 - -## 🎉 预期效果 - -### 连接成功: -- ✅ 立即找到匹配的NUS服务 -- ✅ 快速建立数据通道 -- ✅ 开始接收ECG数据 - -### 数据处理: -- ✅ 数据传递给DataManager -- ✅ 进行通道映射和滤波 -- ✅ 显示实时ECG波形 - -### 应用功能: -- ✅ 实时心率计算 -- ✅ 信号质量评估 -- ✅ 12导联ECG分析 - -## ⚠️ 注意事项 - -### 1. 数据格式 -- NUS通常发送原始数据 -- 可能需要特定的数据解析逻辑 -- 注意字节序和数据对齐 - -### 2. 设备状态 -- 确保设备处于数据发送状态 -- 某些设备需要特定命令激活数据流 -- 检查设备电池和连接状态 - -### 3. 数据质量 -- 监控数据接收频率 -- 检查数据包大小是否合理 -- 观察信号质量指标 - -现在请重新连接你的设备,应该能够成功建立数据通道并开始接收ECG数据了!🎯 diff --git a/PERMISSION_GUIDE.md b/PERMISSION_GUIDE.md deleted file mode 100644 index 5b1cad7..0000000 --- a/PERMISSION_GUIDE.md +++ /dev/null @@ -1,183 +0,0 @@ -# 蓝牙权限申请指南 - -## 🔐 权限说明 - -在Android 12及以上版本,使用蓝牙功能需要动态申请以下权限: - -### 必需权限 -1. **BLUETOOTH_SCAN** - 扫描蓝牙设备 -2. **BLUETOOTH_CONNECT** - 连接蓝牙设备 -3. **ACCESS_FINE_LOCATION** - 精确位置(蓝牙扫描需要) -4. **ACCESS_COARSE_LOCATION** - 粗略位置(蓝牙扫描需要) - -## 📱 权限申请流程 - -### 首次启动应用 -1. 应用启动时会显示当前权限状态 -2. 点击"连接蓝牙"按钮 -3. 系统弹出权限申请对话框 -4. 选择"允许"授予所有权限 - -### 权限状态显示 -``` -权限状态: -BLUETOOTH_SCAN: ✓ -BLUETOOTH_CONNECT: ✓ -ACCESS_FINE_LOCATION: ✓ -ACCESS_COARSE_LOCATION: ✓ -``` - -## ⚠️ 常见问题 - -### 1. 权限被拒绝 -**症状**:按钮显示"权限被拒绝",无法使用蓝牙功能 -**解决**: -- 进入系统设置 → 应用管理 → 本应用 → 权限 -- 手动开启蓝牙和位置权限 -- 重新启动应用 - -### 2. 部分权限缺失 -**症状**:提示"缺少权限: BLUETOOTH_SCAN, ACCESS_FINE_LOCATION" -**解决**: -- 确保所有4个权限都已授予 -- 位置权限对于蓝牙扫描是必需的 - -### 3. 权限申请对话框不出现 -**症状**:点击按钮无反应 -**解决**: -- 检查应用是否被系统限制 -- 重启应用或设备 -- 检查系统版本是否支持 - -## 🔧 技术实现 - -### 权限检查代码 -```kotlin -private fun checkAndRequestPermissions(): Boolean { - val missingPermissions = mutableListOf() - - for (permission in REQUIRED_PERMISSIONS) { - if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) { - missingPermissions.add(permission) - } - } - - if (missingPermissions.isNotEmpty()) { - updateStatus("需要蓝牙权限: ${missingPermissions.joinToString(", ")}") - ActivityCompat.requestPermissions( - this, - missingPermissions.toTypedArray(), - PERMISSION_REQUEST_CODE - ) - return false - } - - return true -} -``` - -### 权限结果处理 -```kotlin -override fun onRequestPermissionsResult( - requestCode: Int, - permissions: Array, - grantResults: IntArray -) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults) - - when (requestCode) { - PERMISSION_REQUEST_CODE -> { - val allGranted = grantResults.all { it == PackageManager.PERMISSION_GRANTED } - - if (allGranted) { - updateStatus("蓝牙权限已授予,开始扫描...") - startBluetoothScan() - } else { - updateStatus("蓝牙权限被拒绝,无法使用蓝牙功能") - binding.bluetoothButton.text = "权限被拒绝" - binding.bluetoothButton.setBackgroundColor(Color.parseColor("#9E9E9E")) - } - } - } -} -``` - -## 📋 权限清单 - -### AndroidManifest.xml -```xml - - - - - - - - - - - -``` - -### MainActivity.kt -```kotlin -companion object { - private const val PERMISSION_REQUEST_CODE = 1001 - private val REQUIRED_PERMISSIONS = arrayOf( - Manifest.permission.BLUETOOTH_SCAN, - Manifest.permission.BLUETOOTH_CONNECT, - Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.ACCESS_COARSE_LOCATION - ) - - // Used to load the 'cmake_project_test' library on application startup. - init { - System.loadLibrary("cmake_project_test") - } -} -``` - -## 🎯 最佳实践 - -### 1. 权限申请时机 -- 在用户主动点击蓝牙功能时申请 -- 避免应用启动时强制申请 -- 提供清晰的权限用途说明 - -### 2. 用户体验 -- 显示当前权限状态 -- 提供权限被拒绝时的解决方案 -- 友好的错误提示 - -### 3. 兼容性 -- 支持Android 6.0+的动态权限 -- 兼容Android 12+的新蓝牙权限 -- 处理权限被拒绝的情况 - -## 🔍 调试技巧 - -### 检查权限状态 -```kotlin -private fun checkPermissionStatus(): String { - val status = mutableListOf() - - for (permission in REQUIRED_PERMISSIONS) { - val granted = ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED - status.add("${permission.split(".").last()}: ${if (granted) "✓" else "✗"}") - } - - return status.joinToString("\n") -} -``` - -### 日志输出 -- 权限申请过程会记录详细日志 -- 可通过Logcat查看权限状态变化 -- 便于调试权限相关问题 - -## 📝 注意事项 - -1. **位置权限必需**:蓝牙扫描需要位置权限,这是系统要求 -2. **权限持久性**:权限一旦授予,应用重启后仍然有效 -3. **系统限制**:某些系统可能限制权限申请 -4. **版本兼容**:不同Android版本的权限要求可能不同 diff --git a/RAW_DATA_DISPLAY_GUIDE.md b/RAW_DATA_DISPLAY_GUIDE.md deleted file mode 100644 index 25d83d1..0000000 --- a/RAW_DATA_DISPLAY_GUIDE.md +++ /dev/null @@ -1,132 +0,0 @@ -# 蓝牙原始数据显示测试指南 - -## 🎯 功能说明 - -现在应用已经支持**立即显示蓝牙接收到的原始数据**,无需等待信号处理完成。 - -### 数据流处理顺序 -1. **蓝牙数据接收** → 立即解析 -2. **原始数据显示** → 立即画图(新增) -3. **信号处理** → 后台处理 -4. **处理后数据显示** → 可选显示 - -## 📱 测试步骤 - -### 第一步:连接蓝牙设备 -1. **点击"连接蓝牙"按钮** -2. **等待扫描完成** -3. **选择你的ECG设备** -4. **等待连接成功** - -### 第二步:验证原始数据显示 -1. **观察状态信息**: - ``` - 蓝牙状态: 设备已连接 - 蓝牙状态: 服务发现成功 - 蓝牙状态: 数据通道已建立,开始接收数据 - ``` - -2. **检查数据接收日志**: - ``` - 接收到蓝牙数据: X 字节 - 解析出 X 个数据包 - 立即发送原始数据到图表,处理 X 个数据包 - 发送原始数据到通道 X,数据长度: X - 显示原始数据到图表,通道: X,数据长度: X - ``` - -3. **观察图表显示**: - - ✅ ECG图表立即变为可见 - - ✅ 原始波形立即开始显示 - - ✅ 双视图同步更新(10秒节奏视图 + 2.5秒波形视图) - -### 第三步:验证实时性 -1. **数据接收延迟**:应该几乎无延迟 -2. **图表更新频率**:与蓝牙数据接收频率一致 -3. **波形连续性**:原始数据应该连续显示 - -## 🔍 关键代码位置 - -### 1. 原始数据发送 -```kotlin -// DataManager.kt - 第97行 -sendRawDataToCharts(packets) - -// DataManager.kt - 第177行 -private fun sendRawDataToCharts(packets: List) { - // 直接处理原始数据包,不进行通道映射 - for (packet in packets) { - val channelData = packet.getChannelData() - if (channelData != null) { - for ((channelIndex, channel) in channelData.withIndex()) { - // 立即发送原始数据到图表 - realTimeCallback?.onRawDataAvailable(channelIndex, channel) - } - } - } -} -``` - -### 2. 原始数据显示 -```kotlin -// MainActivity.kt - 第572行 -override fun onRawDataAvailable(channelIndex: Int, rawData: List) { - // 立即显示原始数据到图表 - binding.ecgChartContainer.visibility = View.VISIBLE - binding.ecgRhythmView.updateData(rawData) - binding.ecgWaveformView.updateData(rawData) -} -``` - -## 🎉 预期效果 - -### 连接成功时: -- ✅ 蓝牙设备连接成功 -- ✅ 数据通道建立 -- ✅ 开始接收ECG数据 - -### 原始数据显示时: -- ✅ ECG图表立即可见 -- ✅ 原始波形立即显示 -- ✅ 双视图同步更新 -- ✅ 无延迟实时显示 - -### 数据特点: -- **原始性**:未经任何滤波处理 -- **实时性**:接收即显示 -- **连续性**:数据流连续 -- **完整性**:包含所有通道数据 - -## ⚠️ 注意事项 - -### 1. 数据格式 -- **支持的数据类型**:所有已定义的数据类型 -- **通道数量**:根据设备类型自动识别 -- **采样率**:根据数据类型自动设置 - -### 2. 显示特点 -- **原始信号**:包含噪声和基线漂移 -- **实时更新**:与蓝牙数据接收同步 -- **多通道**:支持多通道同时显示 - -### 3. 性能考虑 -- **UI线程**:原始数据显示在主线程 -- **内存使用**:图表自动管理数据缓冲 -- **更新频率**:与蓝牙数据接收频率一致 - -## 🔧 调试信息 - -### 关键日志 -``` -DataManager: 立即发送原始数据到图表,处理 X 个数据包 -DataManager: 发送原始数据到通道 X,数据长度: X -MainActivity: 显示原始数据到图表,通道: X,数据长度: X -``` - -### 状态检查 -- 蓝牙连接状态 -- 数据接收频率 -- 图表更新状态 -- 通道数据完整性 - -现在你的应用可以立即显示蓝牙接收到的原始数据了!连接设备后就能看到实时波形。🚀 diff --git a/README_REFACTOR.md b/README_REFACTOR.md deleted file mode 100644 index e5d7da7..0000000 --- a/README_REFACTOR.md +++ /dev/null @@ -1,136 +0,0 @@ -# 代码重构说明 - -## 重构目标 -将原本堆在一个MainActivity.kt文件中的所有逻辑分解为多个职责明确的Kotlin文件,提高代码的可读性和可维护性。 - -## 重构后的文件结构 - -### 1. **MainActivity.kt** - 主界面控制器 -**职责**: 主界面逻辑和生命周期管理 -- 初始化各个管理器 -- 处理按钮点击事件 -- 管理Activity生命周期 -- 协调各个管理器之间的交互 - -**主要方法**: -- `onCreate()`: 初始化UI和管理器 -- `onBleNotify()`: 蓝牙数据通知处理 -- `resetData()`: 重置数据 -- `reloadData()`: 重新加载数据 - -### 2. **Constants.kt** - 常量定义 -**职责**: 集中管理应用中的所有常量 -- UI更新间隔 -- 数据分块大小 -- 缓冲区管理阈值 -- 显示限制参数 - -**主要常量**: -- `UPDATE_INTERVAL`: UI更新间隔(500ms) -- `CHUNK_SIZE`: 数据分块大小(64字节) -- `BUFFER_CLEANUP_THRESHOLD`: 缓冲区清理阈值(50) -- `BUFFER_KEEP_COUNT`: 缓冲区保留数量(30) - -### 3. **DeviceTypeHelper.kt** - 设备类型工具 -**职责**: 设备类型相关的工具方法 -- 设备类型名称映射 -- 通道数据详情构建 - -**主要方法**: -- `getDeviceName()`: 根据数据类型获取设备名称 -- `buildChannelDetails()`: 构建通道数据详情字符串 - -### 4. **DataManager.kt** - 数据管理器 -**职责**: 数据解析、缓冲管理和原生方法调用 -- 管理流式解析器 -- 处理数据块 -- 管理数据缓冲区 -- 调用原生C++方法 - -**主要方法**: -- `ensureParser()`: 确保解析器已创建 -- `onBleNotify()`: 处理蓝牙通知数据块 -- `processFileData()`: 处理文件数据 -- `cleanupBuffer()`: 智能清理缓冲区 -- `resetData()`: 重置所有数据 - -**原生方法**: -- `createStreamParser()`: 创建流式解析器 -- `streamParserAppend()`: 向解析器追加数据 -- `streamParserDrainPackets()`: 从解析器拉取数据包 -- `destroyStreamParser()`: 销毁解析器 - -### 5. **UiManager.kt** - UI管理器 -**职责**: UI更新、统计信息构建和显示逻辑 -- 管理UI更新调度 -- 构建统计信息 -- 构建显示内容 -- 触发UI更新 - -**主要方法**: -- `scheduleUiUpdate()`: 计划UI更新,避免频繁刷新 -- `buildStatisticsString()`: 构建统计信息字符串 -- `buildDisplayContent()`: 构建完整的显示内容 -- `updateDisplay()`: 更新UI显示内容 - -### 6. **FileHelper.kt** - 文件帮助类 -**职责**: 文件读取操作 -- 从assets文件夹读取文件 - -**主要方法**: -- `readAssetFile()`: 读取assets文件到字节数组 - -## 重构优势 - -### 1. **职责分离** -- 每个类都有明确的单一职责 -- 降低了类之间的耦合度 -- 提高了代码的可测试性 - -### 2. **代码复用** -- 工具方法可以在多个地方复用 -- 减少了重复代码 -- 提高了开发效率 - -### 3. **维护性提升** -- 修改某个功能只需要修改对应的类 -- 代码结构更清晰,易于理解 -- 降低了修改的风险 - -### 4. **可扩展性** -- 新增功能可以创建新的类 -- 现有类可以独立扩展 -- 支持团队协作开发 - -## 数据流向 - -``` -MainActivity → DataManager → 原生解析器 - ↓ -UiManager ← DataManager ← 数据缓冲区 - ↓ - UI更新 -``` - -## 使用说明 - -1. **MainActivity**: 作为入口点,协调各个管理器 -2. **DataManager**: 处理所有数据相关的操作 -3. **UiManager**: 负责所有UI相关的更新 -4. **DeviceTypeHelper**: 提供设备类型相关的工具方法 -5. **Constants**: 集中管理所有常量 -6. **FileHelper**: 处理文件读取操作 - -## 注意事项 - -1. **原生方法**: 所有原生C++方法现在都在DataManager中声明 -2. **依赖关系**: 各个类之间有明确的依赖关系,避免循环依赖 -3. **线程安全**: UI更新始终在主线程进行,数据操作在后台线程进行 -4. **错误处理**: 每个类都有适当的错误处理和日志记录 - -## 后续优化建议 - -1. **依赖注入**: 可以考虑使用Dagger或Koin进行依赖注入 -2. **响应式编程**: 可以考虑使用RxJava或Flow进行数据流处理 -3. **单元测试**: 重构后的代码更容易进行单元测试 -4. **文档完善**: 可以添加更详细的API文档和示例代码 diff --git a/README_蓝牙指令使用说明.md b/README_蓝牙指令使用说明.md deleted file mode 100644 index 24422ed..0000000 --- a/README_蓝牙指令使用说明.md +++ /dev/null @@ -1,173 +0,0 @@ -# 蓝牙指令使用说明 - -## 功能概述 -本应用支持通过蓝牙连接设备后,发送指令让设备开始发送单导联ECG数据,然后实时显示ECG曲线图。专门优化用于处理单导联蓝牙实时数据,不再读取12导联文件数据。 - -## 支持的协议 - -### 轻迅蓝牙通信协议 V1.0.1 -本应用完全支持轻迅蓝牙通信协议,包括: -- 数据服务 UUID: `6e400001-b5a3-f393-e0a9-68716563686f` -- Write Characteristic UUID: `6e400002-b5a3-f393-e0a9-68716563686f` -- Notify Characteristic UUID: `6e400003-b5a3-f393-e0a9-68716563686f` - -## 使用步骤 - -### 1. 连接蓝牙设备 -- 点击"连接蓝牙"按钮 -- 选择您的ECG设备 -- 等待连接成功 - -### 2. 发送指令 -连接成功后,点击"发送指令"按钮,会弹出指令选择对话框: - -#### 指令类型选择: -- **轻迅协议指令**:使用轻迅蓝牙通信协议的标准指令 -- **通用指令**:使用通用的字符串或十六进制指令 -- **自定义指令**:输入自定义指令 - -### 3. 轻迅协议指令 -选择"轻迅协议指令"后,会显示以下选项: - -#### 采集控制: -- **开启采集**:发送功能码 `0x0001` 开启数据采集 -- **停止采集**:发送功能码 `0x0001` 停止数据采集 - -#### 设备信息: -- **查询设备信息**:发送功能码 `0x0000` 查询设备参数 -- **查询电量**:发送功能码 `0x0002` 查询设备电量 - -#### 滤波控制: -- **工频滤波开关**:发送功能码 `0x000A` 控制工频滤波 - -### 4. 通用指令 -选择"通用指令"后,会显示以下选项: -- **开始发送数据**:让设备开始发送数据流 -- **停止发送数据**:让设备停止发送数据 -- **开始ECG测量**:开始ECG信号测量 -- **停止ECG测量**:停止ECG信号测量 - -#### 常用指令格式: -- **字符串指令**:`START_DATA`, `STOP`, `BEGIN`, `END` -- **十六进制指令**:`0x01`, `0x02`, `0x53 0x54 0x41 0x52 0x54` - -### 5. 查看实时图表 -发送指令成功后,应用会自动启动数据处理并立即开始绘制单导联ECG图表: -- **自动启动**:发送指令后自动启动数据处理,无需手动点击"启动程序" -- **实时显示**:单导联数据接收后立即更新图表,无需等待 -- **单导联处理**:专门处理单导联ECG数据,主要显示主导联信号 -- **ECG节律视图**:显示10秒的连续单导联信号 -- **ECG波形视图**:显示2.5秒的放大单导联信号 - -### 6. 其他功能 -- **陷波滤波**:开启/关闭50Hz陷波滤波器 -- **清空数据**:清空所有图表数据 -- **停止程序**:停止数据处理 - -## 轻迅协议指令详解 - -### 采集控制指令 - -#### 开启采集 (功能码: 0x0001) -``` -数据包格式: [功能码(2字节)] [数据长度(2字节)] [采集开关(1字节)] [时间戳(8字节)] [CRC16(2字节)] -示例: 01 00 09 00 01 [时间戳8字节] [CRC16 2字节] -``` - -**时间戳选项:** -- **立即开启**:时间戳设为0 -- **延迟开启**:时间戳设为未来时间(毫秒) -- **指定时间戳**:自定义时间戳 - -#### 停止采集 (功能码: 0x0001) -``` -数据包格式: [功能码(2字节)] [数据长度(2字节)] [采集开关(1字节)] [时间戳(8字节)] [CRC16(2字节)] -示例: 01 00 09 00 00 [时间戳8字节] [CRC16 2字节] -``` - -### 设备信息指令 - -#### 查询设备信息 (功能码: 0x0000) -``` -数据包格式: [功能码(2字节)] [数据长度(2字节)] [CRC16(2字节)] -示例: 00 00 00 00 [CRC16 2字节] -``` - -#### 查询电量 (功能码: 0x0002) -``` -数据包格式: [功能码(2字节)] [数据长度(2字节)] [CRC16(2字节)] -示例: 02 00 00 00 [CRC16 2字节] -``` - -### 滤波控制指令 - -#### 工频滤波开关 (功能码: 0x000A) -``` -数据包格式: [功能码(2字节)] [数据长度(2字节)] [开关状态(1字节)] [CRC16(2字节)] -开启示例: 0A 00 01 00 01 [CRC16 2字节] -关闭示例: 0A 00 01 00 00 [CRC16 2字节] -``` - -## 通用指令示例 - -### 开始数据流 -``` -START_DATA -START -0x01 -``` - -### 停止数据流 -``` -STOP_DATA -STOP -0x02 -``` - -### 开始ECG测量 -``` -START_ECG -ECG_START -0x03 -``` - -### 停止ECG测量 -``` -STOP_ECG -ECG_STOP -0x04 -``` - -## 注意事项 -1. 确保蓝牙设备已正确连接 -2. 根据您的设备协议选择合适的指令类型 -3. 轻迅协议设备会自动计算CRC16校验,确保数据完整性 -4. 如果指令发送失败,请检查设备是否支持该指令 -5. 可以尝试不同的指令格式(字符串或十六进制) - -## 调试信息 -应用会在状态栏显示详细的调试信息,包括: -- 蓝牙连接状态 -- 指令发送结果(包括轻迅协议数据包内容) -- 数据接收情况 -- 图表更新状态 - -## 轻迅协议特性 -- **自动CRC16校验**:所有指令包都包含CRC16-CCITT-FALSE校验 -- **时间戳同步**:支持指定时间戳进行同步采集 -- **工频滤波控制**:可动态开启/关闭工频滤波算法 -- **设备信息查询**:支持查询设备状态和电量信息 -- **自动数据处理**:发送指令后自动启动数据处理,无需手动操作 -- **实时图表更新**:单导联数据接收后立即更新图表,提供最佳用户体验 -- **单导联优化**:专门优化处理单导联ECG数据,不再处理12导联文件数据 - -## 测试功能 -- **点击"启动程序"按钮**:立即生成测试数据并显示图表,用于验证图表显示功能 -- **长按"启动程序"按钮**:生成更复杂的模拟ECG数据 -- **蓝牙数据测试**:当接收到蓝牙数据但原生解析器无法解析时,会自动生成测试数据 - -### 测试数据说明 -应用会生成模拟的单导联ECG波形,包含: -- P波、QRS复合波、T波等典型ECG特征 -- 适当的噪声模拟真实信号 -- 500个数据点用于充分显示波形 diff --git a/SIGNAL_PROCESSOR_README.md b/SIGNAL_PROCESSOR_README.md deleted file mode 100644 index 9732d20..0000000 --- a/SIGNAL_PROCESSOR_README.md +++ /dev/null @@ -1,271 +0,0 @@ -# 信号处理JNI封装使用说明 - -## 概述 - -本项目已将C++信号处理功能封装成JNI接口,可以在Android应用中直接调用。主要功能包括: - -- 各种数字滤波器(带通、低通、高通、陷波) -- 信号质量评估 -- ECG信号质量指数计算 -- 信号特征提取 -- 信号预处理(归一化、去直流等) -- 实时信号处理 - -## 文件结构 - -``` -app/src/main/ -├── cpp/ -│ ├── include/cpp/ -│ │ └── signal_processor.h # C++头文件 -│ ├── src/ -│ │ └── signal_processor.cpp # C++实现文件(需要修复乱码) -│ ├── jni/ -│ │ └── jni_bridge.cpp # 统一的JNI桥接文件(包含所有JNI函数) -│ └── CMakeLists.txt # 构建配置 -└── java/ - └── com/example/cmake_project_test/ - ├── SignalProcessorJNI.kt # Java JNI接口类 - └── SignalProcessorExample.kt # 使用示例类 -``` - -## 使用方法 - -### 1. 基本使用 - -```kotlin -// 创建信号处理器实例 -val signalProcessor = SignalProcessorJNI() -if (signalProcessor.createProcessor()) { - // 使用各种信号处理功能 - val filteredSignal = signalProcessor.bandpassFilter( - inputSignal, - sampleRate = 1000.0, - lowFreq = 40.0, - highFreq = 60.0 - ) - - // 清理资源 - signalProcessor.destroyProcessor() -} -``` - -### 2. 主要功能 - -#### 数字滤波 - -```kotlin -// 带通滤波 -val bandpassResult = signalProcessor.bandpassFilter( - signal, sampleRate, lowFreq, highFreq -) - -// 低通滤波 -val lowpassResult = signalProcessor.lowpassFilter( - signal, sampleRate, cutoffFreq -) - -// 高通滤波 -val highpassResult = signalProcessor.highpassFilter( - signal, sampleRate, cutoffFreq -) - -// 陷波滤波(去除工频干扰) -val notchResult = signalProcessor.notchFilter( - signal, sampleRate, notchFreq, qFactor -) -``` - -#### 信号质量评估 - -```kotlin -// 计算信号质量指数 -val quality = signalProcessor.calculateSignalQuality(signal) - -// 计算ECG信号质量指数 -val ecgSQI = signalProcessor.calculateECGSQI(ecgSignal, sampleRate) - -// 计算两个信号的相关性 -val correlation = signalProcessor.calculateCorrelation(signal1, signal2) -``` - -#### 信号预处理 - -```kotlin -// 归一化信号幅度 -signalProcessor.normalizeAmplitude(signal) - -// 提取信号特征 -val features = signalProcessor.extractFeatures(signal, sampleRate) - -// 重置滤波器状态 -signalProcessor.resetFilters() -``` - -#### 实时处理 - -```kotlin -// 实时处理数据块 -val processedChunk = signalProcessor.processRealtimeChunk( - chunk, sampleRate -) -``` - -### 3. 完整示例 - -参考 `SignalProcessorExample.kt` 文件,其中包含了所有功能的演示代码: - -```kotlin -val example = SignalProcessorExample() - -// 运行所有演示 -example.runAllDemonstrations() - -// 清理资源 -example.cleanup() -``` - -## 数据格式 - -### 输入数据 - -- 所有信号数据使用 `FloatArray` 格式 -- 采样率使用 `Double` 类型 -- 频率参数使用 `Double` 类型 - -### 输出数据 - -- 滤波结果返回 `FloatArray?`(可能为null表示处理失败) -- 质量指数返回 `Float` 类型(0.0-1.0) -- 相关性返回 `Float` 类型(-1.0到1.0) - -### 内部转换 - -- Java端使用 `ByteBuffer` 进行 `FloatArray` 和 `ByteArray` 的转换 -- C++端使用 `std::vector` 处理数据 -- 字节序使用小端序(Little Endian) - -## 注意事项 - -### 1. 资源管理 - -```kotlin -// 必须在使用前创建处理器 -if (!signalProcessor.createProcessor()) { - // 处理创建失败的情况 - return -} - -// 使用完毕后必须销毁处理器 -signalProcessor.destroyProcessor() -``` - -### 2. 错误处理 - -```kotlin -val result = signalProcessor.bandpassFilter(signal, sampleRate, lowFreq, highFreq) -if (result != null) { - // 处理成功 - processResult(result) -} else { - // 处理失败 - Log.e("SignalProcessor", "滤波处理失败") -} -``` - -### 3. 性能考虑 - -- 滤波器初始化有一定开销,建议复用处理器实例 -- 大数据量处理时考虑分块处理 -- 实时处理时注意内存分配 - -### 4. 线程安全 - -- JNI函数不是线程安全的 -- 多线程使用时需要适当的同步机制 -- 建议在主线程或专用工作线程中使用 - -## 构建配置 - -### CMakeLists.txt - -确保在 `CMakeLists.txt` 中包含了信号处理库: - -```cmake -# Signal processor static library -add_library(signal_processor STATIC - src/signal_processor.cpp) - -target_include_directories(signal_processor PUBLIC - ${CMAKE_CURRENT_SOURCE_DIR}/include) - -# 链接到主库 -target_link_libraries(${CMAKE_PROJECT_NAME} - core_math - data_parser - signal_processor - android - log) -``` - -**注意**:信号处理的JNI函数现在已整合到 `jni_bridge.cpp` 中,不再需要单独的 `signal_processor_jni.cpp` 文件。 - -### 库加载 - -在Java代码中确保正确加载原生库: - -```kotlin -companion object { - init { - System.loadLibrary("cmake_project_test") - } -} -``` - -## 故障排除 - -### 1. 编译错误 - -- 检查 `CMakeLists.txt` 配置 -- 确保所有源文件路径正确 -- 检查头文件包含路径 - -### 2. 运行时错误 - -- 检查Logcat中的错误信息 -- 确保处理器已正确创建 -- 检查输入数据格式和大小 - -### 3. 性能问题 - -- 使用Android Profiler分析性能瓶颈 -- 考虑减少数据转换开销 -- 优化滤波器参数 - -## 扩展功能 - -### 1. 添加新的滤波器 - -1. 在 `signal_processor.h` 中声明新方法 -2. 在 `signal_processor.cpp` 中实现 -3. 在 `signal_processor_jni.cpp` 中添加JNI封装 -4. 在 `SignalProcessorJNI.kt` 中添加Java接口 - -### 2. 自定义信号处理 - -可以基于现有的JNI接口构建更高级的信号处理功能: - -```kotlin -class AdvancedSignalProcessor(private val jni: SignalProcessorJNI) { - fun adaptiveFilter(signal: FloatArray, sampleRate: Double): FloatArray? { - // 实现自适应滤波算法 - // 使用JNI接口调用底层功能 - } -} -``` - -## 总结 - -通过JNI封装,您现在可以在Android应用中直接使用C++的高性能信号处理功能。这种架构既保持了C++的性能优势,又提供了Java/Kotlin的易用性。 - -建议在使用前先运行示例代码,熟悉各种功能的使用方法,然后根据具体需求进行定制化开发。 diff --git a/SMART_UUID_DETECTION.md b/SMART_UUID_DETECTION.md deleted file mode 100644 index 6cd9523..0000000 --- a/SMART_UUID_DETECTION.md +++ /dev/null @@ -1,151 +0,0 @@ -# 智能UUID检测指南 - -## 🎯 问题分析 - -从你提供的日志可以看出: -``` -发现特征: 00002a29-0000-1000-8000-00805f9b34fb -发现特征: 00002a24-0000-1000-8000-00805f9b34fb -发现特征: 00002a25-0000-1000-8000-00805f9b34fb -``` - -这些都是**标准蓝牙GATT服务**的特征UUID: -- `00002a29` = Manufacturer Name String -- `00002a24` = Model Number String -- `00002a25` = Serial Number String -- `00002a27` = Hardware Revision String -- `00002a26` = Firmware Revision String -- `00002a28` = Software Revision String - -## 🔧 解决方案 - -### 1. 扩展UUID列表 ✅ -- **新增**:14个常见ECG设备UUID变体 -- **覆盖**:更多厂商的设备协议 -- **范围**:`0000ff00` 到 `0000ffb0` - -### 2. 智能UUID检测 ✅ -- **实现**:自动检测非标准蓝牙服务 -- **逻辑**:跳过标准服务(`00002a`、`000018`) -- **目标**:查找自定义服务和特征 - -### 3. 通知特征检测 ✅ -- **实现**:检查特征是否支持通知 -- **条件**:`PROPERTY_NOTIFY` 或 `PROPERTY_INDICATE` -- **目的**:确保能接收数据 - -## 📱 检测流程 - -### 第一步:预设UUID匹配 -``` -尝试匹配预设的ECG设备UUID: -- 0000fff0-0000-1000-8000-00805f9b34fb -- 0000ffe0-0000-1000-8000-00805f9b34fb -- 0000ffe5-0000-1000-8000-00805f9b34fb -- ... (共15个变体) -``` - -### 第二步:智能检测 -``` -如果预设UUID未找到匹配: -1. 扫描所有服务 -2. 跳过标准蓝牙服务(00002a, 000018) -3. 查找自定义服务 -4. 检查特征是否支持通知 -5. 自动选择第一个匹配的特征 -``` - -### 第三步:建立连接 -``` -找到匹配的服务和特征后: -1. 启用特征通知 -2. 开始接收数据 -3. 显示"数据通道已建立" -``` - -## 🚀 测试步骤 - -### 第一步:重新连接设备 -1. **断开当前连接** -2. **重新点击"连接蓝牙"** -3. **选择你的设备** - -### 第二步:观察调试信息 -1. **查看服务发现过程**: - ``` - 蓝牙状态: 服务发现成功 - 设备提供的服务数量: X - 发现服务: [UUID列表] - ``` - -2. **查看智能检测过程**: - ``` - 预设UUID未找到匹配,尝试智能检测 - 发现自定义服务: [UUID] - 发现自定义特征: [UUID] - 找到支持通知的特征: [UUID] - ``` - -3. **查看连接结果**: - ``` - 数据通道已建立,开始接收数据 - ``` - -### 第三步:验证数据接收 -1. **检查状态信息**: - - ✅ "数据通道已建立,开始接收数据" - - ✅ "接收到数据: X 字节" - -2. **检查ECG图表**: - - ✅ 图表变为可见 - - ✅ 开始显示实时数据 - -## 🔍 常见问题 - -### 1. 设备使用标准服务 -**现象**:只显示`00002a`开头的UUID -**解决**:智能检测会自动跳过这些服务 - -### 2. 设备需要特定触发 -**现象**:连接成功但没有数据 -**解决**:可能需要发送特定命令激活数据流 - -### 3. 设备使用非标准UUID -**现象**:UUID不在预设列表中 -**解决**:智能检测会自动发现自定义UUID - -## 📋 设备信息收集 - -请提供以下信息: - -### 1. 设备基本信息 -- **设备名称**:你的ECG设备显示的名称 -- **设备型号**:如果知道的话 -- **厂商信息**:设备制造商 - -### 2. 服务UUID信息 -``` -请提供完整的服务发现日志: -- 所有发现的服务UUID -- 所有发现的特征UUID -- 智能检测的结果 -``` - -### 3. 连接状态 -- **是否成功建立数据通道?** -- **是否开始接收数据?** -- **数据大小是多少字节?** - -## 🎉 预期效果 - -### 成功情况: -- ✅ 显示"数据通道已建立,开始接收数据" -- ✅ 开始接收ECG数据 -- ✅ ECG图表显示实时波形 - -### 需要进一步调试: -- ❌ 仍然显示"未找到匹配的服务或特征" -- ℹ️ 需要查看完整的服务发现日志 -- 🔧 可能需要手动配置特定UUID - -现在请重新连接你的设备,观察新的调试信息!🎯 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md deleted file mode 100644 index 3f158f6..0000000 --- a/TESTING_GUIDE.md +++ /dev/null @@ -1,295 +0,0 @@ -# 完整功能测试指南 - -## 🎯 测试目标 - -验证应用的完整功能,包括: -- ✅ 蓝牙权限申请 -- ✅ 蓝牙设备扫描和连接 -- ✅ 实时数据接收和处理 -- ✅ ECG曲线图显示 -- ✅ 信号处理和指标计算 - -## 📱 测试环境准备 - -### 1. 设备要求 -- **Android设备**:Android 6.0+(推荐Android 12+) -- **蓝牙功能**:支持BLE或传统蓝牙 -- **位置权限**:需要位置权限用于蓝牙扫描 - -### 2. 心电设备 -- **真实设备**:支持BLE或传统蓝牙的心电设备 -- **模拟设备**:可以使用蓝牙调试工具模拟数据 - -### 3. 开发环境 -- **Android Studio**:最新版本 -- **ADB工具**:用于日志查看和调试 - -## 🚀 测试步骤 - -### 第一步:应用安装和启动 - -1. **编译应用** - ```bash - ./gradlew assembleDebug - ``` - -2. **安装到设备** - ```bash - adb install app/build/outputs/apk/debug/app-debug.apk - ``` - -3. **启动应用** - - 在设备上找到并启动应用 - - 观察启动界面和权限状态显示 - -### 第二步:权限申请测试 - -1. **查看权限状态** - ``` - 权限状态: - BLUETOOTH_SCAN: ✗ - BLUETOOTH_CONNECT: ✗ - ACCESS_FINE_LOCATION: ✗ - ACCESS_COARSE_LOCATION: ✗ - ``` - -2. **点击"连接蓝牙"按钮** - - 系统应弹出权限申请对话框 - - 选择"允许"授予所有权限 - -3. **验证权限状态** - ``` - 权限状态: - BLUETOOTH_SCAN: ✓ - BLUETOOTH_CONNECT: ✓ - ACCESS_FINE_LOCATION: ✓ - ACCESS_COARSE_LOCATION: ✓ - ``` - -### 第三步:蓝牙扫描测试 - -1. **开始扫描** - - 点击"连接蓝牙"按钮 - - 按钮文字应变为"扫描中..." - - 按钮颜色应变为橙色 - -2. **观察扫描过程** - ``` - 蓝牙状态: 正在扫描蓝牙设备... - 发现设备: ECG_Device_001 (00:11:22:33:44:55) - 扫描完成,找到 1 个设备 - ``` - -3. **设备选择对话框** - - 扫描完成后应弹出设备选择对话框 - - 显示发现的设备列表 - -### 第四步:设备连接测试 - -1. **选择设备** - - 在设备列表中选择目标设备 - - 点击设备名称进行连接 - -2. **连接过程** - ``` - 正在连接设备: ECG_Device_001 - 设备已连接 - 服务发现成功 - 数据通道已建立 - ``` - -3. **连接状态验证** - - 按钮文字应变为"断开蓝牙" - - 按钮颜色应变为红色 - -### 第五步:数据接收测试 - -1. **启动数据采集** - - 点击"启动程序"按钮 - - 观察ECG曲线图开始显示数据 - -2. **数据接收验证** - ``` - 接收到蓝牙数据: 128 字节 - 接收到蓝牙数据: 256 字节 - 接收到蓝牙数据: 512 字节 - ``` - -3. **实时显示验证** - - ECG节律视图(10秒)应显示实时曲线 - - ECG波形视图(2.5秒)应显示详细波形 - - 曲线应平滑滚动,无卡顿 - -### 第六步:信号处理测试 - -1. **滤波功能** - - 点击"陷波滤波"按钮切换滤波状态 - - 观察曲线图的变化 - -2. **指标计算** - - 查看状态信息中的心率等指标 - - 验证指标计算的准确性 - -3. **性能测试** - - 观察应用的响应速度 - - 检查内存使用情况 - -## 🔍 调试技巧 - -### 1. 日志查看 -```bash -# 查看蓝牙相关日志 -adb logcat | grep BluetoothManager - -# 查看权限相关日志 -adb logcat | grep MainActivity - -# 查看数据处理日志 -adb logcat | grep DataManager - -# 查看信号处理日志 -adb logcat | grep StreamingSignalProcessor -``` - -### 2. 常见日志模式 -``` -BluetoothManager: 蓝牙初始化成功 -BluetoothManager: BLE扫描已开始 -BluetoothManager: 发现BLE设备: ECG_Device_001 -BluetoothManager: 设备已连接 -BluetoothManager: 接收到数据: 128 字节 -``` - -### 3. 错误诊断 -```bash -# 查看错误日志 -adb logcat | grep -i error - -# 查看警告日志 -adb logcat | grep -i warning -``` - -## ⚠️ 常见问题排查 - -### 1. 权限问题 -**症状**:点击按钮无反应 -**排查**: -```bash -# 检查权限状态 -adb shell dumpsys package com.example.cmake_project_test | grep permission -``` - -**解决**: -- 进入系统设置手动授予权限 -- 重启应用 - -### 2. 蓝牙扫描问题 -**症状**:扫描无结果 -**排查**: -```bash -# 检查蓝牙状态 -adb shell dumpsys bluetooth -``` - -**解决**: -- 确认设备蓝牙已开启 -- 确认设备处于可发现状态 -- 检查设备距离 - -### 3. 连接问题 -**症状**:连接失败 -**排查**: -```bash -# 查看连接日志 -adb logcat | grep -i "connection\|connect" -``` - -**解决**: -- 确认设备未被其他应用占用 -- 检查设备电池电量 -- 重启设备和应用 - -### 4. 数据显示问题 -**症状**:曲线图无数据 -**排查**: -```bash -# 查看数据处理日志 -adb logcat | grep -i "data\|process" -``` - -**解决**: -- 检查数据格式是否正确 -- 确认数据处理管道正常工作 -- 检查UI更新逻辑 - -## 📊 性能指标 - -### 1. 响应时间 -- **权限申请**:< 1秒 -- **蓝牙扫描**:< 10秒 -- **设备连接**:< 5秒 -- **数据接收**:实时 - -### 2. 内存使用 -- **应用启动**:< 100MB -- **数据采集**:< 200MB -- **长时间运行**:< 300MB - -### 3. 电池消耗 -- **待机状态**:< 5%/小时 -- **数据采集**:< 15%/小时 - -## 🎉 测试完成标准 - -### 功能完整性 -- ✅ 权限申请正常工作 -- ✅ 蓝牙扫描发现设备 -- ✅ 设备连接成功 -- ✅ 数据接收正常 -- ✅ 实时显示流畅 -- ✅ 信号处理有效 -- ✅ 指标计算准确 - -### 用户体验 -- ✅ 界面响应及时 -- ✅ 状态提示清晰 -- ✅ 错误处理友好 -- ✅ 操作流程顺畅 - -### 稳定性 -- ✅ 长时间运行稳定 -- ✅ 内存使用合理 -- ✅ 电池消耗正常 -- ✅ 无崩溃或卡死 - -## 📝 测试报告模板 - -``` -测试日期:YYYY-MM-DD -测试设备:设备型号 + Android版本 -测试人员:姓名 - -功能测试结果: -□ 权限申请:通过/失败 -□ 蓝牙扫描:通过/失败 -□ 设备连接:通过/失败 -□ 数据接收:通过/失败 -□ 实时显示:通过/失败 -□ 信号处理:通过/失败 -□ 指标计算:通过/失败 - -性能测试结果: -□ 响应时间:正常/异常 -□ 内存使用:正常/异常 -□ 电池消耗:正常/异常 - -问题记录: -1. 问题描述 -2. 复现步骤 -3. 解决方案 - -总体评价: -□ 优秀 □ 良好 □ 一般 □ 需要改进 -``` - -现在你的应用已经具备了完整的功能,可以进行全面的测试了!🎉 diff --git a/UI_FIX_TEST_GUIDE.md b/UI_FIX_TEST_GUIDE.md deleted file mode 100644 index 44abfc7..0000000 --- a/UI_FIX_TEST_GUIDE.md +++ /dev/null @@ -1,182 +0,0 @@ -# UI修复测试指南 - -## 🎯 修复内容 - -### 1. 图表隐藏功能 ✅ -- **问题**:ECG图表在启动时就显示,占用屏幕空间 -- **解决**:图表默认隐藏,只有在接收到数据时才显示 -- **实现**: - ```xml - android:visibility="gone" - ``` - -### 2. 设备选择对话框 ✅ -- **问题**:扫描完成后没有显示设备选择对话框 -- **解决**:确保`onScanComplete`回调正确显示对话框 -- **实现**: - ```kotlin - override fun onScanComplete(devices: List) { - if (devices.isNotEmpty()) { - val dialog = BluetoothDeviceDialog.newInstance(devices) - dialog.show(supportFragmentManager, "BluetoothDeviceDialog") - } - } - ``` - -## 📱 现在的UI布局 - -### 启动时的界面 -``` -┌─────────────────────────────────────────┐ -│ [连接蓝牙] [启动程序] │ -│ [停止程序] [陷波滤波] │ -└─────────────────────────────────────────┘ -┌─────────────────────────────────────────┐ -│ 状态信息区域 │ -│ [应用已就绪,可以开始使用] │ -│ [权限状态信息] │ -│ [点击"连接蓝牙"按钮开始蓝牙连接...] │ -└─────────────────────────────────────────┘ -``` - -### 有数据时的界面 -``` -┌─────────────────────────────────────────┐ -│ [连接蓝牙] [启动程序] │ -│ [停止程序] [陷波滤波] │ -└─────────────────────────────────────────┘ -┌─────────────────────────────────────────┐ -│ ECG实时监测 │ -│ ┌─────────────────────────────────────┐ │ -│ │ [实时ECG曲线图] │ │ -│ └─────────────────────────────────────┘ │ -│ ┌─────────────────────────────────────┐ │ -│ │ [详细ECG波形图] │ │ -│ └─────────────────────────────────────┘ │ -└─────────────────────────────────────────┘ -┌─────────────────────────────────────────┐ -│ 状态信息区域 │ -│ [蓝牙状态信息] │ -│ [数据处理状态] │ -└─────────────────────────────────────────┘ -``` - -## 🚀 测试步骤 - -### 第一步:验证启动界面 -1. **启动应用** -2. **确认界面**: - - ✅ 只显示按钮区域和状态信息区域 - - ✅ ECG图表区域完全隐藏 - - ✅ 按钮没有超出屏幕边界 - -### 第二步:测试蓝牙扫描 -1. **点击"连接蓝牙"按钮** -2. **观察扫描过程**: - ``` - 蓝牙状态: 正在扫描蓝牙设备... - 发现设备: [设备名称] ([地址]) - 发现设备: [设备名称] ([地址]) - ``` - -### 第三步:验证设备选择对话框 -1. **扫描完成后**: - - ✅ 应该弹出设备选择对话框 - - ✅ 显示所有发现的设备列表 - - ✅ 每个设备显示名称和地址 - -### 第四步:测试设备连接 -1. **选择任意设备**: - - 点击设备名称 - - 观察连接过程 -2. **验证连接状态**: - - 按钮文字变为"断开蓝牙" - - 按钮颜色变为红色 - -### 第五步:测试数据显示 -1. **连接成功后点击"启动程序"** -2. **观察图表显示**: - - ✅ ECG图表区域应该出现 - - ✅ 显示实时数据曲线 - - ✅ 界面布局合理,不超出屏幕 - -## 🔧 关键代码修改 - -### 1. 布局文件修改 -```xml - - -``` - -### 2. 数据显示逻辑 -```kotlin -override fun onDataReceived(data: ByteArray) { - runOnUiThread { - updateStatus("接收到蓝牙数据: ${data.size} 字节") - dataManager.onBleNotify(data) - - // 显示ECG图表 - binding.ecgChartContainer.visibility = View.VISIBLE - } -} - -override fun onProcessedDataAvailable(channelIndex: Int, processedData: List) { - if (channelIndex == 0) { - try { - // 显示ECG图表 - binding.ecgChartContainer.visibility = View.VISIBLE - - binding.ecgRhythmView.updateData(processedData) - binding.ecgWaveformView.updateData(processedData) - } catch (e: Exception) { - Log.e("MainActivity", "处理实时数据失败: ${e.message}", e) - } - } -} -``` - -## ⚠️ 注意事项 - -### 1. 设备选择对话框 -- 确保`BluetoothDeviceDialog.kt`文件存在 -- 确保对话框正确显示设备列表 -- 确保点击设备后能正确连接 - -### 2. 图表显示时机 -- 图表只在有数据时才显示 -- 蓝牙数据接收时显示 -- 处理后的数据更新时显示 - -### 3. 界面适配 -- 按钮布局适应不同屏幕尺寸 -- 图表区域合理分配空间 -- 状态信息区域保持可见 - -## 🎉 预期效果 - -### 启动时: -- ✅ 界面简洁,只显示必要元素 -- ✅ 按钮布局合理,不超出屏幕 -- ✅ 状态信息清晰可见 - -### 扫描时: -- ✅ 显示扫描进度和设备发现信息 -- ✅ 扫描完成后弹出设备选择对话框 - -### 连接后: -- ✅ 显示连接状态 -- ✅ 可以启动数据处理 - -### 有数据时: -- ✅ ECG图表自动显示 -- ✅ 实时更新数据曲线 -- ✅ 界面布局完整合理 - -现在可以测试修复后的UI了!🎉 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dd61251..ec13d73 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,7 +12,7 @@ android { minSdk = 24 targetSdk = 36 versionCode = 1 - versionName = "1.0" + versionName = "1.0.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" externalNativeBuild { @@ -22,6 +22,15 @@ android { } } + signingConfigs { + create("release") { + storeFile = file("keystore.jks") + storePassword = "android123" + keyAlias = "key0" + keyPassword = "android123" + } + } + buildTypes { release { isMinifyEnabled = false @@ -29,6 +38,10 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) + signingConfig = signingConfigs.getByName("release") + } + debug { + signingConfig = signingConfigs.getByName("release") } } compileOptions { diff --git a/app/keystore.jks b/app/keystore.jks new file mode 100644 index 0000000000000000000000000000000000000000..55786dca1cb221aa01aeb8256a2cd37c83cfc592 GIT binary patch literal 2613 zcma)+cQ_l08pe}|Ahl~oQgetX5l@>v*Hnp{4 zbF3Cs+MsH4=Lfo1p#VGzgN#vL;O>42qJ zh6ON|VfGiEL||#3{g*^r1jf=5e&M`dlLfi*9~UDO2r9+WYW;$02=~7nv`h#)gwo$9 zBSHeqp2-7#c88 z9L)OGZW|(Us4KE{wDD0VG@~7i1yl5^^@FA?VqQI$>L`BoCrSxNP(a8{A6a};+MSrs zELAf^*XnUw`^&>I4bM1cw09mn8#3r~$kjeq_84CQ>oVOV1{xm`W|vdG*!V34YMB-l z*e{(|cry-(b2i`bWPvZuwP7aYI+ad>sG~zY?v(Oqv8OoM&%usTo>rsu%KEVld^762t4ISwww8rcf&t~l8ZcG(StZ z9&+xe6{L9LnzKK?twja~}4(@SHlb2+f!Mgdzm45Q=vk&Ak zWBsyhibE8YhU9cw#=E;Iy7R5X4gK@EsZ_U_fCuS-ixM4&IJfWNI|k@8}9TQk$ z63;%3Q^(7-=O)e#y5E`mqUMzYEn^b~P=VxeHt0jmVsd`RvKqO~+TFYdw{E2Ne7laR zw~y~N_7>zDyNp5ttOMO!glubG{mNWAsTzsdt!h)Bq0GpjHn9O30c)`VH{pC*h& zkJ@%aRYnyQ0-ZWrqmk{snFCSNy!|;-N39c+_G^ycu>&DXLVDEwI$sz&odU&k>kiN`XoL^I4%SYP3gx9TX|0NM#-`?69VjWmOmXi{wD1lu~AAIX@XyfPb9S)Y>fwakla z#`>Q9PFB=Xn{W~M)Q~_FGlg3@fJRhFCmebqPY;@4qGR~?zX|Qve3KWJgu_NZ;Gb}R z7t61ma8kbfK`aU_rwnDAyxcJFmh#c7fP=#)TIpteog#_S8MQ)R*z)jOM(^Y z8wfu)^R!I1J10{L-k5EKA$b`GH3|$@J_oJ7ZfT7x&$ukduvZp72;e#y4paAeCs;1n z3fE30w^-oHEhUwEGMr6vWX7VYeN~HsyX@km&^_b(Hh3E^GTSo)wcUN(szxMK?hugE zR`~LUe%eiUth43j!ruD+<^A_~DdV-+pU8PpIu4;)4Oiq zf|6E=|3HQ;0grc!Y8xjLu5&pEQ8n)z4i&kGIw@!iU@i2=9?&RDr${~Go3CQ6D^fRA zC$m@I53XmO?E*|?o|K?bZiEF{p%4asLdhr12u+f_&s^u+ex}5&v-hcnr!8ae1{bXX!y86Cbve3K2W`1i7)@COf^>EdInx8#txICkGZ&9U+DHz zf4x{7XV%reZ|hjPV_mc2A8ap@FvVb^CKGyWcVEY4?!D$rYiXFeVowC|qpAU7Y@<>U z*%f1gZ`eLXT0I+MnOqA8NaEb3Rdl`vNBB-Ap1zw!UCLXK7j30&^$(7Sg|2=nh^>el(#1NH)-t z$eAgaI_d;n*4fnFU_RvPkfV{f$k!!lSdsZzuYtn`kVKE6fc7MwsnhGaEz0egN!n+w7OR3t_y9)ICMCRW4xkNnMU&5W8G(e+CEqv-jZdRk3dV)lBkb4 z{(R3qv{AP{vdQ!FJh_qe&&^O>)XC^x#I>82gB;gS>OIU+`Ad%CsMyN2@0BrTA|h2u z6~D2AQE?77BnG$V#|t6iPxea+InT34G}_4169&B}?d&~K1qcm<0D=KRE5S?yg3$uN z952@yjCg2Vu;ph{$3BZIp2ASKd@#pd0WaP&Zpj~D&fgUgoS%cXhux?k8X&IpUz@+i A5&!@I literal 0 HcmV?d00001 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2534e3d..37e9051 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,6 +15,10 @@ + + + + @@ -27,16 +31,39 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" - android:theme="@style/Theme.Cmake_project_test"> + android:theme="@style/Theme.Cmake_project_test" + android:description="@string/app_description" + android:requestLegacyExternalStorage="true"> + android:exported="true" + android:launchMode="singleTop" + android:configChanges="orientation|screenSize|keyboardHidden"> + + + + + + + \ No newline at end of file diff --git a/app/src/main/cpp/include/cpp/signal_processor.h b/app/src/main/cpp/include/cpp/signal_processor.h index 2163c7e..7580a5d 100644 --- a/app/src/main/cpp/include/cpp/signal_processor.h +++ b/app/src/main/cpp/include/cpp/signal_processor.h @@ -172,6 +172,14 @@ private: const std::vector>& channels); std::vector> apply_stethoscope_filters( const std::vector>& channels); + + // 新增:单导联心电专用处理函数 + std::vector> apply_single_lead_ecg_filters( + const std::vector>& channels); + std::vector enhance_signal_quality( + const std::vector& signal, double sample_rate); + std::vector apply_smoothing( + const std::vector& signal, int window_size); }; diff --git a/app/src/main/cpp/src/data_praser.cpp b/app/src/main/cpp/src/data_praser.cpp index 8179f5e..2b0f574 100644 --- a/app/src/main/cpp/src/data_praser.cpp +++ b/app/src/main/cpp/src/data_praser.cpp @@ -241,13 +241,14 @@ SensorData parse_pw_ecg_sl(const uint8_t* data) { payload += 1; // 解析单通道ECG数据 (115个采样点) - auto& channel = result.channel_data.emplace>(); - channel.reserve(115); + auto& channels = result.channel_data.emplace>>(); + channels.resize(1); // 单通道 + channels[0].reserve(115); for (int i = 0; i < 115; ++i) { int16_t adc_value = read_le(payload); payload += 2; - channel.push_back(adc_value * 0.318f); // 转换为μV,与12导联心电保持一致 + channels[0].push_back(adc_value * 0.288f); // 转换为μV,与12导联心电保持一致 } // 跳过预留1字节 diff --git a/app/src/main/cpp/src/signal_processor.cpp b/app/src/main/cpp/src/signal_processor.cpp index 70cb937..3362222 100644 --- a/app/src/main/cpp/src/signal_processor.cpp +++ b/app/src/main/cpp/src/signal_processor.cpp @@ -178,6 +178,9 @@ std::vector> SignalProcessor::apply_channel_filters( case DataType::ECG_12LEAD: filtered_channels = apply_ecg_filters(channels); break; + case DataType::PW_ECG_SL: + filtered_channels = apply_single_lead_ecg_filters(channels); + break; case DataType::PPG: filtered_channels = apply_ppg_filters(channels); break; @@ -1571,3 +1574,117 @@ FeatureSet SignalProcessor::extract_stethoscope_features(const SensorData& data) return result; } +// 新增:单导联心电专用信号处理函数 +std::vector> SignalProcessor::apply_single_lead_ecg_filters( + const std::vector>& channels) { + + std::vector> filtered_channels = channels; + + for (auto& channel : filtered_channels) { + if (channel.empty()) continue; + + try { + // 1. 去除直流分量 + channel = remove_dc_offset(channel); + + // 2. 0.5Hz高通滤波(去除基线漂移) + channel = filter(channel, 250.0, 0, 0.5, filtertype::highpass); + + // 3. 50Hz陷波滤波(去除工频干扰) + channel = filter(channel, 250.0, 49.5, 50.5, filtertype::notchpass); + + // 4. 运动伪迹检测和去除 + channel = remove_motion_artifacts(channel, 250.0); + + // 5. 信号质量评估和增强 + channel = enhance_signal_quality(channel, 250.0); + + } catch (const std::exception& e) { + std::cerr << "单导联心电滤波处理失败: " << e.what() << std::endl; + } + } + + return filtered_channels; +} + +// 新增:信号质量增强函数 +std::vector SignalProcessor::enhance_signal_quality( + const std::vector& signal, double sample_rate) { + + if (signal.empty()) return signal; + + std::vector enhanced_signal = signal; + + try { + // 1. 计算信号质量指标 + double mean_val = 0.0; + double variance = 0.0; + + for (float val : signal) { + mean_val += val; + } + mean_val /= signal.size(); + + for (float val : signal) { + double diff = val - mean_val; + variance += diff * diff; + } + variance /= signal.size(); + + double std_dev = std::sqrt(variance); + + // 2. 异常值检测和修正 + const double threshold = 3.0 * std_dev; // 3σ原则 + for (size_t i = 0; i < enhanced_signal.size(); ++i) { + if (std::abs(enhanced_signal[i] - mean_val) > threshold) { + // 使用前后点的平均值替换异常值 + if (i > 0 && i < enhanced_signal.size() - 1) { + enhanced_signal[i] = (enhanced_signal[i-1] + enhanced_signal[i+1]) / 2.0f; + } else if (i > 0) { + enhanced_signal[i] = enhanced_signal[i-1]; + } else if (i < enhanced_signal.size() - 1) { + enhanced_signal[i] = enhanced_signal[i+1]; + } + } + } + + // 3. 平滑处理(轻微) + enhanced_signal = apply_smoothing(enhanced_signal, 3); + + } catch (const std::exception& e) { + std::cerr << "信号质量增强失败: " << e.what() << std::endl; + } + + return enhanced_signal; +} + +// 新增:平滑处理函数 +std::vector SignalProcessor::apply_smoothing( + const std::vector& signal, int window_size) { + + if (signal.empty() || window_size < 3) return signal; + + std::vector smoothed_signal = signal; + int half_window = window_size / 2; + + for (size_t i = 0; i < signal.size(); ++i) { + double sum = 0.0; + int count = 0; + + // 计算窗口内的平均值 + for (int j = -half_window; j <= half_window; ++j) { + int idx = static_cast(i) + j; + if (idx >= 0 && idx < static_cast(signal.size())) { + sum += signal[idx]; + count++; + } + } + + if (count > 0) { + smoothed_signal[i] = static_cast(sum / count); + } + } + + return smoothed_signal; +} + diff --git a/app/src/main/java/com/example/cmake_project_test/BluetoothManager.kt b/app/src/main/java/com/example/cmake_project_test/BluetoothManager.kt index 698ab3b..9fee58b 100644 --- a/app/src/main/java/com/example/cmake_project_test/BluetoothManager.kt +++ b/app/src/main/java/com/example/cmake_project_test/BluetoothManager.kt @@ -26,6 +26,9 @@ import java.util.UUID */ class BluetoothManager(private val context: Context) { + // PPTM命令编码器 + private val pptmEncoder = PPTMCommandEncoder() + companion object { private const val TAG = "BluetoothManager" @@ -110,61 +113,15 @@ class BluetoothManager(private val context: Context) { // 常见的ECG设备UUID列表 private val SERVICE_UUIDS = listOf( UUID.fromString("6e400001-b5a3-f393-e0a9-68716563686f"), // Nordic UART Service (NUS) - 你的设备 - UUID.fromString("0000fff0-0000-1000-8000-00805f9b34fb"), // 默认 - UUID.fromString("0000ffe0-0000-1000-8000-00805f9b34fb"), // 常见变体1 - UUID.fromString("0000ffe5-0000-1000-8000-00805f9b34fb"), // 常见变体2 - UUID.fromString("0000ff00-0000-1000-8000-00805f9b34fb"), // 常见变体3 - UUID.fromString("0000ff10-0000-1000-8000-00805f9b34fb"), // 常见变体4 - UUID.fromString("0000ff20-0000-1000-8000-00805f9b34fb"), // 常见变体5 - UUID.fromString("0000ff30-0000-1000-8000-00805f9b34fb"), // 常见变体6 - UUID.fromString("0000ff40-0000-1000-8000-00805f9b34fb"), // 常见变体7 - UUID.fromString("0000ff50-0000-1000-8000-00805f9b34fb"), // 常见变体8 - UUID.fromString("0000ff60-0000-1000-8000-00805f9b34fb"), // 常见变体9 - UUID.fromString("0000ff70-0000-1000-8000-00805f9b34fb"), // 常见变体10 - UUID.fromString("0000ff80-0000-1000-8000-00805f9b34fb"), // 常见变体11 - UUID.fromString("0000ff90-0000-1000-8000-00805f9b34fb"), // 常见变体12 - UUID.fromString("0000ffa0-0000-1000-8000-00805f9b34fb"), // 常见变体13 - UUID.fromString("0000ffb0-0000-1000-8000-00805f9b34fb") // 常见变体14 ) private val CHARACTERISTIC_UUIDS = listOf( UUID.fromString("6e400002-b5a3-f393-e0a9-68716563686f"), // Nordic UART TX Characteristic - 发送数据 - UUID.fromString("6e400003-b5a3-f393-e0a9-68716563686f"), // Nordic UART RX Characteristic - 接收数据(Notify) - UUID.fromString("0000fff1-0000-1000-8000-00805f9b34fb"), // 默认 - UUID.fromString("0000ffe1-0000-1000-8000-00805f9b34fb"), // 常见变体1 - UUID.fromString("0000ffe6-0000-1000-8000-00805f9b34fb"), // 常见变体2 - UUID.fromString("0000ff01-0000-1000-8000-00805f9b34fb"), // 常见变体3 - UUID.fromString("0000ff11-0000-1000-8000-00805f9b34fb"), // 常见变体4 - UUID.fromString("0000ff21-0000-1000-8000-00805f9b34fb"), // 常见变体5 - UUID.fromString("0000ff31-0000-1000-8000-00805f9b34fb"), // 常见变体6 - UUID.fromString("0000ff41-0000-1000-8000-00805f9b34fb"), // 常见变体7 - UUID.fromString("0000ff51-0000-1000-8000-00805f9b34fb"), // 常见变体8 - UUID.fromString("0000ff61-0000-1000-8000-00805f9b34fb"), // 常见变体9 - UUID.fromString("0000ff71-0000-1000-8000-00805f9b34fb"), // 常见变体10 - UUID.fromString("0000ff81-0000-1000-8000-00805f9b34fb"), // 常见变体11 - UUID.fromString("0000ff91-0000-1000-8000-00805f9b34fb"), // 常见变体12 - UUID.fromString("0000ffa1-0000-1000-8000-00805f9b34fb"), // 常见变体13 - UUID.fromString("0000ffb1-0000-1000-8000-00805f9b34fb") // 常见变体14 ) // Notify特征UUID列表 private val NOTIFY_CHARACTERISTIC_UUIDS = listOf( UUID.fromString("6e400003-b5a3-f393-e0a9-68716563686f"), // Nordic UART RX Characteristic - 你的设备 - UUID.fromString("0000fff2-0000-1000-8000-00805f9b34fb"), // 默认Notify - UUID.fromString("0000ffe2-0000-1000-8000-00805f9b34fb"), // 常见变体1 Notify - UUID.fromString("0000ffe7-0000-1000-8000-00805f9b34fb"), // 常见变体2 Notify - UUID.fromString("0000ff02-0000-1000-8000-00805f9b34fb"), // 常见变体3 Notify - UUID.fromString("0000ff12-0000-1000-8000-00805f9b34fb"), // 常见变体4 Notify - UUID.fromString("0000ff22-0000-1000-8000-00805f9b34fb"), // 常见变体5 Notify - UUID.fromString("0000ff32-0000-1000-8000-00805f9b34fb"), // 常见变体6 Notify - UUID.fromString("0000ff42-0000-1000-8000-00805f9b34fb"), // 常见变体7 Notify - UUID.fromString("0000ff52-0000-1000-8000-00805f9b34fb"), // 常见变体8 Notify - UUID.fromString("0000ff62-0000-1000-8000-00805f9b34fb"), // 常见变体9 Notify - UUID.fromString("0000ff72-0000-1000-8000-00805f9b34fb"), // 常见变体10 Notify - UUID.fromString("0000ff82-0000-1000-8000-00805f9b34fb"), // 常见变体11 Notify - UUID.fromString("0000ff92-0000-1000-8000-00805f9b34fb"), // 常见变体12 Notify - UUID.fromString("0000ffa2-0000-1000-8000-00805f9b34fb"), // 常见变体13 Notify - UUID.fromString("0000ffb2-0000-1000-8000-00805f9b34fb") // 常见变体14 Notify ) // 设备名称前缀 (根据你的设备调整) @@ -179,6 +136,14 @@ class BluetoothManager(private val context: Context) { // 连接状态 private var isConnected = false private var isConnecting = false + private var isClassicScanReceiverRegistered = false + + // Handler和延迟任务 + private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper()) + private var bleScanStopRunnable: Runnable? = null + + // 清理状态标志 + private var isCleaningUp = false // 设备列表 private val discoveredDevices = mutableListOf() @@ -360,10 +325,27 @@ class BluetoothManager(private val context: Context) { Log.d(TAG, "BLE扫描已开始") callback?.onStatusChanged("📡 BLE扫描进行中...") + // 取消之前的延迟任务(如果存在) + bleScanStopRunnable?.let { mainHandler.removeCallbacks(it) } + // 15秒后停止扫描 - android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ - stopBleScan() - }, 15000) + bleScanStopRunnable = Runnable { + try { + // 检查是否正在清理 + if (isCleaningUp) { + Log.d(TAG, "正在清理中,跳过延迟BLE扫描停止") + return@Runnable + } + stopBleScan() + } catch (e: Exception) { + Log.e(TAG, "延迟停止BLE扫描失败: ${e.message}") + // 如果是FragmentManager相关错误,记录但不抛出异常 + if (e.message?.contains("FragmentManager") == true) { + Log.w(TAG, "FragmentManager已销毁,延迟BLE扫描停止操作被忽略") + } + } + } + mainHandler.postDelayed(bleScanStopRunnable!!, 15000) } catch (e: Exception) { Log.e(TAG, "BLE扫描失败: ${e.message}") @@ -412,7 +394,19 @@ class BluetoothManager(private val context: Context) { * 停止BLE扫描 */ private fun stopBleScan() { + // 如果正在清理,直接返回 + if (isCleaningUp) { + Log.d(TAG, "正在清理中,跳过BLE扫描停止") + return + } + try { + // 取消延迟任务 + bleScanStopRunnable?.let { + mainHandler.removeCallbacks(it) + bleScanStopRunnable = null + } + bluetoothLeScanner?.stopScan(bleScanCallback) Log.d(TAG, "BLE扫描已停止") @@ -433,6 +427,10 @@ class BluetoothManager(private val context: Context) { callback?.onScanComplete(discoveredDevices.toList()) } catch (e: Exception) { Log.e(TAG, "停止BLE扫描失败: ${e.message}") + // 如果是FragmentManager相关错误,记录但不抛出异常 + if (e.message?.contains("FragmentManager") == true) { + Log.w(TAG, "FragmentManager已销毁,BLE扫描停止操作被忽略") + } } } @@ -1077,7 +1075,7 @@ class BluetoothManager(private val context: Context) { } /** - * 直接连接到指定MAC地址的设备(用于测试) + * 直接连接到指定MAC地址的设备 */ fun connectToMacAddress(macAddress: String) { if (isConnecting || isConnected) { @@ -1098,11 +1096,6 @@ class BluetoothManager(private val context: Context) { return } - // 验证MAC地址格式 - if (!isValidMacAddress(macAddress)) { - callback?.onError("MAC地址格式错误: $macAddress") - return - } // 尝试获取设备 val device = bluetoothAdapter!!.getRemoteDevice(macAddress) @@ -1166,14 +1159,6 @@ class BluetoothManager(private val context: Context) { } } - /** - * 验证MAC地址格式 - */ - private fun isValidMacAddress(macAddress: String): Boolean { - // 支持多种格式:60:E9:AA:30:8B:0A 或 60-E9-AA-30-8B-0A - val pattern = Regex("^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$") - return pattern.matches(macAddress) - } /** * 计算CRC16-CCITT-FALSE校验 @@ -1557,12 +1542,23 @@ class BluetoothManager(private val context: Context) { * 注册传统蓝牙扫描广播接收器 */ private fun registerClassicScanReceiver() { - val filter = android.content.IntentFilter().apply { - addAction(BluetoothDevice.ACTION_FOUND) - addAction(BluetoothAdapter.ACTION_DISCOVERY_STARTED) - addAction(BluetoothAdapter.ACTION_DISCOVERY_FINISHED) + try { + if (!isClassicScanReceiverRegistered) { + val filter = android.content.IntentFilter().apply { + addAction(BluetoothDevice.ACTION_FOUND) + addAction(BluetoothAdapter.ACTION_DISCOVERY_STARTED) + addAction(BluetoothAdapter.ACTION_DISCOVERY_FINISHED) + } + context.registerReceiver(classicScanReceiver, filter) + isClassicScanReceiverRegistered = true + Log.d(TAG, "传统蓝牙扫描广播接收器已注册") + } else { + Log.d(TAG, "传统蓝牙扫描广播接收器已注册,跳过重复注册") + } + } catch (e: Exception) { + Log.e(TAG, "注册广播接收器失败: ${e.message}") + isClassicScanReceiverRegistered = false } - context.registerReceiver(classicScanReceiver, filter) } /** @@ -1570,9 +1566,17 @@ class BluetoothManager(private val context: Context) { */ private fun unregisterClassicScanReceiver() { try { - context.unregisterReceiver(classicScanReceiver) + // 检查接收器是否已注册 + if (isClassicScanReceiverRegistered) { + context.unregisterReceiver(classicScanReceiver) + isClassicScanReceiverRegistered = false + Log.d(TAG, "传统蓝牙扫描广播接收器已注销") + } else { + Log.d(TAG, "传统蓝牙扫描广播接收器未注册,跳过注销") + } } catch (e: Exception) { Log.e(TAG, "注销广播接收器失败: ${e.message}") + isClassicScanReceiverRegistered = false } } @@ -1650,8 +1654,117 @@ class BluetoothManager(private val context: Context) { * 清理资源 */ fun cleanup() { - disconnect() - stopScan() - unregisterClassicScanReceiver() + try { + Log.d(TAG, "开始清理BluetoothManager资源") + + // 设置清理标志,防止后续操作 + isCleaningUp = true + + // 取消所有延迟任务 + bleScanStopRunnable?.let { + mainHandler.removeCallbacks(it) + bleScanStopRunnable = null + Log.d(TAG, "已取消BLE扫描延迟任务") + } + + // 断开连接 + disconnect() + + // 停止扫描(现在会检查isCleaningUp标志) + stopScan() + + // 注销广播接收器(如果已注册) + if (isClassicScanReceiverRegistered) { + unregisterClassicScanReceiver() + } + + // 清理状态 + isClassicScanReceiverRegistered = false + isConnected = false + isConnecting = false + + Log.d(TAG, "BluetoothManager资源清理完成") + } catch (e: Exception) { + Log.e(TAG, "清理BluetoothManager资源时发生异常: ${e.message}") + } finally { + // 确保清理标志被设置 + isCleaningUp = true + } + } + + // ==================== PPTM设备命令方法 ==================== + + /** + * 发送PPTM开始采样命令 + * 使用PPTM协议编码器生成带CRC16校验的命令 + */ + fun sendPptmStartSampleCmd() { + try { + val command = pptmEncoder.createPptmStartSampleCmd() + Log.d(TAG, "发送PPTM开始采样命令: ${command.joinToString(", ") { "0x%02X".format(it) }}") + sendCommand(command) + callback?.onStatusChanged("📤 已发送PPTM开始采样命令") + } catch (e: Exception) { + Log.e(TAG, "发送PPTM开始采样命令失败: ${e.message}") + callback?.onCommandSent(false, "发送PPTM开始采样命令失败: ${e.message}") + } + } + + /** + * 发送PPTM停止采样命令 + * 使用PPTM协议编码器生成带CRC16校验的命令 + */ + fun sendPptmStopSampleCmd() { + try { + val command = pptmEncoder.createPptmStopSampleCmd() + Log.d(TAG, "发送PPTM停止采样命令: ${command.joinToString(", ") { "0x%02X".format(it) }}") + sendCommand(command) + callback?.onStatusChanged("📤 已发送PPTM停止采样命令") + } catch (e: Exception) { + Log.e(TAG, "发送PPTM停止采样命令失败: ${e.message}") + callback?.onCommandSent(false, "发送PPTM停止采样命令失败: ${e.message}") + } + } + + /** + * 发送自定义PPTM命令 + * @param opcode 操作码 + * @param payload 负载数据 + */ + fun sendCustomPptmCmd(opcode: Short, payload: ByteArray) { + try { + val command = pptmEncoder.createCustomPptmCmd(opcode, payload) + Log.d(TAG, "发送自定义PPTM命令: 操作码=0x%04X, 负载=${payload.joinToString(", ") { "0x%02X".format(it) }}".format(opcode)) + sendCommand(command) + callback?.onStatusChanged("📤 已发送自定义PPTM命令: 操作码=0x%04X".format(opcode)) + } catch (e: Exception) { + Log.e(TAG, "发送自定义PPTM命令失败: ${e.message}") + callback?.onCommandSent(false, "发送自定义PPTM命令失败: ${e.message}") + } + } + + /** + * 验证PPTM数据包CRC16校验 + * @param packet 完整数据包 + * @return 校验是否通过 + */ + fun verifyPptmPacket(packet: ByteArray): Boolean { + return pptmEncoder.verifyCrc16(packet) + } + + /** + * 解析PPTM数据包 + * @param packet 完整数据包 + * @return 解析结果 (操作码, 负载数据) 或 null + */ + fun parsePptmPacket(packet: ByteArray): Pair? { + return pptmEncoder.parsePacket(packet) + } + + /** + * 获取PPTM编码器实例(用于调试) + */ + fun getPptmEncoder(): PPTMCommandEncoder { + return pptmEncoder } } diff --git a/app/src/main/java/com/example/cmake_project_test/DataManager.kt b/app/src/main/java/com/example/cmake_project_test/DataManager.kt index 90b8724..02b4efa 100644 --- a/app/src/main/java/com/example/cmake_project_test/DataManager.kt +++ b/app/src/main/java/com/example/cmake_project_test/DataManager.kt @@ -21,21 +21,38 @@ class DataManager(private val nativeCallback: NativeMethodCallback) { } private var realTimeCallback: RealTimeDataCallback? = null + private val additionalCallbacks = mutableListOf() fun setRealTimeCallback(callback: RealTimeDataCallback) { this.realTimeCallback = callback } + fun addRealTimeCallback(callback: RealTimeDataCallback) { + if (!additionalCallbacks.contains(callback)) { + additionalCallbacks.add(callback) + } + } + + fun removeRealTimeCallback(callback: RealTimeDataCallback) { + try { + additionalCallbacks.remove(callback) + Log.d("DataManager", "已移除实时数据回调,剩余回调数: ${additionalCallbacks.size}") + } catch (e: Exception) { + Log.e("DataManager", "移除实时数据回调失败: ${e.message}", e) + } + } + private var parserHandle: Long = 0L private val rawStream = ByteArrayOutputStream(4096) private val packetBuffer = mutableListOf() + private val mappedPacketBuffer = mutableListOf() // 新增:映射后的数据包缓冲区 private var totalPacketsParsed = 0L // 信号处理相关 private var streamingSignalProcessor: StreamingSignalProcessor? = null private var streamingSignalProcessorInitialized = false private val processedPackets = mutableListOf() - private val filterSettings = FilterSettings() + private var filterSettings = FilterSettings() // 通道映射相关 private var dataMapper: DataMapper? = null @@ -65,7 +82,13 @@ class DataManager(private val nativeCallback: NativeMethodCallback) { */ fun ensureParser() { if (parserHandle == 0L) { - parserHandle = nativeCallback.createStreamParser() + try { + parserHandle = nativeCallback.createStreamParser() + Log.d("DataManager", "JNI解析器创建成功,句柄: $parserHandle") + } catch (e: Exception) { + Log.e("DataManager", "JNI解析器创建失败: ${e.message}", e) + throw RuntimeException("无法创建JNI解析器", e) + } } } @@ -83,10 +106,22 @@ class DataManager(private val nativeCallback: NativeMethodCallback) { ensureParser() rawStream.write(chunk) - nativeCallback.streamParserAppend(parserHandle, chunk) + + try { + nativeCallback.streamParserAppend(parserHandle, chunk) + } catch (e: Exception) { + Log.e("DataManager", "JNI数据追加失败: ${e.message}", e) + return // 不继续处理,避免进一步错误 + } // 拉取解析出的数据包 - val packets = nativeCallback.streamParserDrainPackets(parserHandle) + val packets = try { + nativeCallback.streamParserDrainPackets(parserHandle) + } catch (e: Exception) { + Log.e("DataManager", "JNI数据拉取失败: ${e.message}", e) + null + } + Log.d("DataManager", "解析结果: ${if (packets != null) packets.size else 0} 个数据包") if (!packets.isNullOrEmpty()) { @@ -98,7 +133,7 @@ class DataManager(private val nativeCallback: NativeMethodCallback) { // 立即发送原始数据到图表显示(优先显示原始数据) sendRawDataToCharts(packets) - // 可选:后台进行流式处理(不影响显示) + // 注释掉后台处理,只显示原始数据 // processStreamingData(packets) } else { Log.w("DataManager", "没有解析出有效数据包,尝试生成测试数据") @@ -216,17 +251,98 @@ class DataManager(private val nativeCallback: NativeMethodCallback) { } /** - * 立即发送原始数据到图表显示 - 专门处理单导联蓝牙数据 + * 立即发送映射后的数据到图表显示 */ private fun sendRawDataToCharts(packets: List) { if (packets.isEmpty()) { return } - // 快速处理:减少日志,提高速度 + // 确保数据映射器已初始化 + ensureDataMapper() + if (!dataMapperInitialized || dataMapper == null) { + Log.w("DataManager", "数据映射器未初始化,使用原始数据") + sendRawDataToChartsDirect(packets) + return + } + + try { + Log.d("DataManager", "开始数据映射处理,输入数据包数: ${packets.size}") + + // 清空之前的映射数据 + mappedPacketBuffer.clear() + + // 对每个数据包进行映射 + val mappedPackets = mutableListOf() + for (packet in packets) { + val mappedPacket = dataMapper?.mapSensorData(packet) + if (mappedPacket != null) { + mappedPackets.add(mappedPacket) + mappedPacketBuffer.add(mappedPacket) // 保存到映射缓冲区 + Log.d("DataManager", "数据包映射成功,原始通道数: ${packet.getChannelData()?.size ?: 0}, 映射后通道数: ${mappedPacket.getChannelData()?.size ?: 0}") + } else { + Log.w("DataManager", "数据包映射失败,使用原始数据") + mappedPackets.add(packet) + mappedPacketBuffer.add(packet) + } + } + + // 发送映射后的数据到图表 + sendMappedDataToCharts(mappedPackets) + + } catch (e: Exception) { + Log.e("DataManager", "数据映射处理失败: ${e.message}", e) + // 映射失败时使用原始数据 + sendRawDataToChartsDirect(packets) + } + } + + /** + * 发送映射后的数据到图表 + */ + private fun sendMappedDataToCharts(mappedPackets: List) { + if (mappedPackets.isEmpty()) { + return + } + var totalDataPoints = 0 - // 专门处理单导联数据包 + // 处理映射后的数据包 + for (packet in mappedPackets) { + val channelData = packet.getChannelData() + if (channelData != null && channelData.isNotEmpty()) { + // 发送所有映射后的通道数据 + for (i in channelData.indices) { + val channel = channelData[i] + if (channel.isNotEmpty()) { + // 发送给主回调 + realTimeCallback?.onRawDataAvailable(i, channel) + // 发送给所有附加回调 + additionalCallbacks.forEach { callback -> + callback.onRawDataAvailable(i, channel) + } + totalDataPoints += channel.size + } + } + } + } + + if (totalDataPoints > 0) { + Log.d("DataManager", "发送映射后数据到图表: ${totalDataPoints} 个数据点") + } + } + + /** + * 直接发送原始数据到图表(备用方法) + */ + private fun sendRawDataToChartsDirect(packets: List) { + if (packets.isEmpty()) { + return + } + + var totalDataPoints = 0 + + // 处理原始数据包 for (packet in packets) { val channelData = packet.getChannelData() if (channelData != null && channelData.isNotEmpty()) { @@ -234,10 +350,12 @@ class DataManager(private val nativeCallback: NativeMethodCallback) { val primaryChannel = channelData[0] if (primaryChannel.isNotEmpty()) { // 立即发送单导联数据到图表 - if (realTimeCallback != null) { - realTimeCallback!!.onRawDataAvailable(0, primaryChannel) // 使用通道0表示主导联 - totalDataPoints += primaryChannel.size + realTimeCallback?.onRawDataAvailable(0, primaryChannel) // 使用通道0表示主导联 + // 发送给所有附加回调 + additionalCallbacks.forEach { callback -> + callback.onRawDataAvailable(0, primaryChannel) } + totalDataPoints += primaryChannel.size // 如果有其他通道(如参考通道),也发送 if (channelData.size > 1) { @@ -245,6 +363,9 @@ class DataManager(private val nativeCallback: NativeMethodCallback) { val additionalChannel = channelData[i] if (additionalChannel.isNotEmpty()) { realTimeCallback?.onRawDataAvailable(i, additionalChannel) + additionalCallbacks.forEach { callback -> + callback.onRawDataAvailable(i, additionalChannel) + } } } } @@ -254,7 +375,7 @@ class DataManager(private val nativeCallback: NativeMethodCallback) { // 只在需要时记录日志 if (totalDataPoints > 0) { - Log.d("DataManager", "快速发送原始数据: ${totalDataPoints} 个数据点") + Log.d("DataManager", "发送原始数据到图表: ${totalDataPoints} 个数据点") } } @@ -393,12 +514,12 @@ class DataManager(private val nativeCallback: NativeMethodCallback) { Log.d("DataManager", "发送处理后的数据到图表,长度: ${processedData.size}") Log.d("DataManager", "处理后数据前3个值: ${processedData.take(3).joinToString(", ")}") - if (realTimeCallback != null) { - realTimeCallback!!.onRawDataAvailable(0, processedData) - Log.d("DataManager", "已发送处理后数据到图表") - } else { - Log.e("DataManager", "realTimeCallback 为空,无法发送处理后数据") + realTimeCallback?.onRawDataAvailable(0, processedData) + // 发送给所有附加回调 + additionalCallbacks.forEach { callback -> + callback.onRawDataAvailable(0, processedData) } + Log.d("DataManager", "已发送处理后数据到图表") } // 计算指标(使用第一个处理后的通道) @@ -566,40 +687,6 @@ class DataManager(private val nativeCallback: NativeMethodCallback) { } } - /** - * 清理缓冲区 - */ - fun cleanupBuffer() { - try { - // 清理原始数据包缓冲区,保留最新的100个数据包 - val maxKeepPackets = 100 - if (packetBuffer.size > maxKeepPackets) { - val toRemove = packetBuffer.size - maxKeepPackets - repeat(toRemove) { - if (packetBuffer.isNotEmpty()) { - packetBuffer.removeAt(0) - } - } - Log.d("DataManager", "清理了 $toRemove 个旧数据包,保留 $maxKeepPackets 个最新数据包") - } - - // 清理处理后数据包缓冲区,保留最新的50个数据包 - val maxKeepProcessedPackets = 50 - if (processedPackets.size > maxKeepProcessedPackets) { - val toRemove = processedPackets.size - maxKeepProcessedPackets - repeat(toRemove) { - if (processedPackets.isNotEmpty()) { - processedPackets.removeAt(0) - } - } - Log.d("DataManager", "清理了 $toRemove 个旧处理后数据包,保留 $maxKeepProcessedPackets 个最新数据包") - } - - } catch (e: Exception) { - Log.e("DataManager", "清理缓冲区时发生错误: ${e.message}") - } - } - /** * 计算信号质量 */ @@ -670,7 +757,7 @@ class DataManager(private val nativeCallback: NativeMethodCallback) { val processedChannel = when (packet.getDataType()) { type.SensorData.DataType.EEG -> applyEEGFilters(channel) type.SensorData.DataType.ECG_2LEAD, type.SensorData.DataType.ECG_12LEAD -> applyECGFilters(channel) - type.SensorData.DataType.PPG -> applyPPGFilters(channel) + type.SensorData.DataType.PPG -> channel // PPG不进行滤波处理,直接返回原始数据 else -> channel // 其他类型暂时不处理 } processedChannels.add(processedChannel) @@ -746,7 +833,8 @@ class DataManager(private val nativeCallback: NativeMethodCallback) { val notchFiltered = streamingSignalProcessor!!.notchFilter( lowpassFiltered, filterSettings.sampleRate.toFloat(), - 50.0f + 50.0f, + 10.0f ) if (notchFiltered != null) { @@ -762,6 +850,42 @@ class DataManager(private val nativeCallback: NativeMethodCallback) { return channel // 处理失败时返回原始数据 } + /** + * 清理缓冲区 + */ + fun cleanupBuffer() { + try { + // 清理原始数据包缓冲区,保留最新的100个数据包 + val maxKeepPackets = 100 + if (packetBuffer.size > maxKeepPackets) { + val toRemove = packetBuffer.size - maxKeepPackets + repeat(toRemove) { + if (packetBuffer.isNotEmpty()) { + packetBuffer.removeAt(0) + } + } + Log.d("DataManager", "清理了 $toRemove 个旧数据包,保留 $maxKeepPackets 个最新数据包") + } + + // 清理处理后数据包缓冲区,保留最新的50个数据包 + val maxKeepProcessedPackets = 50 + if (processedPackets.size > maxKeepProcessedPackets) { + val toRemove = processedPackets.size - maxKeepProcessedPackets + repeat(toRemove) { + if (processedPackets.isNotEmpty()) { + processedPackets.removeAt(0) + } + } + Log.d("DataManager", "清理了 $toRemove 个旧处理后数据包,保留 $maxKeepProcessedPackets 个最新数据包") + } + + } catch (e: Exception) { + Log.e("DataManager", "清理缓冲区时发生错误: ${e.message}") + } + } + + + /** * 应用PPG滤波器 */ @@ -796,18 +920,44 @@ class DataManager(private val nativeCallback: NativeMethodCallback) { return channel // 处理失败时返回原始数据 } + /** + * 设置滤波器参数 + */ + fun setFilterSettings(settings: FilterSettings) { + try { + filterSettings = settings + Log.d("DataManager", "滤波器设置已更新: $settings") + } catch (e: Exception) { + Log.e("DataManager", "设置滤波器参数失败: ${e.message}") + } + } + + /** + * 获取当前滤波器设置 + */ + fun getFilterSettings(): FilterSettings { + return filterSettings + } + /** * 清理资源 */ fun cleanup() { try { if (parserHandle != 0L) { - nativeCallback.destroyStreamParser(parserHandle) - parserHandle = 0L + try { + nativeCallback.destroyStreamParser(parserHandle) + Log.d("DataManager", "JNI解析器销毁成功") + } catch (e: Exception) { + Log.e("DataManager", "JNI解析器销毁失败: ${e.message}", e) + } finally { + parserHandle = 0L + } } // 清理缓冲区 packetBuffer.clear() + mappedPacketBuffer.clear() // 清理映射后的数据包缓冲区 processedPackets.clear() calculatedMetrics.clear() channelBuffers.clear() @@ -820,9 +970,39 @@ class DataManager(private val nativeCallback: NativeMethodCallback) { lastProcessTime = 0L totalProcessedSamples = 0L + // 清理回调列表 + additionalCallbacks.clear() + Log.d("DataManager", "资源清理完成") } catch (e: Exception) { Log.e("DataManager", "清理资源时发生错误: ${e.message}") } } + + /** + * 轻量级清理 - 只清理缓冲区,不销毁解析器 + */ + fun lightCleanup() { + try { + // 只清理缓冲区,保持解析器运行 + packetBuffer.clear() + mappedPacketBuffer.clear() + processedPackets.clear() + calculatedMetrics.clear() + channelBuffers.clear() + processedChannelBuffers.clear() + rawStream.reset() + + Log.d("DataManager", "轻量级清理完成,解析器保持运行") + } catch (e: Exception) { + Log.e("DataManager", "轻量级清理时发生错误: ${e.message}", e) + } + } + + /** + * 获取映射后的数据包缓冲区 + */ + fun getMappedPacketBuffer(): List { + return mappedPacketBuffer.toList() + } } diff --git a/app/src/main/java/com/example/cmake_project_test/DynamicChartManager.kt b/app/src/main/java/com/example/cmake_project_test/DynamicChartManager.kt new file mode 100644 index 0000000..38bc595 --- /dev/null +++ b/app/src/main/java/com/example/cmake_project_test/DynamicChartManager.kt @@ -0,0 +1,392 @@ +package com.example.cmake_project_test + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import android.util.Log +import type.SensorData + +/** + * 动态图表管理器 + * 负责根据设备类型动态创建和管理图表 + */ +class DynamicChartManager( + private val context: Context, + private val chartsContainer: LinearLayout, + private val chartTitle: TextView? +) { + + // 全屏按钮点击监听器 + private var onChartFullscreenClickListener: ((chartIndex: Int, chart: DynamicChartView) -> Unit)? = null + + private val charts = mutableListOf() + private var currentDeviceType: SensorData.DataType? = null + private var currentChannelCount = 0 + + /** + * 设备配置信息 + */ + data class DeviceConfig( + val deviceName: String, + val channelCount: Int, + val sampleRate: Float, + val timeWindow: Float, + val maxDataPoints: Int, + val channelNames: List + ) + + /** + * 根据设备类型获取配置 + */ + private fun getDeviceConfig(dataType: SensorData.DataType): DeviceConfig { + return when (dataType) { + SensorData.DataType.EEG -> DeviceConfig( + deviceName = "脑电设备", + channelCount = 8, // 6个EEG + 2个EOG + sampleRate = 250f, + timeWindow = 2.5f, + maxDataPoints = 625, // 250Hz * 2.5s = 625个点 + channelNames = listOf("Fp1", "Fp2", "F3", "F4", "C3", "C4", "EOG1", "EOG2") + ) + + SensorData.DataType.ECG_2LEAD -> DeviceConfig( + deviceName = "双导联心电", + channelCount = 6, // ECG1, ECG2, EMG1, EMG2, 呼吸温度, 呼吸阻抗 + sampleRate = 250f, + timeWindow = 2.5f, + maxDataPoints = 625, // 250Hz * 2.5s = 625个点 + channelNames = listOf("ECG1", "ECG2", "EMG1", "EMG2", "呼吸温度", "呼吸阻抗") + ) + + SensorData.DataType.ECG_12LEAD -> DeviceConfig( + deviceName = "12导联心电", + channelCount = 12, // 映射后12个通道:I, II, III, aVR, aVL, aVF, V1, V2, V3, V4, V5, V6 + sampleRate = 250f, + timeWindow = 2.5f, + maxDataPoints = 625, // 250Hz * 2.5s = 625个点 + channelNames = listOf("I", "II", "III", "aVR", "aVL", "aVF", "V1", "V2", "V3", "V4", "V5", "V6") + ) + + SensorData.DataType.PW_ECG_SL -> DeviceConfig( + deviceName = "单导联心电", + channelCount = 1, + sampleRate = 250f, + timeWindow = 2.5f, + maxDataPoints = 625, // 250Hz * 2.5s = 625个点 + channelNames = listOf("单导联ECG") + ) + + SensorData.DataType.PPG -> DeviceConfig( + deviceName = "血氧设备", + channelCount = 1, // 两个通道合并为一张图 + sampleRate = 100f, + timeWindow = 2.5f, + maxDataPoints = 250, // 100Hz * 2.5s = 250个点 + channelNames = listOf("PPG信号") // 合并通道名称 + ) + + SensorData.DataType.RESPIRATION -> DeviceConfig( + deviceName = "呼吸设备", + channelCount = 1, + sampleRate = 100f, + timeWindow = 2.5f, + maxDataPoints = 250, // 100Hz * 2.5s = 250个点 + channelNames = listOf("呼吸气流") + ) + + SensorData.DataType.SNORE -> DeviceConfig( + deviceName = "鼾声设备", + channelCount = 1, + sampleRate = 1000f, + timeWindow = 2.5f, + maxDataPoints = 2500, // 1000Hz * 2.5s = 2500个点 + channelNames = listOf("鼾声") + ) + + SensorData.DataType.STETHOSCOPE -> DeviceConfig( + deviceName = "数字听诊器", + channelCount = 2, + sampleRate = 1000f, + timeWindow = 2.5f, + maxDataPoints = 2500, // 1000Hz * 2.5s = 2500个点 + channelNames = listOf("听诊通道1", "听诊通道2") + ) + + else -> DeviceConfig( + deviceName = "未知设备", + channelCount = 1, + sampleRate = 250f, + timeWindow = 2.5f, + maxDataPoints = 625, // 250Hz * 2.5s = 625个点 + channelNames = listOf("通道") + ) + } + } + + /** + * 根据设备类型创建图表 + */ + fun createChartsForDevice(dataType: SensorData.DataType) { + val config = getDeviceConfig(dataType) + + Log.d("DynamicChartManager", "=== 开始创建图表 ===") + Log.d("DynamicChartManager", "设备类型: $dataType") + Log.d("DynamicChartManager", "设备名称: ${config.deviceName}") + Log.d("DynamicChartManager", "通道数量: ${config.channelCount}") + Log.d("DynamicChartManager", "当前设备类型: $currentDeviceType") + Log.d("DynamicChartManager", "当前通道数: $currentChannelCount") + + // 如果设备类型没有变化,不需要重新创建 + if (currentDeviceType == dataType && currentChannelCount == config.channelCount) { + Log.d("DynamicChartManager", "设备类型未变化,跳过创建") + return + } + + Log.d("DynamicChartManager", "为设备 ${config.deviceName} 创建 ${config.channelCount} 个图表") + + // 清除现有图表 + clearAllCharts() + + // 更新标题(如果存在) + chartTitle?.text = "${config.deviceName}实时监测" + + // 特殊处理PPG设备:创建多通道单图表 + if (dataType == type.SensorData.DataType.PPG) { + val chartView = DynamicChartView(context) + + // 设置为多通道模式,原始有两个通道 + chartView.setMultiChannelMode(2) + + // 设置图表配置 + chartView.setChartConfig( + title = "红光PPG/红外光PPG", + channelIdx = 1, + device = config.deviceName, + maxPoints = config.maxDataPoints, + sampleRateHz = config.sampleRate, + timeWindowSec = config.timeWindow + ) + + // 设置布局参数 + val layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { + marginStart = 8 + marginEnd = 8 + topMargin = 4 + bottomMargin = 4 + } + + chartsContainer.addView(chartView, layoutParams) + charts.add(chartView) + + // 设置全屏按钮点击监听器 + chartView.setOnFullscreenClickListener { + onChartFullscreenClickListener?.invoke(0, chartView) + } + + currentDeviceType = dataType + currentChannelCount = config.channelCount + return + } + + // 创建新图表 + for (i in 0 until config.channelCount) { + val chartView = DynamicChartView(context) + + // 设置图表配置 + val channelName = if (i < config.channelNames.size) config.channelNames[i] else "通道" + chartView.setChartConfig( + title = channelName, + channelIdx = i + 1, + device = config.deviceName, + maxPoints = config.maxDataPoints, + sampleRateHz = config.sampleRate, + timeWindowSec = config.timeWindow + ) + + // 设置布局参数 + val layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { + marginStart = 8 + marginEnd = 8 + topMargin = 4 + bottomMargin = 4 + } + + // 添加分隔线(除了最后一个图表) + if (i < config.channelCount - 1) { + val separator = View(context).apply { + setBackgroundColor(android.graphics.Color.parseColor("#CCCCCC")) + } + val separatorParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + 2 + ).apply { + marginStart = 8 + marginEnd = 8 + topMargin = 4 + bottomMargin = 4 + } + chartsContainer.addView(separator, separatorParams) + } + + chartsContainer.addView(chartView, layoutParams) + charts.add(chartView) + + // 设置全屏按钮点击监听器 + val chartIndex = i + chartView.setOnFullscreenClickListener { + onChartFullscreenClickListener?.invoke(chartIndex, chartView) + } + } + + currentDeviceType = dataType + currentChannelCount = config.channelCount + + Log.d("DynamicChartManager", "=== 图表创建完成 ===") + Log.d("DynamicChartManager", "成功创建 ${charts.size} 个图表") + Log.d("DynamicChartManager", "图表容器子视图数量: ${chartsContainer.childCount}") + } + + /** + * 更新图表数据 + */ + fun updateChartsData(channelData: List>) { + Log.d("DynamicChartManager", "=== 开始更新图表数据 ===") + Log.d("DynamicChartManager", "图表数量: ${charts.size}") + Log.d("DynamicChartManager", "数据通道数: ${channelData.size}") + + if (charts.isEmpty() || channelData.isEmpty()) { + Log.w("DynamicChartManager", "图表为空或数据为空,跳过更新") + return + } + + // 性能优化:减少日志输出 + val dataChannels = channelData.size + val chartCount = charts.size + + Log.d("DynamicChartManager", "数据通道: $dataChannels, 图表数量: $chartCount") + + // 特殊处理PPG设备的多通道数据 + if (currentDeviceType == type.SensorData.DataType.PPG && chartCount == 1) { + Log.d("DynamicChartManager", "PPG多通道数据更新: 通道数=${channelData.size}, 数据点=${channelData.map { it.size }}") + // PPG设备只有一个图表,但有两个通道的数据 + val chart = charts[0] + chart.updateMultiChannelData(channelData) + return + } + + // 更新每个图表的数据 - 并行处理 + for (i in 0 until minOf(dataChannels, chartCount)) { + val chart = charts[i] + val data = channelData[i] + chart.updateData(data) + } + + // 如果数据通道少于图表,清空多余的图表 + if (dataChannels < chartCount) { + for (i in dataChannels until chartCount) { + charts[i].clearData() + } + } + } + + /** + * 清空所有图表 + */ + fun clearAllCharts() { + try { + Log.d("DynamicChartManager", "=== 开始清空所有图表 ===") + Log.d("DynamicChartManager", "当前图表数量: ${charts.size}") + Log.d("DynamicChartManager", "容器子视图数量: ${chartsContainer.childCount}") + + if (charts.isEmpty()) { + Log.d("DynamicChartManager", "图表列表为空,跳过清理") + return + } + + // 清理图表数据 + charts.forEachIndexed { index, chart -> + try { + Log.d("DynamicChartManager", "清理图表 $index") + Log.d("DynamicChartManager", "图表类型: ${chart.javaClass.simpleName}") + + // 清理图表数据 + chart.clearData() + Log.d("DynamicChartManager", "图表 $index 数据清理完成") + + // 清理触摸事件处理器 + chart.cleanupTouchHandlers() + Log.d("DynamicChartManager", "图表 $index 触摸处理器清理完成") + + } catch (e: Exception) { + Log.e("DynamicChartManager", "清理图表 $index 失败: ${e.message}", e) + Log.e("DynamicChartManager", "异常堆栈: ${e.stackTraceToString()}") + } + } + + // 清空图表列表 + charts.clear() + Log.d("DynamicChartManager", "图表列表已清空") + + // 移除所有子视图 + chartsContainer.removeAllViews() + Log.d("DynamicChartManager", "容器子视图已移除") + + // 重置状态 + currentDeviceType = null + currentChannelCount = 0 + Log.d("DynamicChartManager", "状态已重置") + + Log.d("DynamicChartManager", "=== 清空所有图表完成 ===") + + } catch (e: Exception) { + Log.e("DynamicChartManager", "清空图表时发生异常: ${e.message}", e) + Log.e("DynamicChartManager", "异常堆栈: ${e.stackTraceToString()}") + } + } + + /** + * 重置所有图表视口 + */ + fun resetAllCharts() { + charts.forEach { chart -> + chart.resetViewport() + } + } + + /** + * 获取当前图表列表 + */ + fun getCharts(): List = charts.toList() + + /** + * 获取当前设备类型 + */ + fun getCurrentDeviceType(): SensorData.DataType? = currentDeviceType + + /** + * 获取当前通道数量 + */ + fun getCurrentChannelCount(): Int = currentChannelCount + + /** + * 获取当前设备配置 + */ + fun getCurrentDeviceConfig(): DeviceConfig? { + return currentDeviceType?.let { getDeviceConfig(it) } + } + + /** + * 设置图表全屏按钮点击监听器 + */ + fun setOnChartFullscreenClickListener(listener: (chartIndex: Int, chart: DynamicChartView) -> Unit) { + onChartFullscreenClickListener = listener + } +} diff --git a/app/src/main/java/com/example/cmake_project_test/DynamicChartView.kt b/app/src/main/java/com/example/cmake_project_test/DynamicChartView.kt new file mode 100644 index 0000000..714a013 --- /dev/null +++ b/app/src/main/java/com/example/cmake_project_test/DynamicChartView.kt @@ -0,0 +1,954 @@ +package com.example.cmake_project_test + +import android.content.Context +import android.graphics.* +import android.util.AttributeSet +import android.util.Log +import android.view.MotionEvent +import android.view.View +import android.view.ScaleGestureDetector +import kotlin.math.max +import kotlin.math.min + +/** + * 可调节的通用生理信号图表视图 + * 支持多种设备类型和通道数量的动态显示,具有缩放、平移、时间窗口调节等功能 + */ +class DynamicChartView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + + private val paint = Paint().apply { + color = Color.BLUE + strokeWidth = 2f + style = Paint.Style.STROKE + isAntiAlias = false // 关闭抗锯齿,提高绘制性能 + } + + private val gridPaint = Paint().apply { + color = Color.LTGRAY + strokeWidth = 1f + style = Paint.Style.STROKE + alpha = 80 + } + + private val textPaint = Paint().apply { + color = Color.BLACK + textSize = 20f + isAntiAlias = true + } + + private val controlPaint = Paint().apply { + color = Color.RED + strokeWidth = 3f + style = Paint.Style.STROKE + isAntiAlias = true + } + + private val path = Path() + private var dataPoints = mutableListOf() + private var maxDataPoints = 1000 // 默认数据点数量 + private var minValue = Float.MAX_VALUE + private var maxValue = Float.MIN_VALUE + private var isDataAvailable = false + + // 累积数据点计数器 + private var totalDataPointsReceived = 0L + + // 全屏模式和自定义Y轴范围 + private var isFullscreenMode = false + private var customMinValue: Float? = null + private var customMaxValue: Float? = null + + // 全屏按钮 + private var fullscreenButtonRect = RectF() + private var showFullscreenButton = true + private var onFullscreenClickListener: (() -> Unit)? = null + + // 图表配置 + private var chartTitle = "通道" + private var channelIndex = 0 + private var deviceType = "未知设备" + private var sampleRate = 250f // 默认采样率 + private var timeWindow = 4f // 默认时间窗口(秒) + + // 简化的数据管理:直接显示新数据 + private val dataBuffer = mutableListOf() + + // 多通道数据支持(用于PPG等设备) + private val multiChannelDataPoints = mutableListOf>() + private val multiChannelDataBuffers = mutableListOf>() + private var isMultiChannelMode = false + private val channelColors = listOf(Color.RED, Color.BLUE) // 通道颜色 + + // 交互控制参数 + private var scaleFactor = 1.0f + private var translateX = 0f + private var translateY = 0f + private var viewportStart = 0f + private var viewportEnd = 1.0f + private var isDragging = false + private var lastTouchX = 0f + private var lastTouchY = 0f + + // 手势检测器 + private val scaleGestureDetector = ScaleGestureDetector(context, ScaleListener()) + + // 控制按钮区域 + private val controlButtonRect = RectF() + + /** + * 设置图表配置 + */ + fun setChartConfig( + title: String, + channelIdx: Int, + device: String, + maxPoints: Int, + sampleRateHz: Float, + timeWindowSec: Float + ) { + chartTitle = title + channelIndex = channelIdx + deviceType = device + maxDataPoints = optimizeMaxDataPoints(maxPoints, sampleRateHz) + sampleRate = sampleRateHz + timeWindow = timeWindowSec + resetViewport() + invalidate() + } + + /** + * 根据设备类型和内存情况优化最大数据点数量 + */ + private fun optimizeMaxDataPoints(baseMaxPoints: Int, sampleRate: Float): Int { + try { + val runtime = Runtime.getRuntime() + val maxMemoryMB = runtime.maxMemory() / 1024 / 1024 + + var optimizedPoints = baseMaxPoints + + // 根据采样率调整 + optimizedPoints = when { + sampleRate >= 8000f -> (baseMaxPoints * 0.3).toInt() // 音频设备减少到30% + sampleRate >= 1000f -> (baseMaxPoints * 0.5).toInt() // 高速设备减少到50% + sampleRate >= 250f -> baseMaxPoints // ECG等保持不变 + else -> (baseMaxPoints * 1.2).toInt() // 低速设备可以稍微增加 + } + + // 根据内存情况调整 + optimizedPoints = when { + maxMemoryMB < 128 -> (optimizedPoints * 0.5).toInt() // 低内存设备 + maxMemoryMB < 256 -> (optimizedPoints * 0.75).toInt() // 中等内存设备 + maxMemoryMB >= 512 -> (optimizedPoints * 1.25).toInt() // 高内存设备 + else -> optimizedPoints + } + + // 确保在合理范围内 + optimizedPoints = optimizedPoints.coerceIn(500, 10000) + + Log.d("DynamicChartView", "数据点限制优化: 原始=$baseMaxPoints, 优化后=$optimizedPoints, 采样率=${sampleRate}Hz, 内存=${maxMemoryMB}MB") + return optimizedPoints + + } catch (e: Exception) { + Log.e("DynamicChartView", "数据点限制优化失败: ${e.message}") + return baseMaxPoints + } + } + + /** + * 设置全屏模式 + */ + fun setFullscreenMode(isFullscreen: Boolean) { + isFullscreenMode = isFullscreen + // 全屏模式下隐藏全屏按钮 + showFullscreenButton = !isFullscreen + invalidate() + } + + /** + * 设置自定义Y轴范围 + */ + fun setCustomYRange(min: Float, max: Float) { + customMinValue = min + customMaxValue = max + invalidate() + } + + /** + * 清除自定义Y轴范围,恢复自动计算 + */ + fun clearCustomYRange() { + customMinValue = null + customMaxValue = null + invalidate() + } + + /** + * 设置全屏按钮点击监听器 + */ + fun setOnFullscreenClickListener(listener: () -> Unit) { + onFullscreenClickListener = listener + } + + /** + * 设置是否显示全屏按钮 + */ + fun setShowFullscreenButton(show: Boolean) { + showFullscreenButton = show + invalidate() + } + + /** + * 设置多通道模式(用于PPG等设备) + */ + fun setMultiChannelMode(channelCount: Int) { + isMultiChannelMode = true + multiChannelDataPoints.clear() + multiChannelDataBuffers.clear() + + // 初始化每个通道的数据结构 + for (i in 0 until channelCount) { + multiChannelDataPoints.add(mutableListOf()) + multiChannelDataBuffers.add(mutableListOf()) + } + } + + fun updateData(newData: List) { + if (newData.isEmpty()) return + + isDataAvailable = true + + // 更新累积数据点计数器 + totalDataPointsReceived += newData.size + + // 直接添加新数据,来多少显示多少 + dataPoints.addAll(newData) + + // 限制数据点数量,保持滚动显示 + if (dataPoints.size > maxDataPoints) { + val excessData = dataPoints.size - maxDataPoints + dataPoints = dataPoints.takeLast(maxDataPoints).toMutableList() + Log.d("DynamicChartView", "清理了 $excessData 个数据点,当前数据点: ${dataPoints.size}") + } + + // 定期检查内存使用情况 + checkMemoryUsage() + + // 更新数据范围 + updateDataRange(newData) + + // 立即重绘 + invalidate() + } + + /** + * 检查内存使用情况,必要时进行清理 + */ + private fun checkMemoryUsage() { + try { + // 每1000个数据点检查一次内存使用情况 + if (totalDataPointsReceived % 1000 == 0L) { + val runtime = Runtime.getRuntime() + val usedMemory = runtime.totalMemory() - runtime.freeMemory() + val maxMemory = runtime.maxMemory() + val memoryUsagePercent = (usedMemory.toDouble() / maxMemory.toDouble()) * 100 + + Log.d("DynamicChartView", "内存使用情况: ${usedMemory / 1024 / 1024}MB/${maxMemory / 1024 / 1024}MB (${String.format("%.1f", memoryUsagePercent)}%)") + + // 如果内存使用超过80%,强制清理 + if (memoryUsagePercent > 80.0) { + Log.w("DynamicChartView", "内存使用过高 (${String.format("%.1f", memoryUsagePercent)}%),执行紧急清理") + emergencyCleanup() + } + } + } catch (e: Exception) { + Log.e("DynamicChartView", "检查内存使用情况失败: ${e.message}") + } + } + + /** + * 紧急内存清理 + */ + private fun emergencyCleanup() { + try { + Log.d("DynamicChartView", "=== 执行紧急内存清理 ===") + + // 强制清理数据点,只保留最新的50%数据 + val keepSize = (dataPoints.size * 0.5).toInt() + if (keepSize > 0 && dataPoints.size > keepSize) { + dataPoints = dataPoints.takeLast(keepSize).toMutableList() + Log.d("DynamicChartView", "紧急清理数据点,保留 $keepSize 个数据点") + } + + // 清理多通道数据 + multiChannelDataPoints.forEachIndexed { index, channelData -> + val keepChannelSize = (channelData.size * 0.5).toInt() + if (keepChannelSize > 0 && channelData.size > keepChannelSize) { + multiChannelDataPoints[index] = channelData.takeLast(keepChannelSize).toMutableList() + } + } + + // 重新计算数据范围 + updateDataRange(dataPoints) + + // 强制垃圾回收 + System.gc() + + Log.d("DynamicChartView", "=== 紧急内存清理完成 ===") + + } catch (e: Exception) { + Log.e("DynamicChartView", "紧急内存清理失败: ${e.message}") + } + } + + /** + * 获取当前数据点数量 + */ + fun getDataPointCount(): Int { + return if (isMultiChannelMode) { + multiChannelDataPoints.sumOf { it.size } + } else { + dataPoints.size + } + } + + /** + * 裁剪数据到指定大小 + */ + fun trimDataToSize(maxSize: Int) { + try { + if (isMultiChannelMode) { + // 多通道模式 + multiChannelDataPoints.forEachIndexed { index, channelData -> + if (channelData.size > maxSize) { + multiChannelDataPoints[index] = channelData.takeLast(maxSize).toMutableList() + } + } + // 重新计算数据范围 + updateMultiChannelDataRange() + } else { + // 单通道模式 + if (dataPoints.size > maxSize) { + dataPoints = dataPoints.takeLast(maxSize).toMutableList() + // 重新计算数据范围 + updateDataRange(dataPoints) + } + } + + // 重绘 + invalidate() + Log.d("DynamicChartView", "数据已裁剪到 $maxSize 个点") + + } catch (e: Exception) { + Log.e("DynamicChartView", "裁剪数据失败: ${e.message}") + } + } + + /** + * 更新多通道数据(用于PPG等设备) + */ + fun updateMultiChannelData(channelData: List>) { + if (channelData.isEmpty() || !isMultiChannelMode) { + Log.d("DynamicChartView", "跳过多通道更新: channelData.isEmpty=${channelData.isEmpty()}, isMultiChannelMode=$isMultiChannelMode") + return + } + + Log.d("DynamicChartView", "多通道数据更新: 通道数=${channelData.size}, 每个通道数据点=${channelData.map { it.size }}") + isDataAvailable = true + + // 更新累积数据点计数器(使用第一个通道的数据点数量) + if (channelData.isNotEmpty()) { + totalDataPointsReceived += channelData[0].size + } + + // 直接添加新数据,来多少显示多少 + for (channelIndex in channelData.indices) { + if (channelIndex >= multiChannelDataPoints.size) { + Log.w("DynamicChartView", "通道索引超出范围: $channelIndex >= ${multiChannelDataPoints.size}") + continue + } + + val channelDataPoints = multiChannelDataPoints[channelIndex] + channelDataPoints.addAll(channelData[channelIndex]) + + // 限制数据点数量,保持滚动显示 + if (channelDataPoints.size > maxDataPoints) { + multiChannelDataPoints[channelIndex] = channelDataPoints.takeLast(maxDataPoints).toMutableList() + } + + Log.d("DynamicChartView", "通道$channelIndex 添加了${channelData[channelIndex].size}个数据点") + } + + // 更新数据范围 - 计算所有通道的数据范围 + updateMultiChannelDataRange() + + Log.d("DynamicChartView", "多通道数据处理完成: 通道数据点=${multiChannelDataPoints.map { it.size }}, 范围=${minValue}-${maxValue}") + + // 检查内存使用情况 + checkMemoryUsage() + + // 立即重绘 + invalidate() + } + + private fun updateDataRange(newData: List) { + // 如果设置了自定义Y轴范围,使用自定义范围 + if (customMinValue != null && customMaxValue != null) { + minValue = customMinValue!! + maxValue = customMaxValue!! + return + } + + // 使用全部数据点计算范围,提供更准确的范围 + if (dataPoints.isEmpty()) return + + // 计算全部数据点的范围 + minValue = dataPoints.minOrNull() ?: 0f + maxValue = dataPoints.maxOrNull() ?: 0f + + // 确保有足够的显示范围 + val range = maxValue - minValue + if (range < 0.1f) { + val center = (maxValue + minValue) / 2 + minValue = center - 0.05f + maxValue = center + 0.05f + } else { + val margin = range * 0.1f + minValue -= margin + maxValue += margin + } + } + + /** + * 更新多通道数据范围 + */ + private fun updateMultiChannelDataRange() { + // 如果设置了自定义Y轴范围,使用自定义范围 + if (customMinValue != null && customMaxValue != null) { + minValue = customMinValue!! + maxValue = customMaxValue!! + Log.d("DynamicChartView", "使用自定义Y轴范围: ${minValue}-${maxValue}") + return + } + + if (multiChannelDataPoints.isEmpty()) { + Log.w("DynamicChartView", "多通道数据点为空,无法计算范围") + return + } + + // 计算所有通道的最小值和最大值 + var globalMin = Float.MAX_VALUE + var globalMax = Float.MIN_VALUE + var hasValidData = false + + for (channelIndex in multiChannelDataPoints.indices) { + val channelData = multiChannelDataPoints[channelIndex] + if (channelData.isNotEmpty()) { + val channelMin = channelData.minOrNull() ?: 0f + val channelMax = channelData.maxOrNull() ?: 0f + globalMin = minOf(globalMin, channelMin) + globalMax = maxOf(globalMax, channelMax) + hasValidData = true + Log.d("DynamicChartView", "通道$channelIndex: 范围=${channelMin}-${channelMax}, 数据点=${channelData.size}") + } + } + + if (!hasValidData) { + Log.w("DynamicChartView", "没有有效的多通道数据") + return + } + + minValue = globalMin + maxValue = globalMax + + Log.d("DynamicChartView", "多通道全局范围: ${minValue}-${maxValue}") + + // 确保有足够的显示范围 + val range = maxValue - minValue + if (range < 0.1f) { + val center = (maxValue + minValue) / 2 + minValue = center - 0.05f + maxValue = center + 0.05f + } else { + val margin = range * 0.1f + minValue -= margin + maxValue += margin + } + + Log.d("DynamicChartView", "多通道显示范围: ${minValue}-${maxValue}") + } + + fun clearData() { + try { + Log.d("DynamicChartView", "=== 开始清理图表数据 ===") + + // 清理单通道数据 + dataPoints.clear() + dataBuffer.clear() + Log.d("DynamicChartView", "单通道数据已清理") + + // 清除多通道数据 + multiChannelDataPoints.forEachIndexed { index, channelData -> + try { + channelData.clear() + } catch (e: Exception) { + Log.e("DynamicChartView", "清理多通道数据 $index 失败: ${e.message}", e) + } + } + multiChannelDataBuffers.forEachIndexed { index, channelBuffer -> + try { + channelBuffer.clear() + } catch (e: Exception) { + Log.e("DynamicChartView", "清理多通道缓冲区 $index 失败: ${e.message}", e) + } + } + Log.d("DynamicChartView", "多通道数据已清理") + + // 重置状态 + isDataAvailable = false + totalDataPointsReceived = 0L // 重置累积数据点计数器 + Log.d("DynamicChartView", "数据状态已重置") + + // 重置视口 + resetViewport() + Log.d("DynamicChartView", "视口已重置") + + // 重绘 + invalidate() + Log.d("DynamicChartView", "=== 图表数据清理完成 ===") + + } catch (e: Exception) { + Log.e("DynamicChartView", "清理图表数据时发生异常: ${e.message}", e) + Log.e("DynamicChartView", "异常堆栈: ${e.stackTraceToString()}") + } + } + + /** + * 清理触摸事件处理器 + */ + fun cleanupTouchHandlers() { + try { + Log.d("DynamicChartView", "=== 开始清理触摸事件处理器 ===") + + // 重置触摸状态 + isDragging = false + lastTouchX = 0f + lastTouchY = 0f + + // 清理全屏按钮监听器 + onFullscreenClickListener = null + + Log.d("DynamicChartView", "=== 触摸事件处理器清理完成 ===") + + } catch (e: Exception) { + Log.e("DynamicChartView", "清理触摸事件处理器时发生异常: ${e.message}", e) + Log.e("DynamicChartView", "异常堆栈: ${e.stackTraceToString()}") + } + } + + /** + * 当视图从窗口分离时调用 + */ + override fun onDetachedFromWindow() { + try { + Log.d("DynamicChartView", "=== onDetachedFromWindow 开始 ===") + + // 清理触摸事件处理器 + cleanupTouchHandlers() + + // 清理数据 + clearData() + + Log.d("DynamicChartView", "=== onDetachedFromWindow 完成 ===") + + } catch (e: Exception) { + Log.e("DynamicChartView", "onDetachedFromWindow 异常: ${e.message}", e) + Log.e("DynamicChartView", "异常堆栈: ${e.stackTraceToString()}") + } finally { + super.onDetachedFromWindow() + } + } + + /** + * 重置视口到默认状态 + */ + fun resetViewport() { + scaleFactor = 1.0f + translateX = 0f + translateY = 0f + viewportStart = 0f + viewportEnd = 1.0f + invalidate() + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + scaleGestureDetector.onTouchEvent(event) + + when (event.action) { + MotionEvent.ACTION_DOWN -> { + val x = event.x + val y = event.y + + // 检查是否点击了全屏按钮 + if (showFullscreenButton && fullscreenButtonRect.contains(x, y)) { + onFullscreenClickListener?.invoke() + return true + } + + // 开始拖拽 + isDragging = true + lastTouchX = x + lastTouchY = y + return true + } + + MotionEvent.ACTION_MOVE -> { + if (isDragging) { + val deltaX = event.x - lastTouchX + val deltaY = event.y - lastTouchY + + translateX += deltaX + translateY += deltaY + + // 限制平移范围 + val maxTranslate = width * 0.5f + translateX = translateX.coerceIn(-maxTranslate, maxTranslate) + translateY = translateY.coerceIn(-maxTranslate, maxTranslate) + + lastTouchX = event.x + lastTouchY = event.y + invalidate() + return true + } + } + + MotionEvent.ACTION_UP -> { + isDragging = false + return true + } + } + + return super.onTouchEvent(event) + } + + private inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() { + override fun onScale(detector: ScaleGestureDetector): Boolean { + scaleFactor *= detector.scaleFactor + scaleFactor = scaleFactor.coerceIn(0.2f, 5.0f) + invalidate() + return true + } + } + + override fun onDraw(canvas: Canvas) { + try { + super.onDraw(canvas) + + val width = width.toFloat() + val height = height.toFloat() + + // 应用变换 + canvas.save() + canvas.translate(translateX, translateY) + canvas.scale(scaleFactor, scaleFactor, width / 2, height / 2) + + // 绘制网格 + drawGrid(canvas, width, height) + + // 绘制标题和通道信息 + drawTitle(canvas, width, height) + + // 绘制数据统计 + drawStats(canvas, width, height) + + // 绘制全屏按钮 + if (showFullscreenButton) { + drawFullscreenButton(canvas, width, height) + } + + // 如果没有数据,显示默认内容 + if (!isDataAvailable) { + drawDefaultContent(canvas, width, height) + } else { + // 绘制曲线 - 检查单通道或多通道数据 + val hasDataToDraw = if (isMultiChannelMode) { + multiChannelDataPoints.any { it.size > 1 } + } else { + dataPoints.size > 1 + } + + if (hasDataToDraw) { + drawCurve(canvas, width, height) + } + } + + // 恢复画布状态 + canvas.restore() + } catch (e: Exception) { + Log.e("DynamicChartView", "绘制时发生异常: ${e.message}", e) + // 确保画布状态被恢复 + try { + canvas.restore() + } catch (e2: Exception) { + Log.e("DynamicChartView", "恢复画布状态失败: ${e2.message}", e2) + } + } + } + + private fun drawGrid(canvas: Canvas, width: Float, height: Float) { + val padding = if (isFullscreenMode) 20f else 60f + val drawWidth = width - 2 * padding + val drawHeight = height - 2 * padding + + // 绘制水平网格线和刻度 + val gridLines = 5 + for (i in 0..gridLines) { + val y = padding + (drawHeight * i / gridLines) + canvas.drawLine(padding, y, width - padding, y, gridPaint) + + // 只在全屏模式下绘制Y轴刻度值 + if (isFullscreenMode) { + val value = maxValue - (maxValue - minValue) * i / gridLines + val valueText = String.format("%.1f", value) + canvas.drawText(valueText, 5f, y + 5f, textPaint) + } + } + + // 绘制垂直网格线和刻度 + for (i in 0..gridLines) { + val x = padding + (drawWidth * i / gridLines) + canvas.drawLine(x, padding, x, height - padding, gridPaint) + + // 只在全屏模式下绘制X轴刻度值(累积数据点索引) + if (isFullscreenMode) { + val dataPointIndex = if (isMultiChannelMode) { + // 多通道模式:使用累积数据点计数器 + val stepSize = totalDataPointsReceived.toFloat() / gridLines + (stepSize * i).toInt() + } else { + // 单通道模式:使用累积数据点计数器 + val stepSize = totalDataPointsReceived.toFloat() / gridLines + (stepSize * i).toInt() + } + val indexText = dataPointIndex.toString() + canvas.drawText(indexText, x - 10f, height - 10f, textPaint) + } + } + } + + private fun drawTitle(canvas: Canvas, width: Float, height: Float) { + val title = "$chartTitle $channelIndex" + canvas.drawText(title, 10f, 25f, textPaint) + + val deviceInfo = "$deviceType (${sampleRate.toInt()}Hz)" + canvas.drawText(deviceInfo, 10f, 50f, textPaint) + + // 显示当前缩放和时间窗口信息 + val scaleInfo = "缩放: ${String.format("%.1f", scaleFactor)}x" + canvas.drawText(scaleInfo, width - 150f, 25f, textPaint) + + val timeInfo = "时间窗口: ${timeWindow.toInt()}秒" + canvas.drawText(timeInfo, width - 150f, 50f, textPaint) + } + + private fun drawStats(canvas: Canvas, width: Float, height: Float) { + val totalDataPoints = if (isMultiChannelMode) { + multiChannelDataPoints.sumOf { it.size } + } else { + dataPoints.size + } + + val statsText = if (isMultiChannelMode) { + "显示数据点: ${totalDataPoints} (${multiChannelDataPoints.size}通道) | 累积: ${totalDataPointsReceived}" + } else { + "显示数据点: ${totalDataPoints} | 累积: ${totalDataPointsReceived}" + } + canvas.drawText(statsText, 10f, height - 35f, textPaint) + + if (totalDataPoints > 0) { + val rangeText = if (customMinValue != null && customMaxValue != null) { + "Y轴范围: ${String.format("%.0f", minValue)} - ${String.format("%.0f", maxValue)} (固定)" + } else { + "Y轴范围: ${String.format("%.2f", minValue)} - ${String.format("%.2f", maxValue)} (自动)" + } + canvas.drawText(rangeText, 10f, height - 15f, textPaint) + } + } + + private fun drawDefaultContent(canvas: Canvas, width: Float, height: Float) { + val centerX = width / 2 + val centerY = height / 2 + + val hintPaint = Paint().apply { + color = Color.GRAY + textSize = 16f + textAlign = Paint.Align.CENTER + isAntiAlias = true + } + + canvas.drawText("等待数据...", centerX, centerY - 20f, hintPaint) + canvas.drawText("请先连接蓝牙设备", centerX, centerY + 20f, hintPaint) + canvas.drawText("然后点击'启动程序'显示数据", centerX, centerY + 60f, hintPaint) + } + + private fun drawFullscreenButton(canvas: Canvas, width: Float, height: Float) { + val buttonSize = 40f + val margin = 10f + val buttonX = width - buttonSize - margin + val buttonY = margin + + // 设置按钮区域 + fullscreenButtonRect.set(buttonX, buttonY, buttonX + buttonSize, buttonY + buttonSize) + + // 绘制按钮背景 + val buttonPaint = Paint().apply { + color = Color.parseColor("#4CAF50") + style = Paint.Style.FILL + isAntiAlias = true + } + canvas.drawRoundRect(fullscreenButtonRect, 8f, 8f, buttonPaint) + + // 绘制按钮边框 + val borderPaint = Paint().apply { + color = Color.WHITE + style = Paint.Style.STROKE + strokeWidth = 2f + isAntiAlias = true + } + canvas.drawRoundRect(fullscreenButtonRect, 8f, 8f, borderPaint) + + // 绘制全屏图标(简单的矩形) + val iconPaint = Paint().apply { + color = Color.WHITE + style = Paint.Style.STROKE + strokeWidth = 2f + isAntiAlias = true + } + + val iconMargin = 8f + val iconRect = RectF( + buttonX + iconMargin, + buttonY + iconMargin, + buttonX + buttonSize - iconMargin, + buttonY + buttonSize - iconMargin + ) + canvas.drawRect(iconRect, iconPaint) + + // 绘制全屏文字 + val textPaint = Paint().apply { + color = Color.WHITE + textSize = 10f + textAlign = Paint.Align.CENTER + isAntiAlias = true + } + canvas.drawText("全屏", buttonX + buttonSize/2, buttonY + buttonSize/2 + 3f, textPaint) + } + + private fun drawCurve(canvas: Canvas, width: Float, height: Float) { + val padding = if (isFullscreenMode) 20f else 60f + val drawWidth = width - 2 * padding + val drawHeight = height - 2 * padding + + if (isMultiChannelMode && multiChannelDataPoints.isNotEmpty()) { + Log.d("DynamicChartView", "多通道绘制: 通道数=${multiChannelDataPoints.size}, 数据点=${multiChannelDataPoints.map { it.size }}") + // 多通道绘制模式 + for (channelIndex in multiChannelDataPoints.indices) { + val channelData = multiChannelDataPoints[channelIndex] + if (channelData.isEmpty()) { + Log.d("DynamicChartView", "通道$channelIndex 数据为空,跳过绘制") + continue + } + + path.reset() + + // 设置通道颜色 + val channelPaint = Paint(paint).apply { + color = channelColors.getOrElse(channelIndex) { Color.GRAY } + strokeWidth = 2f + } + + val xStep = if (channelData.size > 1) drawWidth / (channelData.size - 1) else drawWidth + + for (i in channelData.indices) { + val x = padding + i * xStep + val normalizedValue = (channelData[i] - minValue) / (maxValue - minValue) + val y = padding + (0.1f + normalizedValue * 0.8f) * drawHeight + + if (i == 0) { + path.moveTo(x, y) + } else { + path.lineTo(x, y) + } + } + + canvas.drawPath(path, channelPaint) + } + } else { + // 单通道绘制模式 + if (dataPoints.isEmpty()) return + + path.reset() + val xStep = if (dataPoints.size > 1) drawWidth / (dataPoints.size - 1) else drawWidth + + for (i in dataPoints.indices) { + val x = padding + i * xStep + val normalizedValue = (dataPoints[i] - minValue) / (maxValue - minValue) + val y = padding + (0.1f + normalizedValue * 0.8f) * drawHeight + + if (i == 0) { + path.moveTo(x, y) + } else { + path.lineTo(x, y) + } + } + + canvas.drawPath(path, paint) + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + + val desiredHeight = 200 // 每个图表的高度 + val height = resolveSize(desiredHeight, heightMeasureSpec) + setMeasuredDimension(widthMeasureSpec, height) + } + + /** + * 获取当前图表数据 + */ + fun getCurrentData(): List { + return dataPoints.toList() + } + + /** + * 获取数据统计信息 + */ + fun getDataStats(): Map { + val totalDataPoints = if (isMultiChannelMode) { + multiChannelDataPoints.sumOf { it.size } + } else { + dataPoints.size + } + + return mapOf( + "dataPoints" to totalDataPoints, + "multiChannelMode" to isMultiChannelMode, + "channelCount" to if (isMultiChannelMode) multiChannelDataPoints.size else 1, + "minValue" to minValue, + "maxValue" to maxValue, + "range" to (maxValue - minValue), + "isDataAvailable" to isDataAvailable, + "scaleFactor" to scaleFactor, + "timeWindow" to timeWindow + ) + } + + /** + * 获取是否是多通道模式 + */ + fun isMultiChannelMode(): Boolean = isMultiChannelMode +} diff --git a/app/src/main/java/com/example/cmake_project_test/ECGRhythmView.kt b/app/src/main/java/com/example/cmake_project_test/ECGRhythmView.kt index 6670abf..6924947 100644 --- a/app/src/main/java/com/example/cmake_project_test/ECGRhythmView.kt +++ b/app/src/main/java/com/example/cmake_project_test/ECGRhythmView.kt @@ -24,7 +24,7 @@ class ECGRhythmView @JvmOverloads constructor( color = Color.BLUE strokeWidth = 1.5f style = Paint.Style.STROKE - isAntiAlias = true + isAntiAlias = false // 关闭抗锯齿,提高绘制性能 } private val gridPaint = Paint().apply { @@ -47,10 +47,7 @@ class ECGRhythmView @JvmOverloads constructor( private var maxValue = Float.MIN_VALUE private var isDataAvailable = false // 标记是否有数据 - // 性能优化:数据缓冲和批量更新 - private val dataBuffer = mutableListOf() - private var lastUpdateTime = 0L - private val updateInterval = 50L // 50ms更新间隔,提高流畅度 + // 简化的数据管理:直接显示新数据 // 缩放控制参数 private var scaleX = 1.0f @@ -95,49 +92,44 @@ class ECGRhythmView @JvmOverloads constructor( private var isDragging = false fun updateData(newData: List) { - if (newData.isNotEmpty()) { - isDataAvailable = true - Log.d("ECGRhythmView", "收到新数据: ${newData.size} 个点") + if (newData.isEmpty()) return + + isDataAvailable = true + + // 直接添加新数据,来多少显示多少 + dataPoints.addAll(newData) + + // 限制数据点数量,保持滚动显示 + if (dataPoints.size > maxDataPoints) { + dataPoints = dataPoints.takeLast(maxDataPoints).toMutableList() } - // 性能优化:批量更新数据 - dataBuffer.addAll(newData) + // 更新数据范围 + updateDataRange(newData) - val currentTime = System.currentTimeMillis() - if (currentTime - lastUpdateTime >= updateInterval) { - // 批量处理缓冲区的数据 - if (dataBuffer.isNotEmpty()) { - dataPoints.addAll(dataBuffer) - dataBuffer.clear() - - // 限制数据点数量 - if (dataPoints.size > maxDataPoints) { - dataPoints = dataPoints.takeLast(maxDataPoints).toMutableList() - } - - // 计算数据范围 - if (dataPoints.isNotEmpty()) { - minValue = dataPoints.minOrNull() ?: 0f - maxValue = dataPoints.maxOrNull() ?: 0f - - // 确保有足够的显示范围 - val range = maxValue - minValue - if (range < 0.1f) { - val center = (maxValue + minValue) / 2 - minValue = center - 0.05f - maxValue = center + 0.05f - } else { - // 添加10%的上下边距 - val margin = range * 0.1f - minValue -= margin - maxValue += margin - } - } - - lastUpdateTime = currentTime - Log.d("ECGRhythmView", "更新图表,数据点: ${dataPoints.size}, 范围: $minValue - $maxValue") - invalidate() - } + // 立即重绘 + invalidate() + } + + private fun updateDataRange(newData: List) { + // 使用全部数据点计算范围,提供更准确的范围 + if (dataPoints.isEmpty()) return + + // 计算全部数据点的范围 + minValue = dataPoints.minOrNull() ?: 0f + maxValue = dataPoints.maxOrNull() ?: 0f + + // 确保有足够的显示范围 + val range = maxValue - minValue + if (range < 0.1f) { + val center = (maxValue + minValue) / 2 + minValue = center - 0.05f + maxValue = center + 0.05f + } else { + // 添加10%的上下边距 + val margin = range * 0.1f + minValue -= margin + maxValue += margin } } @@ -145,6 +137,26 @@ class ECGRhythmView @JvmOverloads constructor( dataPoints.clear() invalidate() } + + /** + * 获取当前图表数据 + */ + fun getCurrentData(): List { + return dataPoints.toList() + } + + /** + * 获取数据统计信息 + */ + fun getDataStats(): Map { + return mapOf( + "dataPoints" to dataPoints.size, + "minValue" to minValue, + "maxValue" to maxValue, + "range" to (maxValue - minValue), + "isDataAvailable" to isDataAvailable + ) + } fun resetZoom() { scaleX = 1.0f @@ -260,7 +272,7 @@ class ECGRhythmView @JvmOverloads constructor( canvas.drawText("等待数据...", centerX, centerY - 20f, hintPaint) canvas.drawText("请先连接蓝牙设备", centerX, centerY + 20f, hintPaint) - canvas.drawText("然后点击'启动程序'", centerX, centerY + 60f, hintPaint) + canvas.drawText("然后点击'启动程序'显示原始数据", centerX, centerY + 60f, hintPaint) // 绘制一个简单的示例波形 drawSampleWaveform(canvas, width, height) @@ -304,9 +316,12 @@ class ECGRhythmView @JvmOverloads constructor( val scaledWidth = drawWidth * scaleX val scaledHeight = drawHeight * scaleY + // 性能优化:减少数据点绘制,提高流畅度 + val stepSize = maxOf(1, dataPoints.size / 1000) // 最多绘制1000个点 val xStep = scaledWidth / (dataPoints.size - 1) - for (i in dataPoints.indices) { + var firstPoint = true + for (i in dataPoints.indices step stepSize) { val x = padding + i * xStep + offsetX val normalizedValue = (dataPoints[i] - minValue) / (maxValue - minValue) // 使用0.1到0.9的范围,确保曲线在中间80%的区域显示 @@ -314,8 +329,9 @@ class ECGRhythmView @JvmOverloads constructor( // 确保点在可见区域内 if (x >= padding && x <= width - padding && y >= padding && y <= height - padding) { - if (i == 0) { + if (firstPoint) { path.moveTo(x, y) + firstPoint = false } else { path.lineTo(x, y) } diff --git a/app/src/main/java/com/example/cmake_project_test/ECGWaveformView.kt b/app/src/main/java/com/example/cmake_project_test/ECGWaveformView.kt index 7086113..5a27680 100644 --- a/app/src/main/java/com/example/cmake_project_test/ECGWaveformView.kt +++ b/app/src/main/java/com/example/cmake_project_test/ECGWaveformView.kt @@ -47,10 +47,7 @@ class ECGWaveformView @JvmOverloads constructor( private var maxValue = Float.MIN_VALUE private var isDataAvailable = false // 标记是否有数据 - // 性能优化:数据缓冲和批量更新 - private val dataBuffer = mutableListOf() - private var lastUpdateTime = 0L - private val updateInterval = 50L // 50ms更新间隔,提高流畅度 + // 简化的数据管理:直接显示新数据 // 缩放控制参数 private var scaleX = 1.0f @@ -95,49 +92,44 @@ class ECGWaveformView @JvmOverloads constructor( private var isDragging = false fun updateData(newData: List) { - if (newData.isNotEmpty()) { - isDataAvailable = true - Log.d("ECGWaveformView", "收到新数据: ${newData.size} 个点") + if (newData.isEmpty()) return + + isDataAvailable = true + + // 直接添加新数据,来多少显示多少 + dataPoints.addAll(newData) + + // 限制数据点数量,保持滚动显示 + if (dataPoints.size > maxDataPoints) { + dataPoints = dataPoints.takeLast(maxDataPoints).toMutableList() } - // 性能优化:批量更新数据 - dataBuffer.addAll(newData) + // 更新数据范围 + updateDataRange(newData) - val currentTime = System.currentTimeMillis() - if (currentTime - lastUpdateTime >= updateInterval) { - // 批量处理缓冲区的数据 - if (dataBuffer.isNotEmpty()) { - dataPoints.addAll(dataBuffer) - dataBuffer.clear() - - // 限制数据点数量 - if (dataPoints.size > maxDataPoints) { - dataPoints = dataPoints.takeLast(maxDataPoints).toMutableList() - } - - // 计算数据范围 - if (dataPoints.isNotEmpty()) { - minValue = dataPoints.minOrNull() ?: 0f - maxValue = dataPoints.maxOrNull() ?: 0f - - // 确保有足够的显示范围 - val range = maxValue - minValue - if (range < 0.1f) { - val center = (maxValue + minValue) / 2 - minValue = center - 0.05f - maxValue = center + 0.05f - } else { - // 添加15%的上下边距,提供更好的放大效果 - val margin = range * 0.15f - minValue -= margin - maxValue += margin - } - } - - lastUpdateTime = currentTime - Log.d("ECGWaveformView", "更新图表,数据点: ${dataPoints.size}, 范围: $minValue - $maxValue") - invalidate() - } + // 立即重绘 + invalidate() + } + + private fun updateDataRange(newData: List) { + // 使用全部数据点计算范围,提供更准确的范围 + if (dataPoints.isEmpty()) return + + // 计算全部数据点的范围 + minValue = dataPoints.minOrNull() ?: 0f + maxValue = dataPoints.maxOrNull() ?: 0f + + // 确保有足够的显示范围 + val range = maxValue - minValue + if (range < 0.1f) { + val center = (maxValue + minValue) / 2 + minValue = center - 0.05f + maxValue = center + 0.05f + } else { + // 添加15%的上下边距 + val margin = range * 0.15f + minValue -= margin + maxValue += margin } } @@ -145,6 +137,26 @@ class ECGWaveformView @JvmOverloads constructor( dataPoints.clear() invalidate() } + + /** + * 获取当前图表数据 + */ + fun getCurrentData(): List { + return dataPoints.toList() + } + + /** + * 获取数据统计信息 + */ + fun getDataStats(): Map { + return mapOf( + "dataPoints" to dataPoints.size, + "minValue" to minValue, + "maxValue" to maxValue, + "range" to (maxValue - minValue), + "isDataAvailable" to isDataAvailable + ) + } fun resetZoom() { scaleX = 1.0f @@ -260,7 +272,7 @@ class ECGWaveformView @JvmOverloads constructor( canvas.drawText("等待数据...", centerX, centerY - 20f, hintPaint) canvas.drawText("请先连接蓝牙设备", centerX, centerY + 20f, hintPaint) - canvas.drawText("然后点击'启动程序'", centerX, centerY + 60f, hintPaint) + canvas.drawText("然后点击'启动程序'显示原始数据", centerX, centerY + 60f, hintPaint) // 绘制一个简单的示例波形 drawSampleWaveform(canvas, width, height) diff --git a/app/src/main/java/com/example/cmake_project_test/FullscreenChartActivity.kt b/app/src/main/java/com/example/cmake_project_test/FullscreenChartActivity.kt new file mode 100644 index 0000000..6186ad5 --- /dev/null +++ b/app/src/main/java/com/example/cmake_project_test/FullscreenChartActivity.kt @@ -0,0 +1,901 @@ +package com.example.cmake_project_test + +import android.app.Activity +import android.content.Intent +import android.graphics.Color +import android.os.Bundle +import android.util.Log +import android.view.View +import android.view.WindowManager +import android.widget.Button +import android.widget.SeekBar +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.lifecycle.lifecycleScope +import com.example.cmake_project_test.databinding.ActivityFullscreenBinding +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import type.SensorData + +/** + * 全屏图表显示Activity + * 支持ECG和PPG的不同Y轴范围设置 + */ +class FullscreenChartActivity : AppCompatActivity(), DataManager.RealTimeDataCallback, NativeMethodCallback { + + private lateinit var binding: ActivityFullscreenBinding + private var fullscreenChartManager: DynamicChartManager? = null + private var deviceType: String = "未知设备" + private var isSingleChart = false + private var chartIndex = 0 + + // 滤波器管理器 + private var filterManager: FullscreenFilterManager? = null + + // Activity退出标志 + private var isExiting = false + + // 数据回调清理标志 + private var callbackRemoved = false + + + + + companion object { + // 静态数据管理器引用,用于接收MainActivity的数据 + private var mainDataManager: DataManager? = null + + fun setMainDataManager(dataManager: DataManager) { + mainDataManager = dataManager + Log.d("FullscreenChartActivity", "设置主数据管理器引用") + } + + fun clearMainDataManager() { + if (mainDataManager != null) { + Log.d("FullscreenChartActivity", "清理主数据管理器引用") + Log.d("FullscreenChartActivity", "注意:这只是清理引用,MainActivity的DataManager应该继续运行") + mainDataManager = null + } else { + Log.d("FullscreenChartActivity", "主数据管理器引用已为空,跳过清理") + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // 设置全局异常处理器 + setupGlobalExceptionHandler() + + // 设置全屏模式 + setupFullscreen() + + binding = ActivityFullscreenBinding.inflate(layoutInflater) + setContentView(binding.root) + + // 初始化全屏图表管理器(避免重复初始化) + if (fullscreenChartManager == null) { + fullscreenChartManager = DynamicChartManager( + context = this, + chartsContainer = binding.fullscreenChartsContainer, + chartTitle = null // 全屏模式下不需要标题 + ) + } + + // 注册为数据回调接收器 + val dataManager = mainDataManager + if (dataManager != null) { + dataManager.addRealTimeCallback(this) + Log.d("FullscreenChartActivity", "已注册为数据回调接收器") + } else { + Log.w("FullscreenChartActivity", "主数据管理器为空,无法注册回调") + } + + // 获取传递的数据 + val chartDataArray = intent.getSerializableExtra("chartData") as? Array> + deviceType = intent.getStringExtra("deviceType") ?: "未知设备" + isSingleChart = intent.getBooleanExtra("isSingleChart", false) + chartIndex = intent.getIntExtra("chartIndex", 0) + + if (chartDataArray != null) { + setupFullscreenCharts(chartDataArray) + } else { + Log.e("FullscreenChartActivity", "没有接收到图表数据") + finish() + } + + // 设置退出全屏按钮 + binding.exitFullscreenButton.setOnClickListener { + exitFullscreen() + } + + // 初始化心率和血氧显示 + setupVitalSignsDisplay() + + // 初始化滤波器管理器 + setupFilterManager() + + } + + + + + + /** + * 检查内存泄漏迹象 + */ + private fun checkForMemoryLeaks_DISABLED(currentMemoryPercent: Double) { + try { + // 如果内存使用率持续高于75%且没有明显的数据积累,可能是内存泄漏 + val chartDataCount = fullscreenChartManager?.let { manager -> + manager.getCharts().sumOf { chart -> chart.getDataPointCount() } + } ?: 0 + + val filterCacheCount = filterManager?.let { fm -> + // 简单估算缓存大小 + try { + val originalCacheSize = fm.javaClass.getDeclaredField("originalDataCache") + .apply { isAccessible = true } + .get(fm) as Map<*, *> + val filteredCacheSize = fm.javaClass.getDeclaredField("filteredDataCache") + .apply { isAccessible = true } + .get(fm) as Map<*, *> + + originalCacheSize.size + filteredCacheSize.size + } catch (e: Exception) { + 0 + } + } ?: 0 + + if (currentMemoryPercent > 75.0 && chartDataCount < 5000 && filterCacheCount < 20) { + Log.w("FullscreenChartActivity", "⚠️ 疑似内存泄漏: 内存使用率${String.format("%.1f", currentMemoryPercent)}%但数据量较少") + Log.w("FullscreenChartActivity", "图表数据点: $chartDataCount, 滤波缓存: $filterCacheCount") + } + + } catch (e: Exception) { + Log.e("FullscreenChartActivity", "内存泄漏检查失败: ${e.message}") + } + } + + /** + * 执行紧急内存清理 + */ + private fun performEmergencyMemoryCleanup_DISABLED() { + try { + Log.d("FullscreenChartActivity", "=== 执行紧急内存清理 ===") + + // 清理图表数据 + fullscreenChartManager?.let { chartManager -> + val charts = chartManager.getCharts() + var totalCleanedPoints = 0 + + charts.forEach { chart -> + val currentPoints = chart.getDataPointCount() + if (currentPoints > 500) { // 如果数据点超过500个,强制清理到50% + val keepPoints = (currentPoints * 0.5).toInt() + chart.trimDataToSize(keepPoints) + totalCleanedPoints += (currentPoints - keepPoints) + } + } + + if (totalCleanedPoints > 0) { + Log.d("FullscreenChartActivity", "紧急清理释放了 $totalCleanedPoints 个数据点") + } + } + + // 清理滤波器缓存 + filterManager?.let { fm -> + try { + fm.cleanupMemory() + } catch (e: Exception) { + Log.e("FullscreenChartActivity", "清理滤波器缓存失败: ${e.message}") + } + } + + // 强制垃圾回收 + System.gc() + Log.d("FullscreenChartActivity", "=== 紧急内存清理完成 ===") + + } catch (e: Exception) { + Log.e("FullscreenChartActivity", "紧急内存清理失败: ${e.message}") + } + } + + + + /** + * 设置心率和血氧显示 + */ + private fun setupVitalSignsDisplay() { + // 初始化心率和血氧显示区域 + binding.heartRateValue.text = "--" + binding.spo2Value.text = "--" + binding.temperatureValue.text = "--" + + // 设置标签 + binding.heartRateLabel.text = "心率 (bpm)" + binding.spo2Label.text = "血氧 (%)" + binding.temperatureLabel.text = "体温 (°C)" + + Log.d("FullscreenChartActivity", "心率和血氧显示区域已初始化") + } + + /** + * 初始化滤波器管理器 + */ + private fun setupFilterManager() { + try { + // 避免重复初始化滤波器管理器 + if (filterManager == null) { + filterManager = FullscreenFilterManager( + context = this, + scope = lifecycleScope, + dataManager = mainDataManager, + chartManager = fullscreenChartManager, + deviceType = deviceType + ) + } + + // 初始化UI组件 + filterManager?.initializeUI(filterBtn = binding.filterButton) + + } catch (e: Exception) { + Log.e("FullscreenChartActivity", "初始化滤波器管理器失败: ${e.message}", e) + } + } + + /** + * 更新心率和血氧显示 + */ + private fun updateVitalSignsDisplay(additionalData: type.SensorData.AdditionalData?) { + if (additionalData == null) return + + runOnUiThread { + try { + // 更新心率显示 + val heartRate = additionalData.hr + if (heartRate > 0) { + binding.heartRateValue.text = heartRate.toString() + binding.heartRateValue.setTextColor(android.graphics.Color.GREEN) + } else { + binding.heartRateValue.text = "--" + binding.heartRateValue.setTextColor(android.graphics.Color.GRAY) + } + + // 更新血氧显示 + val spo2 = additionalData.spo2 + if (spo2 > 0) { + binding.spo2Value.text = spo2.toString() + binding.spo2Value.setTextColor(android.graphics.Color.BLUE) + } else { + binding.spo2Value.text = "--" + binding.spo2Value.setTextColor(android.graphics.Color.GRAY) + } + + // 更新体温显示 + val temperature = additionalData.temperature + if (temperature > 0) { + binding.temperatureValue.text = String.format("%.1f", temperature) + binding.temperatureValue.setTextColor(android.graphics.Color.RED) + } else { + binding.temperatureValue.text = "--" + binding.temperatureValue.setTextColor(android.graphics.Color.GRAY) + } + + Log.d("FullscreenChartActivity", "更新生命体征: HR=$heartRate, SpO2=$spo2, Temp=$temperature") + + } catch (e: Exception) { + Log.e("FullscreenChartActivity", "更新生命体征显示失败: ${e.message}") + } + } + } + + /** + * 设置全局异常处理器 + */ + private fun setupGlobalExceptionHandler() { + val defaultHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, exception -> + Log.e("FullscreenChartActivity", "=== 未捕获的异常 ===") + Log.e("FullscreenChartActivity", "线程: ${thread.name}") + Log.e("FullscreenChartActivity", "异常类型: ${exception.javaClass.simpleName}") + Log.e("FullscreenChartActivity", "异常消息: ${exception.message}") + Log.e("FullscreenChartActivity", "异常堆栈: ${exception.stackTraceToString()}") + + // 检查是否是JNI相关异常 + if (exception.message?.contains("JNI") == true || + exception.stackTrace.any { it.className.contains("native") || it.methodName.contains("native") }) { + Log.e("FullscreenChartActivity", "检测到JNI相关异常,可能需要检查native代码") + } + + // 检查是否是内存相关异常 + if (exception is OutOfMemoryError) { + Log.e("FullscreenChartActivity", "检测到内存不足异常,建议优化内存使用") + } + + // 检查是否是数组越界异常 + if (exception is IndexOutOfBoundsException) { + Log.e("FullscreenChartActivity", "检测到数组越界异常,可能需要检查数据访问逻辑") + } + + // 调用默认处理器 + try { + defaultHandler?.uncaughtException(thread, exception) + } catch (e: Exception) { + Log.e("FullscreenChartActivity", "默认异常处理器也失败了: ${e.message}") + // 最后手段:强制重启应用 + android.os.Process.killProcess(android.os.Process.myPid()) + } + } + } + + /** + * 设置全屏模式 + */ + private fun setupFullscreen() { + // 隐藏状态栏和导航栏 + window.decorView.systemUiVisibility = ( + View.SYSTEM_UI_FLAG_FULLSCREEN + or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + ) + + // 保持屏幕常亮 + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + + // 设置全屏标题 + supportActionBar?.hide() + } + + /** + * 设置全屏图表 + */ + private fun setupFullscreenCharts(chartDataArray: Array>) { + Log.d("FullscreenChartActivity", "设置全屏图表,设备类型: $deviceType,图表数量: ${chartDataArray.size}") + + // 根据设备类型创建图表 + val dataType = try { + SensorData.DataType.valueOf(deviceType) + } catch (e: IllegalArgumentException) { + Log.w("FullscreenChartActivity", "未知设备类型: $deviceType,使用默认类型") + SensorData.DataType.PW_ECG_SL // 默认使用单导联心电 + } + fullscreenChartManager?.createChartsForDevice(dataType) + + // 设置全屏图表管理器的事件监听器 + fullscreenChartManager?.setOnChartFullscreenClickListener { chartIndex, chart -> + // 在全屏模式下不需要处理全屏按钮点击 + } + + // 设置自定义Y轴范围和全屏布局 + val charts = fullscreenChartManager?.getCharts() ?: emptyList() + for (i in charts.indices) { + val chart = charts[i] + + // 设置全屏模式 + chart.setFullscreenMode(true) + + // 设置全屏布局参数 + val layoutParams = android.widget.LinearLayout.LayoutParams( + android.widget.LinearLayout.LayoutParams.MATCH_PARENT, + android.widget.LinearLayout.LayoutParams.MATCH_PARENT + ).apply { + marginStart = 0 + marginEnd = 0 + topMargin = 0 + bottomMargin = 0 + } + chart.layoutParams = layoutParams + + // 根据设备类型设置Y轴范围 + when (deviceType) { + "ECG_2LEAD", + "ECG_12LEAD", + "PW_ECG_SL" -> { + // ECG设备:Y轴范围 -20 到 20 + chart.setCustomYRange(-20f, 20f) + Log.d("FullscreenChartActivity", "设置ECG图表$i Y轴范围: -20 到 20") + } + "PPG" -> { + // PPG设备:Y轴范围 -2000 到 2000 + chart.setCustomYRange(-2000f, 2000f) + Log.d("FullscreenChartActivity", "设置PPG图表$i Y轴范围: -2000 到 2000") + } + else -> { + // 其他设备:使用自动范围 + chart.clearCustomYRange() + Log.d("FullscreenChartActivity", "设置图表$i 使用自动Y轴范围") + } + } + } + + // 更新图表数据 + val channelData = chartDataArray.map { chartData -> + @Suppress("UNCHECKED_CAST") + chartData["data"] as List + } + + if (channelData.isNotEmpty()) { + fullscreenChartManager?.updateChartsData(channelData) + Log.d("FullscreenChartActivity", "已更新全屏图表数据,通道数: ${channelData.size}") + } + + // 全屏模式下不需要更新标题 + } + + override fun onResume() { + super.onResume() + // 确保全屏模式 + setupFullscreen() + + } + + override fun onPause() { + super.onPause() + Log.d("FullscreenChartActivity", "Activity暂停") + } + + override fun onStop() { + super.onStop() + isExiting = true + Log.d("FullscreenChartActivity", "Activity停止") + } + + override fun onDestroy() { + try { + Log.d("FullscreenChartActivity", "开始销毁Activity") + + // 设置退出标志,阻止所有回调 + isExiting = true + + // 移除数据回调(防止重复移除) + if (!callbackRemoved) { + try { + mainDataManager?.removeRealTimeCallback(this) + callbackRemoved = true + Log.d("FullscreenChartActivity", "数据回调已移除") + } catch (e: Exception) { + Log.e("FullscreenChartActivity", "移除数据回调失败: ${e.message}") + } + } else { + Log.d("FullscreenChartActivity", "数据回调已移除,跳过重复移除") + } + + // 清理静态引用 + clearMainDataManager() + + Log.d("FullscreenChartActivity", "Activity销毁完成") + + } catch (e: Exception) { + Log.e("FullscreenChartActivity", "Activity销毁过程中出现异常: ${e.message}") + } finally { + super.onDestroy() + } + } + + override fun onBackPressed() { + if (!isFinishing && !isDestroyed) { + exitFullscreen() + } + } + + /** + * 安全退出全屏模式 + */ + private fun exitFullscreen() { + try { + Log.d("FullscreenChartActivity", "开始退出全屏模式") + + // 立即移除数据回调,防止在finish()后仍有回调执行 + if (!callbackRemoved) { + try { + mainDataManager?.removeRealTimeCallback(this) + callbackRemoved = true + Log.d("FullscreenChartActivity", "数据回调已移除") + } catch (e: Exception) { + Log.e("FullscreenChartActivity", "移除数据回调失败: ${e.message}") + } + } else { + Log.d("FullscreenChartActivity", "数据回调已移除,跳过重复移除") + } + + // 设置退出标志,阻止所有回调 + isExiting = true + + // 设置结果 + setResult(Activity.RESULT_OK) + + // 使用更安全的方式结束Activity + if (!isFinishing && !isDestroyed) { + finish() + } else { + Log.w("FullscreenChartActivity", "Activity已经在销毁过程中,跳过finish()") + } + + } catch (e: Exception) { + Log.e("FullscreenChartActivity", "退出全屏模式失败: ${e.message}") + // 如果出错,直接结束 + finish() + } + } + + + // 实时数据回调实现 - 从MainActivity接收数据 + override fun onProcessedDataAvailable(channelIndex: Int, processedData: List) { + // 检查Activity是否还在运行或正在退出 + if (isFinishing || isDestroyed || isExiting) { + return + } + + // 检查资源是否可用 + if (fullscreenChartManager == null || binding == null) { + return + } + + // 如果是单图表模式,只更新对应的图表 + if (isSingleChart && channelIndex != chartIndex) { + return + } + + // 检查数据有效性 + if (processedData.isEmpty()) { + return + } + + runOnUiThread { + try { + // 再次检查Activity状态 + if (isFinishing || isDestroyed || isExiting) { + Log.d("FullscreenChartActivity", "UI线程中Activity正在销毁,忽略数据回调") + return@runOnUiThread + } + + + // 检查图表管理器是否有效 + val chartManager = fullscreenChartManager + if (chartManager == null) { + Log.w("FullscreenChartActivity", "图表管理器为空,忽略数据回调") + return@runOnUiThread + } + + // 通过滤波器管理器处理数据 + val charts = chartManager.getCharts() + if (charts.isNotEmpty()) { + val chart = charts.getOrNull(0) + if (chart == null) { + Log.e("FullscreenChartActivity", "图表为空,跳过数据处理") + return@runOnUiThread + } + + // 再次检查Activity状态,防止在数据处理过程中Activity被销毁 + if (isFinishing || isDestroyed || isExiting) { + Log.d("FullscreenChartActivity", "数据处理过程中Activity状态变化,停止处理") + return@runOnUiThread + } + + // 检查是否是PPG设备的多通道模式 + if (deviceType == "PPG" && chart.isMultiChannelMode()) { + // PPG设备:需要获取多通道数据 + updatePPGData(chart) + } else { + // 单通道数据:检查是否有滤波器启用 + try { + // 检查数据有效性 + if (processedData.isEmpty()) { + Log.w("FullscreenChartActivity", "处理后数据为空,跳过处理") + return@runOnUiThread + } + + // 检查数据是否包含异常值 + val hasInvalidValues = processedData.any { it.isNaN() || it.isInfinite() } + if (hasInvalidValues) { + Log.w("FullscreenChartActivity", "处理后数据包含异常值,跳过处理") + return@runOnUiThread + } + + // 检查是否有滤波器启用 + val hasActiveFilters = filterManager?.let { manager -> + try { + val field = manager.javaClass.getDeclaredField("isAnyFilterEnabled") + field.isAccessible = true + field.getBoolean(manager) + } catch (e: Exception) { + false + } + } ?: false + + if (hasActiveFilters) { + // 有滤波器启用时,使用滤波器处理 + filterManager?.processRealTimeData("channel_$channelIndex", processedData) { filteredData -> + try { + // 再次检查Activity状态 + if (isFinishing || isDestroyed) { + Log.d("FullscreenChartActivity", "Activity正在销毁,跳过图表更新") + return@processRealTimeData + } + + // 检查滤波后数据有效性 + if (filteredData.isEmpty()) { + Log.w("FullscreenChartActivity", "滤波后数据为空,跳过更新") + return@processRealTimeData + } + + chart.updateData(filteredData) + Log.d("FullscreenChartActivity", "已更新滤波后的单通道数据") + } catch (e: Exception) { + Log.e("FullscreenChartActivity", "更新单通道数据失败: ${e.message}", e) + } + } + } else { + // 没有滤波器启用时,直接更新图表 + chart.updateData(processedData) + Log.d("FullscreenChartActivity", "已直接更新处理后数据(无滤波)") + } + } catch (e: Exception) { + Log.e("FullscreenChartActivity", "数据处理失败: ${e.message}", e) + // 出错时直接使用原始数据 + try { + if (!isFinishing && !isDestroyed && processedData.isNotEmpty()) { + chart.updateData(processedData) + Log.d("FullscreenChartActivity", "已更新原始单通道数据") + } + } catch (e2: Exception) { + Log.e("FullscreenChartActivity", "更新原始数据也失败: ${e2.message}", e2) + } + } + } + } + } catch (e: Exception) { + Log.e("FullscreenChartActivity", "处理实时数据失败: ${e.message}", e) + } + } + } + + override fun onRawDataAvailable(channelIndex: Int, rawData: List) { + // 检查Activity是否还在运行或正在退出 + if (isFinishing || isDestroyed || isExiting) { + return + } + + // 检查资源是否可用 + if (fullscreenChartManager == null || binding == null) { + return + } + + // 如果是单图表模式,只更新对应的图表 + if (isSingleChart && channelIndex != chartIndex) { + return + } + + // 检查数据有效性 + if (rawData.isEmpty()) { + return + } + + runOnUiThread { + try { + // 再次检查Activity状态和退出标志 + if (isFinishing || isDestroyed || isExiting) { + Log.d("FullscreenChartActivity", "UI线程中Activity正在销毁,忽略原始数据回调") + return@runOnUiThread + } + + // 检查资源是否仍然可用 + if (fullscreenChartManager == null || binding == null) { + return@runOnUiThread + } + + + // 检查图表管理器是否有效 + val chartManager = fullscreenChartManager + if (chartManager == null) { + Log.w("FullscreenChartActivity", "图表管理器为空,忽略原始数据回调") + return@runOnUiThread + } + + // 通过滤波器管理器处理数据 + val charts = chartManager.getCharts() + if (charts.isNotEmpty()) { + val chart = charts.getOrNull(channelIndex.coerceAtMost(charts.size - 1)) + if (chart == null) { + Log.e("FullscreenChartActivity", "通道${channelIndex}对应的图表为空,跳过数据处理") + return@runOnUiThread + } + + // 再次检查Activity状态,防止在数据处理过程中Activity被销毁 + if (isFinishing || isDestroyed || isExiting) { + Log.d("FullscreenChartActivity", "原始数据处理过程中Activity状态变化,停止处理") + return@runOnUiThread + } + + // 检查是否是PPG设备的多通道模式 + if (deviceType == "PPG" && chart.isMultiChannelMode()) { + // PPG设备:需要获取多通道数据 + updatePPGData(chart) + } else { + // 单通道数据:检查是否有滤波器启用 + try { + // 检查数据有效性 + if (rawData.isEmpty()) { + Log.w("FullscreenChartActivity", "原始数据为空,跳过处理") + return@runOnUiThread + } + + // 检查数据是否包含异常值 + val hasInvalidValues = rawData.any { it.isNaN() || it.isInfinite() } + if (hasInvalidValues) { + Log.w("FullscreenChartActivity", "原始数据包含异常值,跳过处理") + return@runOnUiThread + } + + // 检查是否有滤波器启用 + val hasActiveFilters = filterManager?.let { manager -> + // 通过反射或其他方式检查滤波器状态 + try { + val field = manager.javaClass.getDeclaredField("isAnyFilterEnabled") + field.isAccessible = true + field.getBoolean(manager) + } catch (e: Exception) { + false // 如果无法检查,假设没有启用 + } + } ?: false + + if (hasActiveFilters) { + // 有滤波器启用时,使用滤波器处理 + filterManager?.processRealTimeData("channel_$channelIndex", rawData) { filteredData -> + try { + // 再次检查Activity状态 + if (isFinishing || isDestroyed) { + Log.d("FullscreenChartActivity", "Activity正在销毁,跳过图表更新") + return@processRealTimeData + } + + // 检查滤波后数据有效性 + if (filteredData.isEmpty()) { + Log.w("FullscreenChartActivity", "滤波后数据为空,跳过更新") + return@processRealTimeData + } + + chart.updateData(filteredData) + Log.d("FullscreenChartActivity", "已更新滤波后的原始数据") + } catch (e: Exception) { + Log.e("FullscreenChartActivity", "更新原始数据失败: ${e.message}", e) + } + } + } else { + // 没有滤波器启用时,直接更新图表 + chart.updateData(rawData) + Log.d("FullscreenChartActivity", "已直接更新原始数据(无滤波)") + } + } catch (e: Exception) { + Log.e("FullscreenChartActivity", "数据处理失败: ${e.message}", e) + // 出错时直接使用原始数据 + try { + if (!isFinishing && !isDestroyed && rawData.isNotEmpty()) { + chart.updateData(rawData) + Log.d("FullscreenChartActivity", "已更新原始数据") + } + } catch (e2: Exception) { + Log.e("FullscreenChartActivity", "更新原始数据也失败: ${e2.message}", e2) + } + } + } + } + + // 尝试从DataManager获取最新的AdditionalData并更新生命体征显示 + updateVitalSignsFromDataManager() + } catch (e: Exception) { + Log.e("FullscreenChartActivity", "处理原始数据失败: ${e.message}", e) + } + } + } + + /** + * 从DataManager获取最新的生命体征数据并更新显示 + */ + private fun updateVitalSignsFromDataManager() { + // 检查Activity状态 + if (isFinishing || isDestroyed) { + Log.d("FullscreenChartActivity", "Activity正在销毁,跳过生命体征更新") + return + } + + try { + val dataManager = mainDataManager + if (dataManager != null) { + // 获取最新的数据包 + val latestPackets = dataManager.getPacketBuffer() + if (latestPackets.isNotEmpty()) { + // 获取最新的数据包中的AdditionalData + val latestPacket = latestPackets.last() + val additionalData = latestPacket.additionalData + + if (additionalData != null) { + updateVitalSignsDisplay(additionalData) + } + } + } + } catch (e: Exception) { + Log.e("FullscreenChartActivity", "获取生命体征数据失败: ${e.message}") + } + } + + /** + * 更新PPG多通道数据 + */ + private fun updatePPGData(chart: DynamicChartView) { + try { + // 检查Activity状态 + if (isFinishing || isDestroyed) { + Log.d("FullscreenChartActivity", "Activity正在销毁,跳过PPG数据更新") + return + } + + // 检查图表是否有效 + if (chart == null) { + Log.w("FullscreenChartActivity", "图表为空,跳过PPG数据更新") + return + } + + // 从主数据管理器获取PPG数据 + val dataManager = mainDataManager + if (dataManager != null) { + try { + val mappedPackets = dataManager.getMappedPacketBuffer() + if (mappedPackets.isNotEmpty()) { + val latestMappedPacket = mappedPackets.last() + val channelData = latestMappedPacket.getChannelData() + if (channelData != null && channelData.isNotEmpty()) { + // 验证数据格式 + if (channelData.size >= 2 && channelData[0].isNotEmpty() && channelData[1].isNotEmpty()) { + chart.updateMultiChannelData(channelData) + Log.d("FullscreenChartActivity", "更新PPG多通道数据: ${channelData.size}通道") + return + } else { + Log.w("FullscreenChartActivity", "PPG数据格式不正确: ${channelData.map { it.size }}") + } + } else { + Log.w("FullscreenChartActivity", "PPG通道数据为空") + } + } else { + Log.w("FullscreenChartActivity", "映射数据包为空") + } + } catch (e: Exception) { + Log.e("FullscreenChartActivity", "获取PPG数据失败: ${e.message}", e) + } + } else { + Log.w("FullscreenChartActivity", "数据管理器为空") + } + + // 如果无法获取数据,使用模拟数据 + try { + val mockPPGData = listOf( + listOf(100f, 120f, 110f, 130f, 115f), // 红光PPG + listOf(200f, 220f, 210f, 230f, 215f) // 红外光PPG + ) + chart.updateMultiChannelData(mockPPGData) + Log.d("FullscreenChartActivity", "使用模拟PPG数据") + } catch (e: Exception) { + Log.e("FullscreenChartActivity", "更新模拟PPG数据失败: ${e.message}", e) + } + } catch (e: Exception) { + Log.e("FullscreenChartActivity", "更新PPG数据失败: ${e.message}", e) + } + } + + override fun onStreamingProgress(progress: Int, totalSamples: Int, processedSamples: Int) { + // 检查Activity是否还在运行或正在退出 + if (isFinishing || isDestroyed || isExiting) { + return + } + // 全屏模式下不需要显示进度 + } + + // NativeMethodCallback实现 + override external fun createStreamParser(): Long + override external fun destroyStreamParser(handle: Long) + override external fun streamParserAppend(handle: Long, chunk: ByteArray) + override external fun streamParserDrainPackets(handle: Long): List? +} diff --git a/app/src/main/java/com/example/cmake_project_test/FullscreenFilterManager.kt b/app/src/main/java/com/example/cmake_project_test/FullscreenFilterManager.kt new file mode 100644 index 0000000..97ccc81 --- /dev/null +++ b/app/src/main/java/com/example/cmake_project_test/FullscreenFilterManager.kt @@ -0,0 +1,847 @@ +package com.example.cmake_project_test + +import android.app.AlertDialog +import android.content.Context +import android.text.TextUtils +import android.util.Log +import android.view.LayoutInflater +import android.widget.Button +import android.widget.EditText +import android.widget.Switch +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +/** + * 全屏模式滤波器管理器 + * 管理滤波器状态、UI交互和数据处理 + */ +class FullscreenFilterManager( + private val context: Context, + private val scope: CoroutineScope, + private val dataManager: DataManager?, + private val chartManager: DynamicChartManager?, + private val deviceType: String = "PW_ECG_SL" // 默认设备类型 +) { + + // 滤波器状态数据类 + data class FilterState( + var enabled: Boolean = false, + var frequency: Double = 0.0 + ) + + /** + * 根据设备类型获取采样率 + */ + private fun getSampleRate(): Double { + return when (deviceType) { + "ECG_2LEAD", "ECG_12LEAD", "PW_ECG_SL", "EEG" -> 250.0 + "PPG" -> 50.0 + "STETHOSCOPE", "SNORE" -> 8000.0 + "RESPIRATION" -> 50.0 + else -> 250.0 // 默认采样率 + } + } + + // 当前滤波器状态 + private val lowpassFilter = FilterState(false, 40.0) + private val highpassFilter = FilterState(false, 1.0) + private val notchFilter = FilterState(false, 50.0) + + // 是否有任何滤波器启用 + private var isAnyFilterEnabled = false + + // 内存管理:动态调整的缓冲区大小 + private var maxDataPointsPerBatch = 100 // 每次最多处理100个数据点 + private val dataBuffer = mutableListOf() + private var maxBufferSize = 500 // 缓冲区最大大小(动态调整) + + // UI组件引用 + private var filterButton: Button? = null + private var currentDialog: android.app.AlertDialog? = null + + // 原始数据和滤波后数据缓存 - 改进为带时间戳的缓存 + private val originalDataCache = mutableMapOf() + private val filteredDataCache = mutableMapOf() + + // 缓存配置 + private val maxCacheEntries = 50 // 最大缓存条目数 + private val maxCacheDataSize = 10000 // 每个缓存条目的最大数据点数 + private val cacheExpirationTime = 5 * 60 * 1000L // 缓存过期时间:5分钟 + + // 缓存条目类 + data class CacheEntry( + val data: MutableList, + val timestamp: Long = System.currentTimeMillis(), + var accessCount: Int = 0 + ) + + /** + * 初始化UI组件 + */ + fun initializeUI(filterBtn: Button) { + filterButton = filterBtn + + // 根据设备配置动态调整缓冲区大小 + optimizeBufferSizes() + + setupUIListeners() + updateUIState() + } + + /** + * 根据设备配置和内存情况动态优化缓冲区大小 + */ + private fun optimizeBufferSizes() { + try { + val runtime = Runtime.getRuntime() + val maxMemoryMB = runtime.maxMemory() / 1024 / 1024 + + // 根据设备采样率调整批处理大小 + val sampleRate = getSampleRate() + when { + sampleRate >= 8000.0 -> { // 音频设备 + maxDataPointsPerBatch = 50 + maxBufferSize = 200 + } + sampleRate >= 1000.0 -> { // 高速设备 + maxDataPointsPerBatch = 100 + maxBufferSize = 400 + } + sampleRate >= 250.0 -> { // 中速设备(ECG等) + maxDataPointsPerBatch = 150 + maxBufferSize = 600 + } + else -> { // 低速设备 + maxDataPointsPerBatch = 200 + maxBufferSize = 800 + } + } + + // 根据可用内存进一步调整 + when { + maxMemoryMB < 128 -> { // 低内存设备 + maxDataPointsPerBatch = (maxDataPointsPerBatch * 0.5).toInt() + maxBufferSize = (maxBufferSize * 0.5).toInt() + } + maxMemoryMB < 256 -> { // 中等内存设备 + maxDataPointsPerBatch = (maxDataPointsPerBatch * 0.75).toInt() + maxBufferSize = (maxBufferSize * 0.75).toInt() + } + maxMemoryMB >= 512 -> { // 高内存设备 + maxDataPointsPerBatch = (maxDataPointsPerBatch * 1.5).toInt() + maxBufferSize = (maxBufferSize * 1.5).toInt() + } + } + + // 确保最小值 + maxDataPointsPerBatch = maxOf(maxDataPointsPerBatch, 25) + maxBufferSize = maxOf(maxBufferSize, 100) + + Log.d("FullscreenFilterManager", "缓冲区大小优化完成: 批处理=$maxDataPointsPerBatch, 缓冲区=$maxBufferSize, 内存=${maxMemoryMB}MB") + + } catch (e: Exception) { + Log.e("FullscreenFilterManager", "缓冲区大小优化失败: ${e.message}") + // 使用默认值 + maxDataPointsPerBatch = 100 + maxBufferSize = 500 + } + } + + /** + * 设置UI监听器 + */ + private fun setupUIListeners() { + // 滤波器按键 + filterButton?.setOnClickListener { + showFilterDialog() + } + } + + /** + * 显示滤波器设置对话框 + */ + private fun showFilterDialog() { + try { + // Context在构造函数中传入,不会为null,但为了安全起见保留检查 + + val dialogView = LayoutInflater.from(context).inflate(R.layout.dialog_filter_settings, null) + + // 获取对话框中的组件 + val lowpassSwitch = dialogView.findViewById(R.id.lowpass_enable_switch) + val highpassSwitch = dialogView.findViewById(R.id.highpass_enable_switch) + val notchSwitch = dialogView.findViewById(R.id.notch_enable_switch) + val lowpassFreqInput = dialogView.findViewById(R.id.lowpass_frequency_input) + val highpassFreqInput = dialogView.findViewById(R.id.highpass_frequency_input) + val notchFreqInput = dialogView.findViewById(R.id.notch_frequency_input) + val btnCancel = dialogView.findViewById