Android健身系统开发:从零构建MVVM架构与Room数据库实战
2026/9/4 4:17:45 网站建设 项目流程

简介:这是一份面向安卓开发初学者与课程设计学生的完整移动应用实战项目,聚焦健身主题,覆盖从用户认证到运动管理的全流程功能实现。资源包共626个文件,包含20个Java源码、32个XML布局、37个PNG图标、122个JSON配置及1个MP4锻炼视频等核心资产,总大小85.28MB,结构清晰便于模块化学习。已有1724人下载学习,适用于高校Android大作业、移动应用开发实训或毕业设计参考。项目完整实现了MD5密码加密、SQLite本地数据持久化、VideoView视频播放、RecyclerView运动列表展示、Handler+Thread计时控制、OkHttp网络请求及Glide图片加载等关键技术点,配套源码可直接编译运行,含登录注册、个人信息维护、名言轮播、运动视频播放与交互式计时等全部功能模块,是掌握安卓主流开发技能的高实用性入门范例。

1. 项目缘起与核心价值:为什么选择“健身系统”作为安卓课程设计?

如果你是一名计算机或软件工程专业的学生,正在为安卓开发课程设计选题而发愁,或者你是一个刚入门Android开发、想找一个综合性项目练手的新手,那么“健身系统”这个选题,你算是找对了。我当年做课程设计时,也纠结过很久,最后选了一个“新闻客户端”,结果发现大部分时间都在和网络请求、JSON解析、列表刷新这些基础且重复的轮子打交道,做完之后感觉技术栈很窄,对Android应用的整体架构理解依然模糊。

“健身系统”则完全不同。它听起来简单,但麻雀虽小,五脏俱全。一个好的课程设计,核心价值不在于功能多么炫酷,而在于它能系统性地串联起Android开发的核心知识点,并且贴近实际应用场景,让你在解决真实问题的过程中,把书本上的理论“盘活”。这个健身系统项目,恰好完美地满足了这两点。

首先,从技术栈覆盖来看,一个完整的健身系统至少会涉及:

  1. UI设计与布局:你需要设计用户登录/注册界面、主功能导航界面、训练计划列表、动作详情页、个人数据统计图表页等。这会逼着你熟练使用ConstraintLayout、RecyclerView、ViewPager2、Fragment等现代Android UI组件,而不是停留在简单的LinearLayout堆叠。
  2. 数据存储与管理:用户信息、训练计划、每次的训练记录、身体数据(体重、体脂)都需要持久化保存。这里就引出了Android数据存储的经典选择:本地数据库(Room)和轻量级键值对(SharedPreferences)。你需要思考哪些数据用SQLite存,哪些用SP存,并设计合理的数据库表结构。
  3. 业务逻辑与架构:这是区分“代码搬运工”和“有思想的开发者”的关键。你会面临如何组织代码的抉择:是所有逻辑都写在Activity/Fragment里(上帝类),还是采用MVVM或MVP架构进行解耦?如何管理训练计划的状态?如何计算卡路里消耗?这些思考会让你真正理解面向对象和设计模式的价值。
  4. 用户交互与体验:计时器功能、训练动作的动画演示、完成训练后的成就反馈、数据变化的图表展示(可以集成MPAndroidChart库)。这些功能点能让你接触到Service、动画、自定义View、第三方库集成等进阶内容。

其次,从场景贴合度来看,健身是高频且个人化的需求。开发过程中,你会自然而然地站在用户角度思考:怎么让添加训练计划更便捷?历史记录如何查询更直观?如何激励用户坚持?这种产品思维是课堂上学不到,但又是优秀开发者必备的素质。

所以,这个项目绝不是一个简单的“增删改查”Demo。它是一个微型的、完整的商业应用原型。完成它,你不仅能交出一份让老师眼前一亮的课程设计,更能获得一份可以写进简历的、体面的个人项目经验。下面,我就以一个“过来人”的身份,带你从零开始,拆解这个项目的完整实现路径。

2. 项目蓝图与核心功能模块拆解

在打开Android Studio之前,我们必须先想清楚这个App要做什么,核心功能有哪些。盲目开干,很容易陷入“边做边改,越改越乱”的泥潭。根据常见的健身应用和课程设计的实用性要求,我建议将系统划分为以下四个核心模块:

2.1 用户中心模块

这是应用的入口和基础。功能包括:

  • 注册与登录:实现本地账号体系。这里为了简化,我们可以不接入第三方登录(如微信、QQ),专注于本地逻辑。注册时需要验证用户名是否重复、密码强度等。
  • 个人资料管理:用户可以设置昵称、头像(涉及图片选择与裁剪)、身高、体重、年龄、性别等基础信息。这些信息将用于后续的卡路里计算。
  • 数据看板:在主页或个人中心,以卡片或图表形式展示关键数据,如“本周训练总时长”、“累计消耗卡路里”、“最新体重变化曲线”。这里可以引入简单的折线图库来可视化体重变化。

技术选型思考:用户密码绝不能明文存储。即使只是课程设计,我们也应该养成好习惯。对密码进行MD5或SHA-256哈希处理后,再存入数据库。头像图片的处理是个小难点,可以使用ActivityResult API调用系统相册或相机,然后利用GlidePicasso库进行加载和缓存,避免内存溢出。

2.2 训练计划库模块

这是系统的“内容引擎”。功能包括:

  • 计划浏览与筛选:以列表形式展示预置的多种训练计划,如“新手全身适应”、“核心力量提升”、“HIIT燃脂挑战”等。可以按难度(初级、中级、高级)、部位(全身、上肢、下肢、核心)、器械需求(无器械、哑铃、弹力带)进行筛选。
  • 计划详情:点击进入具体计划,展示该计划的简介、预计周期、每日训练安排。最重要的是,展示计划包含的训练动作列表,每个动作配以名称、示意图(本地图片资源)、目标肌群、建议组数与次数。
  • 计划订阅与管理:用户可以将心仪的计划“加入我的训练”,相当于订阅。用户可以在“我的计划”页面管理自己已订阅的所有计划,并可以自定义创建新计划(高阶功能)。

技术实现关键:这部分的数据最好是静态的,可以内置在App中。我们可以将训练计划和动作数据做成JSON文件,放在assets目录下,应用启动时解析并缓存。这样做的好处是内容可配置,且不需要网络请求。数据库则用来存储用户“我的计划”这种动态关系数据。

2.3 训练执行与记录模块

这是系统的核心交互模块,直接决定用户体验。功能包括:

  • 今日训练预览:基于用户订阅的计划,生成今天的训练任务列表。
  • 训练执行界面:这是最复杂的界面。需要展示当前动作的示意图、名称、目标组数/次数/重量。核心是一个训练计时器,包括组间休息倒计时(如60秒)、动作执行计时(可选)。要有清晰的控制按钮:“开始休息”、“下一组”、“完成动作”。
  • 训练记录保存:用户每完成一个动作的一组训练,应能记录实际完成的次数和使用的重量(如果适用)。整个训练完成后,需要保存本次训练的总时长、完成的所有动作记录、估算的总卡路里消耗。
  • 动作动画引导:如果能加入GIF或短视频演示动作标准流程,会极大提升专业性。可以考虑将GIF资源放在本地,使用Glide加载。

避坑指南:计时器的实现务必使用HandlerCoroutine(协程)来更新UI,切忌在主线中进行Thread.sleep。训练状态(正在训练哪个动作、第几组、休息还剩多久)的管理是关键,建议使用一个全局的ViewModel或单独的服务(Service)来持有状态,防止屏幕旋转等配置变更导致状态丢失。

2.4 数据统计与历史模块

这是系统的“价值反馈”模块,帮助用户坚持。功能包括:

  • 训练历史日历:以日历形式直观展示哪几天有训练记录,点击可查看当日详情。
  • 数据统计图表:绘制用户体重、训练频率、卡路里消耗随时间变化的折线图或柱状图。
  • 成就系统:设立一些简单的成就,如“连续训练7天”、“完成100次深蹲”、“总消耗10000大卡”,并给予虚拟勋章或文字鼓励。

技术要点:图表库推荐MPAndroidChart,功能强大且文档齐全。历史数据的查询会涉及数据库的复杂查询,例如按时间范围分组汇总,这正是练习SQL的好机会。成就系统的逻辑判断可以写在每次训练记录保存之后进行检查。

3. 技术架构与开发环境搭建

明确了功能,我们就要搭建战场。对于新手而言,一个清晰、不过度设计的架构至关重要。我推荐采用“单Activity + 多Fragment”的导航模式,配合Android Jetpack组件实现简易MVVM。这既能保证代码结构清晰,又不会因过度设计而增加学习成本。

3.1 项目架构设计思路

  • UI层 (View): 由ActivityFragment构成。主ActivityMainActivity)只负责承载导航组件(如BottomNavigationView)和托管各个功能Fragment(如HomeFragment,PlanFragment,TrainFragment,ProfileFragment)。所有界面逻辑和用户交互都在Fragment中处理。
  • 逻辑层 (ViewModel): 为每个需要管理数据的Fragment创建对应的ViewModel(来自androidx.lifecycle:lifecycle-viewmodel-ktx)。ViewModel负责从Repository获取数据,并持有可以被Fragment观察的LiveDataStateFlow。例如,TrainViewModel会持有当前训练计划、计时器状态等数据。
  • 数据层 (Repository & Data Source):Repository(仓库)是单一可信数据源。它决定数据是来自本地数据库(Room)、内存缓存、还是内置的JSON文件。FragmentViewModel不关心数据具体从哪里来,只跟Repository要。这是架构中最关键的解耦点。
  • 本地数据库 (Room): 使用Room持久化库来管理用户数据、训练记录、身体数据等结构化数据。我们需要定义Entity(实体类,即表结构)、Dao(数据访问对象,即接口,定义增删改查方法)、Database(数据库类)。

3.2 开发环境与依赖配置

  1. 安装Android Studio:确保安装最新稳定版。安装过程中,SDK Manager里要勾选相应版本的Android SDK(推荐API 28以上)和构建工具。
  2. 创建新项目:选择“Empty Activity”模板即可。语言选择Kotlin,这是现在的官方首选和未来趋势,对新手也更友好。Minimum SDK根据你的目标用户设备选择,API 24 (Android 7.0) 是一个兼顾覆盖率和现代特性的选择。
  3. 配置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): Flow<List<WorkoutRecord>> // 查询某用户在特定日期范围内的训练记录(用于图表) @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): Flow<List<CalorieDatePoint>> // 需要定义一个 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 } } } }

踩坑实录与心得

  1. 主键设计PlanExerciseJoin使用了复合主键,因为同一个计划同一天同一个顺序只能对应一个动作。这是典型的多对多关系中间表设计。
  2. 外键与关联:我们这里没有显式使用@ForeignKey,而是通过逻辑关联(uid,planId,recordId)。对于课程设计,逻辑关联足够清晰。若想更严谨,可以加上外键约束。
  3. Flow的使用:注意getRecentRecords返回的是Flow<List<T>>。这是Room对Kotlin协程的支持,配合LiveDataLifecycle在UI层观察数据变化非常方便。当数据库中的数据更新时,UI会自动收到新数据并刷新。
  4. 数据库初始化:预置的TrainingPlanExercise数据需要在数据库第一次创建时插入。可以通过Room的CallbackaddCallback)在onCreate回调中执行IO线程的插入操作,或者更简单的,在应用启动时检查并插入。

5. 从零到一:关键功能界面实现详解

有了数据库和架构,我们就可以动手构建界面了。由于篇幅所限,我挑几个最具代表性、也最容易踩坑的界面详细讲解。

5.1 用户注册与登录实现

这是第一个功能点,涉及UI、输入校验、数据库操作和简单的密码学。

布局文件 (activity_auth.xml):可以使用一个ViewPager2包含LoginFragmentRegisterFragment两个页面,用TabLayout关联。每个Fragment内放置TextInputLayout包裹的EditText用于输入用户名、密码等。

注册逻辑 (RegisterViewModelRegisterFragment)

  1. ViewModel:持有注册状态(loading,success,error)的LiveData,以及一个Repository实例。
  2. Fragment:在onViewCreated中绑定ViewModel,设置按钮点击监听。
  3. 点击注册按钮后
    • Fragment收集输入(用户名、密码、确认密码、昵称)。
    • 进行前端校验:非空、密码长度、两次密码一致性、用户名格式(是否已存在需要在后端校验)。
    • 调用ViewModel的register方法,传入参数。
    • ViewModel内,启动协程(viewModelScope.launch):
      suspend 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) : ListAdapter<TrainingPlan, 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.ItemCallback<TrainingPlan>() { 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 = MutableStateFlow<String?>(null) private val _focusAreaFilter = MutableStateFlow<String?>(null) // 组合流:当任一筛选条件变化时,重新查询 val plans: LiveData<List<TrainingPlan>> = 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?): Flow<List<TrainingPlan>> { 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):它将是这个界面的“大脑”。

  1. 持有状态:一个MutableStateFlow<TrainingState>,UI通过收集这个Flow来更新界面。
  2. 依赖注入:需要注入当前用户ID、选择的计划ID和训练日。
  3. 初始化:在init块中,从数据库加载当天的动作列表,并初始化状态为第一个动作的Preparing
  4. 计时器逻辑:使用Coroutinedelay来实现倒计时。关键在于确保计时器在配置变更(如屏幕旋转)时不被中断。这可以通过在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() } }
  5. 动作转换逻辑:提供startExercise(),completeSet(actualReps: Int, weightUsed: Float?),startRest(),finishWorkout()等方法,这些方法会根据当前状态和输入,计算出下一个状态,并更新_trainingState。同时,在completeSet中,要将本组数据临时保存到一个列表里。

Fragment (TrainingFragment)

  1. 观察状态:使用lifecycleScope.launchrepeatOnLifecycle(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}秒" } // ... 处理其他状态 } } } }
  2. 绑定按钮:将按钮点击事件调用到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来绘制训练频率和体重变化曲线。

集成步骤

  1. 添加依赖(前面已做)。
  2. 在布局文件中加入LineChart视图。
  3. 在Fragment中获取图表实例,并进行基本配置(描述、图例、坐标轴等)。
  4. ViewModel获取数据(例如过去30天的每日卡路里消耗列表)。
  5. 将数据转换为Entry列表,并创建LineDataSetLineData
  6. 将数据设置给图表,并调用invalidate()刷新。

示例代码片段

private fun setupCalorieChart(calorieData: List<CalorieDatePoint>) { 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): Flow<List<CalorieDatePoint>>

这里用到了SQLite的日期函数date(...)来将时间戳转换为本地日期并进行分组,从而得到每日总和。

6. 项目调试、优化与课程设计报告撰写心得

功能实现后,项目离完成还差最后两步:调试优化和整理文档。这两步往往决定了你的课程设计是“能用”还是“优秀”。

6.1 常见问题调试与性能优化

  • 数据库操作在主线程:这是最常见的崩溃原因。所有Room的@Insert@Query@Update@Delete操作,只要不是返回LiveDataFlow的,都必须在后台线程执行。确保你在RepositoryViewModel中使用viewModelScope.launch(Dispatchers.IO) { ... }来调用这些挂起函数。
  • 内存泄漏:在FragmentActivity中观察LiveDataFlow时,使用viewLifecycleOwner(在Fragment中)或lifecycleScope,可以避免因生命周期不一致导致的内存泄漏。在onDestroyView中取消Glide的加载请求也是一个好习惯。
  • 列表卡顿RecyclerView滑动卡顿,通常是因为onBindViewHolder中进行了耗时操作(如解码大图、复杂计算)。确保图片加载使用Glide并指定合适尺寸,复杂计算移到后台线程。使用DiffUtil也能极大提升列表更新效率。
  • 状态管理混乱:训练界面状态复杂,容易出bug。务必为每个状态设计清晰的Sealed Class,并在ViewModel中集中管理状态转换。使用StateFlowMutableStateFlow可以方便地观察状态变化。
  • 应用冷启动慢:如果初始化时加载大量数据,会导致白屏时间长。可以考虑使用App Startup库延迟初始化非关键组件,或将数据加载放到后台线程,先显示一个加载界面。

6.2 课程设计报告与答辩准备

一份好的报告和清晰的演示,能让你的努力获得应有的分数。

报告结构建议

  1. 摘要:简要说明项目名称、开发工具、实现的主要功能、项目特点。
  2. 需求分析:用文字或用例图描述系统的功能需求(就是我们第二部分拆解的那些)和非功能需求(如界面友好、响应迅速、数据安全)。
  3. 系统设计
    • 架构设计:画出系统模块图,说明MVVM或你采用的架构,以及各层职责。
    • 数据库设计:给出完整的E-R图,并附上核心表的字段说明(就是我们的Entity类)。
    • 界面设计:提供主要界面的效果图或截图,并说明布局思路。
    • 类图:挑选几个核心的ViewModelRepositoryEntity,画出它们之间的关系类图。
  4. 核心功能实现:挑选2-3个最具技术亮点的功能(如训练状态机、动态筛选列表、图表集成),详细说明实现思路、关键代码和解决的技术难点。一定要配上核心代码片段和截图
  5. 测试:说明你进行了哪些测试(如单元测试ViewModel、界面手动测试),并附上测试用例和结果截图。
  6. 总结与展望:总结项目收获,反思不足之处(如UI设计不够精美、部分功能未实现),并提出可能的改进方向(如接入社交功能、集成健康数据API、增加语音指导等)。

答辩技巧

  • 演示是关键:确保演示流程顺畅。从注册登录,到浏览计划、开始训练、记录数据、查看统计,走一个完整的用户旅程。提前准备好测试账号和数据。
  • 突出重点:不要平铺直叙所有代码。重点讲解你的架构设计(为什么用MVVM和Room)、复杂状态管理(训练计时器)、数据流(从UI到数据库的完整链路)和解决的具体问题(如用DiffUtil优化列表)。
  • 准备好问答:老师常问的问题包括:“为什么选这个架构?”“数据库表之间是怎么关联的?”“如果用户量很大,你的设计有哪些瓶颈?”“计时器是如何保证准确的?” 提前思考并准备好答案。

完成这个项目,你收获的不仅仅是一个课程设计的分数。你完整地走了一遍Android应用开发的核心流程:从需求分析、技术选型、架构设计、数据库建模、UI实现、业务逻辑编写,到最后的调试和优化。这个过程里踩过的每一个坑,解决的每一个问题,都会成为你宝贵的实战经验。当你下次再面对一个陌生的App需求时,你脑子里会自然浮现出清晰的实现路径,这才是这个项目带给你的最大价值。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询