深夜提醒

现在是深夜,建议您注意休息,不要熬夜哦~

🏮 🏮 🏮

新年快乐

祝君万事如意心想事成!

share-image
ESC

Heart Sync 心同步:Android Kotlin + Jetpack Compose 全栈实战指南

本文详细记录了一款功能完备的运动健康App「Heart Sync 心同步」的完整开发过程。项目涵盖GPS定位与轨迹追踪、蓝牙心率带连接、HRV心率变异性分析、智能暂停/恢复、体重/血压/血糖健康管理、用户认证与数据同步、GPX/TCX数据导出等核心功能。

目录

  1. 项目概述与架构设计
  2. 技术栈选型与依赖配置
  3. GPS定位与卡尔曼滤波优化
  4. 高德地图SDK深度集成
  5. 蓝牙BLE设备连接
  6. HRV心率变异性分析
  7. 健康管理功能
  8. 运动数据计算与算法
  9. 智能暂停与自动恢复
  10. 用户认证与数据同步
  11. 数据持久化与导出
  12. Jetpack Compose UI实现
  13. 踩坑记录与解决方案
  14. 总结与展望

效果展示


1. 项目概述与架构设计

1.1 功能概览

Heart Sync 心同步 是一款专业的骑行运动追踪与健康管理App,主要功能包括:

运动追踪

  • 实时GPS轨迹追踪:高精度定位、卡尔曼滤波平滑、轨迹可视化
  • 蓝牙心率监测:BLE心率带连接、实时心率显示、心率区间训练
  • HRV分析:心率变异性测量(SDNN、RMSSD)、长期趋势追踪
  • 运动数据统计:距离、配速、速度、时长、卡路里消耗、累计爬升
  • 智能暂停/恢复:静止检测自动暂停/恢复运动记录

健康管理

  • 体重记录:蓝牙电子秤/手动输入,BMI计算与趋势分析
  • 血压监测:蓝牙血压计/手动输入,血压分级与趋势图
  • 血糖记录:蓝牙血糖仪/手动输入,血糖分级与趋势追踪
  • 健康趋势:使用MPAndroidChart展示历史数据可视化

数据管理

  • 地图轨迹展示:高德地图集成、轨迹绘制、公里标记
  • 运动记录管理:历史记录列表、详情查看、轨迹缩略图
  • 用户认证系统:登录注册、JWT认证、密码修改
  • 数据云同步:本地与服务端双向同步、多设备数据同步
  • 数据导出分享:GPX、TCX标准格式导出,支持Strava等平台

1.2 整体架构

项目采用 MVVM(Model-View-ViewModel) 架构模式:

┌─────────────────────────────────────────────────────────────┐
│ UI Layer │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │RunningScreen│ │RecordsScreen│ │ HealthScreen │ │
│ │ WithMap │ │ │ │ (体重/血压/血糖) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘ │
│ │ │ │ │
├─────────┼────────────────┼─────────────────────┼─────────────┤
│ ▼ ▼ ▼ │
│ ViewModel Layer │
│ ┌─────────────────┐ ┌──────────────────────────────────┐ │
│ │ RunningViewModel│ │ HealthViewModel │ │
│ ├─────────────────┤ ├──────────────────────────────────┤ │
│ │ HrvViewModel │ │ BluetoothViewModel │ │
│ └────────┬────────┘ └─────────────────┬────────────────┘ │
│ │ │ │
├───────────┼─────────────────────────────┼────────────────────┤
│ ▼ ▼ │
│ Data Layer │
│ ┌───────────────┐ ┌───────────────┐ ┌─────────────────┐ │
│ │LocationTracker│ │BluetoothLeManager│ │Repository │ │
│ ├───────────────┤ ├───────────────┤ ├─────────────────┤ │
│ │HrvMeasurement │ │BluetoothScale │ │HealthRepository │ │
│ │ Manager │ │BluetoothBP │ │ │ │
│ │ │ │BluetoothGlucose│ │ │ │
│ └───────────────┘ └───────────────┘ └─────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Network Layer (Retrofit) │ │
│ │ API Server: Go/Gin + MySQL 8.0 │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘

1.3 数据模型设计

核心数据模型采用 Kotlin Data Class:

/**
* GPS轨迹点
*/
data class GpsPoint(
val latitude: Double,
val longitude: Double,
val altitude: Double,
val timestamp: Instant,
val accuracy: Float,
val speed: Float = 0f,
val heartRate: Int = 0
)

/**
* 骑行会话数据
*/
data class RunningSession(
val id: String = UUID.randomUUID().toString(),
val startTime: Instant,
val endTime: Instant? = null,
val gpsPoints: List<GpsPoint> = emptyList(),
val totalDistanceMeters: Double = 0.0,
val totalDurationSeconds: Long = 0,
val averageSpeed: Float = 0f,
val maxSpeed: Float = 0f,
val averageHeartRate: Int = 0,
val maxHeartRate: Int = 0,
val calories: Int = 0,
val cityName: String = "",
val activityType: String = "骑行",
val totalAscent: Double = 0.0,
val thumbnailPath: String = ""
)

/**
* HRV测量数据
*/
data class HrvData(
val id: String = UUID.randomUUID().toString(),
val timestamp: Instant = Instant.now(),
val sdnn: Int, // 标准差 of NN intervals
val rmssd: Int, // 均方根差 of successive differences
val rrIntervals: List<Int>, // RR间期原始数据
val duration: Int // 测量时长(秒)
)

/**
* 健康记录基类
*/
sealed class HealthRecord {
abstract val id: String
abstract val timestamp: Instant
}

/**
* 体重记录
*/
data class WeightRecord(
override val id: String = UUID.randomUUID().toString(),
override val timestamp: Instant = Instant.now(),
val weightKg: Double,
val bodyFatPercent: Double? = null,
val source: String = "manual" // manual/bluetooth
) : HealthRecord()

/**
* 血压记录
*/
data class BloodPressureRecord(
override val id: String = UUID.randomUUID().toString(),
override val timestamp: Instant = Instant.now(),
val systolic: Int, // 收缩压(高压)
val diastolic: Int, // 舒张压(低压)
val pulse: Int, // 脉搏
val source: String = "manual"
) : HealthRecord()

/**
* 血糖记录
*/
data class BloodGlucoseRecord(
override val id: String = UUID.randomUUID().toString(),
override val timestamp: Instant = Instant.now(),
val value: Double, // mmol/L
val measurementType: String, // fasting/after_meal/before_meal/bedtime/random
val source: String = "manual"
) : HealthRecord()

2. 技术栈选型与依赖配置

2.1 核心技术栈

技术领域 选型方案 版本
UI框架 Jetpack Compose BOM 2024.09.00
架构组件 ViewModel + StateFlow 2.6.1
地图服务 高德地图SDK 3D地图+定位
蓝牙连接 Android BLE API 原生实现
心率协议 ANT+ 支持ANT+心率带
网络请求 Retrofit + OkHttp 2.9.0 / 4.11.0
图表库 MPAndroidChart 3.1.0
图片加载 Coil 2.5.0
JSON解析 Gson 2.10.1

2.2 Gradle依赖配置

// build.gradle.kts
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
}

android {
namespace = "com.awen.running"
compileSdk = 36

defaultConfig {
applicationId = "com.awen.running"
minSdk = 24
targetSdk = 36

// 指定支持的CPU架构(高德地图SO库)
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
}
}

buildFeatures {
compose = true
}
}

dependencies {
// Jetpack Compose
implementation(platform("androidx.compose:compose-bom:2024.09.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")

// Lifecycle
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.1")

// 高德地图SDK(本地JAR)
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))

// 图表库
implementation("com.github.PhilJay:MPAndroidChart:v3.1.0")

// 网络请求
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")

// 图片加载
implementation("io.coil-kt:coil-compose:2.5.0")

// 安全存储
implementation("androidx.security:security-crypto:1.1.0-alpha06")
}

2.3 权限配置

<!-- AndroidManifest.xml -->
<manifest>
<!-- 定位权限 -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />

<!-- 蓝牙权限 -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

<!-- 网络权限 -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />

<!-- 存储权限 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

<!-- 震动权限 -->
<uses-permission android:name="android.permission.VIBRATE" />
</manifest>

3. GPS定位与卡尔曼滤波优化

3.1 高德定位SDK集成

高德定位SDK支持GPS、WiFi、基站多源融合定位,特别适合室内外混合场景:

class AmapLocationTracker(private val context: Context) {

private var locationClient: AMapLocationClient? = null

// 卡尔曼滤波器参数
private var lastFilteredLat: Double? = null
private var lastFilteredLng: Double? = null
private var kalmanGain = 0.3

/**
* 创建定位选项
* @param strictMode true=骑行模式(高精度),false=检测模式(室内友好)
*/
private fun createLocationOption(strictMode: Boolean): AMapLocationClientOption {
return AMapLocationClientOption().apply {
// 高精度模式:GPS + WiFi + 基站
locationMode = AMapLocationClientOption.AMapLocationMode.Hight_Accuracy

// 定位间隔:1秒
interval = 1000L

// 是否使用传感器(提高定位精度)
isSensorEnable = true

// WiFi扫描(室内定位关键)
isWifiScan = true

// GPS优先(骑行模式下GPS优先,检测模式下允许WiFi定位)
isGpsFirst = strictMode

// 缓存定位(检测模式下使用缓存加快响应)
isLocationCacheEnable = !strictMode
}
}

/**
* 获取位置更新Flow
*/
fun getLocationUpdates(strictMode: Boolean = false): Flow<GpsPoint> = callbackFlow {
val client = AMapLocationClient(context)
locationClient = client

client.setLocationOption(createLocationOption(strictMode))

val listener = AMapLocationListener { location ->
if (location == null || location.errorCode != 0) return@AMapLocationListener

// 精度过滤(骑行30m,检测500m)
val accuracyThreshold = if (strictMode) 30f else 500f
if (location.accuracy > accuracyThreshold) return@AMapLocationListener

// 速度异常过滤(>50km/h=13.9m/s,骑行场景合理上限)
if (strictMode && location.speed > 13.9f) return@AMapLocationListener

// 应用卡尔曼滤波
val (filteredLat, filteredLng) = applyKalmanFilter(
location.latitude,
location.longitude,
location.accuracy
)

val gpsPoint = GpsPoint(
latitude = filteredLat,
longitude = filteredLng,
altitude = location.altitude,
timestamp = Instant.ofEpochMilli(location.time),
accuracy = location.accuracy,
speed = location.speed
)

trySend(gpsPoint)
}

client.setLocationListener(listener)
client.startLocation()

awaitClose {
client.stopLocation()
client.onDestroy()
}
}
}

3.2 卡尔曼滤波算法实现

GPS原始数据存在噪声和跳变,卡尔曼滤波可以有效平滑轨迹:

/**
* 卡尔曼滤波 - 平滑GPS数据
*
* 原理:根据GPS精度动态调整滤波增益
* - 精度高(accuracy小):增益大,更信任新数据
* - 精度低(accuracy大):增益小,更信任历史数据
*/
private fun applyKalmanFilter(lat: Double, lng: Double, accuracy: Float): Pair<Double, Double> {
// 首次定位直接返回
if (lastFilteredLat == null || lastFilteredLng == null) {
lastFilteredLat = lat
lastFilteredLng = lng
return Pair(lat, lng)
}

// 根据精度动态调整卡尔曼增益
kalmanGain = when {
accuracy <= 5f -> 0.8 // 高精度GPS,高信任
accuracy <= 10f -> 0.5
accuracy <= 20f -> 0.3
accuracy <= 50f -> 0.2
else -> 0.1 // 低精度,低信任
}

// 滤波公式:新值 = 旧值 + 增益 × (观测值 - 旧值)
val filteredLat = lastFilteredLat!! + kalmanGain * (lat - lastFilteredLat!!)
val filteredLng = lastFilteredLng!! + kalmanGain * (lng - lastFilteredLng!!)

lastFilteredLat = filteredLat
lastFilteredLng = filteredLng

return Pair(filteredLat, filteredLng)
}

3.3 GPS预热机制

GPS初始定位精度较差,需要丢弃前几个点:

// GPS预热计数器
private var gpsWarmupCounter = 0
private val GPS_WARMUP_POINTS = 5

private fun addGpsPoint(point: GpsPoint) {
// GPS预热期:丢弃前5个GPS点
gpsWarmupCounter++
if (gpsWarmupCounter <= GPS_WARMUP_POINTS) {
Log.d(TAG, "GPS warmup: skipping point $gpsWarmupCounter/$GPS_WARMUP_POINTS")
return
}

// 距离突变检测:两点间距离>50米则判定为飘移
if (currentPoints.isNotEmpty()) {
val prevPoint = currentPoints.last()
val distance = calculateDistance(prevPoint, point)

if (distance > 50.0) {
Log.w(TAG, "Distance jump detected: ${distance}m, skipping")
return
}
}

// 正常添加GPS点
currentPoints.add(point)
updateStatistics()
}

4. 高德地图SDK深度集成

4.1 SDK集成步骤

  1. 下载SDK:从高德开放平台下载Android地图SDK全量包
  2. 配置JAR:将JAR文件放入app/libs/目录
  3. 配置SO库:将SO文件放入app/src/main/jniLibs/对应架构目录
  4. 申请Key:在高德控制台创建应用,获取API Key
<!-- AndroidManifest.xml -->
<application>
<!-- 高德API Key -->
<meta-data
android:name="com.amap.api.v2.apikey"
android:value="your_api_key_here" />
</application>

4.2 隐私合规配置

高德SDK要求在Application中进行隐私合规设置:

class RunningApplication : Application() {
override fun onCreate() {
super.onCreate()

// 高德SDK隐私合规
AMapLocationClient.updatePrivacyShow(this, true, true)
AMapLocationClient.updatePrivacyAgree(this, true)

// 初始化RetrofitClient
RetrofitClient.init(this)
}
}

4.3 Compose封装MapView

@Composable
fun AmapView(
modifier: Modifier = Modifier,
gpsPoints: List<GpsPoint> = emptyList(),
currentLocation: GpsPoint? = null,
isRunning: Boolean = false
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current

val mapView = remember { MapView(context) }
var aMap by remember { mutableStateOf<AMap?>(null) }
var polyline by remember { mutableStateOf<Polyline?>(null) }

// 生命周期管理
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> mapView.onResume()
Lifecycle.Event.ON_PAUSE -> mapView.onPause()
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)

onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
mapView.onDestroy()
}
}

// 更新轨迹
LaunchedEffect(gpsPoints, aMap) {
val map = aMap ?: return@LaunchedEffect

if (gpsPoints.isEmpty()) {
polyline?.remove()
return@LaunchedEffect
}

val latLngList = gpsPoints.map { LatLng(it.latitude, it.longitude) }

// 创建或更新轨迹线
if (polyline == null) {
val options = PolylineOptions()
.addAll(latLngList)
.width(12f)
.color(0xFF4CAF50.toInt()) // 绿色
.geodesic(true)
polyline = map.addPolyline(options)

// 自动缩放到轨迹范围
if (!isRunning && latLngList.size >= 2) {
val bounds = LatLngBounds.Builder()
.apply { latLngList.forEach { include(it) } }
.build()
map.animateCamera(CameraUpdateFactory.newLatLngBounds(bounds, 80))
}
} else {
polyline?.points = latLngList
}
}

// 渲染地图
AndroidView(
factory = {
mapView.apply {
onCreate(Bundle())
aMap = this.map

map.uiSettings.apply {
isZoomControlsEnabled = true
isCompassEnabled = true
isScaleControlsEnabled = true
}

map.mapType = AMap.MAP_TYPE_NORMAL
}
},
modifier = modifier
)
}

4.4 逆地理编码(获取城市名)

class AmapGeocoder(private val context: Context) {

suspend fun getCityName(latitude: Double, longitude: Double): String {
return suspendCancellableCoroutine { continuation ->
val geocodeSearch = GeocodeSearch(context)

geocodeSearch.setOnGeocodeSearchListener(object : GeocodeSearch.OnGeocodeSearchListener {
override fun onRegeocodeSearched(result: RegeocodeResult?, code: Int) {
if (code == 1000 && result?.regeocodeAddress != null) {
val address = result.regeocodeAddress
val cityName = address.city.ifEmpty { address.province }
continuation.resume(cityName) {}
} else {
continuation.resume("未知城市") {}
}
}

override fun onGeocodeSearched(result: GeocodeResult?, code: Int) {}
})

val query = RegeocodeQuery(
LatLonPoint(latitude, longitude),
200f, // 搜索半径200米
GeocodeSearch.AMAP
)

geocodeSearch.getFromLocationAsyn(query)
}
}
}

5. 蓝牙BLE设备连接

5.1 BLE心率服务规范

心率带遵循蓝牙标准心率服务(Heart Rate Service):

UUID 名称 说明
0x180D Heart Rate Service 心率服务
0x2A37 Heart Rate Measurement 心率测量特征
0x2902 CCCD 客户端特征配置描述符

5.2 BLE心率管理器实现

@SuppressLint("MissingPermission")
class BluetoothLeManager(private val context: Context) {

private val bluetoothAdapter: BluetoothAdapter? by lazy {
val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
bluetoothManager.adapter
}

private var bluetoothGatt: BluetoothGatt? = null

private val _heartRate = MutableStateFlow(0)
val heartRate: StateFlow<Int> = _heartRate.asStateFlow()

private val _rrIntervals = MutableSharedFlow<Int>(extraBufferCapacity = 64)
val rrIntervals: SharedFlow<Int> = _rrIntervals.asSharedFlow()

private val _connectionState = MutableStateFlow(false)
val connectionState: StateFlow<Boolean> = _connectionState.asStateFlow()

// GATT回调
private val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
_connectionState.value = true
gatt.discoverServices()
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
_connectionState.value = false
}
}

override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) {
// 获取心率服务
val service = gatt.getService(UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb"))
val characteristic = service?.getCharacteristic(
UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb")
)

if (characteristic != null) {
// 启用通知
gatt.setCharacteristicNotification(characteristic, true)

val descriptor = characteristic.getDescriptor(
UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
)

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
gatt.writeDescriptor(descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)
} else {
@Suppress("DEPRECATION")
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
@Suppress("DEPRECATION")
gatt.writeDescriptor(descriptor)
}
}
}
}

override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray
) {
processHeartRateData(value)
}
}

/**
* 解析心率数据
*
* 数据格式(按蓝牙规范):
* Byte 0: Flags
* - Bit 0: 心率值格式 (0=UINT8, 1=UINT16)
* - Bit 3: 能量消耗存在标志
* - Bit 4: RR间期存在标志
* Byte 1(-2): 心率值
* Byte N+: RR间期(每个2字节,单位1/1024秒)
*/
private fun processHeartRateData(data: ByteArray) {
if (data.isEmpty()) return

val flag = data[0].toInt()

// 解析心率值
val heartRate = if ((flag and 0x01) != 0) {
// UINT16格式
((data[2].toInt() and 0xFF) shl 8) or (data[1].toInt() and 0xFF)
} else {
// UINT8格式
data[1].toInt() and 0xFF
}
_heartRate.value = heartRate

// 解析RR间期(用于HRV计算)
if ((flag and 0x10) != 0) {
var offset = if ((flag and 0x01) != 0) 3 else 2
if ((flag and 0x08) != 0) offset += 2 // 跳过能量消耗字段

while (offset + 2 <= data.size) {
val rrRaw = ((data[offset + 1].toInt() and 0xFF) shl 8) or (data[offset].toInt() and 0xFF)
val rrMs = (rrRaw * 1000) / 1024 // 转换为毫秒
_rrIntervals.tryEmit(rrMs)
offset += 2
}
}
}

fun connect(device: BluetoothDevice) {
bluetoothGatt = device.connectGatt(context, false, gattCallback)
}

fun disconnect() {
bluetoothGatt?.disconnect()
bluetoothGatt?.close()
bluetoothGatt = null
}
}

5.3 蓝牙体重秤连接

@SuppressLint("MissingPermission")
class BluetoothScaleManager(private val context: Context) {

private val bluetoothAdapter: BluetoothAdapter? by lazy {
val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
bluetoothManager.adapter
}

private var bluetoothGatt: BluetoothGatt? = null

private val _weightData = MutableSharedFlow<WeightData>(extraBufferCapacity = 1)
val weightData: SharedFlow<WeightData> = _weightData.asSharedFlow()

data class WeightData(
val weightKg: Double,
val bodyFatPercent: Double? = null,
val isStable: Boolean = false
)

private val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
gatt.discoverServices()
}
}

override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
// 不同品牌的体重秤使用不同的服务和特征值
// 常见的有:0x181B (Body Composition Service) 或自定义服务
val service = gatt.getService(UUID.fromString("0000181b-0000-1000-8000-00805f9b34fb"))
val characteristic = service?.getCharacteristic(
UUID.fromString("00002a9c-0000-1000-8000-00805f9b34fb")
)

characteristic?.let { enableNotification(gatt, it) }
}

override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray
) {
parseWeightData(value)
}
}

private fun parseWeightData(data: ByteArray) {
// 根据具体协议解析,这里以常见格式为例
if (data.size >= 4) {
// 假设数据格式:Flags(1字节) + Weight(2-3字节)
val flags = data[0].toInt()
val isStable = (flags and 0x01) != 0

val weightRaw = ((data[2].toInt() and 0xFF) shl 8) or (data[1].toInt() and 0xFF)
val weightKg = weightRaw / 100.0 // 假设单位为0.01kg

_weightData.tryEmit(WeightData(weightKg, null, isStable))
}
}

fun connect(device: BluetoothDevice) {
bluetoothGatt = device.connectGatt(context, false, gattCallback)
}

fun disconnect() {
bluetoothGatt?.disconnect()
bluetoothGatt?.close()
bluetoothGatt = null
}
}

6. HRV心率变异性分析

6.1 HRV概述

心率变异性(Heart Rate Variability, HRV)是指连续心跳之间时间间隔的变化程度。HRV是评估自主神经系统功能和心血管健康的重要指标。

常用HRV指标:

  • SDNN:所有RR间期的标准差,反映整体心率变异性
  • RMSSD:相邻RR间期差值的均方根,主要反映副交感神经活性
  • pNN50:相邻RR间期差值超过50ms的比例

6.2 HRV测量管理器

class HrvMeasurementManager(
private val context: Context,
private val heartRateFlow: SharedFlow<Int>,
private val rrIntervalFlow: SharedFlow<Int>
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

private val _measurementState = MutableStateFlow<MeasurementState>(MeasurementState.Idle)
val measurementState: StateFlow<MeasurementState> = _measurementState.asStateFlow()

private val _currentHrvData = MutableStateFlow<HrvData?>(null)
val currentHrvData: StateFlow<HrvData?> = _currentHrvData.asStateFlow()

private var measurementJob: Job? = null
private val rrIntervals = mutableListOf<Int>()

sealed class MeasurementState {
object Idle : MeasurementState()
object Measuring : MeasurementState()
data class Complete(val data: HrvData) : MeasurementState()
data class Error(val message: String) : MeasurementState()
}

/**
* 开始HRV测量
* @param duration 测量时长(秒),默认3分钟
*/
fun startMeasurement(duration: Int = 180) {
if (_measurementState.value is MeasurementState.Measuring) return

rrIntervals.clear()
_measurementState.value = MeasurementState.Measuring

measurementJob = scope.launch {
// 收集RR间期数据
val rrJob = launch {
rrIntervalFlow.collect { rr ->
if (rr in 300..2000) { // 过滤不合理的RR间期
rrIntervals.add(rr)
}
}
}

// 测量倒计时
delay(duration * 1000L)

rrJob.cancel()

// 计算HRV指标
if (rrIntervals.size >= 30) { // 至少需要30个有效RR间期
val hrvData = calculateHrvMetrics(rrIntervals, duration)
_currentHrvData.value = hrvData
_measurementState.value = MeasurementState.Complete(hrvData)
} else {
_measurementState.value = MeasurementState.Error("有效数据不足,请重新测量")
}
}
}

fun stopMeasurement() {
measurementJob?.cancel()
_measurementState.value = MeasurementState.Idle
}

/**
* 计算HRV指标
*/
private fun calculateHrvMetrics(rrIntervals: List<Int>, duration: Int): HrvData {
// SDNN计算
val meanRR = rrIntervals.average()
val variance = rrIntervals.map { (it - meanRR).pow(2) }.average()
val sdnn = sqrt(variance).toInt()

// RMSSD计算
val successiveDiffs = rrIntervals.windowed(2) { (a, b) -> (b - a).toDouble() }
val rmssd = sqrt(successiveDiffs.map { it.pow(2) }.average()).toInt()

return HrvData(
sdnn = sdnn,
rmssd = rmssd,
rrIntervals = rrIntervals.toList(),
duration = duration
)
}

fun cleanup() {
stopMeasurement()
scope.cancel()
}
}

6.3 HRV趋势分析

class HrvRepository(private val context: Context) {

private val prefs = context.getSharedPreferences("hrv_data", Context.MODE_PRIVATE)
private val gson = Gson()

private val _hrvRecords = MutableStateFlow<List<HrvData>>(emptyList())
val hrvRecords: StateFlow<List<HrvData>> = _hrvRecords.asStateFlow()

init {
loadRecords()
}

fun saveHrvRecord(data: HrvData) {
val currentRecords = _hrvRecords.value.toMutableList()
currentRecords.add(0, data) // 添加到列表开头

// 只保留最近100条记录
if (currentRecords.size > 100) {
currentRecords.removeAt(currentRecords.size - 1)
}

_hrvRecords.value = currentRecords

// 持久化
val json = gson.toJson(currentRecords)
prefs.edit().putString("hrv_records", json).apply()
}

/**
* 获取HRV趋势数据
* @param days 天数
*/
fun getHrvTrend(days: Int): List<HrvTrendPoint> {
val cutoffTime = Instant.now().minus(days.toLong(), ChronoUnit.DAYS)

return _hrvRecords.value
.filter { it.timestamp.isAfter(cutoffTime) }
.groupBy {
LocalDateTime.ofInstant(it.timestamp, ZoneId.systemDefault()).toLocalDate()
}
.map { (date, records) ->
HrvTrendPoint(
date = date,
avgSdnn = records.map { it.sdnn }.average().toInt(),
avgRmssd = records.map { it.rmssd }.average().toInt(),
recordCount = records.size
)
}
.sortedBy { it.date }
}

data class HrvTrendPoint(
val date: LocalDate,
val avgSdnn: Int,
val avgRmssd: Int,
val recordCount: Int
)
}

7. 健康管理功能

7.1 体重管理

class WeightViewModel(private val repository: HealthRepository) : ViewModel() {

private val _weightRecords = MutableStateFlow<List<WeightRecord>>(emptyList())
val weightRecords: StateFlow<List<WeightRecord>> = _weightRecords.asStateFlow()

private val _currentWeight = MutableStateFlow<Double?>(null)
val currentWeight: StateFlow<Double?> = _currentWeight.asStateFlow()

private val _bmi = MutableStateFlow<Double?>(null)
val bmi: StateFlow<Double?> = _bmi.asStateFlow()

fun addWeightRecord(weightKg: Double, bodyFatPercent: Double? = null) {
viewModelScope.launch {
val record = WeightRecord(
weightKg = weightKg,
bodyFatPercent = bodyFatPercent,
source = "manual"
)
repository.saveWeightRecord(record)
_currentWeight.value = weightKg
calculateBmi(weightKg)
}
}

fun addBluetoothWeight(weightKg: Double, bodyFatPercent: Double?) {
viewModelScope.launch {
val record = WeightRecord(
weightKg = weightKg,
bodyFatPercent = bodyFatPercent,
source = "bluetooth"
)
repository.saveWeightRecord(record)
_currentWeight.value = weightKg
calculateBmi(weightKg)
}
}

private fun calculateBmi(weightKg: Double) {
// 从用户配置获取身高
val heightM = repository.getUserHeightCm() / 100.0
if (heightM > 0) {
_bmi.value = weightKg / (heightM * heightM)
}
}

/**
* 获取BMI分类
*/
fun getBmiCategory(bmi: Double): String {
return when {
bmi < 18.5 -> "偏瘦"
bmi < 24.0 -> "正常"
bmi < 28.0 -> "偏胖"
else -> "肥胖"
}
}
}

7.2 血压管理

class BloodPressureViewModel(private val repository: HealthRepository) : ViewModel() {

private val _bpRecords = MutableStateFlow<List<BloodPressureRecord>>(emptyList())
val bpRecords: StateFlow<List<BloodPressureRecord>> = _bpRecords.asStateFlow()

fun addBloodPressureRecord(
systolic: Int,
diastolic: Int,
pulse: Int
) {
viewModelScope.launch {
val record = BloodPressureRecord(
systolic = systolic,
diastolic = diastolic,
pulse = pulse
)
repository.saveBloodPressureRecord(record)
}
}

/**
* 获取血压分级
*/
fun getBloodPressureCategory(systolic: Int, diastolic: Int): BpCategory {
return when {
systolic < 90 || diastolic < 60 -> BpCategory.LOW
systolic < 120 && diastolic < 80 -> BpCategory.NORMAL
systolic < 130 && diastolic < 80 -> BpCategory.ELEVATED
systolic < 140 || diastolic < 90 -> BpCategory.HIGH_STAGE1
systolic >= 140 || diastolic >= 90 -> BpCategory.HIGH_STAGE2
else -> BpCategory.UNKNOWN
}
}

enum class BpCategory(val label: String, val color: Color) {
LOW("偏低", Color.Blue),
NORMAL("正常", Color.Green),
ELEVATED("正常高值", Color.Yellow),
HIGH_STAGE1("轻度高血压", Color(0xFFFFA500)),
HIGH_STAGE2("中重度高血压", Color.Red),
UNKNOWN("未知", Color.Gray)
}
}

7.3 血糖管理

class BloodGlucoseViewModel(private val repository: HealthRepository) : ViewModel() {

private val _glucoseRecords = MutableStateFlow<List<BloodGlucoseRecord>>(emptyList())
val glucoseRecords: StateFlow<List<BloodGlucoseRecord>> = _glucoseRecords.asStateFlow()

fun addGlucoseRecord(value: Double, measurementType: String) {
viewModelScope.launch {
val record = BloodGlucoseRecord(
value = value,
measurementType = measurementType
)
repository.saveBloodGlucoseRecord(record)
}
}

/**
* 获取血糖分级
*/
fun getGlucoseCategory(value: Double, measurementType: String): GlucoseCategory {
return when (measurementType) {
"fasting" -> when {
value < 3.9 -> GlucoseCategory.LOW
value < 6.1 -> GlucoseCategory.NORMAL
value < 7.0 -> GlucoseCategory.ELEVATED
else -> GlucoseCategory.HIGH
}
"after_meal" -> when {
value < 3.9 -> GlucoseCategory.LOW
value < 7.8 -> GlucoseCategory.NORMAL
value < 11.1 -> GlucoseCategory.ELEVATED
else -> GlucoseCategory.HIGH
}
else -> GlucoseCategory.UNKNOWN
}
}

enum class GlucoseCategory(val label: String, val color: Color, val description: String) {
LOW("偏低", Color.Blue, "血糖过低,请及时补充糖分"),
NORMAL("正常", Color.Green, "血糖处于正常范围"),
ELEVATED("偏高", Color.Yellow, "血糖偏高,注意饮食控制"),
HIGH("过高", Color.Red, "血糖过高,建议咨询医生"),
UNKNOWN("未知", Color.Gray, "")
}
}

7.4 健康趋势图表

@Composable
fun HealthTrendChart(
records: List<HealthRecord>,
modifier: Modifier = Modifier,
chartType: ChartType = ChartType.LINE
) {
val context = LocalContext.current

AndroidView(
modifier = modifier.fillMaxSize(),
factory = { ctx ->
LineChart(ctx).apply {
description.isEnabled = false
legend.isEnabled = true
setBackgroundColor(Color.TRANSPARENT.toArgb())

xAxis.apply {
position = XAxis.XAxisPosition.BOTTOM
textColor = Color.Gray.toArgb()
valueFormatter = object : ValueFormatter() {
private val formatter = SimpleDateFormat("MM/dd", Locale.getDefault())
override fun getFormattedValue(value: Float): String {
return formatter.format(Date(value.toLong()))
}
}
}

axisLeft.textColor = Color.Gray.toArgb()
axisRight.isEnabled = false
}
},
update = { chart ->
when (val firstRecord = records.firstOrNull()) {
is WeightRecord -> updateWeightChart(chart, records.filterIsInstance<WeightRecord>())
is BloodPressureRecord -> updateBpChart(chart, records.filterIsInstance<BloodPressureRecord>())
is BloodGlucoseRecord -> updateGlucoseChart(chart, records.filterIsInstance<BloodGlucoseRecord>())
}
}
)
}

private fun updateWeightChart(chart: LineChart, records: List<WeightRecord>) {
val entries = records.map {
Entry(it.timestamp.toEpochMilli().toFloat(), it.weightKg.toFloat())
}

val dataSet = LineDataSet(entries, "体重(kg)").apply {
color = Color(0xFF4CAF50).toArgb()
lineWidth = 2f
setDrawCircles(true)
setDrawFilled(true)
fillColor = Color(0xFF4CAF50).toArgb()
fillAlpha = 30
}

chart.data = LineData(dataSet)
chart.invalidate()
}

8. 运动数据计算与算法

8.1 距离计算

使用Android原生API计算两点间球面距离:

private fun calculateDistance(p1: GpsPoint, p2: GpsPoint): Double {
val results = FloatArray(1)
android.location.Location.distanceBetween(
p1.latitude, p1.longitude,
p2.latitude, p2.longitude,
results
)
return results[0].toDouble()
}

8.2 配速计算

配速(Pace)= 时间 / 距离,单位:分钟/公里

fun getPaceMinPerKm(): String {
if (totalDistanceMeters == 0.0) return "--:--"

// 配速 = 总时间(秒) / 距离(公里)
val paceSeconds = totalDurationSeconds / (totalDistanceMeters / 1000.0)
val minutes = (paceSeconds / 60).toInt()
val seconds = (paceSeconds % 60).toInt()

return String.format("%d:%02d", minutes, seconds)
}

8.3 卡路里计算(骑行MET公式)

卡路里消耗基于代谢当量(MET)计算,骑行的MET值与跑步不同:

/**
* 骑行卡路里计算公式:Calories = MET × 体重(kg) × 时间(小时)
*
* 骑行MET值根据运动强度(速度)确定:
* | 速度(km/h) | MET | 强度描述 |
* |-----------|------|-------------|
* | < 16 | 4.0 | 休闲骑行 |
* | 16-19 | 6.0 | 轻松骑行 |
* | 19-22 | 8.0 | 中等强度 |
* | 22-25 | 10.0 | 较快骑行 |
* | 25-30 | 12.0 | 快速骑行 |
* | > 30 | 15.8 | 竞速骑行 |
*/
fun calculateCalories(weightKg: Double = 65.0): Int {
if (totalDurationSeconds == 0L || totalDistanceMeters == 0.0) return 0

val speedKmh = (totalDistanceMeters / 1000.0) / (totalDurationSeconds / 3600.0)

val met = when {
speedKmh < 16.0 -> 4.0 // 休闲骑行
speedKmh < 19.0 -> 6.0 // 轻松骑行
speedKmh < 22.0 -> 8.0 // 中等强度
speedKmh < 25.0 -> 10.0 // 较快骑行
speedKmh < 30.0 -> 12.0 // 快速骑行
else -> 15.8 // 竞速骑行
}

val durationHours = totalDurationSeconds / 3600.0
return (met * weightKg * durationHours).toInt()
}

8.4 心率区间计算(储备心率法)

/**
* 储备心率法计算心率区间
* 储备心率 = 最大心率 - 静息心率
* 各区间 = 静息心率 + 储备心率 × 百分比
*/
fun calculateHeartRateZones(maxHr: Int, restingHr: Int): HeartRateZones {
val reserveHr = maxHr - restingHr

return HeartRateZones(
zone1 = HeartRateZone("热身", restingHr, restingHr + (reserveHr * 0.50).toInt(), Color.Gray),
zone2 = HeartRateZone("燃脂", restingHr + (reserveHr * 0.50).toInt(), restingHr + (reserveHr * 0.60).toInt(), Color.Blue),
zone3 = HeartRateZone("有氧", restingHr + (reserveHr * 0.60).toInt(), restingHr + (reserveHr * 0.70).toInt(), Color.Green),
zone4 = HeartRateZone("阈值", restingHr + (reserveHr * 0.70).toInt(), restingHr + (reserveHr * 0.80).toInt(), Color.Yellow),
zone5 = HeartRateZone("无氧", restingHr + (reserveHr * 0.80).toInt(), maxHr, Color.Red)
)
}

data class HeartRateZone(
val name: String,
val minHr: Int,
val maxHr: Int,
val color: Color
)

data class HeartRateZones(
val zone1: HeartRateZone,
val zone2: HeartRateZone,
val zone3: HeartRateZone,
val zone4: HeartRateZone,
val zone5: HeartRateZone
) {
fun getZoneForHeartRate(hr: Int): HeartRateZone {
return when {
hr <= zone1.maxHr -> zone1
hr <= zone2.maxHr -> zone2
hr <= zone3.maxHr -> zone3
hr <= zone4.maxHr -> zone4
else -> zone5
}
}
}

9. 智能暂停与自动恢复

9.1 功能说明

骑行过程中实现智能暂停和自动恢复功能:

  • 自动暂停:当速度 < 0.5m/s 持续 10秒 时自动暂停
  • 自动恢复:暂停后速度 ≥ 0.5m/s 持续 5秒 时自动恢复
  • 暂停期间GPS轨迹连续性:暂停时继续记录GPS点(保持轨迹连续),但不计入距离和时长

9.2 实现代码

// 速度为0的计时器(用于自动暂停)
private var zeroSpeedCounter = 0
private val AUTO_PAUSE_THRESHOLD = 10 // 速度为0持续10秒自动暂停

// 速度不为0的计时器(用于自动恢复)
private var nonZeroSpeedCounter = 0
private val AUTO_RESUME_THRESHOLD = 5 // 速度不为0持续5秒自动恢复

/**
* 检测是否需要自动暂停或自动恢复
*/
private fun checkAutoPause(speed: Float) {
if (speed < 0.5f) { // 速度小于0.5m/s视为静止
zeroSpeedCounter++
nonZeroSpeedCounter = 0 // 重置非零速度计数器
if (zeroSpeedCounter >= AUTO_PAUSE_THRESHOLD && !_isPaused.value) {
Log.d(TAG, "Auto pause triggered: speed=0 for ${zeroSpeedCounter}s")
pauseRiding()
}
} else {
zeroSpeedCounter = 0
// 暂停时检测是否需要自动恢复
if (_isPaused.value) {
nonZeroSpeedCounter++
Log.d(TAG, "Non-zero speed detected while paused: ${nonZeroSpeedCounter}/${AUTO_RESUME_THRESHOLD}s")
if (nonZeroSpeedCounter >= AUTO_RESUME_THRESHOLD) {
Log.d(TAG, "Auto resume triggered: speed>0 for ${nonZeroSpeedCounter}s")
resumeRiding()
}
} else {
nonZeroSpeedCounter = 0
}
}
}

/**
* 暂停骑行
*/
fun pauseRiding() {
if (!_isRunning.value || _isPaused.value) return

Log.d(TAG, "Pausing cycling...")
_isPaused.value = true
pauseStartTime = Instant.now()
vibrate(200) // 震动提示暂停
}

/**
* 恢复骑行
*/
fun resumeRiding() {
if (!_isRunning.value || !_isPaused.value) return

Log.d(TAG, "Resuming cycling...")

// 计算暂停的时长并累加
pauseStartTime?.let {
pausedDuration += Duration.between(it, Instant.now()).seconds
}

_isPaused.value = false
pauseStartTime = null
zeroSpeedCounter = 0
nonZeroSpeedCounter = 0 // 重置非零速度计数器
vibrate(200) // 震动提示恢复
}

9.3 暂停期间GPS轨迹处理

locationFlow.collect { gpsPoint ->
// 添加心率数据到GPS点
val pointWithHeartRate = gpsPoint.copy(heartRate = _currentHeartRate.value)
_currentLocation.value = pointWithHeartRate
_currentSpeed.value = gpsPoint.speed

// 检测速度用于自动暂停/恢复(无论是否暂停都要检测)
checkAutoPause(gpsPoint.speed)

// 暂停时也记录GPS点(保持轨迹连续性),但不计入距离
if (_isPaused.value) {
// 暂停期间只记录GPS点位置,不计算距离
addGpsPointWithoutDistance(pointWithHeartRate)
} else {
// 正常运动时记录GPS点并计算距离
addGpsPoint(pointWithHeartRate)
}
}

10. 用户认证与数据同步

10.1 JWT Token认证

object RetrofitClient {

private const val BASE_URL = "https://api.awen.me/api/v1/"

private lateinit var appContext: Context
private var token: String? = null

fun init(context: Context) {
appContext = context.applicationContext
// 从加密存储恢复Token
token = EncryptedStorage.getToken(context)
}

fun setToken(newToken: String) {
token = newToken
// 加密持久化Token
EncryptedStorage.saveToken(appContext, newToken)
}

fun hasToken(): Boolean = !token.isNullOrEmpty()

fun clearToken() {
token = null
EncryptedStorage.clearToken(appContext)
}

private val okHttpClient = OkHttpClient.Builder()
.addInterceptor { chain ->
val request = chain.request().newBuilder().apply {
token?.let { addHeader("Authorization", "Bearer $it") }
}.build()
chain.proceed(request)
}
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()

private val retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()

val apiService: ApiService = retrofit.create(ApiService::class.java)
}

10.2 加密存储

object EncryptedStorage {
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
private const val KEY_ALIAS = "heart_sync_key"
private const val PREFS_NAME = "encrypted_prefs"

private fun getMasterKey(context: Context): MasterKey {
return MasterKey.Builder(context, KEY_ALIAS)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
}

private fun getEncryptedSharedPreferences(context: Context): SharedPreferences {
return EncryptedSharedPreferences.create(
context,
PREFS_NAME,
getMasterKey(context),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}

fun saveToken(context: Context, token: String) {
getEncryptedSharedPreferences(context).edit()
.putString("jwt_token", token)
.apply()
}

fun getToken(context: Context): String? {
return getEncryptedSharedPreferences(context).getString("jwt_token", null)
}

fun clearToken(context: Context) {
getEncryptedSharedPreferences(context).edit()
.remove("jwt_token")
.apply()
}
}

10.3 运动记录云同步

class RunningRecordsRepository(context: Context) {

private val api = RetrofitClient.apiService

/**
* 从服务端同步记录
*/
fun syncFromServer() {
if (!RetrofitClient.hasToken()) return

scope.launch {
try {
val response = api.getSessions(page = 1, pageSize = 100)
if (response.isSuccessful) {
val apiResponse = response.body()
if (apiResponse?.code == 0 && apiResponse.data != null) {
val serverRecords = apiResponse.data.sessions.map { it.toRunningSession() }

// 合并本地和服务端记录
val localRecords = _records.value
val localRecordsMap = localRecords.associateBy { it.id }
val serverIds = serverRecords.map { it.id }.toSet()

// 本地独有的记录上传到服务端
val localOnlyRecords = localRecords.filter { it.id !in serverIds }
localOnlyRecords.forEach { record ->
uploadToServer(record)
}

// 合并记录:如果本地有GPS数据,优先保留本地版本
val mergedServerRecords = serverRecords.map { serverRecord ->
val localRecord = localRecordsMap[serverRecord.id]
if (localRecord != null && localRecord.gpsPoints.isNotEmpty()
&& serverRecord.gpsPoints.isEmpty()) {
serverRecord.copy(gpsPoints = localRecord.gpsPoints)
} else {
serverRecord
}
}

val mergedRecords = (mergedServerRecords + localOnlyRecords)
.distinctBy { it.id }
.sortedByDescending { it.startTime }

_records.value = mergedRecords
saveToLocal(mergedRecords)
}
}
} catch (e: Exception) {
Log.e(TAG, "Sync error", e)
}
}
}
}

11. 数据持久化与导出

11.1 GPX格式导出

object GpxExporter {

fun generateGpxContent(session: RunningSession): String {
val sb = StringBuilder()

sb.appendLine("""<?xml version="1.0" encoding="UTF-8"?>""")
sb.appendLine("""<gpx version="1.1" creator="Heart Sync"
xmlns="http://www.topografix.com/GPX/1/1"
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1">""")

sb.appendLine(" <metadata>")
sb.appendLine(" <name>Cycling Session ${session.id}</name>")
sb.appendLine(" <time>${session.startTime}</time>")
sb.appendLine(" </metadata>")

sb.appendLine(" <trk>")
sb.appendLine(" <name>Cycling Track</name>")
sb.appendLine(" <type>cycling</type>")
sb.appendLine(" <trkseg>")

session.gpsPoints.forEach { point ->
sb.appendLine(" <trkpt lat=\"${point.latitude}\" lon=\"${point.longitude}\">")
sb.appendLine(" <ele>${point.altitude}</ele>")
sb.appendLine(" <time>${point.timestamp}</time>")

if (point.heartRate > 0) {
sb.appendLine(" <extensions>")
sb.appendLine(" <gpxtpx:TrackPointExtension>")
sb.appendLine(" <gpxtpx:hr>${point.heartRate}</gpxtpx:hr>")
sb.appendLine(" </gpxtpx:TrackPointExtension>")
sb.appendLine(" </extensions>")
}

sb.appendLine(" </trkpt>")
}

sb.appendLine(" </trkseg>")
sb.appendLine(" </trk>")
sb.appendLine("</gpx>")

return sb.toString()
}
}

11.2 分享功能

fun shareGpxFile(context: Context, session: RunningSession) {
val gpxContent = GpxExporter.generateGpxContent(session)
val fileName = "heart_sync_${session.startTime.epochSecond}.gpx"

// 写入缓存目录
val file = File(context.cacheDir, fileName)
file.writeText(gpxContent)

// 获取FileProvider URI
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)

// 创建分享Intent
val shareIntent = Intent().apply {
action = Intent.ACTION_SEND
type = "application/gpx+xml"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_SUBJECT, "骑行轨迹: ${session.startTime}")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}

context.startActivity(Intent.createChooser(shareIntent, "分享轨迹"))
}

12. Jetpack Compose UI实现

12.1 主界面结构

@Composable
fun MainScreen(
navController: NavHostController = rememberNavController()
) {
Scaffold(
bottomBar = { BottomNavigationBar(navController) }
) { paddingValues ->
NavHost(
navController = navController,
startDestination = "running",
modifier = Modifier.padding(paddingValues)
) {
composable("running") { RunningScreenWithMap() }
composable("records") { RecordsScreen() }
composable("health") { HealthScreen() }
composable("settings") { SettingsScreen() }
}
}
}

@Composable
fun BottomNavigationBar(navController: NavHostController) {
val items = listOf(
NavigationItem("运动", Icons.Default.DirectionsBike, "running"),
NavigationItem("记录", Icons.Default.List, "records"),
NavigationItem("健康", Icons.Default.Favorite, "health"),
NavigationItem("设置", Icons.Default.Settings, "settings")
)

NavigationBar {
val currentRoute = currentRoute(navController)
items.forEach { item ->
NavigationBarItem(
icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) },
selected = currentRoute == item.route,
onClick = { navController.navigate(item.route) }
)
}
}
}

12.2 运动数据卡片

@Composable
fun RunningDataPanel(
distance: Double,
duration: Long,
pace: String,
heartRate: Int,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = Color(0xFF1E1E1E).copy(alpha = 0.9f)
),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier.padding(20.dp)
) {
// 距离(大字显示)
Text(
text = String.format("%.2f", distance / 1000),
fontSize = 48.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
Text(
text = "公里",
fontSize = 14.sp,
color = Color.Gray
)

Spacer(modifier = Modifier.height(16.dp))

// 其他数据
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
DataItem("时长", TimeUtils.formatDuration(duration), Icons.Default.Timer)
DataItem("配速", pace, Icons.Default.Speed)
DataItem("心率", "$heartRate", Icons.Default.Favorite)
}
}
}
}

@Composable
private fun DataItem(label: String, value: String, icon: ImageVector) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(icon, contentDescription = label, tint = Color.Gray)
Spacer(modifier = Modifier.height(4.dp))
Text(text = value, color = Color.White, fontWeight = FontWeight.Bold)
Text(text = label, color = Color.Gray, fontSize = 12.sp)
}
}

13. 踩坑记录与解决方案

13.1 GPS室内定位失效

问题:室内无法获取GPS定位,但高德地图App可以定位

原因:精度阈值过滤太严格(100米),室内WiFi定位精度通常200-500米

解决方案

// 检测模式放宽精度阈值到500米
val accuracyThreshold = if (strictMode) 30f else 500f

13.2 位置权限弹窗不显示

问题:进入骑行页面时权限弹窗不显示

原因:Android 10+ 不能同时请求前台和后台位置权限

解决方案

// 只请求前台位置权限
val locationPermissions = arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
// 移除 ACCESS_BACKGROUND_LOCATION

13.3 暂停后GPS轨迹丢失

问题:自动暂停后恢复骑行,轨迹出现断点

原因:暂停时完全停止记录GPS点

解决方案

// 暂停期间继续记录GPS点(不计入距离)
if (_isPaused.value) {
addGpsPointWithoutDistance(pointWithHeartRate)
} else {
addGpsPoint(pointWithHeartRate)
}

13.4 Token明文存储安全风险

问题:JWT Token使用普通SharedPreferences存储,Root设备可读取

解决方案:使用Android Keystore加密存储

// 使用EncryptedSharedPreferences
val prefs = EncryptedSharedPreferences.create(
context,
"encrypted_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)

13.5 HRV测量数据不准确

问题:HRV测量结果波动很大,与专业设备差距较大

原因

  1. RR间期数据未过滤异常值
  2. 测量时长不足
  3. 用户未保持静止

解决方案

// 1. 过滤不合理的RR间期(300-2000ms为正常范围)
if (rr in 300..2000) {
rrIntervals.add(rr)
}

// 2. 增加测量时长到3分钟
fun startMeasurement(duration: Int = 180) { ... }

// 3. 提醒用户保持静止
Text("请保持静止,深呼吸,不要说话")

14. 总结与展望

14.1 项目亮点

  1. 完整的运动追踪:GPS定位 + 卡尔曼滤波 + 智能暂停恢复
  2. 多设备蓝牙支持:心率带、体重秤、血压计、血糖仪
  3. 专业HRV分析:SDNN、RMSSD指标计算与趋势追踪
  4. 全面的健康管理:体重、血压、血糖记录与趋势图表
  5. 安全的数据存储:Android Keystore加密保护敏感信息
  6. 标准数据格式:支持GPX/TCX导出,兼容主流运动平台

14.2 后续优化方向

  • 功率计支持:接入ANT+功率计,实现功率训练
  • 训练计划:基于心率和功率的训练计划制定
  • 社交功能:运动数据分享、好友PK
  • AI分析:基于历史数据的运动表现分析和建议
  • 智能手表:Wear OS版本开发

参考资料

文章作者:阿文
文章链接: https://www.awen.me/post/8649c502.html
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 阿文的博客

评论

0 条评论
😀😃😄 😁😅😂 🤣😊😇 🙂🙃😉 😌😍🥰 😘😗😙 😚😋😛 😝😜🤪 🤨🧐🤓 😎🥸🤩 🥳😏😒 😞😔😟 😕🙁☹️ 😣😖😫 😩🥺😢 😭😤😠 😡🤬🤯 😳🥵🥶 😱😨😰 😥😓🤗 🤔🤭🤫 🤥😶😐 😑😬🙄 😯😦😧 😮😲🥱 😴🤤😪 😵🤐🥴 🤢🤮🤧 😷🤒🤕 🤑🤠😈 👿👹👺 🤡💩👻 💀☠️👽 👾🤖🎃 😺😸😹 😻😼😽 🙀😿😾 👍👎👏 🙌👐🤲 🤝🤜🤛 ✌️🤞🤟 🤘👌🤏 👈👉👆 👇☝️ 🤚🖐️🖖 👋🤙💪 🦾🖕✍️ 🙏💅🤳 💯💢💥 💫💦💨 🕳️💣💬 👁️‍🗨️🗨️🗯️ 💭💤❤️ 🧡💛💚 💙💜🖤 🤍🤎💔 ❣️💕💞 💓💗💖 💘💝💟 ☮️✝️☪️ 🕉️☸️✡️ 🔯🕎☯️ ☦️🛐 🆔⚛️🉑 ☢️☣️📴 📳🈶🈚 🈸🈺🈷️ ✴️🆚💮 🉐㊙️㊗️ 🈴🈵🈹 🈲🅰️🅱️ 🆎🆑🅾️ 🆘 🛑📛 🚫💯💢 ♨️🚷🚯 🚳🚱🔞 📵🚭 ‼️⁉️🔅 🔆〽️⚠️ 🚸🔱⚜️ 🔰♻️ 🈯💹❇️ ✳️🌐 💠Ⓜ️🌀 💤🏧🚾 🅿️🈳 🈂🛂🛃 🛄🛅🛗 🚀🛸🚁 🚉🚆🚅 ✈️🛫🛬 🛩️💺🛰️
您的评论由 AI 智能审核,一般1分钟内会展示,若不展示请确认你的评论是否符合社区和法律规范
加载中...

留言反馈

😀😃😄 😁😅😂 🤣😊😇 🙂🙃😉 😌😍🥰 😘😗😙 😚😋😛 😝😜🤪 🤨🧐🤓 😎🥸🤩 🥳😏😒 😞😔😟 😕🙁☹️ 😣😖😫 😩🥺😢 😭😤😠 😡🤬🤯 😳🥵🥶 😱😨😰 😥😓🤗 🤔🤭🤫 🤥😶😐 😑😬🙄 😯😦😧 😮😲🥱 😴🤤😪 😵🤐🥴 🤢🤮🤧 😷🤒🤕 🤑🤠😈 👿👹👺 🤡💩👻 💀☠️👽 👾🤖🎃 😺😸😹 😻😼😽 🙀😿😾 👍👎👏 🙌👐🤲 🤝🤜🤛 ✌️🤞🤟 🤘👌🤏 👈👉👆 👇☝️ 🤚🖐️🖖 👋🤙💪 🦾🖕✍️ 🙏💅🤳 💯💢💥 💫💦💨 🕳️💣💬 👁️‍🗨️🗨️🗯️ 💭💤❤️ 🧡💛💚 💙💜🖤 🤍🤎💔 ❣️💕💞 💓💗💖 💘💝💟 ☮️✝️☪️ 🕉️☸️✡️ 🔯🕎☯️ ☦️🛐 🆔⚛️🉑 ☢️☣️📴 📳🈶🈚 🈸🈺🈷️ ✴️🆚💮 🉐㊙️㊗️ 🈴🈵🈹 🈲🅰️🅱️ 🆎🆑🅾️ 🆘 🛑📛 🚫💯💢 ♨️🚷🚯 🚳🚱🔞 📵🚭 ‼️⁉️🔅 🔆〽️⚠️ 🚸🔱⚜️ 🔰♻️ 🈯💹❇️ ✳️🌐 💠Ⓜ️🌀 💤🏧🚾 🅿️🈳 🈂🛂🛃 🛄🛅🛗 🚀🛸🚁 🚉🚆🚅 ✈️🛫🛬 🛩️💺🛰️