最近我把一个跑步打卡项目的安卓端从零到一完整做完了,顺手把源码结构和开发文档也梳理了一遍。这篇文章不打算讲那种“从入门到放弃”的空泛理论,直接把项目里最核心的定位、计步、打卡记录、数据存储这几个模块拆开讲,配上我实际写代码时的思路和踩坑记录,给正在做同类App或者准备系统学习安卓源码的朋友一份能直接参考的实战资料。
先说清楚这个项目到底解决什么问题:用户打开App,开始跑步,App实时记录里程、配速、耗时,跑步结束后生成一条打卡记录,同时把轨迹、时间、步频这些数据存到本地数据库,供历史记录和统计页面查询。整个项目只用了安卓原生技术栈,没有依赖第三方定位SDK,所有源码都是可以本地编译运行的。适合刚学完安卓四大组件、想通过一个完整项目把知识串起来的人,也适合那些已经能写小Demo、但还没接触过真实项目目录组织和文档规范的开发者。
1. 项目整体设计与架构思路
1.1 功能拆解与需求边界
开始写代码之前,我先把需求拆成了四个模块:运动数据采集、打卡记录管理、历史数据展示、个人设置。每个模块再往下细化,比如运动数据采集就包含GPS定位、传感器计步、计时器、轨迹绘制四块;打卡记录管理则涉及新建记录、编辑备注、删除记录、按日期筛选。
这里要强调一个经验:刚拿到需求时不要急着搭界面,先花半天时间把数据流转图画清楚。这个项目里核心的数据链路是“传感器数据 → 后台Service → Room数据库 → UI层观察”,如果这条链路设计不合理,后面写哪一层都别扭。我第一次做的时候想省事,直接在Activity里开线程跑定位,结果屏幕一锁就断,数据全丢,后来才老老实实改成前台Service + 通知栏常驻的方案。
需求边界也很重要。跑步打卡听起来简单,但较真起来有很多分支:用户跑到一半暂停怎么办?GPS信号弱导致轨迹漂移怎么处理?手机重启后未完成的跑步记录要不要恢复?这些在动手前都要有明确决策。我当时的处理是:暂停就冻结计时器和计步器,GPS漂移通过设置最小位移阈值来过滤,重启后未完成记录直接丢弃,不做恢复,因为这种场景概率极低,不值得牺牲代码复杂度。
1.2 技术选型与架构模式取舍
技术栈选型上,我坚持了原生开发,语言用Kotlin,UI用XML布局加少量DataBinding,数据层用Room,异步用协程,架构上选择了MVVM。选Kotlin而不是Java,主要是空安全和协程这两个特性对这类长时间在后台运行的应用太友好了,能省掉大量空指针判断和回调嵌套。
架构模式我对比过MVP和MVVM。MVP在安卓里存在了很久,但问题在于Presenter层要手动管理View的引用,容易内存泄漏。MVVM配合Android Architecture Components里的ViewModel和LiveData,天然处理了配置变更(比如转屏)时的状态保留问题,数据倒灌和生命周期绑定也做得很干净。最关键的是,协程在ViewModelScope里可以非常优雅地处理耗时任务取消,跑步时用户突然退出运动页面,协程会跟着ViewModel自动取消,不会跑到一半还在偷偷更新数据库。
项目目录结构我是按模块分的,而不是按层分的,这个决策放到第四章详细说。这里先提一句:很多人习惯建一个包放所有Activity,一个包放所有Adapter,项目小了还好,一旦功能多起来,找文件能找哭。
2. 核心功能模块与源码实现细节
2.1 前台Service:让跑步记录不中断
跑步打卡App里最重要也最容易翻车的就是后台运行。安卓系统为了省电,会在应用处于后台时限制Activity的优先级,如果只靠Activity里的逻辑,锁屏几分钟进程就会被系统回收。解决办法是用前台Service,并且启动时调用startForeground()传入一个通知,让系统知道这个服务正在为用户执行一个可见的任务。
实现上我写了一个RunningService,继承自Service,在onStartCommand()里做了三件事:初始化定位客户端、注册传感器监听、启动计时器。这里的消息传递我用了系统的LiveData加companion object的实现方式,跑完后的数据通过ViewModel暴露给界面,避免Service和Activity直接持有对方的引用。
前台服务必须配通知栏,这是安卓8.0以后的规定。通知的内容我放了三项:已跑距离、当前配速、运动时长。这里有一个细节:通知不能更新太频繁,否则会有通知栏刷屏的嫌疑,实测下来每5秒钟更新一次最合适,既及时又省电。另外通知渠道(NotificationChannel)必须提前创建,不然Android 8.0以上的设备会直接抛异常。
注意:Android 12(API 31)开始,启动前台服务会受到限制,从后台启动前台服务会抛出
ForegroundServiceStartNotAllowedException。解决方法是让用户从前台Activity主动点击启动按钮,或者先调startForegroundService()再在Service的onCreate里快速转成前台。
2.2 定位与轨迹追踪:精度和耗电的平衡
位置服务我选的是FusedLocationProviderClient,这是Google Play服务里的统一定位接口。它的好处是会自动帮你在GPS、Wi-Fi、基站三种定位方式之间做选择,不用自己管理LocationManager的复杂度。
不过这里有个坑:这个项目如果拿不到Google Play服务怎么办?国内环境很多设备没有预装。我的方案是做了个Feature Flag,运行时检测有没有Google Play服务,有就走FusedLocation,没有就退回到系统自带的LocationManager。这部分的源码我写了一个抽象接口LocationProvider,两个实现类,业务层只依赖接口,完全不用关心底层差异,后面换定位SDK也只用改一行实例化代码。
定位参数的设置直接影响轨迹质量。我试过两套参数,第一套是setInterval(1000)每秒定位一次、setSmallestDisplacement(0)不设最小位移,结果轨迹非常细腻,但电量像瀑布一样流。第二套是setInterval(5000)、setSmallestDisplacement(5),效果就均衡很多,跑步场景下每秒移动也就2到3米,5米的位移采集足够还原轨迹形状了。最终线上跑的是第二套,实测1小时跑步耗电大约是总量的5%到6%,用户还能接受。
轨迹绘制我用的是MapView加Polyline。每次定位回调里拿到新的经纬度,动态往Polyline的points列表里追加一个点。这里要提一个算法层面的细节:如果两点之间距离小于1米,就不要画线段,否则轨迹会变成一团密密麻麻的短线,既难看又耗性能。原生的Polyline绘制上千个点会有明显卡顿,所以我还做了一个间隔采样的逻辑,每累计10个点才刷新一次地图上的线条。
2.3 计步传感器:自己算步数还是用系统步数
计步这块有两条路:一条是自己监听加速度传感器TYPE_ACCELEROMETER,用算法去识别步伐;另一条是直接用TYPE_STEP_COUNTER计步传感器。我两条都写过,最终项目里用的是系统计步传感器加一个步点识别兜底。
系统计步传感器的好处是省电且已经过了大量设备验证,但它有个特性:记录的是系统开机以来的总步数,不是本次运动的步数,所以需要在开始运动时记录一个初始值,之后拿当前值减去初始值。这个逻辑很简单,代码里就是两个变量相减的事,但真有不少人在这里翻车,忘记存初始值,结果一次运动显示了几万步。
TYPE_STEP_COUNTER不是所有设备都有,所以我在SensorManager里做了个写法检查:如果设备没有这个传感器,就注册步幅检测逻辑。步幅检测的核心算法是检测加速度模值的波峰,通过高低阈值和最小间隔时间过滤无效摆动。这里经验值是:上下阈值分别设2.2和1.8倍重力加速度,最小间隔300毫秒,匹配跑步频率的范围。不过说实话,这种兜底算法的准确率也就85%左右,能撑住绝大多数场景,没必要追求完美的计步算法。
2.4 Room数据库设计:打卡记录怎么存
打卡记录的字段我设计成了这样:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | Long | 主键,自增 |
| date | Long | 打卡日期,存毫秒时间戳 |
| duration | Long | 运动时长(秒) |
| distance | Double | 运动距离(米) |
| calories | Double | 消耗热量(千卡) |
| avgPace | String | 平均配速,如"5'30"" |
| trackPoints | String | 轨迹点JSON字符串 |
| note | String | 用户备注 |
distance和duration为什么要分开存而不直接算配速?因为统计页面要做周汇总、月汇总,如果只在展示时计算,每次都要遍历大量数据,而存储时算好这个"平均配速"字段,统计查询直接拿到结果,性能会好很多。这里的取舍逻辑就是:能用一条SQL做聚合的,绝不靠Java代码循环算。
轨迹点存JSON字符串而不是单独建一张表,是考虑到曲线图、轨迹回放这些功能都是针对单次跑步记录的,不存在跨记录查询单个点的情况,所以没必要拆表,一个字段存JSON字符串,解析时用Gson反序列化成List就能用。真等到哪天需要做"在所有记录里搜索经过某点的跑步",再拆表也不迟。这就是典型的重构时机判断:不要提前设计过度抽象的结构,先让代码跑通再优化。
3. 核心流程实现与源码解读
3.1 从主界面到开始跑步:一条完整链路
主界面底部有三个Tab,运动、历史、我的。运动页是核心,上面的跑步按钮点击后,会先做一次权限检查:定位权限、后台定位权限、通知权限。
对,后台定位权限是单独的一项。安卓10开始,即使已经给了前台定位权限,在后台继续使用定位还需要额外申请ACCESS_BACKGROUND_LOCATION权限。这个权限审批比较麻烦,而且很多应用市场对后台定位权限的审核卡得很严,如果你的应用功能不是强依赖后台连续定位,建议别申请。跑步App属于强依赖场景,可以申请,但必须在隐私政策里明确说明。
权限拿到之后,跳转到运动记录页(SportActivity),这个页面启动时通过startForegroundService()拉起RunningService,然后页面进入观察状态。RunningService里每5秒把最新的距离、配速、时长通过LiveData推送出来,运动页上的几个大号数字就是绑定这些LiveData的。
暂停和继续的逻辑我放在Service里,而不是放在Activity里。理由很简单:用户按了Home键回桌面,Activity进入不可见状态,但跑步仍在继续,如果暂停逻辑依赖Activity里的按钮,用户就根本没有机会和它交互。所以Service维护了一个sportState枚举,RUNNING、PAUSED、FINISHED三个状态,Activity只是调用Service暴露的方法来触发状态切换。
3.2 多页面数据共享:ViewModel的作用域问题
跑步过程中,用户可能切到历史页看一眼,再切回来。每次切换其实Activity都会经历onPause()和onResume(),如果数据存在Activity里,返回时稍不注意就会刷新清零。我用的ViewModel就是为了解决这个问题的。
但是有个坑:如果ViewModel作用域绑定的是Activity,转屏或者Activity重建后,ViewModel虽然还在,但RunningService和ViewModel之间的通信会失效。我的方案是把运动和统计数据都放在Activity的ViewModel里,运动页和历史页共用同一个Activity容器,底部Tab只是切换Fragment,这样ViewModel跟着Activity一起存活,切换Tab数据不丢。这也是我为什么用单Activity加多Fragment的架构,而不是三个Activity切换的原因。
3.3 打卡成功:一份合格的打卡记录要写什么
跑完步,用户点击结束按钮,RunningService停止计时,把本次数据组装成一个RunRecord对象,通过ViewModel传给数据库写入。写入前我会做一层校验:如果总距离小于50米,认定为误触,弹出提示问用户是否确认保存。这个50米阈值的设定是参考过很多健身App的产品逻辑的,太短保存了会污染历史数据,太长会把用户认真跑的短距离锻炼也挡掉。
打卡成功之后会弹出一个结果页,展示本运动距离、时长、平均配速、热量消耗以及一张简单的轨迹缩略图(自定义View画的)。这个结果页截图分享的功能我也做了,用View.setDrawingCacheEnabled(true)就能拿到当前View的Bitmap,存到本地再调起系统分享面板。这里有一个体验优化的点:分享出去的图上带一个二维码,扫码可以跳转到App下载页,算是给运营留的一个小入口。
4. 源码目录结构与文档编写指南
4.1 一个鼓励你抄作业的目录结构
项目的源码组织结构是这样的:
app/src/main/java/com/example/running/ ├── data/ │ ├── db/ // Room 数据库、DAO、Entity │ ├── repository/ // 数据仓库,统一管理数据来源 │ └── prefs/ // SharedPreferences 封装 ├── service/ │ └── RunningService.kt ├── ui/ │ ├── main/ // 主界面 Activity、Fragment │ ├── sport/ // 运动记录页和结果页 │ ├── history/ // 历史记录列表和详情 │ └── stats/ // 统计分析页 ├── utils/ // 工具类:时间格式化、距离计算、配速计算 └── location/ // 定位 Provider 抽象和实现这个结构最明显的特征是"功能优先"的包划分。data放数据层,service放后台服务,ui放界面,每个ui子包里放该功能相关的Activity、Fragment、ViewModel和Adapter。这样找一个功能的代码,只需要展开对应的包就能看到全部相关类,比按"activity包+adapter包+fragment包"的老式分法要直观很多。我接手过不少按层分包的老项目,找一个功能要同时打开三四个包,来回跳来跳去,开发体验实在太差。
4.2 数据库和接口文档:写文档的实用套路
文档这块,我重点写了两类:一类是数据层设计文档,另一类是模块间接口说明。数据层文档就是把上面那个字段表格原样搬进README,再加上各DAO方法的说明和事务边界。
写DAOs文档有讲究。我见过很多人把DAO接口代码直接贴一遍就算完事,其实读者想看的不是方法签名,而是这个查询的语义和典型耗时。比如getRecordsByMonth(long start, long end)这个方法,文档里我会标注"按月查询会先按日期字段建立索引,建议传入月初零点的时间戳和月末零点的时间戳,闭区间查询"。再比如insertRecord方法,标注"事务操作,耗时约2到5毫秒,由Room自动处理线程切换,无需手动加事务"。
模块接口说明文档我用了接口签名加实例的方式。因为RunningService和UI之间是通过LiveData通信的,我把每个LiveData的初始值、发射频率、可能为空的情况都记录下来了。比如"distanceLiveData初始值0,每5秒发射一次,范围为0到100000(米)",这样的文档对后来的维护者来说,比什么架构图都管用。
4.3 给源码加注释的原则
源码注释方面我的原则是:写"为什么"而不写"是什么"。比如下面这段:
// 这里不能用 setInterval(1000),虽然轨迹更平滑, // 但1小时会多消耗约30%电量,用户会在应用市场上骂人 locationRequest.setInterval(5000) locationRequest.setSmallestDisplacement(5f)注释是为了交代写这段代码时的决策背景,而不是翻译代码。那些// 创建一个字符串之类的注释,写了等于没写,还会干扰真正有用的信息。同样,复杂的算法(比如步频检测的波峰过滤)一定要画清楚思路再贴代码,不然过三个月连自己都看不懂。
5. 常见问题与排查技巧实录
5.1 定位不更新,先别怪手机
定位不更新是我在这个项目里遇到最多的问题。排查思路按优先级排列:第一,检查AndroidManifest里的权限声明,确认ACCESS_FINE_LOCATION和ACCESS_COARSE_LOCATION都存在;第二,检查运行时权限有没有真正授予,有的手机还要去设置里单独开启"精确定位";第三,确认位置服务(GPS开关)是否打开;第四,检查调用的定位API是不是被降级到了网络定位,有些系统会在省电模式下自动切换。
我调这个功能时用了一个土办法:在每次定位回调里打印当前定位来源和精度范围。如果看到来源是"network"而精度半径超过500米,大概率是GPS还没锁定到卫星,这时候需要把手机拿到开阔地带,或者等待几十秒让系统完成首次定位。很多所谓"手机问题"其实是测试环境的问题,在室内测GPS,信号差是必然的,不要怪代码。
5.2 Room数据库升级导致崩溃
版本升级是数据库逃不开的坎。我第二次改版本时,给RunRecord加了一个note字段,然后数据库版本从1升到2,结果忘了写Migration,老用户一打开App直接崩溃:IllegalStateException: Room cannot verify the data integrity.
正确做法是:
val migration_1_2 = object : Migration(1, 2) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL("ALTER TABLE run_record ADD COLUMN note TEXT DEFAULT ''") } }然后Room.databaseBuilder(...).addMigrations(migration_1_2).build()。这块要说三点:第一,Migration里不要写创建新数据库的逻辑,只写从旧版本到新版本的增量改动;第二,每个版本都要加对应的Migration,不要偷懒只保留最后一个;第三,写完Migration要在测试机上升级验证一遍,别只跑干净的安装。没有迁移升级到2的办法,遇到多版本升级的情况就需要写链式Migration:1到2、2到3,依次串联,Room会帮你按顺序执行,不需要写1到3的直接迁移。
5.3 内存泄漏:跑步页面反复进出后变卡
运动页有一个自定义View实时绘制轨迹,我在Activity.onDestroy里调用了view.clear(),但是忘了在onDestroy里把传感器监听给解绑。Android的SensorManager在Activity销毁后会继续回调,如果回调方法里引用了Activity,就形成了持有链,垃圾回收根本回收不了这个Activity。
还有一个更隐蔽的泄漏:RunningService里持有Context引用没释放。我在Service里创建了一个定位回调,这个回调里用了binding.lifecycleOwner的上下文,结果服务都停了,回调还挂在定位客户端上,Activity的Context被它间接引用着。排查工具用的是AndroidStudio自带的Profiler,抓了Heap Dump后分析引用路径,几秒就定位到了。后来养成了一个习惯:凡是注册了Listener的地方,必须在对应的生命周期里注销,这一条真值得写进团队的Code Review清单里。
6. 从源码到上线:打包、签名与后续规划
6.1 打包遇到的那些坑
项目开发完要出Release包,第一次打包就被我撞上一个问题:运行报错Cannot fit requested classes in a single dex file。这是方法数超过64K限制导致的,解决方法是开启multidex:
defaultConfig { multiDexEnabled true }现在的目标平台最低版本定在API 21以上,本身就支持multidex,不用额外处理主Dex里的启动类问题。另外签名这块,我建议直接上V1+V2双签名,虽然V2签名校验更严格也更安全,但有些老的系统只认V1。尤其是如果要上部分第三方应用市场,V1签名兼容性更好,两套都签上总没有坏处。
6.2 后续还能往哪些方向扩展
这个项目目前完成度已经能支撑一个MVP版本的发布。我后续打算做的方向有三个:一是接入心率设备,跑步时展示实时心率区间,这个需要蓝牙BLE相关的知识,和计步传感器的数据流可以共用一套架构;二是把统计页做成多维度图表,用MPAndroidChart画折线图和环形图,从数据库聚合出每周、每月跑量趋势;三是增加社交元素,打卡记录可以生成一张海报分享到朋友圈,这个我已经做好了基础分享功能,后续可以接入自定义海报组件。
如果还有余力,可以做一个配套的网页版数据看板,把跑步记录同步到服务端,在PC上管理训练计划。这个会涉及到账号体系和云端存储,属于另一个大工程,但架构上只要现在的Repository层加上网络数据源就能平滑扩展,不会动到UI层。
最后再分享一点我的个人体会
跑步打卡这个项目,技术深度不在某一个单一技术上,而在如何把定位、计步、数据库、后台服务这些零散的点组合成一个稳定可用的产品。我自己在做这个项目的过程中,最大的收获不是学会了某个API怎么调,而是建立了一套完整的源码组织意识和文档记录习惯。现在回看那些只有自己一个人写的项目,凡是当时偷懒没写注释、没画字段表的地方,后面几乎都会返工。建议大家一边写项目一边就顺手把文档做起来,源码和文档同步维护,才是一个有价值的完整交付。
如果你们也正在做类似的运动健康类App,或者刚做完一个项目不知道下一步该学什么,可以顺着这套代码继续往深了挖一挖。后面有时间我会把Service保活策略和数据库索引优化这块单独写一篇,感兴趣的可以留意。