
简介这是一份基于 Android Studio 与 Java 开发的运动跑步类 App 完整工程源码面向具备一定安卓基础、希望练习定位与地图绘制实战的开发者可用于课程设计、毕业设计或自学参考。工程围绕跑步场景实现实时速度记录、跑步路径绘制、跑步数据履历管理与数据详情查看等核心功能并集成百度地图 Android 端 SDK 相关 jar 包与 so 库便于理解定位、轨迹绘制与本地数据存储的完整链路。压缩包共 129 个文件约 18.03MB其中 22 个 java 源文件承载业务逻辑32 个 xml 负责界面布局与配置56 个 png 提供图标与界面素材另有 gradle 构建脚本、properties 配置及 jar、so 依赖等目录结构清晰导入 Android Studio 即可编译运行。目前已有 2540 人学习下载适合对照源码梳理地图轨迹绘制、速度计算与履历管理的实现思路快速搭建自己的跑步类应用原型。1. 从零搭一个跑步 AppAndroid Studio 里实时速度与轨迹到底怎么落地打开 Android Studio 新建一个 Empty Activity到能跑通「实时速度 轨迹绘制 历史履历」这条完整链路中间隔着的不是代码量而是几个关键选型定位用 FusedLocationProviderClient 还是原生 LocationManager轨迹画在 Canvas 上还是塞进地图 SDK数据存 Room 还是 SQLite 裸写。我见过太多人卡在第一步——权限申请完onLocationChanged 回调里拿到一堆漂移点速度值在 0 和 12m/s 之间反复横跳画出来的路径像毛线团。这篇笔记就按我实际做过的一个跑步 App 的结构拆开讲怎么拿到可用的定位流、怎么把经纬度变成平滑轨迹、怎么算实时配速、怎么把每次跑步存成履历并能点进去看详情。适合已经会 Android 基础、想做一个能真正跑起来的运动类 App 的开发者也适合想搞清楚定位与轨迹处理边界的熟手。2. 定位与速度采集FusedLocationProviderClient 的最小可用配置2.1 为什么优先选 FusedLocationProviderClient 而不是 LocationManagerAndroid 的定位 API 有两套老的LocationManager和 Google Play Services 里的FusedLocationProviderClient。跑步 App 的核心诉求是「连续、低功耗、在城市楼宇间尽量不飘」Fused 会把 GPS、Wi-Fi、基站、传感器融合成一个结果并且自带PRIORITY_HIGH_ACCURACY这种档位比自己去监听GPS_PROVIDER再手动过滤要省事得多。常见做法是如果设备有 Google Play Services 就用 Fused没有就降级到LocationManager的GPS_PROVIDER。我一般直接上 Fused因为国内大部分机型也带 GMS 框架实在没有再做兼容分支。选 Fused 的另一个理由是它支持LocationRequest的间隔和最小位移控制。跑步场景不需要每秒 10 个点1 秒 1 个点足够配合setMinUpdateDistanceMeters可以过滤掉静止时的抖动点。这个参数设成 3 到 5 米比较合适太小了漂移点全进来太大了弯道会被切角。2.2 权限申请与 LocationRequest 参数怎么设先加依赖在app/build.gradle里dependencies { implementation com.google.android.gms:play-services-location:21.0.1 implementation androidx.room:room-runtime:2.5.2 annotationProcessor androidx.room:room-compiler:2.5.2 }权限在AndroidManifest.xml里声明uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION / uses-permission android:nameandroid.permission.ACCESS_COARSE_LOCATION / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE_LOCATION /Android 6.0 以上要动态申请Android 10 以上还要处理后台定位。跑步时 App 切到后台不能断所以必须起一个前台服务通知栏常驻。下面是请求定位更新的核心代码private val locationRequest LocationRequest.Builder( Priority.PRIORITY_HIGH_ACCURACY, 1000L ).apply { setMinUpdateIntervalMillis(500L) // 最快 0.5 秒一次 setMinUpdateDistanceMeters(3f) // 位移小于 3 米不回调 setWaitForAccurateLocation(false) }.build() private val locationCallback object : LocationCallback() { override fun onLocationResult(result: LocationResult) { for (location in result.locations) { // 过滤掉精度差于 20 米的点 if (location.accuracy 20f) continue handleNewPoint(location) } } } fun startTracking() { if (ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) ! PackageManager.PERMISSION_GRANTED) return fusedClient.requestLocationUpdates( locationRequest, locationCallback, Looper.getMainLooper() ) }Priority.PRIORITY_HIGH_ACCURACY会同时用 GPS 和网络定位耗电但精度高跑步场景值得。setMinUpdateIntervalMillis(500L)是告诉系统「最快可以 0.5 秒给我一次」实际频率还受setIntervalMillis影响。setMinUpdateDistanceMeters(3f)是省电和去抖的关键静止时不会一直回调。location.accuracy是水平精度半径单位米大于 20 米的点基本是基站或 Wi-Fi 定位直接丢掉否则轨迹会突然跳出去几百米。2.3 实时速度怎么算才不跳Location对象自带getSpeed()单位是米/秒但很多设备这个值不稳定尤其是刚定位成功的前几秒。我一般用两点间距离除以时间差来算瞬时速度再做滑动平均。距离用Location.distanceBetween或SphericalUtil.computeDistanceBetween后者来自 Google Maps 工具库精度更好。private val speedWindow ArrayDequeFloat() // 保存最近 5 个速度值 private var lastLocation: Location? null fun handleNewPoint(location: Location) { val last lastLocation if (last ! null) { val distance last.distanceTo(location) // 米 val timeDelta (location.time - last.time) / 1000f // 秒 if (timeDelta 0) { val instantSpeed distance / timeDelta // m/s speedWindow.addLast(instantSpeed) if (speedWindow.size 5) speedWindow.removeFirst() val smoothSpeed speedWindow.average().toFloat() updateSpeedUi(smoothSpeed * 3.6f) // 转成 km/h } } lastLocation location appendToTrack(location) }distanceTo内部用的是 WGS84 椭球比平面勾股定理准。timeDelta用location.time而不是System.currentTimeMillis()因为定位回调有延迟用系统时间会算出偏大的速度。滑动窗口取 5 个点相当于 5 秒平均配速显示不会一直跳。转 km/h 乘 3.6这是跑步 App 最常用的单位。3. 轨迹绘制把经纬度变成屏幕上的平滑路径3.1 坐标系转换经纬度到屏幕像素拿到一串LatLng后不能直接画因为经纬度是地理坐标屏幕是像素坐标。常见做法是算出一个包围盒把最小最大经纬度映射到 View 的宽高。但这样每次加点都要重算轨迹会「缩放跳动」。更好的方式是固定一个中心点和缩放级别用墨卡托投影把经纬度转成平面坐标再平移缩放。fun latLngToPoint(lat: Double, lng: Double, zoom: Float): PointF { val scale 256.0 * Math.pow(2.0, zoom.toDouble()) val x (lng 180.0) / 360.0 * scale val sinLat Math.sin(lat * Math.PI / 180.0) val y (0.5 - Math.log((1 sinLat) / (1 - sinLat)) / (4 * Math.PI)) * scale return PointF(x.toFloat(), y.toFloat()) }zoom控制缩放级别跑步时一般用 16 到 18。这个公式是 Web 墨卡托和大多数地图 SDK 一致。算出所有点的平面坐标后再根据当前 View 的中心和缩放做平移就能保证轨迹平滑增长而不是每次重排。3.2 用 Canvas 画轨迹Path 与抗锯齿自定义一个TrackView继承View在onDraw里用Path把点连起来。关键参数Paint的strokeWidth设 8 到 12 像素strokeCap和strokeJoin都设ROUND这样拐弯处不会出现尖角。抗锯齿isAntiAlias true必开否则轨迹边缘有锯齿。class TrackView(context: Context, attrs: AttributeSet?) : View(context, attrs) { private val path Path() private val paint Paint(Paint.ANTI_ALIAS_FLAG).apply { color Color.parseColor(#FF5722) style Paint.Style.STROKE strokeWidth 10f strokeCap Paint.Cap.ROUND strokeJoin Paint.Join.ROUND } private val points mutableListOfPointF() fun addPoint(p: PointF) { points.add(p) if (points.size 1) { path.moveTo(p.x, p.y) } else { path.lineTo(p.x, p.y) } invalidate() } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) canvas.drawPath(path, paint) } }path.moveTo只在第一个点调用之后都是lineTo。如果每次重绘都重建 Path点多了会卡所以增量添加。invalidate()触发重绘但不要每个点都调可以每 3 到 5 个点调一次减少 UI 线程压力。3.3 轨迹平滑为什么要做卡尔曼或滑动平均原始定位点即使过滤了精度差的仍然会有小幅度抖动画出来像锯齿。常见做法有两种一是滑动平均取最近 3 个点的经纬度平均二是卡尔曼滤波效果更好但参数难调。我一般先用滑动平均简单够用。private val rawPoints mutableListOfLatLng() private val smoothPoints mutableListOfLatLng() fun appendToTrack(location: Location) { rawPoints.add(LatLng(location.latitude, location.longitude)) if (rawPoints.size 3) { val last3 rawPoints.takeLast(3) val avgLat last3.map { it.latitude }.average() val avgLng last3.map { it.longitude }.average() smoothPoints.add(LatLng(avgLat, avgLng)) } }滑动窗口取 3 个点延迟约 3 秒跑步场景可以接受。如果做骑行或赛车延迟就太大了得换卡尔曼。注意平滑后的点只用于绘制算距离和配速还是用原始点否则总距离会偏小。4. 数据履历Room 存每次跑步记录与详情页4.1 表结构设计Run 与 TrackPoint 一对多一次跑步是一条Run记录包含开始时间、结束时间、总距离、总时长、平均配速。轨迹点单独一张表TrackPoint外键关联runId。这样详情页可以按需加载轨迹列表页只查Run表不用把几千个点全读出来。Entity(tableName runs) data class Run( PrimaryKey(autoGenerate true) val id: Long 0, val startTime: Long, val endTime: Long, val distanceMeters: Double, val durationSeconds: Long, val avgPaceSecPerKm: Double ) Entity(tableName track_points, foreignKeys [ ForeignKey(entity Run::class, parentColumns [id], childColumns [runId], onDelete ForeignKey.CASCADE) ]) data class TrackPoint( PrimaryKey(autoGenerate true) val id: Long 0, val runId: Long, val lat: Double, val lng: Double, val timestamp: Long )onDelete CASCADE保证删 Run 时轨迹点自动删。avgPaceSecPerKm存秒/公里显示时再转成「530」格式。距离用Double存米不要用Float长距离累积误差会明显。4.2 写入与查询Room DAO 的批量插入跑步过程中不要每个点都写数据库IO 太频繁。我一般先在内存里攒着每 10 个点或每 10 秒批量插一次。结束时再统一更新 Run 的总距离和时长。Dao interface RunDao { Insert suspend fun insertRun(run: Run): Long Insert suspend fun insertPoints(points: ListTrackPoint) Query(SELECT * FROM runs ORDER BY startTime DESC) suspend fun getAllRuns(): ListRun Query(SELECT * FROM track_points WHERE runId :runId ORDER BY timestamp) suspend fun getPointsForRun(runId: Long): ListTrackPoint Query(UPDATE runs SET distanceMeters :dist, durationSeconds :dur, avgPaceSecPerKm :pace WHERE id :id) suspend fun updateRunStats(id: Long, dist: Double, dur: Long, pace: Double) }insertRun返回自增 id后续TrackPoint的runId就用这个。查询用suspend配合协程不要在主线程查。列表页用LiveData或Flow监听变化新跑完一次自动刷新。4.3 详情页把轨迹重新画出来详情页拿到runId后查TrackPoint列表转成PointF再喂给TrackView。注意详情页的轨迹是静态的不需要增量添加可以一次性算好包围盒再缩放让轨迹铺满屏幕。suspend fun loadRunDetail(runId: Long) { val points runDao.getPointsForRun(runId) val latLngs points.map { LatLng(it.lat, it.lng) } val bounds computeBounds(latLngs) // 算最小最大经纬度 val projected latLngs.map { projectToView(it, bounds, trackView.width, trackView.height) } trackView.setPoints(projected) }computeBounds遍历一次求 min/maxprojectToView做线性映射并留 10% 边距。这样不管轨迹多长都能完整显示在详情页里。5. 避坑与排查跑步 App 定位轨迹的 5 个血泪教训5.1 现象轨迹在起点附近画出一团乱线原因刚启动定位时设备会先返回基站或 Wi-Fi 定位结果精度可能上百米然后才切到 GPS。这些低精度点如果直接进轨迹就会在起点炸开。解决在onLocationResult里判断location.accuracy大于 20 米直接丢弃。同时可以加一个「前 3 个点不画」的策略等精度稳定后再开始记录。5.2 现象App 切到后台后轨迹断了一大段原因Android 8.0 以上后台定位受限没有前台服务的话息屏或切后台后定位回调会被系统暂停。解决起一个ForegroundService在onStartCommand里调startForeground并传一个常驻通知。Android 10 以上还要在 Manifest 里声明foregroundServiceTypelocation。通知文案写「正在记录跑步」用户能接受。5.3 现象总距离比实际偏大 10% 以上原因用了平滑后的点算距离或者没有过滤漂移点。漂移点会让相邻两点距离突然变大累积起来总距离就偏大。解决距离计算用原始点且只累加相邻两点距离小于 100 米的段。如果两点间距离超过 100 米大概率是漂移跳过不计。这个阈值跑步场景够用骑行可以放宽到 200 米。5.4 现象轨迹画出来是折线弯道被切角原因setMinUpdateDistanceMeters设太大比如 10 米弯道时两个点之间直线连接弧线变成折线。解决这个值设 3 到 5 米比较平衡。如果还嫌不够平滑可以在绘制时用贝塞尔曲线连接但计算量会大一些。跑步 App 一般 3 米够用。5.5 现象Room 插入几千个点后列表页卡顿原因TrackPoint表没有索引按runId查询时全表扫描。解决在runId字段上加ColumnInfo(index true)或者用Entity(indices [Index(runId)])。几千条数据加索引后查询从几百毫秒降到几毫秒。6. 进阶技巧用前台服务 协程 Flow 把定位流串成一条可观测链路前面几章把定位、绘制、存储拆开讲了实际项目里这三者是并发的定位回调在主线程数据库写在 IO 线程UI 刷新又在主线程。如果直接在回调里写数据库主线程会卡如果每个点都发一次 UI 更新invalidate太频繁也会掉帧。我最后收敛到的做法是前台服务里用callbackFlow把定位回调转成Flow在 ViewModel 里collect并做过滤和平滑攒够一批再写 Room同时用StateFlow驱动 UI。fun locationFlow(): FlowLocation callbackFlow { val callback object : LocationCallback() { override fun onLocationResult(result: LocationResult) { result.locations.forEach { trySend(it) } } } fusedClient.requestLocationUpdates(locationRequest, callback, Looper.getMainLooper()) awaitClose { fusedClient.removeLocationUpdates(callback) } }callbackFlow把回调式 API 转成冷流awaitClose保证取消收集时移除监听不会泄漏。ViewModel 里viewModelScope.launch { locationFlow() .filter { it.accuracy 20f } .map { smooth(it) } .buffer(10) .collect { location - _uiState.update { it.copy(speed location.speed * 3.6f) } pointBuffer.add(location) if (pointBuffer.size 10) { runDao.insertPoints(pointBuffer.toTrackPoints(currentRunId)) pointBuffer.clear() } } }buffer(10)让上游和下游解耦定位回调不会因为数据库写入慢而阻塞。_uiState.update是线程安全的UI 层收集StateFlow自动刷新。这套结构跑下来连续记录 2 小时、上万個点UI 不掉帧数据库写入也不卡。验证方法很简单跑一次 5 公里对比 App 显示距离和地图软件测的距离误差在 2% 以内算合格。如果超过 5%回去检查精度过滤和距离累加逻辑。我自己的习惯是每次改完定位参数先在小区里走一圈 1 公里看轨迹是否闭合、速度是否稳定再上路跑。这个土办法帮我省了很多后悔药。希望帮到你。本文还有配套的精品资源点击获取