Android健身系统开发:从零构建MVVM架构与Room数据库实战
简介这是一份面向安卓开发初学者与课程设计学生的完整移动应用实战项目聚焦健身主题覆盖从用户认证到运动管理的全流程功能实现。资源包共626个文件包含20个Java源码、32个XML布局、37个PNG图标、122个JSON配置及1个MP4锻炼视频等核心资产总大小85.28MB结构清晰便于模块化学习。已有1724人下载学习适用于高校Android大作业、移动应用开发实训或毕业设计参考。项目完整实现了MD5密码加密、SQLite本地数据持久化、VideoView视频播放、RecyclerView运动列表展示、HandlerThread计时控制、OkHttp网络请求及Glide图片加载等关键技术点配套源码可直接编译运行含登录注册、个人信息维护、名言轮播、运动视频播放与交互式计时等全部功能模块是掌握安卓主流开发技能的高实用性入门范例。1. 项目缘起与核心价值为什么选择“健身系统”作为安卓课程设计如果你是一名计算机或软件工程专业的学生正在为安卓开发课程设计选题而发愁或者你是一个刚入门Android开发、想找一个综合性项目练手的新手那么“健身系统”这个选题你算是找对了。我当年做课程设计时也纠结过很久最后选了一个“新闻客户端”结果发现大部分时间都在和网络请求、JSON解析、列表刷新这些基础且重复的轮子打交道做完之后感觉技术栈很窄对Android应用的整体架构理解依然模糊。“健身系统”则完全不同。它听起来简单但麻雀虽小五脏俱全。一个好的课程设计核心价值不在于功能多么炫酷而在于它能系统性地串联起Android开发的核心知识点并且贴近实际应用场景让你在解决真实问题的过程中把书本上的理论“盘活”。这个健身系统项目恰好完美地满足了这两点。首先从技术栈覆盖来看一个完整的健身系统至少会涉及UI设计与布局你需要设计用户登录/注册界面、主功能导航界面、训练计划列表、动作详情页、个人数据统计图表页等。这会逼着你熟练使用ConstraintLayout、RecyclerView、ViewPager2、Fragment等现代Android UI组件而不是停留在简单的LinearLayout堆叠。数据存储与管理用户信息、训练计划、每次的训练记录、身体数据体重、体脂都需要持久化保存。这里就引出了Android数据存储的经典选择本地数据库Room和轻量级键值对SharedPreferences。你需要思考哪些数据用SQLite存哪些用SP存并设计合理的数据库表结构。业务逻辑与架构这是区分“代码搬运工”和“有思想的开发者”的关键。你会面临如何组织代码的抉择是所有逻辑都写在Activity/Fragment里上帝类还是采用MVVM或MVP架构进行解耦如何管理训练计划的状态如何计算卡路里消耗这些思考会让你真正理解面向对象和设计模式的价值。用户交互与体验计时器功能、训练动作的动画演示、完成训练后的成就反馈、数据变化的图表展示可以集成MPAndroidChart库。这些功能点能让你接触到Service、动画、自定义View、第三方库集成等进阶内容。其次从场景贴合度来看健身是高频且个人化的需求。开发过程中你会自然而然地站在用户角度思考怎么让添加训练计划更便捷历史记录如何查询更直观如何激励用户坚持这种产品思维是课堂上学不到但又是优秀开发者必备的素质。所以这个项目绝不是一个简单的“增删改查”Demo。它是一个微型的、完整的商业应用原型。完成它你不仅能交出一份让老师眼前一亮的课程设计更能获得一份可以写进简历的、体面的个人项目经验。下面我就以一个“过来人”的身份带你从零开始拆解这个项目的完整实现路径。2. 项目蓝图与核心功能模块拆解在打开Android Studio之前我们必须先想清楚这个App要做什么核心功能有哪些。盲目开干很容易陷入“边做边改越改越乱”的泥潭。根据常见的健身应用和课程设计的实用性要求我建议将系统划分为以下四个核心模块2.1 用户中心模块这是应用的入口和基础。功能包括注册与登录实现本地账号体系。这里为了简化我们可以不接入第三方登录如微信、QQ专注于本地逻辑。注册时需要验证用户名是否重复、密码强度等。个人资料管理用户可以设置昵称、头像涉及图片选择与裁剪、身高、体重、年龄、性别等基础信息。这些信息将用于后续的卡路里计算。数据看板在主页或个人中心以卡片或图表形式展示关键数据如“本周训练总时长”、“累计消耗卡路里”、“最新体重变化曲线”。这里可以引入简单的折线图库来可视化体重变化。技术选型思考用户密码绝不能明文存储。即使只是课程设计我们也应该养成好习惯。对密码进行MD5或SHA-256哈希处理后再存入数据库。头像图片的处理是个小难点可以使用ActivityResult API调用系统相册或相机然后利用Glide或Picasso库进行加载和缓存避免内存溢出。2.2 训练计划库模块这是系统的“内容引擎”。功能包括计划浏览与筛选以列表形式展示预置的多种训练计划如“新手全身适应”、“核心力量提升”、“HIIT燃脂挑战”等。可以按难度初级、中级、高级、部位全身、上肢、下肢、核心、器械需求无器械、哑铃、弹力带进行筛选。计划详情点击进入具体计划展示该计划的简介、预计周期、每日训练安排。最重要的是展示计划包含的训练动作列表每个动作配以名称、示意图本地图片资源、目标肌群、建议组数与次数。计划订阅与管理用户可以将心仪的计划“加入我的训练”相当于订阅。用户可以在“我的计划”页面管理自己已订阅的所有计划并可以自定义创建新计划高阶功能。技术实现关键这部分的数据最好是静态的可以内置在App中。我们可以将训练计划和动作数据做成JSON文件放在assets目录下应用启动时解析并缓存。这样做的好处是内容可配置且不需要网络请求。数据库则用来存储用户“我的计划”这种动态关系数据。2.3 训练执行与记录模块这是系统的核心交互模块直接决定用户体验。功能包括今日训练预览基于用户订阅的计划生成今天的训练任务列表。训练执行界面这是最复杂的界面。需要展示当前动作的示意图、名称、目标组数/次数/重量。核心是一个训练计时器包括组间休息倒计时如60秒、动作执行计时可选。要有清晰的控制按钮“开始休息”、“下一组”、“完成动作”。训练记录保存用户每完成一个动作的一组训练应能记录实际完成的次数和使用的重量如果适用。整个训练完成后需要保存本次训练的总时长、完成的所有动作记录、估算的总卡路里消耗。动作动画引导如果能加入GIF或短视频演示动作标准流程会极大提升专业性。可以考虑将GIF资源放在本地使用Glide加载。避坑指南计时器的实现务必使用Handler或Coroutine协程来更新UI切忌在主线中进行Thread.sleep。训练状态正在训练哪个动作、第几组、休息还剩多久的管理是关键建议使用一个全局的ViewModel或单独的服务Service来持有状态防止屏幕旋转等配置变更导致状态丢失。2.4 数据统计与历史模块这是系统的“价值反馈”模块帮助用户坚持。功能包括训练历史日历以日历形式直观展示哪几天有训练记录点击可查看当日详情。数据统计图表绘制用户体重、训练频率、卡路里消耗随时间变化的折线图或柱状图。成就系统设立一些简单的成就如“连续训练7天”、“完成100次深蹲”、“总消耗10000大卡”并给予虚拟勋章或文字鼓励。技术要点图表库推荐MPAndroidChart功能强大且文档齐全。历史数据的查询会涉及数据库的复杂查询例如按时间范围分组汇总这正是练习SQL的好机会。成就系统的逻辑判断可以写在每次训练记录保存之后进行检查。3. 技术架构与开发环境搭建明确了功能我们就要搭建战场。对于新手而言一个清晰、不过度设计的架构至关重要。我推荐采用“单Activity 多Fragment”的导航模式配合Android Jetpack组件实现简易MVVM。这既能保证代码结构清晰又不会因过度设计而增加学习成本。3.1 项目架构设计思路UI层 (View): 由Activity和Fragment构成。主ActivityMainActivity只负责承载导航组件如BottomNavigationView和托管各个功能Fragment如HomeFragment,PlanFragment,TrainFragment,ProfileFragment。所有界面逻辑和用户交互都在Fragment中处理。逻辑层 (ViewModel): 为每个需要管理数据的Fragment创建对应的ViewModel来自androidx.lifecycle:lifecycle-viewmodel-ktx。ViewModel负责从Repository获取数据并持有可以被Fragment观察的LiveData或StateFlow。例如TrainViewModel会持有当前训练计划、计时器状态等数据。数据层 (Repository Data Source):Repository仓库是单一可信数据源。它决定数据是来自本地数据库Room、内存缓存、还是内置的JSON文件。Fragment和ViewModel不关心数据具体从哪里来只跟Repository要。这是架构中最关键的解耦点。本地数据库 (Room): 使用Room持久化库来管理用户数据、训练记录、身体数据等结构化数据。我们需要定义Entity实体类即表结构、Dao数据访问对象即接口定义增删改查方法、Database数据库类。3.2 开发环境与依赖配置安装Android Studio确保安装最新稳定版。安装过程中SDK Manager里要勾选相应版本的Android SDK推荐API 28以上和构建工具。创建新项目选择“Empty Activity”模板即可。语言选择Kotlin这是现在的官方首选和未来趋势对新手也更友好。Minimum SDK根据你的目标用户设备选择API 24 (Android 7.0) 是一个兼顾覆盖率和现代特性的选择。配置Gradle依赖这是项目的“食材清单”。打开app/build.gradle.kts(或build.gradle) 文件在dependencies块中添加以下核心库dependencies { // ... 其他默认依赖 // Core Architecture Components implementation(androidx.core:core-ktx:1.12.0) implementation(androidx.lifecycle:lifecycle-runtime-ktx:2.7.0) implementation(androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0) // ViewModel implementation(androidx.lifecycle:lifecycle-livedata-ktx:2.7.0) // LiveData implementation(androidx.activity:activity-ktx:1.8.2) // 简化Activity调用 implementation(androidx.fragment:fragment-ktx:1.6.2) // 简化Fragment调用 // UI implementation(androidx.appcompat:appcompat:1.6.1) implementation(com.google.android.material:material:1.11.0) // Material Design组件 implementation(androidx.constraintlayout:constraintlayout:2.1.4) // 约束布局 implementation(androidx.recyclerview:recyclerview:1.3.2) // 列表 implementation(androidx.viewpager2:viewpager2:1.0.0) // 视图翻页 // Database (Room) implementation(androidx.room:room-runtime:2.6.1) kapt(androidx.room:room-compiler:2.6.1) // Kotlin注解处理器 implementation(androidx.room:room-ktx:2.6.1) // Kotlin扩展和协程支持 // Image Loading implementation(com.github.bumptech.glide:glide:4.16.0) kapt(com.github.bumptech.glide:compiler:4.16.0) // Chart implementation(com.github.PhilJay:MPAndroidChart:v3.1.0) // Coroutines (异步处理) implementation(org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3) }添加后点击Sync Now同步项目。如果遇到网络问题下载失败可以检查Android Studio的HTTP Proxy设置或使用国内镜像源。重要提示依赖版本号可能会更新写文档时我使用的是相对稳定的版本。在实际创建项目时Android Studio可能会推荐更新的版本通常可以直接使用。如果遇到兼容性问题可以适当回退到上述版本。4. 核心数据库设计与Room实战数据是应用的基石。我们先设计数据库因为很多业务逻辑都围绕数据展开。根据功能模块我们至少需要五张表。4.1 实体类Entity设计在entity包下创建以下Kotlin data class并用Entity注解标记。// User.kt - 用户表 Entity(tableName users) data class User( PrimaryKey(autoGenerate true) val uid: Int 0, ColumnInfo(name username) val username: String, ColumnInfo(name password_hash) val passwordHash: String, // 存储哈希值非明文密码 ColumnInfo(name nickname) val nickname: String?, ColumnInfo(name avatar_path) val avatarPath: String?, // 头像本地路径 ColumnInfo(name height_cm) val heightCm: Int?, // 身高厘米 ColumnInfo(name weight_kg) val weightKg: Float?, // 体重公斤 ColumnInfo(name gender) val gender: Int?, // 0未知1男2女 ColumnInfo(name birthday) val birthday: Long? // 生日时间戳 ) // BodyRecord.kt - 身体记录表记录每次测量的体重体脂 Entity(tableName body_records) data class BodyRecord( PrimaryKey(autoGenerate true) val id: Long 0, val uid: Int, // 关联用户ID val date: Long, // 记录日期时间戳 val weightKg: Float, // 体重 val bodyFatPercentage: Float? null, // 体脂率可选 val note: String? null // 备注 ) // TrainingPlan.kt - 训练计划表存储预置或用户自定义的计划概要 Entity(tableName training_plans) data class TrainingPlan( PrimaryKey val planId: String, // 可以用UUID或固定ID val planName: String, val description: String?, val difficulty: String, // beginner, intermediate, advanced val focusArea: String, // full_body, upper, lower, core val equipmentNeeded: String, // none, dumbbell, resistance_band val totalWeeks: Int, val imageResId: Int // 关联本地图片资源ID ) // Exercise.kt - 训练动作表 Entity(tableName exercises) data class Exercise( PrimaryKey val exerciseId: String, val exerciseName: String, val muscleGroup: String, // 目标肌群 val description: String?, val gifResId: Int, // GIF资源ID或存储assets路径 val defaultSets: Int 3, // 默认组数 val defaultReps: Int 12 // 默认次数 ) // PlanExerciseJoin.kt - 计划与动作的关联表多对多关系 Entity( tableName plan_exercise_join, primaryKeys [planId, exerciseId, dayNumber, orderInDay] ) data class PlanExerciseJoin( val planId: String, val exerciseId: String, val dayNumber: Int, // 第几天 val orderInDay: Int // 当天的第几个动作 ) // UserPlanSubscription.kt - 用户订阅计划表 Entity(tableName user_plan_subscriptions) data class UserPlanSubscription( PrimaryKey(autoGenerate true) val id: Long 0, val uid: Int, val planId: String, val startDate: Long, // 开始日期 val active: Boolean true // 是否活跃 ) // WorkoutRecord.kt - 单次训练记录表 Entity(tableName workout_records) data class WorkoutRecord( PrimaryKey(autoGenerate true) val recordId: Long 0, val uid: Int, val planId: String?, val date: Long, // 训练开始时间 val totalDuration: Int, // 总时长秒 val estimatedCalories: Float // 估算卡路里 ) // ExerciseLog.kt - 单次训练中每个动作每组的详细记录 Entity(tableName exercise_logs) data class ExerciseLog( PrimaryKey(autoGenerate true) val logId: Long 0, val recordId: Long, // 关联WorkoutRecord val exerciseId: String, val setNumber: Int, // 第几组 val actualReps: Int, // 实际完成次数 val weightUsed: Float?, // 使用重量可选 val restTimeTaken: Int? // 实际休息时间秒 )4.2 数据访问对象Dao与数据库类在dao包下为每个实体创建Dao接口。以UserDao和复杂的查询为例// UserDao.kt Dao interface UserDao { Insert(onConflict OnConflictStrategy.REPLACE) suspend fun insertUser(user: User): Long Query(SELECT * FROM users WHERE username :username LIMIT 1) suspend fun getUserByUsername(username: String): User? Update suspend fun updateUser(user: User) // 更多查询... } // WorkoutRecordDao.kt Dao interface WorkoutRecordDao { Insert suspend fun insertRecord(record: WorkoutRecord): Long // 查询某用户最近N次训练记录 Query(SELECT * FROM workout_records WHERE uid :uid ORDER BY date DESC LIMIT :limit) fun getRecentRecords(uid: Int, limit: Int): FlowListWorkoutRecord // 查询某用户在特定日期范围内的训练记录用于图表 Query(SELECT date, estimatedCalories FROM workout_records WHERE uid :uid AND date BETWEEN :start AND :end ORDER BY date) fun getCaloriesBetweenDates(uid: Int, start: Long, end: Long): FlowListCalorieDatePoint // 需要定义一个 data class CalorieDatePoint(val date: Long, val estimatedCalories: Float) }最后创建AppDatabase.kt// AppDatabase.kt Database( entities [ User::class, BodyRecord::class, TrainingPlan::class, Exercise::class, PlanExerciseJoin::class, UserPlanSubscription::class, WorkoutRecord::class, ExerciseLog::class ], version 1, exportSchema false // 课程设计可设为false简化 ) abstract class AppDatabase : RoomDatabase() { abstract fun userDao(): UserDao abstract fun workoutRecordDao(): WorkoutRecordDao // ... 其他Dao的抽象方法 companion object { Volatile private var INSTANCE: AppDatabase? null fun getDatabase(context: Context): AppDatabase { return INSTANCE ?: synchronized(this) { val instance Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, fitness_database ).build() INSTANCE instance instance } } } }踩坑实录与心得主键设计PlanExerciseJoin使用了复合主键因为同一个计划同一天同一个顺序只能对应一个动作。这是典型的多对多关系中间表设计。外键与关联我们这里没有显式使用ForeignKey而是通过逻辑关联uid,planId,recordId。对于课程设计逻辑关联足够清晰。若想更严谨可以加上外键约束。Flow的使用注意getRecentRecords返回的是FlowListT。这是Room对Kotlin协程的支持配合LiveData或Lifecycle在UI层观察数据变化非常方便。当数据库中的数据更新时UI会自动收到新数据并刷新。数据库初始化预置的TrainingPlan和Exercise数据需要在数据库第一次创建时插入。可以通过Room的CallbackaddCallback在onCreate回调中执行IO线程的插入操作或者更简单的在应用启动时检查并插入。5. 从零到一关键功能界面实现详解有了数据库和架构我们就可以动手构建界面了。由于篇幅所限我挑几个最具代表性、也最容易踩坑的界面详细讲解。5.1 用户注册与登录实现这是第一个功能点涉及UI、输入校验、数据库操作和简单的密码学。布局文件 (activity_auth.xml)可以使用一个ViewPager2包含LoginFragment和RegisterFragment两个页面用TabLayout关联。每个Fragment内放置TextInputLayout包裹的EditText用于输入用户名、密码等。注册逻辑 (RegisterViewModel和RegisterFragment)ViewModel持有注册状态loading,success,error的LiveData以及一个Repository实例。Fragment在onViewCreated中绑定ViewModel设置按钮点击监听。点击注册按钮后Fragment收集输入用户名、密码、确认密码、昵称。进行前端校验非空、密码长度、两次密码一致性、用户名格式是否已存在需要在后端校验。调用ViewModel的register方法传入参数。ViewModel内启动协程viewModelScope.launchsuspend fun register(username: String, password: String, nickname: String) { _registerState.value LoadingState try { // 1. 检查用户名是否存在 val existingUser repository.getUserByUsername(username) if (existingUser ! null) { _registerState.value ErrorState(用户名已存在) return } // 2. 密码哈希处理 val passwordHash hashPassword(password) // 使用SHA-256等算法 // 3. 创建User对象并插入数据库 val newUser User(username username, passwordHash passwordHash, nickname nickname) val newUserId repository.insertUser(newUser) // 4. 注册成功更新状态 _registerState.value SuccessState(newUserId) } catch (e: Exception) { _registerState.value ErrorState(注册失败: ${e.message}) } }Fragment观察registerState的变化在成功时跳转到主界面失败时用Snackbar提示错误信息。登录逻辑类似但校验过程是验证用户名和密码哈希是否匹配。重要安全提示课程设计中我们简单使用哈希。但在真实项目中必须使用加盐Salt的密码哈希算法如bcrypt, PBKDF2来抵御彩虹表攻击。hashPassword函数应使用SecureRandom生成随机的盐并将盐和哈希值一起存储。5.2 训练计划浏览与RecyclerView高级应用PlanFragment的核心是一个展示计划列表的RecyclerView。这里我们实现一个带筛选功能和差异化Item布局的列表。Adapter设计我们使用ListAdapter它是RecyclerView.Adapter的优化子类配合DiffUtil来高效更新数据。class PlanAdapter(private val onItemClick: (TrainingPlan) - Unit) : ListAdapterTrainingPlan, PlanAdapter.PlanViewHolder(PlanDiffCallback()) { class PlanViewHolder(private val binding: ItemPlanBinding) : RecyclerView.ViewHolder(binding.root) { fun bind(plan: TrainingPlan) { binding.planName.text plan.planName binding.planDescription.text plan.description binding.planDifficulty.text 难度: ${plan.difficulty} // 使用Glide加载图片 Glide.with(binding.planImage.context) .load(plan.imageResId) // 如果是资源ID .into(binding.planImage) binding.root.setOnClickListener { // 点击事件通过构造函数传入的lambda回传 } } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PlanViewHolder { val binding ItemPlanBinding.inflate(LayoutInflater.from(parent.context), parent, false) return PlanViewHolder(binding) } override fun onBindViewHolder(holder: PlanViewHolder, position: Int) { holder.bind(getItem(position)) } } class PlanDiffCallback : DiffUtil.ItemCallbackTrainingPlan() { override fun areItemsTheSame(oldItem: TrainingPlan, newItem: TrainingPlan): Boolean { return oldItem.planId newItem.planId } override fun areContentsTheSame(oldItem: TrainingPlan, newItem: TrainingPlan): Boolean { return oldItem newItem // data class 会自动生成equals方法 } }Fragment中的数据加载与筛选class PlanFragment : Fragment() { private val viewModel: PlanViewModel by viewModels() private lateinit var binding: FragmentPlanBinding private lateinit var adapter: PlanAdapter override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { binding FragmentPlanBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) adapter PlanAdapter { plan - navigateToPlanDetail(plan.planId) } binding.recyclerViewPlans.adapter adapter binding.recyclerViewPlans.layoutManager LinearLayoutManager(requireContext()) // 观察ViewModel中的计划列表数据流 viewModel.plans.observe(viewLifecycleOwner) { planList - adapter.submitList(planList) } // 设置筛选按钮监听 binding.buttonFilterBeginner.setOnClickListener { viewModel.setDifficultyFilter(beginner) } binding.buttonFilterFullBody.setOnClickListener { viewModel.setFocusAreaFilter(full_body) } // ... 其他筛选按钮 } }ViewModel中的筛选逻辑class PlanViewModel(application: Application) : AndroidViewModel(application) { private val repository FitnessRepository.get(application) // 使用MutableStateFlow管理筛选条件 private val _difficultyFilter MutableStateFlowString?(null) private val _focusAreaFilter MutableStateFlowString?(null) // 组合流当任一筛选条件变化时重新查询 val plans: LiveDataListTrainingPlan combine(_difficultyFilter, _focusAreaFilter) { difficulty, focusArea - Pair(difficulty, focusArea) }.flatMapLatest { (difficulty, focusArea) - // 将Flow转换为LiveData供Fragment观察 repository.getPlansFiltered(difficulty, focusArea).asLiveData() }.asLiveData() fun setDifficultyFilter(difficulty: String?) { _difficultyFilter.value difficulty } fun setFocusAreaFilter(focusArea: String?) { _focusAreaFilter.value focusArea } }Repository中的查询方法// 在FitnessRepository中 fun getPlansFiltered(difficulty: String?, focusArea: String?): FlowListTrainingPlan { return if (difficulty null focusArea null) { // 无筛选返回所有 planDao.getAllPlans() } else { // 构建动态查询。注意Room不支持直接传null在WHERE子句中进行动态拼接。 // 更优做法是使用复杂的DAO查询或一次性加载所有后在内存中过滤数据量小的情况下。 // 这里演示一个简单的内存过滤假设数据量不大 planDao.getAllPlans().map { allPlans - allPlans.filter { plan - (difficulty null || plan.difficulty difficulty) (focusArea null || plan.focusArea focusArea) } } } }性能考量如果计划数据很多比如上百条内存过滤可能影响性能。更专业的做法是在DAO中编写动态SQL查询使用RawQuery或条件构建查询语句。但对于课程设计几十条数据的内存过滤完全可接受且逻辑更清晰。5.3 训练执行界面状态管理与计时器这是整个App最复杂、状态最多的界面。我们需要管理当前训练计划、当前是第几天、当前动作列表、当前动作索引、当前组数、休息倒计时、训练计时等。状态建模我们用一个Sealed Class密封类来清晰地定义所有可能的状态。sealed class TrainingState { object Idle : TrainingState() // 未开始 data class Preparing(val exercise: Exercise, val setIndex: Int) : TrainingState() // 准备开始下一组显示动作说明 data class Exercising(val exercise: Exercise, val setIndex: Int, val startTime: Long) : TrainingState() // 正在执行动作 data class Resting(val exercise: Exercise, val setIndex: Int, val remainingSeconds: Int) : TrainingState() // 组间休息中 data class SetCompleted(val exercise: Exercise, val setIndex: Int) : TrainingState() // 本组完成等待确认 object WorkoutCompleted : TrainingState() // 本次训练全部完成 }ViewModel (TrainingViewModel)它将是这个界面的“大脑”。持有状态一个MutableStateFlowTrainingStateUI通过收集这个Flow来更新界面。依赖注入需要注入当前用户ID、选择的计划ID和训练日。初始化在init块中从数据库加载当天的动作列表并初始化状态为第一个动作的Preparing。计时器逻辑使用Coroutine和delay来实现倒计时。关键在于确保计时器在配置变更如屏幕旋转时不被中断。这可以通过在ViewModel中启动协程并利用viewModelScope来自动管理生命周期。private fun startRestTimer(durationSeconds: Int) { _trainingState.value TrainingState.Resting(currentExercise, currentSetIndex, durationSeconds) viewModelScope.launch { for (i in durationSeconds downTo 1) { delay(1000L) // 更新状态触发UI刷新 _trainingState.value TrainingState.Resting(currentExercise, currentSetIndex, i - 1) } // 休息结束进入下一组准备或完成状态 onRestTimerFinished() } }动作转换逻辑提供startExercise(),completeSet(actualReps: Int, weightUsed: Float?),startRest(),finishWorkout()等方法这些方法会根据当前状态和输入计算出下一个状态并更新_trainingState。同时在completeSet中要将本组数据临时保存到一个列表里。Fragment (TrainingFragment)观察状态使用lifecycleScope.launch和repeatOnLifecycle(Lifecycle.State.STARTED)来安全地收集trainingStateFlow。viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.trainingState.collect { state - when (state) { is TrainingState.Preparing - { // 显示动作示意图、名称、目标组数次数 binding.textViewExerciseName.text state.exercise.exerciseName binding.textViewSetInfo.text 第${state.setIndex 1}组目标${state.exercise.defaultReps}次 binding.buttonStartSet.isEnabled true } is TrainingState.Resting - { // 显示倒计时 binding.textViewTimer.text ${state.remainingSeconds}秒 binding.buttonStartSet.isEnabled false } is TrainingState.Exercising - { // 显示执行中并可以计算已进行时间 val elapsedSeconds (System.currentTimeMillis() - state.startTime) / 1000 binding.textViewTimer.text 进行中: ${elapsedSeconds}秒 } // ... 处理其他状态 } } } }绑定按钮将按钮点击事件调用到ViewModel的对应方法。数据保存当训练完成WorkoutCompleted状态时ViewModel需要将临时保存的ExerciseLog列表和总的WorkoutRecord一次性插入数据库。这里涉及事务操作确保数据一致性。suspend fun saveWorkoutRecord() { val record WorkoutRecord( uid userId, planId planId, date workoutStartTime, totalDuration totalDurationSeconds, estimatedCalories calculateCalories() // 根据公式估算 ) val recordId repository.insertWorkoutRecord(record) val logs exerciseLogs.map { it.copy(recordId recordId) } repository.insertExerciseLogs(logs) }5.4 数据统计与图表集成在StatisticsFragment中我们使用MPAndroidChart来绘制训练频率和体重变化曲线。集成步骤添加依赖前面已做。在布局文件中加入LineChart视图。在Fragment中获取图表实例并进行基本配置描述、图例、坐标轴等。从ViewModel获取数据例如过去30天的每日卡路里消耗列表。将数据转换为Entry列表并创建LineDataSet和LineData。将数据设置给图表并调用invalidate()刷新。示例代码片段private fun setupCalorieChart(calorieData: ListCalorieDatePoint) { val entries calorieData.mapIndexed { index, point - // X轴为索引天数Y轴为卡路里值。更佳做法是将时间戳转换为日期。 Entry(index.toFloat(), point.estimatedCalories) } val dataSet LineDataSet(entries, 每日消耗卡路里).apply { color Color.BLUE valueTextColor Color.BLACK lineWidth 2f setCircleColor(Color.BLUE) // ... 更多样式配置 } val lineData LineData(dataSet) binding.lineChartCalories.data lineData binding.lineChartCalories.description.text 时间 binding.lineChartCalories.xAxis.valueFormatter object : ValueFormatter() { // 自定义X轴标签将索引转换为日期 override fun getFormattedValue(value: Float): String { val index value.toInt() return if (index calorieData.size) { SimpleDateFormat(MM/dd, Locale.getDefault()).format(Date(calorieData[index].date)) } else } } binding.lineChartCalories.invalidate() }数据查询在Repository中编写复杂的SQL查询来按天聚合数据。// 在 WorkoutRecordDao 中 Query( SELECT date, SUM(estimatedCalories) as estimatedCalories FROM workout_records WHERE uid :uid AND date :startDate GROUP BY date(date/1000, unixepoch, localtime) ORDER BY date ) fun getDailyCaloriesSince(uid: Int, startDate: Long): FlowListCalorieDatePoint这里用到了SQLite的日期函数date(...)来将时间戳转换为本地日期并进行分组从而得到每日总和。6. 项目调试、优化与课程设计报告撰写心得功能实现后项目离完成还差最后两步调试优化和整理文档。这两步往往决定了你的课程设计是“能用”还是“优秀”。6.1 常见问题调试与性能优化数据库操作在主线程这是最常见的崩溃原因。所有Room的Insert、Query、Update、Delete操作只要不是返回LiveData或Flow的都必须在后台线程执行。确保你在Repository或ViewModel中使用viewModelScope.launch(Dispatchers.IO) { ... }来调用这些挂起函数。内存泄漏在Fragment或Activity中观察LiveData或Flow时使用viewLifecycleOwner在Fragment中或lifecycleScope可以避免因生命周期不一致导致的内存泄漏。在onDestroyView中取消Glide的加载请求也是一个好习惯。列表卡顿RecyclerView滑动卡顿通常是因为onBindViewHolder中进行了耗时操作如解码大图、复杂计算。确保图片加载使用Glide并指定合适尺寸复杂计算移到后台线程。使用DiffUtil也能极大提升列表更新效率。状态管理混乱训练界面状态复杂容易出bug。务必为每个状态设计清晰的Sealed Class并在ViewModel中集中管理状态转换。使用StateFlow或MutableStateFlow可以方便地观察状态变化。应用冷启动慢如果初始化时加载大量数据会导致白屏时间长。可以考虑使用App Startup库延迟初始化非关键组件或将数据加载放到后台线程先显示一个加载界面。6.2 课程设计报告与答辩准备一份好的报告和清晰的演示能让你的努力获得应有的分数。报告结构建议摘要简要说明项目名称、开发工具、实现的主要功能、项目特点。需求分析用文字或用例图描述系统的功能需求就是我们第二部分拆解的那些和非功能需求如界面友好、响应迅速、数据安全。系统设计架构设计画出系统模块图说明MVVM或你采用的架构以及各层职责。数据库设计给出完整的E-R图并附上核心表的字段说明就是我们的Entity类。界面设计提供主要界面的效果图或截图并说明布局思路。类图挑选几个核心的ViewModel、Repository、Entity画出它们之间的关系类图。核心功能实现挑选2-3个最具技术亮点的功能如训练状态机、动态筛选列表、图表集成详细说明实现思路、关键代码和解决的技术难点。一定要配上核心代码片段和截图。测试说明你进行了哪些测试如单元测试ViewModel、界面手动测试并附上测试用例和结果截图。总结与展望总结项目收获反思不足之处如UI设计不够精美、部分功能未实现并提出可能的改进方向如接入社交功能、集成健康数据API、增加语音指导等。答辩技巧演示是关键确保演示流程顺畅。从注册登录到浏览计划、开始训练、记录数据、查看统计走一个完整的用户旅程。提前准备好测试账号和数据。突出重点不要平铺直叙所有代码。重点讲解你的架构设计为什么用MVVM和Room、复杂状态管理训练计时器、数据流从UI到数据库的完整链路和解决的具体问题如用DiffUtil优化列表。准备好问答老师常问的问题包括“为什么选这个架构”“数据库表之间是怎么关联的”“如果用户量很大你的设计有哪些瓶颈”“计时器是如何保证准确的” 提前思考并准备好答案。完成这个项目你收获的不仅仅是一个课程设计的分数。你完整地走了一遍Android应用开发的核心流程从需求分析、技术选型、架构设计、数据库建模、UI实现、业务逻辑编写到最后的调试和优化。这个过程里踩过的每一个坑解决的每一个问题都会成为你宝贵的实战经验。当你下次再面对一个陌生的App需求时你脑子里会自然浮现出清晰的实现路径这才是这个项目带给你的最大价值。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →