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 0000000..55786dc Binary files /dev/null and b/app/keystore.jks differ 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