开头写得有吸引力一点然后开始各个章节。每个H2/H3编号正确。代码块标注语言类型。不用mermaid不用emoji。我先草拟全文确保字数足够。开头约300字健身的人真正缺的不是“下一个动作怎么练”的教程而是一套能把“今天练什么、上次练多重、这周练了几次”自动串起来的记录系统。基于Android的健身房助手系统App想解决的就是这个需求。它把训练计划、动作库、训练记录、数据统计整合到一个移动端用户打开手机就能看到今日计划训练时手动记录重量和次数结束后自动汇总周数据。整个项目从零到一大约花了一个半月时间技术栈选用了Kotlin Jetpack Compose Room ViewModel WorkManager没有接后端所有数据本地存储。这个方案特别适合两种人群参考一是想独立完成一个完整Android应用的开发者二是想给健身房场景做数字化工具的产品设计者。下文按项目拆解、技术选型、数据库设计、核心功能实现、项目结构、踩坑记录的顺序展开每个部分都会给出可以直接抄作业的代码、配置和排错思路。然后主体。1. 项目整体思路拆解健身房助手到底该做什么1.1 先想清楚“助手”两个字“健身房助手”这个定位很容易被做成“健身房记事本”。记事本只解决“记录”但助手要解决“引导”。一个运动员走进健身房最烦人的不是不知道怎么练而是想不起上次练胸用了多少重量不确定今天该做哪几个动作训练完又懒得翻历史数据对比进步。助手这个词意味着App要在用户练之前给出计划练的过程中接收输入练完之后自动反馈结果。所以我给这个项目定的第一版范围是训练计划、训练记录、数据统计、每日提醒四个模块缺一不可。1.2 MVP版本的取舍逻辑第一版没有做什么我没有做社区动态、没有做视频教程、没有做健身餐计算、没有做器械预约。原因很简单这些功能单独拆出来每个都是一个独立项目而MVP阶段最重要的是把“计划→记录→统计”这条主链路走通。哪怕界面简陋一点只要用户在健身房掏出手机能完成一次完整的训练记录这个App的核心价值就已经成立了。MVP版本的完整功能清单如下用户信息昵称、性别、身高、体重、目标体重训练计划内置五分化训练模板按用户训练水平推荐不同计划训练记录每个动作记录组数、重量、次数训练结束后生成总时长数据统计本周训练次数、本周总容量、训练时长、较上周变化每日提醒通过系统通知在设定时间提醒“该去训练了”休息日不提醒1.3 用户场景与核心操作路径我画过一张最基础的用户路径图用户打开App → 首页展示今日训练计划 → 点击“开始训练”进入训练页 → 按动作逐组录入重量和次数 → 点击“完成训练” → 自动跳转统计页查看本次汇总。这个路径就是整个App的骨架。所有界面设计、数据库表结构、ViewModel的状态管理都是围绕这条路径展开的。首页的逻辑不是简单展示一个计划列表而是按星期几分发训练内容。比如周一是“推胸日”首页就展示胸部和三头相关的动作周二是“背部日”首页展示背部和二头相关的动作。训练计划的分配逻辑放在domain层这样可以保证未来如果切换UI框架业务逻辑不需要重写。2. 技术选型与架构设计为什么是Kotlin Room而不是其他组合2.1 技术栈选型这个项目最终确定的技术栈是Kotlin Jetpack Compose Room ViewModel WorkManager Hilt。这里每一个选型都是有明确理由的不是网上随便拼的。为什么用Kotlin而不是Java因为协程。训练记录的录入过程有大量短暂IO操作比如每记完一组数据就要写入Room数据库如果用Java写回调会非常痛苦。Kotlin协程配合Room的suspend DAO方法可以在IO线程上自动处理数据写入界面层完全不需要关心线程切换。这个体验上的差距在开发阶段就能感受到需要频繁保存数据的应用尤其明显。为什么用Jetpack Compose而不是XML布局这个项目有大量的动态列表今日计划列表、历史记录列表、动作选择列表。Compose的声明式UI在写LazyColumn的时候非常顺手重组机制也可以让训练记录页的重量、次数在变化时自动更新不需要手动调用notifyDataSetChanged。缺点是从XML迁移到Compose需要适应时间但如果是从零开始的新项目直接上Compose成本最低。为什么用Room而不是SQLiteOpenHelper或GreenDAORoom提供了编译期的SQL查询校验表结构升级的Migration机制也很规范。健身记录这种数据是强关系型的训练计划关联动作表、动作表关联记录表用ORM框架可以少写大量样板代码。而且Room的Flow返回值可以自动将数据变化推送到UI层这正好对应了训练记录完成后首页统计数据的自动刷新。2.2 是否要引入后端服务第一版没有接后端原因很实际这个App的核心价值在本地用户数据放在本地数据库足够满足日常使用。引入后端意味着要处理注册登录、鉴权、数据同步、服务器部署工作量和复杂度直接翻倍。而且对于健身房场景来说断网环境下依然能记录训练是刚需。本地存储的方案天然满足这个需求。后续如果要做云端同步Room数据库可以整体导出并通过接口同步到服务端这个扩展路径是预留好的。我建议所有做同类型项目的开发者第一版一律采用本地优先的设计先把本地体验做扎实再考虑云同步。2.3 三层架构的划分项目采用data / domain / ui三层架构。data层负责数据来源具体包含Room数据库的Entity、Dao、Database以及Repository的实现类。domain层负责业务规则比如训练计划的分配逻辑、训练记录的统计计算、RM值估算等。这些逻辑不依赖任何Android特性可以单元测试。ui层负责界面展示和用户交互包含Compose页面、ViewModel、状态容器。这个分层最关键的作用是让业务逻辑可以脱离Android框架进行单元测试。比如我在开发过程中给“七天训练统计计算”写过纯JVM单元测试无需启动模拟器跑一次只需要几十毫秒。如果业务逻辑散落在Activity或Fragment里这种测试根本不可能实现。3. 数据库与核心数据模型设计训练数据的骨架3.1 核心表结构数据模型是整个项目最重要的部分没有之一。我在设计表结构上花了整整两天时间改过四个版本最终确定的表结构如下用户表userid主键自增nickname昵称gender性别height_cm身高厘米Doubleweight_kg体重千克Doubletarget_weight_kg目标体重level训练水平1新手2进阶3高级created_at创建时间训练计划表planid主键plan_name计划名称比如“五分化增肌计划”level_required适用训练水平days_per_week每周训练天数动作表exerciseid主键name动作名称muscle_group目标肌群胸、背、腿、肩、手臂、核心equipment所需器材杠铃、哑铃、器械、自重is_compound是否复合动作1复合0孤立计划动作关联表plan_exerciseid 主键plan_id关联计划exercise_id关联动作day_of_week星期几1-7训练日有效sets推荐组数reps_min推荐次数下限reps_max推荐次数上限训练记录表workout_recordid主键user_id关联用户exercise_id关联动作set_number第几组weight_kg重量reps实际次数completed_at完成时间yyyy-MM-dd HH:mm:ss3.2 表关系与外键约束这个设计里有明显的多对多关系训练计划和动作之间是多对多需要通过plan_exercise关联表来解耦。训练记录和动作之间是一对多关系一个动作可以在多条记录中出现。外键约束在这套设计里非常重要。workout_record表的exercise_id指向exercise表的id这个外键可以保证不会录入了不存在的动作。同时我在外键上设置了ON DELETE CASCADE也就是说如果删除了一个动作它对应的所有训练记录也会自动删除避免产生脏数据。这个设计在删除动作的时候可能让人犹豫但实际使用中正是需要的动作一旦废弃历史记录保留也没有意义。需要特别注意的是索引设计不能马虎。workout_record表上必须建一个(completed_at, exercise_id)联合索引因为统计页面最常见的查询是“某个时间段内的所有训练记录”有了联合索引查询时间从全表扫描的几百毫秒降低到了几毫秒。开发阶段没有加索引的时候训练记录超过500条以后统计页面明显变卡加完索引立刻流畅了。3.3 统计数据如何计算训练统计里最核心的指标是“训练总容量”计算公式是总容量 Σ(重量 × 次数)。比如卧推三组分别是60kg×10次、65kg×8次、55kg×12次那么这三次的总容量就是6005206601780kg。这个指标比单纯记录“练了多久”更能反映训练量。周对比的逻辑是把本周训练容量和上周同时段的训练容量相减算出变化幅度。这里有个边界情况需要处理——如果上周没有训练记录直接用“无法对比”而不是“增长100%”避免误导用户。另一个容易忽视的是训练频率统计。一周训练次数需要去重不是统计训练记录的条数而是统计完成训练的次数。我在训练记录表之外单独设计了一张training_session表每次点“完成训练”生成一条session记录包含开始时间和结束时间。这样统计一周训练次数就直接count session表不用从每条动作记录去group by日期。4. 核心功能实现训练计划分配与记录录入4.1 训练计划的“自动分配”逻辑计划分配并没有用到什么人工智能而是一套简单的规则引擎。项目预置了三个模板新手三分化、进阶四分化、高级五分化。新手三分化练三天休一天第一天推胸、第二天拉背、第三天练腿。每个动作固定3组每组8-12次。进阶四分化胸三头、背二头、腿、肩核心每天4个动作4组每组8-12次。高级五分化胸、背、腿、肩、手臂每天5个动作部分复合动作做5组每组5次辅以2组的高次数训练。判断用户水平的方式很简单注册时让用户选择“完全没有训练经验”“断断续续练习一年以内”“系统训练一年以上”分别对应level 1/2/3。分配计划时根据user表的level字段去plan表匹配level_required字段。这套逻辑我写成了纯函数输入用户等级和当前日期输出当天应该做的训练内容单元测试覆盖了周一、切换周、休息日三种场景。4.2 训练记录的交互设计训练页是使用频率最高的页面交互设计决定了用户体验。实际操作时用户一只手拿着手机另一只手可能在组间休息时擦汗所以界面必须做到大按钮、少输入、单手可操作。每组记录的交互流程是当前动作卡片展示“第2组 / 共4组”下方两个大数字输入区分别是重量和次数默认显示上一次训练时同动作同组数的历史数据用户只需修改有变化的数据。这个“默认带出上次数据”的功能实现方式是在ViewModel里用上次完成时间最近的数据做本地查询然后填充默认值。组间休息计时器是训练页的加分项。项目里用一个Handler实现了一个简单的60秒倒计时在休息期间显示剩余秒数结束后震动提醒。需要注意的是倒计时不能在页面销毁后继续在后台乱跑要在onDispose中取消Handler回调不然会出现“页面关了几分钟手机还在震动”的尴尬情况。4.3 每日提醒的避坑实现每日提醒用WorkManager实现。创建一个PeriodicWorkRequest间隔设为24小时再加上setInitialDelay把首次执行时间指定到用户选定的提醒时间。不要在应用内用AlarmManager做每天定时任务因为Android 12以上的后台限制非常严格AlarmManager的exact alarm大概率会被延迟触发。WorkManager虽然也不保证精确到秒但每天提醒这种场景完全够用。这里有个非常关键的时间计算逻辑用户希望每天20:00提醒App计算从“当前时间”到“下一个20:00”的毫秒数作为setInitialDelay的数值。这个计算不能用简单的当前时间减目标时间要处理跨天边界。我封装了一个方法先构建目标时间的LocalDateTime如果已经过了今天的20:00就加一天然后用ChronoUnit.MILLIS.between计算差值。这个小方法的测试就写了四个case当前早上、当前晚上、正好是20:00、跨到第二天。休息日不提醒的判断逻辑放在接收通知的处理流程里先查出用户当天是否有安排训练如果plan_exercise表中当天无记录直接返回不构建通知。5. 实操项目结构与关键代码解析5.1 项目目录结构与依赖配置整个项目的包结构是这样的com.example.gymhelper/ ├── data/ │ ├── db/ │ │ ├── GymDatabase.kt │ │ ├── UserDao.kt │ │ ├── ExerciseDao.kt │ │ └── WorkoutDao.kt │ ├── entity/ │ │ ├── User.kt │ │ ├── Plan.kt │ │ ├── Exercise.kt │ │ ├── PlanExercise.kt │ │ └── WorkoutRecord.kt │ └── repository/ │ ├── UserRepository.kt │ └── WorkoutRepository.kt ├── domain/ │ ├── model/ │ ├── usercase/ │ └── util/ ├── ui/ │ ├── home/ │ ├── workout/ │ ├── stats/ │ └── profile/ └── MainActivity.ktGradle依赖的核心部分如下dependencies { implementation(androidx.core:core-ktx:1.12.0) implementation(androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0) implementation(androidx.room:room-runtime:2.6.1) implementation(androidx.room:room-ktx:2.6.1) ksp(androidx.room:room-compiler:2.6.1) implementation(androidx.work:work-runtime-ktx:2.9.0) implementation(com.google.dagger:hilt-android:2.50) ksp(com.google.dagger:hilt-android-compiler:2.50) }Room的编译器我用的是KSP而不是kapt。KSP处理注解的速度比kapt快很多在Compose项目里还能避免一些只在kapt中出现的问题。5.2 DAO与Repository的写法训练记录是整个App的核心热路径这里直接贴出核心DAO接口代码Dao interface WorkoutDao { Insert suspend fun insertRecord(record: WorkoutRecord): Long Query( SELECT * FROM workout_record WHERE exercise_id :exerciseId ORDER BY completed_at DESC LIMIT 1 ) suspend fun getLastRecordForExercise(exerciseId: Long): WorkoutRecord? Query( SELECT * FROM workout_record WHERE completed_at BETWEEN :start AND :end ORDER BY completed_at DESC ) fun getRecordsInRange(start: Long, end: Long): FlowListWorkoutRecord Query( SELECT SUM(weight_kg * reps) FROM workout_record WHERE completed_at BETWEEN :start AND :end ) fun getTotalVolumeInRange(start: Long, end: Long): FlowLong? Query( SELECT COUNT(DISTINCT session_id) FROM workout_record WHERE completed_at BETWEEN :start AND :end ) fun getTrainingCountInRange(start: Long, end: Long): FlowInt }Repository层做的事情很单纯从ViewModel接收调用将DAO返回的数据转换成UI层需要的状态。这里建议不要在Repository里写太多业务逻辑比如“根据用户等级匹配计划”这种就不该放Repository里应该放domain层的UseCase。5.3 Compose页面骨架首页计划列表的核心代码可以简化成这样Composable fun TodayPlanScreen( vm: TodayPlanViewModel hiltViewModel() ) { val uiState by vm.uiState.collectAsState() LazyColumn( contentPadding PaddingValues(16.dp), verticalArrangement Arrangement.spacedBy(12.dp) ) { item { Text( text 今日训练${uiState.planName}, style MaterialTheme.typography.headlineSmall ) } items(uiState.exercises) { exercise - ExerciseCard( exercise exercise, lastRecord uiState.lastRecordMap[exercise.id], onClick { vm.onExerciseClick(exercise.id) } ) } } }Compose这里最大的优势就是不需要写Adapter和ViewHolder页面状态完全由ViewModel的State驱动。uiState使用StateFlow承载所有数据更新通过协程在ViewModel里完成界面上只需要collectAsState等待变更。5.4 性能与适配经验训练记录列表如果数据量超过1000条用LazyColumn没有任何问题前提是列表项不要做重复的数据库查询。我的经验是在进入列表页之前一次性查询出当天的全部记录构建成一个Mapkey是exercise_idvalue是该动作当天已记录的组列表。列表项渲染时直接从Map取值避免在item组合函数里触发Room查询。关于深色模式健身类App在夜晚使用频率很高我在主题里针对暗色背景专门调过训练页的卡片背景色。默认的深灰色卡片在暗色模式下观感太“糊”我把训练页卡片背景调成了比页面背景高一档的浅灰色这样重量数字的对比度更高用户在灯光昏暗的健身房也能看清楚。6. 常见问题与排查技巧实录6.1 Room数据库升级崩溃在开发过程中我经历过一次升级崩溃给exercise表增加了一个is_compound字段Room直接启动时抛出了IllegalStateException提示需要提供Migration。解决办法是写一个Migration 1到2的迁移脚本val MIGRATION_1_2 object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL(ALTER TABLE exercise ADD COLUMN is_compound INTEGER NOT NULL DEFAULT 0) } }这里有个很多人容易遗忘的细节新增列的SQL语句中非空字段必须带DEFAULT值否则迁移到老用户设备上会直接失败因为已有行没有该字段的值。这个坑我踩了两次才记住。6.2 列表滚动卡顿排查流程训练历史页面出现过加载后滑动掉帧的情况我的排查路径是先用Systrace抓帧发现丢帧集中在item组合阶段说明每个列表项内部做了太多计算。进一步定位后发现每个列表项都用SimpleDateFormat格式化时间这个方法在对象创建时开销很大。优化方案也不难建立一个ThreadLocal的SimpleDateFormat实例或者直接改用Instant和Duration做格式化耗时下降了一个量级。最终改用DateTimeFormatter列表滚动恢复流畅。6.3 周统计的跨周问题统计页面“本周训练次数”在一段时间内显示错误排查后发现是计算周的起点搞错了。当时用的Calendar默认把周一定为一周起点但星期日当天统计时会把下周一的训练算成下周次数。正确的做法是指定每周从周一开始计算fun getWeekStart(date: LocalDate): LocalDate { val monday date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) return monday }周结束时间用monday.plusDays(7)而不是“周日23:59:59”因为用户可能在周日深夜训练直接计算边界时间的毫秒值更容易踩坑。建议时间范围查询统一用“周一00:00:00到下周周一00:00:00”这种左闭右开区间。6.4 通知不触发的排查思路每日提醒在测试机上不触发第一反应先看是否申请了通知权限。Android 13以上默认不允许应用推送通知需要在运行时请求POST_NOTIFICATIONS权限而且用户拒绝后必须在设置里手动打开代码里再调用一次也没用。第二个坑是WorkManager的周期任务不是上午设置下午就能生效它有自己的最小间隔限制。周期任务的最小间隔是15分钟时间计算精确到毫秒没有意义我建议容忍几分钟的误差在用户设定提醒时间的基础上提前5分钟执行如果训练计划为空就静默退出。第三个坑是Doze模式。当设备进入深度休眠周期任务不会按预定时间唤醒会在下一个维护窗口集中执行。如果训练提醒晚了半小时大概率是触发了Doze限制不要继续排查应用代码了这是系统行为唯一能调整的是提醒策略把“固定时间提醒”扩展为“时间段提醒”比如提醒级别分为“不提醒、饭点提醒、睡前提醒”牺牲精确度换取更高的到达率。结尾这个项目做完我个人最大的体会是数据模型设计决定了这个App能走多远其次是业务规则的单元测试这两个部分占的时间最多但回头看也是最值回票价的投入。代码可以推倒重写界面可以随时换风格但如果训练记录这张表一开始没设计好主键和索引后面每一种统计功能都要为当初的设计买单。最后再分享一个小技巧可以用SQLite的触发器自动维护每个用户的总训练次数不用每次统计都全表扫描。在workout_record表建一个AFTER INSERT触发器更新user表里的一个总次数列。冷门但好用的办法数据量上去之后性能差异很直观感兴趣可以自己试着写一遍。
阅读完成 · 觉得有帮助?