简介这是一个基于安卓开发环境的跑步运动应用完整工程使用 Java 语言编写适合具备一定移动端基础的学习者也适合课程设计与毕业设计参考。工程实现实时速度记录、跑步路径绘制、跑步数据履历管理、历史详情查看等核心功能地图轨迹部分接入高德三维地图开发包可完成定位与画线操作。全部资料包含一百二十九个文件压缩后约十八兆字节其中二十二个 Java 源码负责主要业务逻辑三十二个布局描述文件完成界面与配置五十六个图标图片提供界面素材另含构建脚本、本地动态库与依赖包工程结构比较完整。导入安卓开发工具后能够快速运行并可通过地图初始化、速度刷新、轨迹更新、数据保存等核心模块逐步理解跑步应用的整体实现思路。目前已有二千五百四十人学习下载适合作为移动端练手项目或二次开发基础后续还可扩展配速统计、历史图表等更丰富的功能。1. 从零写一个运动跑步App为什么说这是个「高性价比」的安卓练手项目跑步类App在安卓开发里是个被低估的练手方向它不像电商App那样需要复杂的后端和支付也不像社交App那样纠缠于IM长连接但麻雀虽小五脏俱全——GPS定位、传感器数据处理、地图绘制、数据库持久化、列表展示、统计图表几乎覆盖了安卓客户端开发的所有核心知识点。你只需要一部带GPS的安卓手机和一台能跑Android Studio的电脑就能在一周内做出一个能真实记录跑步轨迹的App跑完出去遛一圈数据是真的能落地的那种。这篇笔记就是要把这个方案的完整落地路径讲清楚怎么用LocationManager或FusedLocationProvider拿到位置数据怎么在Google Maps或高德地图上把路径画出来怎么用Room把每次跑步的履历存下来以及那些不跑到真机上根本发现不了的坑——比如GPS漂移、息屏断连、权限适配。适合已经会Android基础Activity、Intent、RecyclerView但还没做过完整项目的开发者也适合想快速出一个可演示作品的学生。2. 运动数据的采集速度、里程与定位精度是怎么算出来的2.1 选型LocationManager还是FusedLocationProvider跑步App的数据源头就一个——定位。安卓里拿定位有两条路老的LocationManagerAPI 1就有和Google Play服务里的FusedLocationProvider以下简称FLP。我的建议是无脑选FLP除非你的App不打算上Google Play、只在国产ROM上跑。FLP的优势是它做了传感器融合GPS、Wi-Fi、基站、加速度计的数据都会参与定位决策系统会综合判断当前场景该用哪个源。跑步场景下GPS是主力但在高楼密集区GPS信号差的时候FLP能自动用Wi-Fi辅助定位不会让轨迹断掉。LocationManager则完全靠你自己判断用哪个ProviderGPS信号一弱就干瞪眼。提示如果目标市场是纯国内且不依赖GMS高德定位SDK也是成熟方案接口设计跟FLP几乎一一对应换起来不费劲。还有一点必须提前说定位精度和功耗是直接冲突的。跑步App是前台连续定位用高精度模式没问题但要在Activity销毁或退到后台时主动降级或停止请求否则手机会烫到能煎鸡蛋。2.2 最小实现从请求权限到拿到第一个定位点在Android 6.0之后定位权限是运行时权限光在AndroidManifest里声明不够还得在代码里动态申请。这是个老生常谈但永远有人踩坑的点。!-- 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 /粗粒度COARSE和细粒度FINE都声明兼容Android 10以下的机型。Android 10以上如果只声明FINE部分机型会出问题两个都写最稳。// MainActivity.kt - 核心定位逻辑 class MainActivity : AppCompatActivity() { private lateinit var fusedLocationClient: FusedLocationProviderClient private val locationCallback object : LocationCallback() { override fun onLocationResult(result: LocationResult) { val location result.lastLocation if (location ! null) { // 把定位点加入轨迹集合后续画路线和算配速都用它 trackPoints.add(location) updateSpeedUI(location.speed) } } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) fusedLocationClient LocationServices.getFusedLocationProviderClient(this) checkLocationPermission() } private fun startLocationUpdates() { val locationRequest LocationRequest.create().apply { interval 3000 // 每3秒请求一次定位 fastestInterval 2000 // 最快2秒一次 priority LocationRequest.PRIORITY_HIGH_ACCURACY } fusedLocationClient.requestLocationUpdates( locationRequest, locationCallback, Looper.getMainLooper() ) } }这段代码里三个参数是关键interval是目标定位间隔跑步场景3秒一次够用1秒一次太费电且容易产生大量漂移点fastestInterval是系统能在特殊情况下提前回调的上限设2秒是给系统留缓冲priority必须用PRIORITY_HIGH_ACCURACY这是跑步轨迹质量的底线。如果用了PRIORITY_BALANCED_POWER_ACCURACY轨迹会变成折线路径画出来像心电图。2.3 速度与配速的计算别直接信location.speedLocation对象自带speed字段米/秒但你得知道这个值的来路——它在GPS信号差的时候可能返回0.0也可能在静止时蹦出一个诡异的大数。跑步App里更可靠的做法是自己算拿两次定位点之间的距离除以时间差。// SpeedCalculator.kt - 手动计算速度与配速 fun calculatePace(prev: Location, curr: Location): Float { val distance prev.distanceTo(curr) // 单位米 val timeMs curr.time - prev.time // 单位毫秒 if (distance 1.0f) return -1f // 距离太短视为噪声 val speedMs distance / (timeMs / 1000f) // 米/秒 if (speedMs 0.3f) return -1f // 低于步行速度过滤静止漂移 return speedMs } fun speedToPace(speedMs: Float): String { // 配速 每公里所需分钟数速度是米/秒所以 1000 / (speedMs * 60) val minutesPerKm 1000f / (speedMs * 60f) val min minutesPerKm.toInt() val sec ((minutesPerKm - min) * 60).toInt() return ${min}${sec}\ }distanceTo算的是两个经纬度点之间的大地距离不是直线距离足够用于跑步场景。为什么过滤掉distance 1.0f因为GPS在静止时也会抖动两个点距离不到一米但时间差却有好几秒算出来的速度会明显偏小把这个数据放进配速统计里会拉低整公里配速。speedMs 0.3f过滤的是原地踏步和静止漂移跑者的真实配速再慢也在1.5米/秒以上。注意location.speed在某些国产机型上永远返回0.0这是厂商阉割了NMEA输出导致的。所以自算速度不只是为了精确更是为了兼容性。3. 把跑步路径画到地图上从Polyline到轨迹渲染3.1 地图SDK选型Google Maps还是高德画轨迹的第一步是选地图SDK。Google Maps Maps SDK for Android集成最简单——加依赖、配API Key、一个SupportMapFragment就能搞定Polyline画线也是现成的。但问题在于国内发行的App用不了Google服务调试时模拟器里没装GMS也看不见地图。如果做国内版本高德地图SDK是更实际的选择。它的MapView控件跟Google Maps用法相似Polyline绘制几乎一样的API还额外提供了AMapUtils.calculateLineDistance这种算距离的工具方法能省不少事。我这篇笔记以高德为例写因为对国内开发者更落地如果你的目标市场是海外或纯技术演示Google Maps的代码结构高度相似换皮不换骨。3.2 绘制轨迹的最小代码PolylineOptions的用法拿到一串Location点之后画线本质上就两步把Location转成高德的LatLng再addPolyline。// TrackMapManager.kt - 路径绘制核心 class TrackMapManager(private val map: AMap) { private val trackPoints mutableListOfLatLng() private var trackPolyline: Polyline? null fun addTrackPoint(location: Location) { val latLng LatLng(location.latitude, location.longitude) trackPoints.add(latLng) // 超过2个点才画线单点没有线可画 if (trackPoints.size 2) { drawTrack() } } private fun drawTrack() { trackPolyline?.remove() // 删掉旧线重画 trackPolyline map.addPolyline( PolylineOptions() .addAll(trackPoints) .color(0xFF00B4D8.toInt()) // 蓝色轨迹 .width(12f) // 12像素宽 .geodesic(false) // 线段直连不需要贴合球面 ) } fun centerMapOnStart() { if (trackPoints.isNotEmpty()) { map.moveCamera(CameraUpdateFactory.newLatLngZoom(trackPoints.first(), 17f)) } } }这段代码的逻辑是「删旧线、画新线」——每来一个点就把之前的Polyline移除再重建。点少时没问题但跑完5公里轨迹点上千个每次都全量重画会明显卡顿。更优的做法是维护一个Polyline引用用polyline.points newPoints更新它的点集而不是remove后重新add。geodesic(false)这个参数值得多说一句高德的PolylineOptions里geodesic表示线段是否沿地球球面弧度绘制。跑步轨迹的相邻两点距离最多几十米球面弧度差几乎可忽略设false用直线连更快。但如果画的是跨城市的路线图就必须设true否则线会穿过建筑物。3.3 轨迹平滑与抽稀为什么你的路径像锯齿跑完一次打开轨迹一看明明走的直线图上却是锯齿状——这是GPS漂移叠加定位噪声的典型表现。三个处理手段按性价比排序抽稀、滤波、纠偏。抽稀最常用的算法是Douglas-PeuckerAndroid里可以直接用Google的com.google.maps.android:android-maps-utils库里的PolyUtil.simplify()但它只适配Google Maps高德没有现成的需要自己写。我一般用的是更简单的窗口过滤相邻两个点距离小于3米就丢弃后一个点这个阈值对付跑者晃动足够了。滤波我用的是轻量的卡尔曼滤波在速度值上做平滑位置点不做滤波——位置滤波容易把弯道轨迹拉直。速度平滑的好处是配速曲线不会忽高忽低展示给用户看的时候不会出现「前一秒6分配速后一秒5分配速」的尴尬。纠偏是真正玄学的环节——高德提供了CoordinateConverter接口可以把GPS坐标转成高德坐标。但这东西对跑步轨迹的帮助有限它主要解决的是「地图显示偏了几百米」的问题跑步轨迹的相对位置关系是正确的就够了不用每个点都纠偏。真机实测下来GPS坐标在高德地图上的偏差通常在10米以内不影响轨迹美观。4. 跑步履历的存储与展示Room数据库与RecyclerView的配合4.1 为什么选Room而不是SQLite原生或文件存储跑步历史数据要支持「查看明细、按日期筛选、统计总里程」这是典型的结构化查询场景原生SQLite写起来太啰嗦——要自己管理SQLiteOpenHelper、手写表结构升级逻辑、手动做Cursor映射。而用文件存储JSON/CSV更坑查询一次要把整个文件读进来过滤数据量大了以后卡到怀疑人生。Room是Google官方的ORM层它在SQLite外面包了一层编译期帮你检查SQL语句、自动生成实现代码还内置了数据库版本迁移的方案。对一个跑步App来说每次跑步的数据量不大几千个轨迹点但查询模式复杂Room正好合适。4.2 建表设计与代码生成RunRecord和TrackPoint跑步履历需要两张表一张存每次跑步的汇总信息总时长、总距离、平均配速、起止时间一张存轨迹点经纬度、时间戳。两张表用runId关联。// RunRecord.kt - 跑步记录实体 Entity(tableName run_records) data class RunRecord( PrimaryKey(autoGenerate true) val id: Long 0, val startTime: Long, // 开始时间戳 val durationMs: Long, // 总时长毫秒 val totalDistanceMeters: Double, // 总距离米 val avgPace: String, // 平均配速格式 530\ val totalCalories: Int, // 估算卡路里 val trackPath: String // 轨迹点的JSON序列化字符串 ) // TrackPoint.kt - 轨迹点实体 Entity(tableName track_points) data class TrackPoint( PrimaryKey(autoGenerate true) val id: Long 0, val runId: Long, // 关联run_records.id val latitude: Double, val longitude: Double, val timestamp: Long, val speed: Float )这里有个设计决策要说清楚为什么不单独建一张track_points表存点而是把轨迹点JSON序列化成字符串塞进run_records表两种方案我都试过结论是独立轨迹表对「轨迹点级查询」友好——比如要查「上周三跑步轨迹经过某个区域」这种需求但跑步App的绝大多数场景是「打开跑步记录列表 → 点开某条看轨迹」它是整条读的不涉及跨记录的点级查询。整条记录用JSON存读出来反序列化一次就行省了一次数据库查询也避免了两张表的join操作。Room的DAO和Database定义是固定模板写法如下// RunDao.kt Dao interface RunDao { Insert suspend fun insertRun(record: RunRecord): Long Query(SELECT * FROM run_records ORDER BY startTime DESC) fun getAllRuns(): FlowListRunRecord Query(SELECT * FROM run_records WHERE id :runId) suspend fun getRunById(runId: Long): RunRecord Query(DELETE FROM run_records WHERE id :runId) suspend fun deleteRun(runId: Long) } // RunDatabase.kt Database(entities [RunRecord::class], version 1, exportSchema false) abstract class RunDatabase : RoomDatabase() { abstract fun runDao(): RunDao companion object { Volatile private var INSTANCE: RunDatabase? null fun getInstance(context: Context): RunDatabase { return INSTANCE ?: synchronized(this) { INSTANCE ?: Room.databaseBuilder( context.applicationContext, RunDatabase::class.java, run_tracker.db ).build().also { INSTANCE it } } } } }DAO里的FlowListRunRecord是Room和Kotlin协程配合的经典写法数据库里数据一变化Flow会自动向外发射新的数据列表界面上的RecyclerView就能自动刷新。suspend修饰的insertRun让插入操作在IO线程执行不会卡UI。注意Room不支持直接存ListLatLng类型字段要么拆表要么序列化成字符串。我这边选了JSON字符串配合kotlinx.serialization或Gson序列化和反序列化一行代码搞定。4.3 跑步记录列表页RecyclerView ListAdapter列表页的核心是RecyclerView的Adapter。用ListAdapter配合DiffUtil数据刷新时的性能会好很多——它只更新变化的item不会闪整个列表。// RunHistoryAdapter.kt class RunHistoryAdapter : ListAdapterRunRecord, RunHistoryAdapter.RunViewHolder(DiffCallback) { companion object DiffCallback : DiffUtil.ItemCallbackRunRecord() { override fun areItemsTheSame(oldItem: RunRecord, newItem: RunRecord) oldItem.id newItem.id override fun areContentsTheSame(oldItem: RunRecord, newItem: RunRecord) oldItem newItem } class RunViewHolder(val binding: ItemRunBinding) : RecyclerView.ViewHolder(binding.root) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RunViewHolder { val binding ItemRunBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return RunViewHolder(binding) } override fun onBindViewHolder(holder: RunViewHolder, position: Int) { val run getItem(position) holder.binding.tvDate.text SimpleDateFormat(MM月dd日 HH:mm, Locale.CHINA) .format(Date(run.startTime)) holder.binding.tvDistance.text String.format(%.2f km, run.totalDistanceMeters / 1000) holder.binding.tvPace.text 配速 ${run.avgPace} } }首次展示列表时记得在Activity的onCreate里做一次「读数据库 → 提交给Adapter」的动作。有了Flow后续任何一次新纪录插入列表都会自动更新——这个特性在做「跑完步自动回到列表页看到新记录」的时候特别好用不用手动通知刷新。5. 避坑指南跑步App在真机上翻车的5个真实案例5.1 一息屏GPS就罢工轨迹直接飞走现象跑着跑着按了电源键息屏放口袋里再打开一看轨迹从A点瞬移到B点中间穿过了三条街。原因Android系统在息屏后为了省电会主动降低CPU频率和GPS采样频率甚至直接挂起非前台进程。你的App虽然是前台应用但没有持有的Wakelock系统照样会延迟定位回调。解决正确做法是启用前台服务Foreground Service在通知栏常驻一个「正在记录运动」的提醒同时获取PARTIAL_WAKE_LOCK。前台服务的核心代码如下// TrackingService.kt - 前台服务保活 class TrackingService : Service() { override fun onCreate() { super.onCreate() val channel NotificationChannel( tracking, 运动记录, NotificationManager.IMPORTANCE_LOW ) val manager getSystemService(NotificationManager::class.java) manager.createNotificationChannel(channel) val notification NotificationCompat.Builder(this, tracking) .setContentTitle(正在记录跑步) .setContentText(GPS连接中请保持屏幕常亮或手机电量充足) .setSmallIcon(android.R.drawable.ic_menu_mylocation) .setOngoing(true) .build() startForeground(1, notification) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { // 在这里启动location updates return START_STICKY } }START_STICKY表示如果服务被系统杀掉系统会重新创建它——跑步记录App一定要用这个返回常量。另外别忘在AndroidManifest.xml里声明前台服务类型android:foregroundServiceTypelocationAndroid 10以上不声明会崩。5.2 权限弹窗永远不出现6.0以上机型定位失败现象代码里写了requestPermissions但真机上跑起来没有任何弹窗定位回调空转一个点都拿不到。原因国内ROM的权限管理是重灾区。部分机型小米、华为默认把定位权限设为「仅在使用中允许」而且它们的权限弹窗走的是自己的逻辑跟AOSP的行为不完全一致。还有更隐蔽的Android 11上如果AGPS没开启部分机型即便给了权限也定不到位。解决排查步骤按顺序来。第一步在onRequestPermissionsResult里打Log确认结果码。第二步打开系统设置里App的权限页手动确认定位权限确实打开了。第三步检查WebView或地图SDK的初始化是否成功——高德地图SDK如果API Key配错地图不会报错但定位功能会静默失效。5.3 轨迹在桥上画出了一条弧线GPS漂移的锅现象跑步路线经过高架桥或隧道轨迹没有沿着桥走而是画出一道好几百米的弧线穿过了河。原因GPS信号在桥下或被建筑物遮挡时接收机计算出错误的位置。这是硬件层面的限制任何软件都改不掉。解决两个手段配合用。第一个是轨迹抽稀时的「大跳跃检测」——当相邻两个定位点距离超过200米且时间间隔小于5秒时认为是漂移点直接丢弃并打断轨迹不连线。第二个是地图SDK的「定位纠偏」接口高德有AMap.setLocationSource()配合LocationSource接口可以接入自己的定位数据并做纠偏Google Maps则是用FusedLocationProviderClient时系统内部已经做了部分过滤。5.4 模拟器上永远定不到位这是玄学也是必然现象在Android Studio的模拟器上跑App地图白屏定位回调一个点都不触发代码翻烂了也找不到问题。原因模拟器的GPS信号是通过Extended Controls模拟发送的默认是关闭状态。而且模拟器默认的单点经纬度发送速度很慢手动点一下地面上定位一次。解决模拟器里点侧边栏三个点的菜单 →Location→ 设置好经纬度 → 勾选Continuous设置发送间隔1秒一次再回App里触发定位。这个操作是每个安卓开发者都踩过的坑属于「知道了一次就再也不会忘」的知识点。5.5 跑完一公里的配速算出来8分钟但实际明明是6分钟现象配速数值跟用户体感严重不符而且每次算出来的结果都比实际慢很多。原因静止时的GPS漂移点污染了平均速度计算。比如你停在一个路口等了30秒红灯GPS在这30秒里生成了20个漂移点每个点的距离都在5~30米之间这些「幽灵距离」被计入了总距离自然拉低了配速。解决计算总距离时先过滤掉静止点——速度低于0.5米/秒或者是calculatePace返回-1的点一律不计入距离。另一个保险是把单次轨迹的配速计算拆成按公里的分段配速展示「最近一公里配速」而不是整圈平均至少用户的挫败感会小很多。6. 进阶把轨迹点存成GPX格式让数据活起来跑步数据不应该困在App里GPXGPS Exchange Format是通用的轨迹交换格式Strava、Garmin、两步路都能识别。给App加一个「导出GPX」功能用户可以把轨迹分享给朋友或导入其他平台这是把项目从「自娱自乐」推向「像个产品」的关键一步。GPX本质就是一段XML核心结构如下?xml version1.0 encodingUTF-8? gpx version1.1 creatorRunTracker trk name跑步轨迹 2024-06-15 18:30/name trkseg trkpt lat39.908823 lon116.397470 ele52.6/ele time2024-06-15T18:30:00Z/time /trkpt /trkseg /trk /gpx生成GPX的代码实现// GpxExporter.kt object GpxExporter { fun export(run: RunRecord, points: ListTrackPoint): String { val builder StringBuilder() builder.append(?xml version\1.0\ encoding\UTF-8\?\n) builder.append(gpx version\1.1\ creator\RunTracker\\n) builder.append( trk\n) builder.append( name${formatDate(run.startTime)}/name\n) builder.append( trkseg\n) points.forEach { pt - builder.append( trkpt lat\${pt.latitude}\ lon\${pt.longitude}\\n) builder.append( ele${pt.altitude}/ele\n) builder.append( time${formatIsoTime(pt.timestamp)}/time\n) builder.append( /trkpt\n) } builder.append( /trkseg\n) builder.append( /trk\n) builder.append(/gpx) return builder.toString() } }导出之后用FileProvider分享写成Intent.ACTION_SEND带一个content://的URI给微信或邮件。注意GPX里的time字段必须用UTC时间格式Z结尾否则导入Garmin和Strava时时间会被当成UTC8再算一遍全部错8小时。最后一个过来人经验做这类App不要一开始就追求轨迹完美——先让App能跑起来、能记数据、能画出路径再逐步加滤波、配速曲线、分段统计这些花活。跑步App的核心价值是「用户跑完一次能看见自己跑了多少、怎么跑的」这个闭环通了其他都是锦上添花。GPS漂移和电池续航问题永远存在但别让它们阻碍你先把第一个版本做出来。希望这些笔记能帮你少走几个坑跑起来。本文还有配套的精品资源点击获取
