2024 桐庐半程马拉松
00:00:00
时间
0.00
距离(公里)
--:--
配速
--
步频
--
心率 (bpm)
--
配速
步频
|
share-image
ESC

从零构建专业骑行App:Android Kotlin + Jetpack Compose 全栈实战指南

本文详细记录了一款功能完备的骑行运动App的完整开发过程,涵盖GPS定位与轨迹追踪、蓝牙心率带连接、运动数据计算、高德地图集成、智能暂停/恢复、用户认证与数据同步、数据导出(GPX/TCX)等核心技术实现。

目录

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

效果

1. 项目概述与架构设计

1.1 功能概览

本项目实现了一款专业级骑行运动App,主要功能包括:

  • 实时GPS轨迹追踪:高精度定位、卡尔曼滤波平滑、轨迹可视化
  • 蓝牙心率监测:BLE心率带连接、实时心率显示、HRV(SDNN)计算
  • 运动数据统计:距离、配速、速度、时长、卡路里消耗、累计爬升
  • 智能暂停/恢复:速度为0持续10秒自动暂停,恢复速度5秒后自动继续
  • 地图轨迹展示:高德地图集成、轨迹绘制、公里标记
  • 运动记录管理:历史记录列表、详情查看、轨迹缩略图
  • 用户认证系统:登录注册、账号管理、密码修改
  • 数据云同步:本地与服务端双向同步
  • 数据导出分享:GPX、TCX标准格式导出,支持Strava等平台

1.2 整体架构

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

┌─────────────────────────────────────────────────────────────┐
│ UI Layer │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │RunningScreen│ │RecordsScreen│ │ DetailScreen │ │
│ │ WithMap │ │ │ │ │ │
│ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘ │
│ │ │ │ │
├─────────┼────────────────┼─────────────────────┼─────────────┤
│ ▼ ▼ ▼ │
│ ViewModel Layer │
│ ┌─────────────────┐ ┌──────────────────────────────────┐ │
│ │ RunningViewModel│ │ RunningRecordsViewModel │ │
│ └────────┬────────┘ └─────────────────┬────────────────┘ │
│ │ │ │
├───────────┼─────────────────────────────┼────────────────────┤
│ ▼ ▼ │
Data Layer │
│ ┌───────────────┐ ┌───────────────┐ ┌─────────────────┐ │
│ │LocationTracker│ │BluetoothLeManager│ │Repository │ │
│ └───────────────┘ └───────────────┘ └─────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Network Layer (Retrofit) │ │
│ │ API Server: 192.168.1.28:8080 │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘

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 = ""
)

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

2.1 核心技术栈

技术领域 选型方案 版本
UI框架 Jetpack Compose BOM 2024.09.00
架构组件 ViewModel + StateFlow 2.6.1
地图服务 高德地图SDK 3D地图+定位
蓝牙连接 Android BLE API 原生实现
网络请求 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")
}

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()
}

3.4 位置权限自动申请

进入骑行页面时自动检测并请求位置权限:

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

val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
val allGranted = permissions.entries.all { it.value }
permissionsGranted = allGranted
if (!allGranted) {
Toast.makeText(context, "需要位置权限才能使用骑行功能", Toast.LENGTH_SHORT).show()
} else {
// 权限授予后立即开始GPS检测
viewModel.startGpsDetection()
}
}

// 自动请求权限并开始GPS检测
LaunchedEffect(Unit) {
val hasPermissions = locationPermissions.all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
permissionsGranted = hasPermissions
if (!hasPermissions) {
// 延迟一小段时间再请求权限,确保UI完全加载
delay(300)
permissionLauncher.launch(locationPermissions)
} else {
viewModel.startGpsDetection()
}
}

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()

// GATT回调
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) {
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 HRV(心率变异性)计算

/**
* 计算SDNN(RR间期标准差)
* SDNN是评估心率变异性的重要指标
*/
fun calculateSDNN(rrIntervals: List<Int>): Int {
if (rrIntervals.size < 2) return 0

val mean = rrIntervals.average()
val variance = rrIntervals.map { (it - mean).pow(2) }.average()
val sdnn = sqrt(variance)

return sdnn.toInt()
}

6. 运动数据计算与算法

6.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()
}

6.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)
}

6.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()
}

6.4 累计爬升计算

fun calculateTotalAscent(): Double {
if (gpsPoints.size < 2) return 0.0

var ascent = 0.0
for (i in 1 until gpsPoints.size) {
val elevationDiff = gpsPoints[i].altitude - gpsPoints[i - 1].altitude
if (elevationDiff > 0) {
ascent += elevationDiff
}
}
return ascent
}

7. 智能暂停与自动恢复

7.1 功能说明

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

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

7.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) // 震动提示恢复
}

7.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)
}
}

/**
* 添加GPS点但不计算距离(用于暂停期间保持轨迹连续性)
*/
private fun addGpsPointWithoutDistance(point: GpsPoint) {
// GPS预热期已过才记录
if (gpsWarmupCounter < GPS_WARMUP_POINTS) {
gpsWarmupCounter++
return
}

val currentPoints = _gpsPoints.value.toMutableList()

// 距离突变检测:两点间距离>100米则判定为飘移(暂停时阈值放宽)
if (currentPoints.isNotEmpty()) {
val prevPoint = currentPoints.last()
val distance = calculateDistance(prevPoint, point)

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

currentPoints.add(point)
_gpsPoints.value = currentPoints
Log.d(TAG, "GPS point added (paused, no distance): ${point.latitude}, ${point.longitude}")
}

8. 用户认证与数据同步

8.1 用户认证系统

应用支持用户登录、注册、密码修改等功能:

/**
* 登录仓库
*/
class LoginRepository(private val context: Context) {

companion object {
const val DEFAULT_USERNAME = "admin"
}

private val api = RetrofitClient.apiService

/**
* 异步登录
*/
suspend fun loginAsync(username: String, password: String): LoginResult {
return try {
val response = api.login(LoginRequest(username.trim(), password))
if (response.isSuccessful) {
val apiResponse = response.body()
if (apiResponse?.code == 0 && apiResponse.data != null) {
// 保存Token
RetrofitClient.setToken(apiResponse.data.token)
LoginResult.Success
} else {
LoginResult.Error(apiResponse?.message ?: "登录失败")
}
} else {
LoginResult.Error("网络请求失败: ${response.code()}")
}
} catch (e: Exception) {
LoginResult.Error("网络异常: ${e.message}")
}
}

/**
* 异步注册
*/
suspend fun registerAsync(
username: String,
password: String,
registerType: String = "phone",
phone: String? = null,
email: String? = null
): LoginResult {
return try {
val request = RegisterRequest(
username = username.trim(),
password = password,
registerType = registerType,
phone = phone?.trim(),
email = email?.trim()
)
val response = api.register(request)
if (response.isSuccessful) {
val apiResponse = response.body()
if (apiResponse?.code == 0) {
LoginResult.Success
} else {
LoginResult.Error(apiResponse?.message ?: "注册失败")
}
} else {
LoginResult.Error("网络请求失败: ${response.code()}")
}
} catch (e: Exception) {
LoginResult.Error("网络异常: ${e.message}")
}
}
}

sealed class LoginResult {
object Success : LoginResult()
data class Error(val message: String) : LoginResult()
}

8.2 Retrofit网络客户端

object RetrofitClient {

private const val BASE_URL = "http://192.168.1.28:8080/api/v1/"

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

fun init(context: Context) {
appContext = context.applicationContext
// 从SharedPreferences恢复Token
val prefs = appContext.getSharedPreferences("auth", Context.MODE_PRIVATE)
token = prefs.getString("token", null)
}

fun setToken(newToken: String) {
token = newToken
// 持久化Token
appContext.getSharedPreferences("auth", Context.MODE_PRIVATE)
.edit()
.putString("token", newToken)
.apply()
}

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

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)
}

8.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)
}
}
}

/**
* 获取带GPS轨迹的完整记录
*/
suspend fun getRecordWithGps(id: String): RunningSession? = withContext(Dispatchers.IO) {
val localRecord = _records.value.find { it.id == id }

// 如果本地记录有GPS点,直接返回
if (localRecord != null && localRecord.gpsPoints.isNotEmpty()) {
return@withContext localRecord
}

// 从服务端获取带GPS点的记录
if (!RetrofitClient.hasToken()) {
return@withContext localRecord
}

try {
val response = api.getSession(id, withGps = true)
if (response.isSuccessful) {
val apiResponse = response.body()
if (apiResponse?.code == 0 && apiResponse.data != null) {
val serverRecord = apiResponse.data.toRunningSession()

// 更新本地记录
if (serverRecord.gpsPoints.isNotEmpty()) {
val currentRecords = _records.value.toMutableList()
val index = currentRecords.indexOfFirst { it.id == id }
if (index >= 0) {
currentRecords[index] = serverRecord
_records.value = currentRecords
saveToLocal(currentRecords)
}
return@withContext serverRecord
}
}
}
} catch (e: Exception) {
Log.e(TAG, "Fetch GPS error", e)
}

return@withContext localRecord
}
}

9. 数据持久化与导出

9.1 数据存储(SharedPreferences + Gson)

class RunningRecordsRepository(context: Context) {

private val prefs = context.getSharedPreferences("running_records", Context.MODE_PRIVATE)

private val gson = GsonBuilder()
.registerTypeAdapter(Instant::class.java, InstantTypeAdapter())
.create()

private val _records = MutableStateFlow<List<RunningSession>>(emptyList())
val records: Flow<List<RunningSession>> = _records.asStateFlow()

fun saveRecord(session: RunningSession) {
val currentRecords = _records.value.toMutableList()
currentRecords.removeAll { it.id == session.id }
currentRecords.add(session)

val sortedRecords = currentRecords.sortedByDescending { it.startTime }
val json = gson.toJson(sortedRecords)
prefs.edit().putString("records_list", json).apply()

_records.value = sortedRecords
}

private fun loadRecords() {
val json = prefs.getString("records_list", null) ?: return

val type = object : TypeToken<List<RunningSession>>() {}.type
val loadedRecords: List<RunningSession> = gson.fromJson(json, type)

// 处理旧数据中可能为null的新字段
val sanitizedRecords = loadedRecords.map { record ->
record.copy(
cityName = record.cityName ?: "",
activityType = if (record.activityType == "跑步" || record.activityType == null) "骑行" else record.activityType,
thumbnailPath = record.thumbnailPath ?: ""
)
}

_records.value = sanitizedRecords.sortedByDescending { it.startTime }
}
}

9.2 GPX格式导出

GPX(GPS Exchange Format)是GPS数据的标准交换格式:

object GpxExporter {

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

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

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

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

// Track points
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()
}
}

9.3 TCX格式导出

TCX(Training Center XML)是Garmin的运动数据格式,支持Strava等平台导入:

object TcxExporter {

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

sb.appendLine("""<?xml version="1.0" encoding="UTF-8"?>""")
sb.appendLine("""<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">""")

sb.appendLine(" <Activities>")
sb.appendLine(" <Activity Sport=\"Biking\">")
sb.appendLine(" <Id>${session.startTime}</Id>")

// Lap(一圈数据)
sb.appendLine(" <Lap StartTime=\"${session.startTime}\">")
sb.appendLine(" <TotalTimeSeconds>${session.totalDurationSeconds}</TotalTimeSeconds>")
sb.appendLine(" <DistanceMeters>${session.totalDistanceMeters}</DistanceMeters>")
sb.appendLine(" <Calories>${session.calculateCalories()}</Calories>")
sb.appendLine(" <Intensity>Active</Intensity>")
sb.appendLine(" <TriggerMethod>Manual</TriggerMethod>")

// Track
sb.appendLine(" <Track>")

var cumulativeDistance = 0.0
session.gpsPoints.forEachIndexed { index, point ->
if (index > 0) {
cumulativeDistance += calculateDistance(
session.gpsPoints[index - 1], point
)
}

sb.appendLine(" <Trackpoint>")
sb.appendLine(" <Time>${point.timestamp}</Time>")
sb.appendLine(" <Position>")
sb.appendLine(" <LatitudeDegrees>${point.latitude}</LatitudeDegrees>")
sb.appendLine(" <LongitudeDegrees>${point.longitude}</LongitudeDegrees>")
sb.appendLine(" </Position>")
sb.appendLine(" <AltitudeMeters>${point.altitude}</AltitudeMeters>")
sb.appendLine(" <DistanceMeters>$cumulativeDistance</DistanceMeters>")

if (point.heartRate > 0) {
sb.appendLine(" <HeartRateBpm>")
sb.appendLine(" <Value>${point.heartRate}</Value>")
sb.appendLine(" </HeartRateBpm>")
}

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

sb.appendLine(" </Track>")
sb.appendLine(" </Lap>")
sb.appendLine(" </Activity>")
sb.appendLine(" </Activities>")
sb.appendLine("</TrainingCenterDatabase>")

return sb.toString()
}
}

10. Jetpack Compose UI实现

10.1 骑行主界面

@Composable
fun RunningScreenWithMap(
runningViewModel: RunningViewModel = viewModel(),
mainViewModel: MainViewModel = viewModel()
) {
val isRunning by runningViewModel.isRunning.collectAsState()
val isPaused by runningViewModel.isPaused.collectAsState()
val isGpsReady by runningViewModel.isGpsReady.collectAsState()
val gpsPoints by runningViewModel.gpsPoints.collectAsState()
val currentLocation by runningViewModel.currentLocation.collectAsState()
val distance by runningViewModel.totalDistance.collectAsState()
val duration by runningViewModel.duration.collectAsState()
val currentPace by runningViewModel.currentPace.collectAsState()
val heartRate by mainViewModel.heartRate.collectAsState()

Box(modifier = Modifier.fillMaxSize()) {
// 地图层
AmapView(
modifier = Modifier.fillMaxSize(),
gpsPoints = gpsPoints,
currentLocation = currentLocation,
isRunning = isRunning
)

// GPS状态提示
if (!isGpsReady && !isRunning) {
GpsStatusCard(
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 60.dp)
)
}

// 暂停状态提示
if (isPaused) {
PausedStatusCard(
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 60.dp)
)
}

// 运动数据面板
RunningDataPanel(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 120.dp),
distance = distance,
duration = duration,
pace = currentPace,
heartRate = heartRate
)

// 控制按钮区域
ControlButtonArea(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 32.dp),
isRunning = isRunning,
isPaused = isPaused,
isGpsReady = isGpsReady,
onStart = { runningViewModel.startRunning() },
onPause = { runningViewModel.pauseRiding() },
onResume = { runningViewModel.resumeRiding() },
onStop = { runningViewModel.stopRunning() }
)
}
}

10.2 长按停止按钮

@Composable
fun LongPressStopButton(
onLongPressComplete: () -> Unit,
modifier: Modifier = Modifier
) {
var progress by remember { mutableFloatStateOf(0f) }
var isPressed by remember { mutableStateOf(false) }

val animatedProgress by animateFloatAsState(
targetValue = progress,
animationSpec = tween(durationMillis = 100)
)

LaunchedEffect(isPressed) {
if (isPressed) {
// 2秒长按
val totalTime = 2000L
val startTime = System.currentTimeMillis()

while (isPressed && progress < 1f) {
val elapsed = System.currentTimeMillis() - startTime
progress = (elapsed.toFloat() / totalTime).coerceIn(0f, 1f)

if (progress >= 1f) {
onLongPressComplete()
}
delay(16)
}
} else {
progress = 0f
}
}

Box(
modifier = modifier
.size(80.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed = true
tryAwaitRelease()
isPressed = false
}
)
},
contentAlignment = Alignment.Center
) {
// 背景圆环
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(
color = Color.DarkGray,
radius = size.minDimension / 2
)

// 进度圆弧
drawArc(
color = Color.Red,
startAngle = -90f,
sweepAngle = animatedProgress * 360f,
useCenter = false,
style = Stroke(width = 8.dp.toPx(), cap = StrokeCap.Round)
)
}

// 停止图标
Icon(
imageVector = Icons.Default.Stop,
contentDescription = "停止",
tint = Color.White,
modifier = Modifier.size(32.dp)
)
}
}

10.3 心率趋势图

@Composable
fun HeartRateChart(
gpsPoints: List<GpsPoint>,
modifier: Modifier = Modifier
) {
Card(
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
shape = RoundedCornerShape(12.dp),
modifier = modifier
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
// 标题
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.width(4.dp)
.height(16.dp)
.background(Color(0xFFE53935))
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "心率趋势",
color = Color.White,
fontWeight = FontWeight.Bold
)
}

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

// MPAndroidChart
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
LineChart(context).apply {
description.isEnabled = false
legend.isEnabled = false
setBackgroundColor(Color.TRANSPARENT.toArgb())

xAxis.apply {
position = XAxis.XAxisPosition.BOTTOM
textColor = Color.GRAY.toArgb()
valueFormatter = object : ValueFormatter() {
override fun getFormattedValue(value: Float): String {
return "${value.toInt()}分"
}
}
}

axisLeft.apply {
textColor = Color.GRAY.toArgb()
axisMinimum = 40f
axisMaximum = 200f
}

axisRight.isEnabled = false
}
},
update = { chart ->
val startTime = gpsPoints.first().timestamp.toEpochMilli()

val entries = gpsPoints.map { point ->
val minutes = (point.timestamp.toEpochMilli() - startTime) / 60000f
Entry(minutes, point.heartRate.toFloat())
}

val dataSet = LineDataSet(entries, "心率").apply {
color = Color(0xFFE53935).toArgb()
lineWidth = 2f
setDrawCircles(false)
setDrawFilled(true)
fillAlpha = 30
mode = LineDataSet.Mode.CUBIC_BEZIER
}

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

11. 踩坑记录与解决方案

11.1 GPS室内定位失效

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

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

解决方案

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

11.2 位置权限弹窗不显示

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

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

解决方案

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

11.3 暂停后GPS轨迹丢失

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

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

解决方案

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

11.4 服务端同步覆盖本地GPS数据

问题:同步后本地GPS轨迹数据丢失

原因:服务端列表接口不返回GPS点,合并时覆盖了本地数据

解决方案

// 合并时保留本地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
}
}

11.5 Gson反序列化null字段

问题:旧数据中没有新增字段,Gson反序列化时设为null而不是Kotlin默认值

原因:Gson不会使用Kotlin data class的默认值

解决方案

// 加载数据时手动处理null值
val sanitizedRecords = loadedRecords.map { record ->
record.copy(
cityName = record.cityName ?: "",
activityType = if (record.activityType == "跑步" || record.activityType == null) "骑行" else record.activityType,
thumbnailPath = record.thumbnailPath ?: ""
)
}

11.6 Date.from(Instant) API兼容性

问题Date.from(Instant) 需要API 26+,但minSdk是24

解决方案

// 使用兼容方式转换
val date = Date(instant.toEpochMilli()) // 兼容API 24+

11.7 LocalLifecycleOwner导入错误

问题LocalLifecycleOwner在新版Compose中移动了包路径

解决方案

// 旧导入(已弃用)
// import androidx.compose.ui.platform.LocalLifecycleOwner

// 新导入
import androidx.lifecycle.compose.LocalLifecycleOwner

// 并添加依赖
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.1")

12. 总结与展望

12.1 项目成果

本项目成功实现了一款功能完备的骑行运动App,主要技术亮点:

  1. 高精度GPS追踪:卡尔曼滤波 + 预热机制 + 漂移检测
  2. 多源融合定位:GPS/WiFi/基站混合,室内外无缝切换
  3. 蓝牙心率监测:标准BLE协议 + HRV计算
  4. 科学运动算法:骑行MET卡路里公式 + 实时配速计算
  5. 智能暂停/恢复:速度检测自动暂停,恢复速度自动继续
  6. 用户认证系统:登录注册 + Token管理 + 数据云同步
  7. 标准数据导出:GPX/TCX格式,兼容主流运动平台
  8. 现代UI架构:Jetpack Compose + Material3 + 深色主题

12.2 未来规划

  • FIT格式支持:Garmin设备原生格式
  • 运动计划:周期性训练计划制定
  • 社交功能:运动数据分享、排行榜
  • Wear OS支持:手表端独立应用
  • AI教练:基于运动数据的智能建议
  • 离线地图:无网络环境下的地图显示
  • 多运动类型:支持跑步、徒步等

12.3 项目结构

Running/
├── app/
│ ├── src/main/
│ │ ├── java/com/awen/running/
│ │ │ ├── bluetooth/ # 蓝牙BLE管理
│ │ │ │ └── BluetoothLeManager.kt
│ │ │ ├── data/ # 数据模型与仓库
│ │ │ │ ├── GpsPoint.kt
│ │ │ │ ├── RunningSession.kt
│ │ │ │ ├── RunningRecordsRepository.kt
│ │ │ │ ├── LoginRepository.kt
│ │ │ │ └── HeartRateSettings.kt
│ │ │ ├── location/ # 定位服务
│ │ │ │ ├── AmapLocationTracker.kt
│ │ │ │ ├── AmapGeocoder.kt
│ │ │ │ └── LocationTracker.kt
│ │ │ ├── network/ # 网络请求
│ │ │ │ ├── ApiService.kt
│ │ │ │ ├── ApiModels.kt
│ │ │ │ └── RetrofitClient.kt
│ │ │ ├── ui/ # UI界面
│ │ │ │ ├── components/
│ │ │ │ │ ├── AmapView.kt
│ │ │ │ │ └── HeartRateZoneCard.kt
│ │ │ │ ├── theme/
│ │ │ │ ├── LoginScreen.kt
│ │ │ │ ├── RegisterScreen.kt
│ │ │ │ ├── AccountScreen.kt
│ │ │ │ ├── RunningScreenWithMap.kt
│ │ │ │ ├── RunningRecordsScreen.kt
│ │ │ │ └── RunningDetailScreen.kt
│ │ │ ├── utils/ # 工具类
│ │ │ │ ├── GpxExporter.kt
│ │ │ │ ├── TcxExporter.kt
│ │ │ │ └── ThumbnailGenerator.kt
│ │ │ ├── MainActivity.kt
│ │ │ ├── MainViewModel.kt
│ │ │ ├── RunningViewModel.kt
│ │ │ ├── RunningRecordsViewModel.kt
│ │ │ └── RunningApplication.kt
│ │ ├── jniLibs/ # 高德地图SO库
│ │ │ ├── arm64-v8a/
│ │ │ └── armeabi-v7a/
│ │ └── res/
│ ├── libs/ # 高德地图JAR
│ └── build.gradle.kts
└── README.md

参考资料

  1. 高德地图Android SDK官方文档
  2. Bluetooth Heart Rate Service规范
  3. GPX格式规范
  4. TCX格式规范
  5. 骑行MET运动代谢当量参考表
  6. Jetpack Compose官方指南
  7. MPAndroidChart使用文档

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