Android跑步轨迹App开发实战:定位平滑、地图画线与数据持久化

📅 发布时间:2026/10/9 21:42:48
Android跑步轨迹App开发实战:定位平滑、地图画线与数据持久化
简介这是一套基于 Android Studio 与 Java 开发的运动跑步类 App 完整源码面向具备一定安卓基础、希望练习定位与地图绘制、数据持久化等实战技能的开发者。项目围绕跑步场景实现实时速度记录、跑步路径绘制、跑步数据履历管理与数据详情查看等核心功能适合作为课程设计、毕业设计或移动开发练手项目。压缩包共 129 个文件约 18.03MB其中 22 个 java 文件承载业务逻辑32 个 xml 负责界面布局56 个 png 提供图标与视觉素材另含 so、jar、gradle 等依赖与构建配置工程结构完整可直接导入。目前已有 2540 人学习下载。借助这套源码读者可梳理定位 SDK 接入、轨迹绘制与数据存储的实现思路理解 Activity 与地图组件的协作方式并在此基础上二次开发或排错调试。1. 跑步轨迹 App 的工程真相定位、画线、存数据哪个先崩做过 Android 运动跑步 App 的人都有一个共识定位抖动比产品需求更难缠。标题里说的“实时记录速度、画出跑步路径、管理跑步数据履历、查看数据详细”拆开看是四件事——高频定位采集、地图折线渲染、本地数据持久化、统计维度展示。很多新手一上来就接高德或百度地图 SDK结果发现轨迹画出来像蚯蚓爬速度曲线像心电图用户跑完一看配速 3 分半直接卸载。这个方向适合两类人一是想做一个完整 Android 项目练手的开发者二是需要给运动类硬件做配套 App 的工程师。核心难点不在 UI而在定位数据的清洗与平滑以及轨迹点与地图坐标系的正确映射。我一般会先把定位采集和轨迹平滑跑通再回头做数据履历和详情页否则后面全是返工。下面按“能跑起来 → 能跑准 → 能存住 → 能看细”的顺序把每个环节的参数和坑讲清楚。2. 定位采集与轨迹平滑从 GPS 原始点到可画线的坐标序列2.1 为什么直接拿 Location 画线一定翻车Android 的LocationManager或FusedLocationProviderClient返回的原始点包含大量噪声。静止时经纬度会在几米范围内跳变移动时会出现“飞点”——比如上一秒还在 A 点下一秒跳到 200 米外又跳回来。如果直接把这些点连成线地图上就是一团毛线。更麻烦的是速度location.getSpeed()在低速时误差极大跑步场景下经常出现 0 和 15 m/s 交替跳变。常见做法是先做距离阈值过滤再做滑动平均平滑。距离阈值过滤掉位移小于 3~5 米的点滑动平均对连续 5 个点的经纬度取均值。注意不要用卡尔曼滤波一上来就套参数调不好反而引入延迟跑步场景下 5 点滑动平均足够。2.2 用 FusedLocationProviderClient 搭最小采集链路下面是一个可复现的定位采集封装基于 Google Play Services 的FusedLocationProviderClient适合大多数国内能装 GMS 的设备如果没有 GMS用LocationManager的GPS_PROVIDER替代逻辑一致。// LocationCollector.kt class LocationCollector( private val context: Context, private val onPoint: (RunPoint) - Unit ) { private val client LocationServices.getFusedLocationProviderClient(context) private val buffer mutableListOfRunPoint() // 平滑窗口 private val WINDOW_SIZE 5 private val MIN_DISTANCE 4.0 // 米小于此距离的点丢弃 private val request LocationRequest.Builder( Priority.PRIORITY_HIGH_ACCURACY, 1000L // 1 秒采集一次跑步足够 ).apply { setMinUpdateDistanceMeters(2f) // 系统层再过滤一次 setWaitForAccurateLocation(false) }.build() private val callback object : LocationCallback() { override fun onLocationResult(result: LocationResult) { val loc result.lastLocation ?: return if (loc.accuracy 30) return // 精度差于 30 米直接丢 val raw RunPoint(loc.latitude, loc.longitude, loc.speed, System.currentTimeMillis()) buffer.add(raw) if (buffer.size WINDOW_SIZE) buffer.removeAt(0) val smoothed smooth(buffer) if (lastPoint null || distanceBetween(lastPoint!!, smoothed) MIN_DISTANCE) { lastPoint smoothed onPoint(smoothed) } } } private var lastPoint: RunPoint? null fun start() { client.requestLocationUpdates(request, callback, Looper.getMainLooper()) } fun stop() { client.removeLocationUpdates(callback) } private fun smooth(points: ListRunPoint): RunPoint { val lat points.map { it.lat }.average() val lng points.map { it.lng }.average() val speed points.map { it.speed }.average() return RunPoint(lat, lng, speed, points.last().timestamp) } private fun distanceBetween(a: RunPoint, b: RunPoint): Double { val results FloatArray(1) Location.distanceBetween(a.lat, a.lng, b.lat, b.lng, results) return results[0].toDouble() } }逻辑说明LocationRequest的间隔设为 1000ms跑步场景下 1 秒一个点足够画线再密只会增加电量和噪声。setMinUpdateDistanceMeters(2f)让系统层先过滤掉微小位移。accuracy 30直接丢弃这是血泪经验——精度差的点会把轨迹拉出去几百米。平滑窗口取 5对应 5 秒时间窗既能压噪声又不会明显滞后。MIN_DISTANCE 4.0是画线前的最后一道过滤避免静止时点堆积。参数怎么改如果发现轨迹滞后明显把WINDOW_SIZE降到 3如果轨迹仍然毛糙升到 7 但注意弯道会切角。MIN_DISTANCE在跑步场景 3~5 米都合理骑行可以放到 8~10 米。2.3 速度计算的两种口径与选择location.getSpeed()是设备根据多普勒效应或差分算出来的静止时不可靠。更稳的做法是用相邻两点的距离除以时间差自己算fun calcSpeed(a: RunPoint, b: RunPoint): Float { val dist distanceBetween(a, b) // 米 val dt (b.timestamp - a.timestamp) / 1000f // 秒 return if (dt 0) (dist / dt).toFloat() else 0f }这样算出来的瞬时速度仍然会跳展示时再做一次 3 点平均。配速分钟/公里用1000 / speed / 60换算注意 speed 为 0 时要显示“--”而不是无穷大。我一般会在详情页同时展示瞬时速度和分段配速分段按每公里切这样用户看到的曲线更平滑。3. 地图轨迹绘制Polyline 的坐标精度与渲染性能3.1 坐标系不统一是轨迹偏移的元凶国内地图 SDK 用的是 GCJ-02 坐标系而 GPS 原始输出是 WGS-84。如果直接把 WGS-84 的经纬度丢给高德或百度地图轨迹会整体偏移几百米。常见做法是在采集层统一转成 GCJ-02 再存库这样地图渲染和后续回放都不用再转。转换算法网上有成熟实现注意百度地图用的是 BD-09需要多一步 GCJ-02 转 BD-09。// 简化的 WGS-84 转 GCJ-02实际项目建议用成熟库 fun wgs84ToGcj02(lat: Double, lng: Double): PairDouble, Double { val a 6378245.0 val ee 0.00669342162296594323 var dLat transformLat(lng - 105.0, lat - 35.0) var dLng transformLng(lng - 105.0, lat - 35.0) val radLat lat / 180.0 * Math.PI var magic Math.sin(radLat) magic 1 - ee * magic * magic val sqrtMagic Math.sqrt(magic) dLat (dLat * 180.0) / ((a * (1 - ee)) / (magic * sqrtMagic) * Math.PI) dLng (dLng * 180.0) / (a / sqrtMagic * Math.cos(radLat) * Math.PI) return Pair(lat dLat, lng dLng) }逻辑说明这是标准 GCJ-02 偏移算法transformLat和transformLng是内部辅助函数网上可查。注意只在采集入库时转一次不要在地图渲染时反复转否则误差累积。如果用的是百度地图再套一层 GCJ-02 转 BD-09。3.2 Polyline 分段渲染与内存控制一次跑步 5 公里大约 3000~5000 个点10 公里能到 1 万个点。如果一次性addPolyline一个 1 万点的列表低端机渲染会卡顿。常见做法是按每 500 个点分段每段一个 Polyline颜色和宽度一致视觉上看不出接缝。fun drawTrack(map: GoogleMap, points: ListLatLng) { val chunkSize 500 points.chunked(chunkSize).forEach { chunk - if (chunk.size 2) returnforEach map.addPolyline(PolylineOptions() .addAll(chunk) .width(12f) .color(Color.parseColor(#FF5722)) .geodesic(true) // 长距离折线更准确 ) } }参数说明width(12f)在大多数手机上视觉合适太细看不清太粗弯道会糊。geodesic(true)让折线按大圆航线插值长距离轨迹更贴合实际路径。分段大小 500 是经验值太小会增加对象数量太大单段渲染压力大。如果轨迹点超过 2 万建议做抽稀——每 3 个点取 1 个视觉上几乎无差别。3.3 实时画线与历史回放的区别实时画线时每来一个点就addPolyline一个两点线段性能差且对象多。正确做法是维护一个当前 Polyline 对象用setPoints更新整条线或者每 50 个点重建一次。历史回放则一次性画完但要注意地图相机跟随实时模式用moveCamera跟随当前位置回放模式用animateCamera按时间轴移动。4. 跑步数据履历Room 表结构设计与查询优化4.1 三张表跑步记录、轨迹点、分段统计数据履历的核心是一次跑步一条记录轨迹点单独存表分段统计可算可存。我一般用 Room三张表Entity(tableName run_record) data class RunRecord( PrimaryKey(autoGenerate true) val id: Long 0, val startTime: Long, val endTime: Long, val distance: Double, // 米 val duration: Long, // 毫秒 val avgSpeed: Float, val calories: Int ) Entity(tableName run_point, indices [Index(recordId)]) data class RunPointEntity( PrimaryKey(autoGenerate true) val id: Long 0, val recordId: Long, val lat: Double, val lng: Double, val speed: Float, val timestamp: Long ) Entity(tableName run_split) data class RunSplit( PrimaryKey(autoGenerate true) val id: Long 0, val recordId: Long, val kmIndex: Int, // 第几公里 val splitTime: Long // 该公里耗时毫秒 )逻辑说明run_point表对recordId建索引否则查轨迹时全表扫描。run_split存每公里分段避免详情页每次现算。distance和duration冗余存在run_record列表页不用 join 就能显示。4.2 批量插入与事务跑步结束时一次性写入几千个轨迹点必须用事务否则每条 insert 一次磁盘 IO慢到 ANR。Dao interface RunDao { Insert suspend fun insertRecord(record: RunRecord): Long Insert suspend fun insertPoints(points: ListRunPointEntity) Transaction suspend fun saveRun(record: RunRecord, points: ListRunPointEntity) { val id insertRecord(record) insertPoints(points.map { it.copy(recordId id) }) } }参数说明Transaction保证记录和点要么全成功要么全失败。insertPoints接收列表Room 会生成批量插入语句。注意不要在主线程调用用suspend配合viewModelScope。4.3 履历列表的分页与统计列表页按时间倒序用 Paging 3 分页每页 20 条。统计维度常见的有周跑量、月跑量、总里程、平均配速。这些用 SQL 聚合查询SELECT SUM(distance) as totalDistance, COUNT(*) as count FROM run_record WHERE startTime BETWEEN :weekStart AND :weekEnd注意时间戳单位统一用毫秒BETWEEN的边界要包含当天 23:59:59.999。如果数据量大给startTime建索引。5. 避坑与排查轨迹 App 最容易翻车的 5 个点5.1 轨迹画出来整体偏移几百米现象地图上轨迹和实际道路平行但偏移。原因WGS-84 没转 GCJ-02 就入库。解决采集时统一转检查转换函数是否被调用注意百度地图还要转 BD-09。5.2 静止时轨迹乱跳现象用户站着不动轨迹却画出一团。原因GPS 漂移精度差的点没过滤。解决accuracy 30丢弃加MIN_DISTANCE距离过滤平滑窗口开到 5。5.3 跑步结束写入数据时 ANR现象点结束按钮卡几秒。原因几千个点逐条 insert 在主线程。解决用Transaction批量插入放Dispatchers.IO加 loading 提示。5.4 详情页打开慢现象点进详情页要等 2~3 秒。原因每次现算分段和统计。解决跑步结束时算好存run_split详情页只查不算。轨迹点查询加recordId索引。5.5 后台采集被系统杀掉现象锁屏后轨迹断断续续。原因没前台服务。解决跑步开始时启动前台服务通知栏显示“正在记录跑步”Android 10 需要ACCESS_BACKGROUND_LOCATION权限引导用户选“始终允许”。6. 进阶技巧用抽稀和插值让轨迹又准又省轨迹点太多费内存太少弯道失真。我一般用Douglas-Peucker 抽稀 线性插值补点的组合。抽稀阈值设 2 米能把 1 万点压到 3000 点左右视觉几乎无差别。插值用于回放按时间轴每 100ms 取一个插值点让小车移动平滑。// 简化抽稀距离阈值法比 Douglas-Peucker 更好实现 fun simplify(points: ListRunPoint, threshold: Double 2.0): ListRunPoint { if (points.size 3) return points val result mutableListOf(points.first()) var lastKept points.first() for (i in 1 until points.size - 1) { if (distanceBetween(lastKept, points[i]) threshold) { result.add(points[i]) lastKept points[i] } } result.add(points.last()) return result }验证方法抽稀前后分别算总距离误差应小于 1%。如果误差大把阈值降到 1 米。回放插值用ValueAnimator按时间比例取相邻两点间的插值坐标注意经纬度是线性插值短距离内误差可忽略。我自己的习惯是采集时不过度平滑存库前抽稀一次渲染时再按屏幕分辨率决定是否二次抽稀。这样原始数据保留完整展示层灵活。另外每次改平滑参数后拿同一段跑步数据回放对比看轨迹和实际路线是否贴合别凭感觉调。希望帮到你。本文还有配套的精品资源点击获取