
1. 项目定位与功能边界一个Android健身助手究竟该做什么接到基于Android的智能健身助手APP这个题目时我第一反应不是去列功能清单而是先想清楚一件事这个APP到底是给谁用、解决什么痛点市面上健身类应用多如牛毛有社区型的、有内容型的、有电商型的但作为课程项目或者毕业设计级别的交付物最忌讳的是试图做得太大——社交动态、AI私教推荐、直播课程全塞进去到最后源码乱成一锅粥部署文档也没法写。我这些年看过太多同学在选题阶段就栽了跟头。需求文档写了两千字把Keep、Fittime的首页截图贴了一圈结果开发到一半发现核心训练记录功能还没做完。所以我落地这个项目时把功能边界压在了三条主线上。第一条是训练计划管理。用户能看到预先设定好的训练模板根据自己的时间选择训练天数系统自动生成每周计划。这里涉及的核心技术点是本地数据库的表设计——训练模板表、训练动作表、训练记录表三者之间怎么关联以及计划生成时的日期计算逻辑。第二条是训练过程引导。进入一次训练后APP能引导用户按顺序完成动作每个动作有预设次数、组数有休息计时器有完成打勾的交互。这条线看起来简单但真正做下来要处理计时器的生命周期、屏幕常亮、后台切回状态恢复等一系列细节。这是整个项目里用户最能直接感知的部分也是最容易出Bug的部分。第三条是数据统计可视化。训练完成后生成记录按周、按月的维度展示训练频次、训练时长、总组数等指标配合图表让用户看到自己坚持的结果。这一块对视觉呈现要求高通常也是答辩时最能“出效果”的模块。至于蓝牙心率带、运动传感器识别深蹲次数这类进阶功能我在项目里把它们设计成了可选扩展模块文档里单独写清楚接入方式但不作为核心流程的强依赖。这个决策逻辑很简单核心功能必须先跑通硬件类的依赖会让部署和演示的不确定性成倍增加。万一答辩现场连接不上蓝牙设备核心流程展示就直接翻车了。还有一个容易被忽视的点——离线可用性。这个APP的所有核心功能都基于本地数据库不依赖云端接口。很多同学喜欢在项目里硬塞一个Bmob或者野狗云服务注册登录都走云端结果网络一波动整个Demo就没法演示。我的方案是本地优先即使完全断网训练功能照样完整可用。这也让部署文档的复杂度直线下降不需要教评审老师配置服务器地址、初始化远端数据库拿到APK装上就能跑。2. 技术栈与架构选型为什么选MVVM加Jetpack这套组合技术选型这部分很多教程会直接扔给你一个结论用MVVM。但我觉得必须把背后的权衡讲透不然你拿这套源码去答辩老师一问为什么这么设计你只能憋出一句网上都这么说。这个项目我选的是Kotlin加Jetpack全家桶架构采用MVVM。选择Kotlin几乎没什么争议Google官方支持、协程让异步代码好看很多、空安全机制能挡掉一大类崩溃。重点说说为什么用MVVM而不是MVC或者MVP。对于健身助手这类应用界面状态和业务数据的双向绑定需求非常典型。举个具体例子用户在做组间休息时UI需要倒计时显示同时训练记录的持久化也在进行。如果按传统MVC写法Activity里会堆一堆业务逻辑代码维护起来很痛苦。MVVM的核心价值是让ViewModel成为数据和UI之间的桥梁UI观察到ViewModel暴露的状态ViewModel调用Repository获取数据数据回来后再驱动UI刷新谁都不直接拿着谁的引用乱改。Jetpack层面我重点用了三个组件ViewModel承载训练计时、当前动作索引、组数完成状态这些界面状态配置变更比如转屏时数据不丢。Room数据库访问层。我的训练模板、历史记录都通过它来读写编译期做SQL检查比原生SQLiteOpenHelper安全很多。LiveData或StateFlow数据变化通知UI。LiveData上手快适合作为课程项目StateFlow配合协程性能更好。我最终用的是LiveData理由是为了让源码更容易被初学者读懂——毕竟这是个带讲解的项目阅读体验也是交付物的一部分。再来看数据流设计。这个APP没有采用单Activity加多Fragment的现代风格而是用了一个主Activity承载主要页面部分独立页面比如计划模板详情、历史统计大图单独开了Activity。为什么这么做Fragment的栈管理对初学者是个不小的学习门槛处理不当会出现重叠、状态丢失各种怪问题。反而不如简单的多Activity结构直观。每个页面的职责单一启动另一个页面就直接Intent带参数简单粗暴不容易出错。图表展示我用了MPAndroidChart这个第三方库。选它的理由很朴素免费、社区活跃、曲线图和柱状图的效果完全够用。我自己用过的坑是版本兼容问题——它的较新版本要求最小SDK版本较高如果工程里minSdk设置太低会编译失败这个在部署文档里一定要写清楚。依赖管理方面这个项目没有引入Hilt这类依赖注入框架。原因很实际项目规模还没大到需要DI来解耦的程度手写单例和构造传递完全够用。引入Hilt会增加一层编译时注解处理的复杂度对初学者理解源码不友好。好的架构不是堆砌最潮的技术而是在适当的复杂度下做到职责清晰。3. 核心功能落地训练计划引擎、过程引导与统计展示的实现细节功能开发是整个项目的重头戏我按模块一个一个说每个模块都会尽量给出可以直接复用的设计思路和关键代码片段。3.1 训练计划引擎模板与实例的数据库设计训练计划这块最容易犯的错是把逻辑写死在界面里。比如在Activity里直接new一个ArrayList往RecyclerView塞。这样做Demo跑起来挺顺利但训练记录根本没法存——你的历史数据和计划模板没有关联关系。我的做法是设计三张表Entity(tableName plan_template) data class PlanTemplate( PrimaryKey val id: Long, val name: String, val daysPerWeek: Int, val createdAt: Long ) Entity(tableName workout_record) data class WorkoutRecord( PrimaryKey val id: Long, val planId: Long, val date: Long, val totalDurationSec: Int, val totalSets: Int, val finishCount: Int )训练动作存放在单独的表中通过外键关联到模板ID。三个实体之间用Relation注解建立起一对多关系。这样当你查出一条训练记录时可以一次性把该次训练包含的动作明细也查出来展示当时做了哪些动作、每组多重量这类历史信息。这里有个设计细节值得说明为什么不直接在记录表里冗余一份动作名称因为动作库是可维护的——以后如果增加动作讲解视频你只需要在动作表加一个字段历史记录通过动作ID关联自然就能拿到新数据。但如果当初冗余了名称字符串数据就得迁移很麻烦。这是数据库设计的经典权衡冗余换查询速度还是规范化保一致性在这个场景下我选择规范化。3.2 训练过程引导计时器、生命周期恢复与前台服务训练中的流程引导实现起来有几个容易踩的暗坑。最典型的是倒计时器。很多初学者会用一个自定义View配合Handler每秒钟发一次消息刷新UI。这个方案在屏幕亮着的时候没问题但你一旦锁屏Handler依然在跑却无法更新UI浪费电量更严重的是当用户旋转屏幕或从后台切回来Activity重建后Handler里携带的上一个页面的引用会导致内存泄漏。我的方案是把计时逻辑放在ViewModel里结合协程的delay实现每秒回调。ViewModel通过LiveData把剩余时间推给UI层。屏幕旋转时ViewModel不销毁计时不中断UI重建后自动重新订阅。fun startRestTimer(totalSeconds: Int) { viewModelScope.launch { var remain totalSeconds while (remain 0) { _restTick.value remain delay(1000) remain-- } _restFinished.value true } }这个写法很直观也符合ViewModel的职责定位。唯一要注意的是协程的delay不会像系统闹钟那样精确网络抖动、系统调度都可能让它略有偏移但对于健身计时的精度要求完全足够。另一个必须处理的问题是训练过程中接电话、切后台。如果用户练到第三组来了个电话切出去十分钟再回来页面如果已经销毁重建了当前做到第几个动作的状态必须能恢复。这不能靠onSaveInstanceState硬扛——那玩意适合存轻量级的临时状态不适合作为业务状态的唯一保证。正确做法是把当前训练进度持久化到数据库或SharedPreferences。我专门建了一张in_progress_workout表训练启动时写入每完成一个动作就更新训练结束时清空。这样即使整个APP被杀掉用户重新打开都能继续未完成的训练。这个小小的容错设计在答辩演示时可以主动提出来会让老师觉得你真的考虑过真实使用场景。还有屏幕常亮的问题。训练中用户经常把手机放在架子上看计时屏幕如果灭了体验很糟。我在训练页的onResume里加上window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)onPause时再清除。注意不要用FLAG_KEEP_SCREEN_ON去全局设置那会让APP在任何页面都禁止休眠耗电惊人。3.3 数据统计展示图表与周维度的聚合查询统计模块的核心是SQL的聚合查询能力。我需要回答两类问题一是这周练了几天、多少分钟二是某个动作的历史负重趋势。对于每周训练频次用一条带GROUP BY的查询就行Query(SELECT date, COUNT(*) as cnt FROM workout_record WHERE date BETWEEN :start AND :end GROUP BY date) suspend fun getStatsByRange(start: Long, end: Long): ListDailyStat查出来的结果转成图表库需要的Entry列表一个漂亮的周统计柱状图就出来了。这种先想清楚要什么数据再倒推去设计SQL的思路对于初学者很重要。很多时候大家是先写DAO方法再在界面上看缺什么补什么最后代码里一堆重复查询。曲线图展示某个动作的历史重量变化时需要注意时间轴的规范。我统一把日期换算成当天的零点毫秒数这样无论用户是晚上练还是早上练同一次训练记录都归到同一天。图表横轴用这些零点时间戳显示的时候格式化成M/d很清晰。我在这个模块里还加了一个体重记录的小功能用户可以在个人中心输入当前体重APP会在训练历史页面展示一条体重的平滑曲线。虽然功能本身不复杂但它让整个APP的智能感提升不少——用户会觉得这个应用真的在关心他的身体数据变化。4. 部署文档里的关键细节环境一致性、签名、混淆与交付清单很多人以为部署文档就是把APK发给别人安装大错特错。对于一个包含源码的项目部署文档的核心目标是让另一个人可能是评审老师、可能是接手的同学能在他自己的电脑上把这个项目跑起来。这里最大的难点在于环境差异。4.1 环境要求Gradle、JDK和Android Studio版本怎么对齐Android开发的版本兼容问题真的能让人崩溃。我见过最典型的一个报错是Could not determine the dependencies of task :app:compileDebugJavaWithJavac. Could not resolve all task dependencies for configuration :app:debugCompileClasspath.这个报错看起来是代码依赖问题但十次里有八次是Gradle版本和JDK版本不匹配导致的。比如JDK 17配合老版本Gradle就会出现这种解析任务依赖失败的怪现象。所以我在部署文档的第一章就放了一张巨大的版本对应表组件推荐版本说明Android Studio最新稳定版我用的是Koala Feature Drop版本JDK17老项目用11也能跑但17最稳Gradle8.x对应AGP 8.xAGPAndroid Gradle Plugin8.1.0与Gradle版本强绑定Kotlin1.9.x与AGP 8.x配套minSdk / targetSdkminSdk 24 / targetSdk 34覆盖绝大多数真机这些信息看起来枯燥但缺了任何一项新手拿到源码都可能花一整天在环境问题上。文档里我还额外写了Windows和macOS下的环境变量配置差异以及在Android Studio里通过SDK Manager安装对应构建工具的步骤。4.2 release签名与混淆配置调试阶段Android Studio会用debug签名自动帮你打包但交付的APK必须是release版本而且要配置正式的签名文件。我在文档里写清楚了生成签名密钥的完整命令keytool -genkeypair -v -keystore my-release.keystore -alias fitness -keyalg RSA -keysize 2048 -validity 10000然后是在app/build.gradle里配置signingConfigs和buildTypes。这里要特别提醒签名文件一定要提交到源码包或者文档里并给出清晰的密码说明。不然接手的人想自己打一个release包没有签名文件就只能再生成一个新密钥之前安装过的APK就无法覆盖升级。这在课程项目的演示场景里很常见——老师手机上装着旧版本你给他新版本装不上提示签名冲突。光这一个细节就能毁掉一次展示。混淆规则方面这个项目不涉及太多反射和动态加载所以proguard规则比较简单。唯一要注意的是保持Model类的可序列化名称以及避免混淆后Gson等库解析JSON出现字段名错乱。如果用了第三方SDK每个SDK都有对应的keep规则我都在文档里以追加的方式写清楚了避免直接覆盖默认规则。4.3 真机调试与权限适配部署文档里一定要包含真机运行指导因为很多功能模拟器上没法完整演示。我重点写了以下三点第一权限申请。现代Android版本对权限的管理越来越严格。目标SDK是34的话Android 13及以上系统要求精细的媒体权限读图片、读视频分开Android 12及以上需要精确的位置开关说明。好在这个项目不依赖定位核心权限就一个——如果你要用传感器计步可能需要身体活动权限在Android 11之后被认为是需审批的敏感权限建议文档里说明如何规避或者降级处理。第二分区存储适配。如果APP需要导出训练报告到本地文件就不能再像老版本那样直接往根目录写文件了。Android 10之后强制分区存储应用只能无授权写自己的专属目录或者通过MediaStore接口写入公共目录。我的做法是导出功能直接写入getExternalFilesDir()下避免复杂的权限申请流程。第三刘海屏适配与全面屏显示。很多真机的cutout区域会影响沉浸式布局。我用的是默认的布局安全区域约束没有强行做沉浸式这样每个机型的显示都不会出大问题。不要为了好看去开edge-to-edge全屏显示测量不当就会把按钮顶进屏幕挖孔区域。最后交付清单里我还附上了一个README索引文档把源码包里的每个目录和关键文件都解说了一遍。这个模块在讲解场景下特别有用——讲项目的时候直接按目录结构走一遍逻辑线会非常清晰。5. 自测过程中踩过的坑与完整的排查链路项目开发完不等于能交付。这节我把自己自测过程中真实踩过的几个坑完整记录下来包括排查的思路和修复的路径。这些内容在官方文档里基本找不到但对接手源码的人来说价值极高。5.1 传感器计步在某些机型上完全没反应我做进阶模块时接入了Android的计步传感器TYPE_STEP_COUNTER逻辑很简单注册传感器监听拿到步数增量。测试时我自己用的手机一切正常但交叉测试发现有两台机器完全没有数据回调。排查链路是这样的先确认传感器是否存在调用SensorManager.getDefaultSensor()返回null。确认权限计步传感器在Android 10以上需要ACTIVITY_RECOGNITION权限而且必须动态申请。用系统日志验证SensorEvent是否到达。最后定位到问题是其中一台测试机是国产ROM定制系统在省电策略里默认拦截了后台传感器访问。直接在开发者选项里关闭后台限制就好了。这个坑很隐蔽因为它在标准Android生态里根本不存在但在国内真机上非常常见。结论任何涉及传感器的功能一定要预留一个手动输入代替传感器的降级入口。我把计步数值改成了可以手动点击完成一组自动增加步数的逻辑即使传感器失效训练流程也不会中断。5.2 计时器在转屏后出现重复回调我在测试休息计时器时发现一个诡异问题转屏之后界面上有两套倒计时在同时走。原因很经典——Activity重建时ViewModel没有销毁但我在onCreate里不加判断地重新启动了一次计时协程导致同一ViewModel内部新老两个协程同时跑。修复方案是在启动前先检查状态if (viewModel.restRemainSeconds.value null) { viewModel.startRestTimer(60) }这个解决方案不复杂但排查过程花了我不少时间。后来我养成了一个习惯凡是ViewModel里需要启动异步任务的入口都要考虑Activity重建后的幂等性。这算是MVVM架构下的一个典型经验。5.3 数据库升级崩溃项目迭代过程中我改过几次表结构结果老用户升级安装时出现Room cannot verify the data integrity的崩溃。原因很简单我改了Entity结构但没提供Migration策略。Room的数据库升级不是自动的它需要你提供一个Migration对象写明从版本N到N1怎么迁移。如果你做的是课程项目可能在开发阶段反复卸载安装永远触发不到升级路径。但部署给老师后如果他自己用了一段时间再升级就会撞上这个崩溃。我的解决思路很实用把数据库版本每次改动时都加一个Migration没有复杂数据转换的情况直接ALTER TABLE加列val MIGRATION_1_2 object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL(ALTER TABLE workout_record ADD COLUMN totalDistance REAL NOT NULL DEFAULT 0) } }同时文档里标注了如果开发过程中还没有对外发布最简单的手段是卸载重装不要写Migration。但如果你的项目会被别人长期使用Migration必须从第一个版本就养成习惯这点越早意識到越省事。5.4 协调布局加Banner导致的视觉跳动界面设计上我起初在个人中心页用了很多网课里教的玩法——CollapsingToolbarLayout加Banner轮播图。效果是挺炫的但自测发现一个问题Banner在滑动切换时整个顶部工具栏会被迫跟着动画帧率掉得很明显在小内存手机上尤其卡顿。我最终的方案是把轮播图从协调布局里拆出来放到普通NestedScrollView的子区域中。CardView加普通图片轮播在视觉上没差多少但滑动手感顺滑了很多。花里胡哨的嵌套滚动链会显著增加渲染开销这个决策对于中低端安卓机尤为重要。5.5 应用构建速度优化一次编译从四分钟到四十秒写项目后期我对项目做了一次构建性能体检。默认情况下Kotlin编译和打包耗时随代码量增长显著。我做了三件小事在gradle.properties里开启org.gradle.jvmargs-Xmx4096m增大构建堆内存。开启org.gradle.paralleltrue和org.gradle.cachingtrue。关闭了不必要的Lint检查只保留release包才跑Lint。这三条做完增量构建的时间从四十多秒进一步压到了十秒内。这对日常开发体验的提升是决定性的——构建速度直接影响你的调试节奏一个总是停在编译等待状态的工程很难让人有耐心持续打磨细节。6. 源码组织与讲解思路如何让接手的人一天内跑起来源码包交付的时候目录结构一定要讲究。我见过很多项目所有代码塞进三四个包Activity、Adapter、Bean全混在一起外人根本没法读。我的源码包是这么组织的app/src/main/java/com/fitness/assistant/ ├── data/ │ ├── db/ // Room数据库、Entity、DAO │ ├── repository/ // 仓库层 │ └── model/ // 业务模型 ├── ui/ │ ├── main/ // 主界面 │ ├── plan/ // 训练计划 │ ├── workout/ // 训练过程 │ └── stats/ // 数据统计 ├── utils/ // 日期格式化、常量、扩展函数 └── viewmodel/ // ViewModel层这里有一个特别重要的点包名和目录结构必须对应分层架构的思想。data层和ui层严格分离Repository是唯一的数据访问门面ViewModel不直接碰DAO。这样一个新手拿到代码顺着目录结构就能读懂依赖关系不需要我逐行解释。讲解的思路我建议按下面的顺序来先讲数据层实体类设计、DAO接口方法、数据库版本管理。这是整个项目的地基讲明白数据存哪里、怎么存取后面的功能就顺理成章了。再讲业务逻辑层ViewModel里如何调度Repository、如何暴露状态给UI、计时协程怎么写。这里可以重点强调为什么计时放在ViewModel而不用Handler这个设计决策。最后讲UI层RecyclerView的适配器模式、布局文件和状态绑定的关系。这部分最容易展示但也最容易暴露界面状态和业务数据耦合的问题。交付物里我还准备了一个常见问题速查表比如点编译报resources not found怎么办运行后白屏去哪里看日志这类零基础问题。文档的价值不在于覆盖所有问题而在于让读者遇到问题时能立刻找到排查的起点。最后分享一点个人体会。这类项目做完后源码本身只是交付物的一半另一半是你脑子里的决策过程——为什么这么设计表结构、为什么这里要加权限、为什么保活方案选前台服务而不是双进程守护。把这些想明白并写出来不仅接手的同学能少走弯路你自己在整理的过程中也会把很多半懂不懂的细节彻底盘清楚。我每次交付项目最享受的就是这个复盘阶段它让代码真正变成了可传承的工程经验而不仅仅是一堆能编译的文件。