本文详细记录了一款功能完备的骑行运动App的完整开发过程,涵盖GPS定位与轨迹追踪、蓝牙心率带连接、运动数据计算、高德地图集成、智能暂停/恢复、用户认证与数据同步、数据导出(GPX/TCX)等核心技术实现。
目录
项目概述与架构设计
技术栈选型与依赖配置
GPS定位与卡尔曼滤波优化
高德地图SDK深度集成
蓝牙BLE心率带连接
运动数据计算与算法
智能暂停与自动恢复
用户认证与数据同步
数据持久化与导出
Jetpack Compose UI实现
踩坑记录与解决方案
总结与展望
效果
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:
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依赖配置 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 ndk { abiFilters += listOf("arm64-v8a" , "armeabi-v7a" ) } } buildFeatures { compose = true } } dependencies { implementation(platform("androidx.compose:compose-bom:2024.09.00" )) implementation("androidx.compose.ui:ui" ) implementation("androidx.compose.material3:material3" ) 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" ) 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 权限配置 <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 private fun createLocationOption (strictMode: Boolean ) : AMapLocationClientOption { return AMapLocationClientOption().apply { locationMode = AMapLocationClientOption.AMapLocationMode.Hight_Accuracy interval = 1000L isSensorEnable = true isWifiScan = true isGpsFirst = strictMode isLocationCacheEnable = !strictMode } } 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 val accuracyThreshold = if (strictMode) 30f else 500f if (location.accuracy > accuracyThreshold) return @AMapLocationListener 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原始数据存在噪声和跳变,卡尔曼滤波可以有效平滑轨迹:
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 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初始定位精度较差,需要丢弃前几个点:
private var gpsWarmupCounter = 0 private val GPS_WARMUP_POINTS = 5 private fun addGpsPoint (point: GpsPoint ) { gpsWarmupCounter++ if (gpsWarmupCounter <= GPS_WARMUP_POINTS) { Log.d(TAG, "GPS warmup: skipping point $gpsWarmupCounter /$GPS_WARMUP_POINTS " ) return } 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 } } 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 { viewModel.startGpsDetection() } } LaunchedEffect(Unit ) { val hasPermissions = locationPermissions.all { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED } permissionsGranted = hasPermissions if (!hasPermissions) { delay(300 ) permissionLauncher.launch(locationPermissions) } else { viewModel.startGpsDetection() } }
4. 高德地图SDK深度集成 4.1 SDK集成步骤
下载SDK :从高德开放平台下载Android地图SDK全量包
配置JAR :将JAR文件放入app/libs/目录
配置SO库 :将SO文件放入app/src/main/jniLibs/对应架构目录
申请Key :在高德控制台创建应用,获取API Key
<application > <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() AMapLocationClient.updatePrivacyShow(this , true , true ) AMapLocationClient.updatePrivacyAgree(this , true ) 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 , 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 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) } } private fun processHeartRateData (data : ByteArray ) { if (data .isEmpty()) return val flag = data [0 ].toInt() val heartRate = if ((flag and 0x01 ) != 0 ) { ((data [2 ].toInt() and 0xFF ) shl 8 ) or (data [1 ].toInt() and 0xFF ) } else { data [1 ].toInt() and 0xFF } _heartRate.value = heartRate 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(心率变异性)计算 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值与跑步不同:
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 实现代码 private var zeroSpeedCounter = 0 private val AUTO_PAUSE_THRESHOLD = 10 private var nonZeroSpeedCounter = 0 private val AUTO_RESUME_THRESHOLD = 5 private fun checkAutoPause (speed: Float ) { if (speed < 0.5f ) { 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 -> val pointWithHeartRate = gpsPoint.copy(heartRate = _currentHeartRate.value) _currentLocation.value = pointWithHeartRate _currentSpeed.value = gpsPoint.speed checkAutoPause(gpsPoint.speed) if (_isPaused.value) { addGpsPointWithoutDistance(pointWithHeartRate) } else { addGpsPoint(pointWithHeartRate) } }private fun addGpsPointWithoutDistance (point: GpsPoint ) { if (gpsWarmupCounter < GPS_WARMUP_POINTS) { gpsWarmupCounter++ return } val currentPoints = _gpsPoints.value.toMutableList() 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 ) { 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 val prefs = appContext.getSharedPreferences("auth" , Context.MODE_PRIVATE) token = prefs.getString("token" , null ) } fun setToken (newToken: String ) { token = newToken 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) } 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) } } } suspend fun getRecordWithGps (id: String ) : RunningSession? = withContext(Dispatchers.IO) { val localRecord = _records.value.find { it.id == id } if (localRecord != null && localRecord.gpsPoints.isNotEmpty()) { return @withContext localRecord } 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) 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() 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">""" ) 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() } }
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>" ) 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>" ) 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 ) 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) { 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)) 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米
解决方案 :
val accuracyThreshold = if (strictMode) 30f else 500f
11.2 位置权限弹窗不显示 问题 :进入骑行页面时权限弹窗不显示
原因 :Android 10+ 不能同时请求前台和后台位置权限
解决方案 :
val locationPermissions = arrayOf( Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION )
11.3 暂停后GPS轨迹丢失 问题 :自动暂停后恢复骑行,轨迹出现断点
原因 :暂停时完全停止记录GPS点
解决方案 :
if (_isPaused.value) { addGpsPointWithoutDistance(pointWithHeartRate) } else { addGpsPoint(pointWithHeartRate) }
11.4 服务端同步覆盖本地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的默认值
解决方案 :
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())
11.7 LocalLifecycleOwner导入错误 问题 :LocalLifecycleOwner在新版Compose中移动了包路径
解决方案 :
import androidx.lifecycle.compose.LocalLifecycleOwner implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.1" )
12. 总结与展望 12.1 项目成果 本项目成功实现了一款功能完备的骑行运动App,主要技术亮点:
高精度GPS追踪 :卡尔曼滤波 + 预热机制 + 漂移检测
多源融合定位 :GPS/WiFi/基站混合,室内外无缝切换
蓝牙心率监测 :标准BLE协议 + HRV计算
科学运动算法 :骑行MET卡路里公式 + 实时配速计算
智能暂停/恢复 :速度检测自动暂停,恢复速度自动继续
用户认证系统 :登录注册 + Token管理 + 数据云同步
标准数据导出 :GPX/TCX格式,兼容主流运动平台
现代UI架构 :Jetpack Compose + Material3 + 深色主题
12.2 未来规划
12.3 项目结构 Running/ ├── app/ │ ├── src/ main/ │ │ ├── java/ com/ awen/ running/ │ │ │ ├── bluetooth/ │ │ │ │ └── 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/ │ │ │ │ ├── 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/ │ │ │ ├── arm64-v8a/ │ │ │ └── armeabi-v7a/ │ │ └── res/ │ ├── libs/ │ └── build.gradle.kts └── README.md
参考资料
高德地图Android SDK官方文档
Bluetooth Heart Rate Service规范
GPX格式规范
TCX格式规范
骑行MET运动代谢当量参考表
Jetpack Compose官方指南
MPAndroidChart使用文档
文章作者: 阿文
版权声明: 本博客所有文章除特别声明外,均采用
CC BY-NC-SA 4.0 许可协议。转载请注明来自
阿文的博客 !