简介:这是一套基于 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 = mutableListOf<RunPoint>() // 平滑窗口 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: List<RunPoint>): 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): Pair<Double, 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: List<LatLng>) { val chunkSize = 500 points.chunked(chunkSize).forEach { chunk -> if (chunk.size < 2) return@forEach 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: List<RunPointEntity>) @Transaction suspend fun saveRun(record: RunRecord, points: List<RunPointEntity>) { 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: List<RunPoint>, threshold: Double = 2.0): List<RunPoint> { 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按时间比例取相邻两点间的插值坐标,注意经纬度是线性插值,短距离内误差可忽略。
我自己的习惯是:采集时不过度平滑,存库前抽稀一次,渲染时再按屏幕分辨率决定是否二次抽稀。这样原始数据保留完整,展示层灵活。另外,每次改平滑参数后,拿同一段跑步数据回放对比,看轨迹和实际路线是否贴合,别凭感觉调。希望帮到你。
本文还有配套的精品资源,点击获取