简介:这是一份面向安卓开发初学者与课程设计学生的完整健身类移动应用实战项目,覆盖从登录注册到运动训练的全流程功能实现,解决新手缺乏可运行、可调试、可拓展的综合案例问题。资源包共626个文件,包含20个核心Java源码、32个XML布局与配置文件、37个PNG图标资源、122个JSON数据文件(用于名言警句与运动项目配置)以及1个MP4锻炼视频,配合SQLite数据库操作、MD5密码加密、VideoView视频播放、RecyclerView列表展示等关键技术点,压缩包大小为85.28MB。已有1724人学习下载,项目结构规范,模块划分清晰——含欢迎页、登录注册、个人信息管理、运动项目列表及带计时功能的锻炼界面,并集成Handler异步通信、OkHttp网络请求、Glide图片加载等进阶组件,所有功能均经真机验证可直接编译运行,附带完整注释与逻辑闭环,是Android课程设计与大作业的高分参考范本。
1. 项目概述与核心价值
“用Android Studio搞一个简单的健身系统”,这听起来像是很多计算机或软件工程专业同学在安卓开发课程设计里会遇到的经典题目。我当年读书时也做过类似的东西,现在回头看,这个题目选得确实很妙。它不像“学生管理系统”那么老套,又比“计算器”、“记事本”这类纯练手的小应用多了不少实际价值和应用场景。一个健身系统,麻雀虽小五脏俱全,它几乎涵盖了安卓应用开发从入门到进阶需要掌握的大部分核心技能点:UI界面设计、本地数据存储、用户交互逻辑、甚至还能浅浅地触碰一下网络请求和多媒体。对于新手来说,把它做出来,安卓开发的整个流程和核心概念你就算摸到门了。
这个系统的核心目标很明确:为用户提供一个记录和管理个人健身数据的移动端工具。具体来说,用户应该能创建自己的健身计划(比如周一练胸、周二练背),记录每次训练的具体动作、组数、重量,并能查看自己的历史训练记录和趋势。这背后,就涉及到几个关键的技术模块:如何设计一个清晰且好用的界面?如何把用户输入的计划和记录持久化地保存下来?如何在不同界面(比如计划列表和详情页)之间传递数据?把这些想明白、做出来,你的课程设计高分基本就稳了。
我打算在这篇分享里,不光是给你一个能跑通的代码,更重要的是拆解整个开发过程中的思考逻辑。为什么用SQLite而不用文件存储?RecyclerView的适配器怎么写效率更高?Intent传数据有哪些坑?我会结合自己踩过的那些坑,把每个环节的“为什么”和“怎么做”都讲清楚。你可以把这篇文章看作一份“开发手册”+“避坑指南”,目标是让你看完之后,不仅能复现这个项目,更能理解每一步背后的设计意图,以后做别的应用也能举一反三。
2. 开发环境准备与项目初始化
2.1 Android Studio安装与基础配置
工欲善其事,必先利其器。我们的一切开发都基于Android Studio,这是谷歌官方的集成开发环境。对于新手,我强烈建议去官网下载最新稳定版。安装过程基本就是一路“Next”,但有几个地方需要注意:
- 安装路径:尽量不要放在中文或带有空格的路径下,这是开发工具的通用准则,能避免很多潜在的、稀奇古怪的路径解析错误。
- 组件选择:安装向导会问你安装哪些组件,默认的“Android Virtual Device”(安卓虚拟设备,即模拟器)和“Performance”(性能监控工具)建议都勾上。模拟器虽然吃内存,但对于没有安卓真机调试条件的同学来说是必需品。
- SDK管理:安装完成后首次启动,Android Studio会引导你下载Android SDK(软件开发工具包)。这里建议至少下载一个相对较新且稳定的API版本,比如API 33 (Android 13) 或 API 34 (Android 14)。同时,一定要把对应版本的“系统镜像”也下载下来,这是后面创建模拟器所必需的。
提示:如果网络环境导致SDK下载缓慢或失败,可以查阅如何配置代理或使用国内镜像源,这在很多技术社区都有详细教程,搜索“Android Studio 配置国内镜像”即可。
安装好后,建议进行几个基础设置,让开发更顺手:
- 主题与字体:在
File -> Settings -> Appearance & Behavior -> Appearance里可以切换深色主题(比如Darcula),保护眼睛。在Editor -> Font里可以调整编辑器的字体和大小,我个人喜欢用JetBrains Mono,等宽字体对代码对齐很友好。 - 自动导包:在
Editor -> General -> Auto Import中,将Java/Kotlin的Insert imports on paste设置为All,并勾选Optimize imports on the fly。这样粘贴代码时会自动添加必要的import语句,并自动清理无用导入。 - 快捷键:熟悉常用快捷键能极大提升效率,比如
Ctrl + Space(代码补全)、Alt + Enter(快速修复)、Ctrl + O(重写方法)、Ctrl + Alt + L(格式化代码)。
2.2 创建新项目与模块解析
打开Android Studio,选择“New Project”。在模板选择页面,对于我们的健身系统,最合适的是“Empty Views Activity”。这个模板会生成一个最干净的单Activity项目结构,没有太多预设代码,方便我们从零开始构建。
在项目配置页面,需要填写几个关键信息:
- Name:你的应用名称,比如“FitTracker”。
- Package name:包名,通常是域名倒写+应用名,如
com.yourname.fittracker。这是应用的唯一标识,上架应用市场时很重要。 - Save location:项目保存路径,同样避免中文和空格。
- Language:选择Java或Kotlin。Kotlin现在是谷歌首推的安卓开发语言,语法更简洁安全。如果你是新手,我建议直接学Kotlin,这是趋势。但如果你所在课程要求使用Java,那选Java也无妨,本文的核心思路是相通的。
- Minimum SDK:选择应用支持的最低安卓版本。这需要权衡:版本越低,能覆盖的用户设备越多;版本越高,能使用的新API特性越多。对于课程设计,选择
API 24: Android 7.0 (Nougat)是一个比较稳妥的起点,既能使用较新的特性,又有不错的设备覆盖率。
点击“Finish”后,Android Studio会开始构建项目。首次构建会下载Gradle和相关依赖,需要一些时间。构建成功后,你会看到标准的项目结构,主要关注app模块下的几个目录:
manifests/AndroidManifest.xml:应用的“身份证”,声明了应用所需的权限、入口Activity等。java/com...:存放Java或Kotlin源代码的地方,我们主要的逻辑代码都在这里。res/:存放资源文件。layout/:所有界面布局的XML文件。drawable/:图片、形状定义等可绘制资源。values/:字符串、颜色、样式等常量定义。这里有个重要习惯:所有界面显示的文本,都应该定义在res/values/strings.xml中,而不是硬编码在布局或代码里。这样做便于后续的多语言国际化和管理。
Gradle Scripts:这里主要是build.gradle文件,用于管理项目依赖和构建配置。我们后面添加数据库等依赖库就需要在这里操作。
3. 系统功能设计与技术选型
3.1 核心功能模块拆解
在动手写代码之前,我们必须先把系统要做什么想清楚。一个简单的健身系统,可以拆解为以下四个核心功能模块:
- 健身计划管理:这是系统的骨架。用户应该能创建多个健身计划(例如“增肌计划”、“减脂计划”),每个计划包含计划名称、训练周期(如每周训练天数)、计划说明等。计划由多个具体的训练日组成。
- 训练动作库与记录:这是系统的血肉。我们需要一个动作库(如“卧推”、“深蹲”、“引体向上”),每个动作有名称、目标肌群、示范图(可选)。在记录训练时,用户选择某个计划下的某个训练日,然后添加本次训练完成的动作,并为每个动作记录具体的组数、次数、重量。
- 数据存储与持久化:这是系统的记忆。用户创建的计划和每次的训练记录都必须保存在手机里,下次打开App还能看到。这就涉及到本地数据存储方案的选择。
- 历史数据查看与分析:这是系统的价值延伸。用户应该能按日期查看所有的训练记录,并能以某种形式(如简单的列表、图表)看到自己某个动作的重量或次数随时间的变化趋势,直观感受自己的进步。
3.2 关键技术方案决策
围绕上述功能,我们需要做出几个关键的技术决策:
数据存储:SQLite数据库 + Room持久化库
- 为什么不用文件存储?用
SharedPreferences存键值对,适合存配置(如用户名、设置开关),但存复杂的、关系型的数据(计划、动作、记录)会非常混乱和低效。用普通文件(如JSON、XML)自己解析,则需要处理大量的IO和序列化/反序列化逻辑,且数据查询、更新困难。 - 为什么是SQLite?SQLite是安卓系统内置的轻量级关系型数据库,它通过SQL语句来操作数据,非常适合我们这种具有明确关系(一个计划有多个训练日,一次记录包含多个动作)的数据结构。查询、插入、更新、删除都非常高效和灵活。
- 为什么用Room?直接使用原生的SQLiteOpenHelper和Cursor需要编写大量样板代码和SQL字符串,容易出错且难以维护。Room是谷歌官方推出的一个SQLite对象映射(ORM)库,它让你能用注解(如
@Entity,@Dao,@Database)来定义数据实体和操作接口,编译器会帮你生成具体的实现代码。这大大简化了数据库操作,并且提供了编译时SQL语法检查,安全又省心。对于新手来说,学习Room的收益远大于直接操作SQLite。
- 为什么不用文件存储?用
界面列表展示:RecyclerView
- 无论是计划列表、动作列表还是历史记录列表,都需要用到列表控件。
ListView是旧方案,存在性能问题(所有Item视图都保存在内存中)。RecyclerView是现代安卓开发中列表展示的标准解决方案,它通过视图复用机制来高效展示大量数据,并且布局方式灵活(通过LayoutManager可以轻松实现线性列表、网格列表、瀑布流等)。我们必须掌握它。
- 无论是计划列表、动作列表还是历史记录列表,都需要用到列表控件。
界面导航与数据传递:Activity + Fragment + Intent/Navigation Component
- 基础模式:一个界面通常对应一个
Activity。不同Activity之间的跳转和简单数据传递,使用Intent。例如,从“计划列表”点击某项跳转到“计划详情”。 - 进阶结构:对于一个复杂的界面,比如详情页顶部是计划信息,下方用Tab展示不同的训练日,每个Tab下又是一个动作列表。这种结构就适合使用一个
Activity承载多个Fragment(碎片)。Fragment可以理解为界面模块,它拥有自己的生命周期,可以在一个Activity内灵活组合、替换。Fragment之间的数据传递,可以通过共享其所属Activity的ViewModel,或者使用Fragment的arguments。 - 现代导航:谷歌推荐使用
Navigation Component来管理Fragment之间的导航,它通过一个可视化的导航图来定义跳转关系,更加清晰和安全。对于课程设计,从Activity+Intent开始理解基本概念是没问题的,学有余力可以尝试Navigation Component。
- 基础模式:一个界面通常对应一个
架构模式:MVVM (Model-View-ViewModel)
- 为了代码清晰、易于测试和维护,我们采用MVVM模式。简单理解:
- Model:代表数据和业务逻辑,这里就是我们的实体类(如
Plan,Exercise)和Room的Dao(数据访问对象)。 - View:就是我们的
Activity和Fragment,负责显示UI和捕获用户输入。 - ViewModel:作为
View和Model之间的桥梁。它持有UI相关的数据,并处理View的交互逻辑。当Model(数据库)中的数据变化时,通过LiveData或Flow通知ViewModel,进而自动更新View。这样做的好处是将UI逻辑与数据逻辑分离,Activity/Fragment不再需要关心数据如何加载和保存,生命周期管理也更简单(ViewModel在屏幕旋转等配置变化时数据不会丢失)。
- Model:代表数据和业务逻辑,这里就是我们的实体类(如
- 为了代码清晰、易于测试和维护,我们采用MVVM模式。简单理解:
4. 数据库设计与Room集成
4.1 实体类(Entity)设计
根据功能分析,我们至少需要四张核心表。这里我们用Kotlindata class来定义,并用Room的注解进行标记。
// Plan.kt - 健身计划表 @Entity(tableName = "plans") data class Plan( @PrimaryKey(autoGenerate = true) val id: Long = 0, val name: String, // 计划名称,如“5x5力量提升” val description: String? = null, // 计划描述 val createTime: Long = System.currentTimeMillis() // 创建时间戳 ) // WorkoutDay.kt - 训练日表 (与Plan是1对多关系) @Entity( tableName = "workout_days", foreignKeys = [ForeignKey( entity = Plan::class, parentColumns = ["id"], childColumns = ["planId"], onDelete = ForeignKey.CASCADE // 计划删除时,关联的训练日也级联删除 )] ) data class WorkoutDay( @PrimaryKey(autoGenerate = true) val id: Long = 0, val planId: Long, // 外键,关联到Plan.id val dayOfWeek: Int, // 星期几,如1代表周一 val focus: String // 训练重点,如“胸肌”、“背部” ) // Exercise.kt - 动作库表 @Entity(tableName = "exercises") data class Exercise( @PrimaryKey(autoGenerate = true) val id: Long = 0, val name: String, // 动作名称,如“杠铃卧推” val muscleGroup: String, // 目标肌群,如“胸部” val demoImageUrl: String? = null // 示范图链接(本地路径或网络URL) ) // WorkoutRecord.kt - 训练记录表 (核心表,关联多个实体) @Entity( tableName = "workout_records", foreignKeys = [ ForeignKey( entity = WorkoutDay::class, parentColumns = ["id"], childColumns = ["workoutDayId"], onDelete = ForeignKey.CASCADE ), ForeignKey( entity = Exercise::class, parentColumns = ["id"], childColumns = ["exerciseId"], onDelete = ForeignKey.CASCADE ) ] ) data class WorkoutRecord( @PrimaryKey(autoGenerate = true) val id: Long = 0, val workoutDayId: Long, // 属于哪个训练日 val exerciseId: Long, // 做的哪个动作 val sets: Int, // 组数 val reps: Int, // 每组的次数 val weight: Double, // 使用的重量(单位:公斤) val recordTime: Long = System.currentTimeMillis() // 记录时间 )设计要点:
@PrimaryKey(autoGenerate = true)表示主键由数据库自动生成。@ForeignKey定义了表之间的关联关系。onDelete = ForeignKey.CASCADE是级联删除,当父表记录(如Plan)被删除,子表(WorkoutDay)相关记录自动删除,保证了数据完整性。WorkoutRecord是核心的事实表,它通过外键同时关联到WorkoutDay和Exercise,记录了“在某个训练日,完成了某个动作,具体做了几组几次多重”。
4.2 数据访问对象(Dao)定义
Dao是一个接口,里面定义了访问数据库的各种方法(增删改查)。Room会在编译时为我们生成具体的实现。
// PlanDao.kt @Dao interface PlanDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(plan: Plan): Long // 返回插入的ID @Update suspend fun update(plan: Plan) @Delete suspend fun delete(plan: Plan) @Query("SELECT * FROM plans ORDER BY createTime DESC") fun getAllPlans(): Flow<List<Plan>> // 使用Flow,可以持续观察数据变化 @Query("SELECT * FROM plans WHERE id = :planId") suspend fun getPlanById(planId: Long): Plan? } // WorkoutRecordDao.kt (部分复杂查询示例) @Dao interface WorkoutRecordDao { @Insert suspend fun insert(record: WorkoutRecord): Long // 查询某个动作的历史记录,用于绘制进步曲线 @Query(""" SELECT * FROM workout_records WHERE exerciseId = :exerciseId ORDER BY recordTime ASC """) fun getRecordsByExercise(exerciseId: Long): Flow<List<WorkoutRecord>> // 一个更复杂的联表查询:获取某一天的所有训练记录,并包含动作名称 @Query(""" SELECT wr.*, e.name as exerciseName FROM workout_records wr INNER JOIN exercises e ON wr.exerciseId = e.id WHERE wr.workoutDayId IN ( SELECT id FROM workout_days WHERE planId = :planId AND dayOfWeek = :dayOfWeek ) AND date(wr.recordTime / 1000, 'unixepoch', 'localtime') = :dateStr ORDER BY wr.recordTime """) suspend fun getDailyRecordsWithExercise(planId: Long, dayOfWeek: Int, dateStr: String): List<RecordWithExercise> // 这里定义了一个数据类 RecordWithExercise 来接收查询结果 data class RecordWithExercise( val id: Long, val workoutDayId: Long, val exerciseId: Long, val sets: Int, val reps: Int, val weight: Double, val recordTime: Long, val exerciseName: String ) }技术细节:
suspend关键字表示这是一个挂起函数,必须在协程或其他挂起函数中调用。Room完美支持Kotlin协程,使得异步数据库操作写起来像同步代码一样简单。Flow<List<Plan>>是一种数据流。当数据库中的plans表数据发生变化时,这个Flow会自动发射新的数据列表给观察者(通常是UI层的ViewModel),从而实现数据的实时更新。这是MVVM中数据驱动UI的关键。- 复杂的查询使用
@Query注解直接编写SQL语句,Room会进行编译时检查。联表查询可以一次性获取关联数据,减少多次查询的耗时。
4.3 数据库抽象与Gradle依赖
最后,我们需要创建一个继承自RoomDatabase的抽象类,将所有的实体和Dao关联起来。
// AppDatabase.kt @Database( entities = [Plan::class, WorkoutDay::class, Exercise::class, WorkoutRecord::class], version = 1, exportSchema = false // 课程设计中可以设为false简化,正式项目建议维护schema ) abstract class AppDatabase : RoomDatabase() { abstract fun planDao(): PlanDao abstract fun workoutDayDao(): WorkoutDayDao abstract fun exerciseDao(): ExerciseDao abstract fun workoutRecordDao(): WorkoutRecordDao 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 } } } }为了让Room工作,我们需要在app/build.gradle.kts(或app/build.gradle) 文件的dependencies块中添加依赖:
dependencies { // ... 其他依赖 val room_version = "2.6.1" implementation("androidx.room:room-runtime:$room_version") kapt("androidx.room:room-compiler:$room_version") // 如果是Kotlin项目,用kapt;Java用annotationProcessor // 可选 - Kotlin扩展和协程支持 implementation("androidx.room:room-ktx:$room_version") }同步Gradle后,Room的配置就完成了。数据库实例通过AppDatabase.getDatabase(context)获取,所有的操作都通过对应的Dao进行。
5. 用户界面开发与逻辑实现
5.1 主界面与计划列表(RecyclerView实战)
主界面通常是一个计划列表。我们使用Activity+RecyclerView+ViewModel的组合。
1. 布局文件 (activity_main.xml):核心是一个RecyclerView和一个“添加计划”的悬浮按钮(FAB)。
<?xml version="1.0" encoding="utf-8"?> <androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent"> <androidx.recyclerview.widget.RecyclerView android:id="@+id/rv_plans" android:layout_width="match_parent" android:layout_height="match_parent" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" /> <com.google.android.material.floatingactionbutton.FloatingActionButton android:id="@+id/fab_add_plan" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="bottom|end" android:layout_margin="16dp" android:src="@android:drawable/ic_input_add" app:backgroundTint="@color/purple_500" /> </androidx.coordinatorlayout.widget.CoordinatorLayout>2. 列表项布局 (item_plan.xml):定义每个计划卡片长什么样。
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_margin="8dp" app:cardCornerRadius="8dp" app:cardElevation="4dp"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="16dp"> <TextView android:id="@+id/tv_plan_name" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="18sp" android:textStyle="bold" /> <TextView android:id="@+id/tv_plan_desc" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="4dp" android:textSize="14sp" android:textColor="@android:color/darker_gray" /> <TextView android:id="@+id/tv_create_time" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:textSize="12sp" /> </LinearLayout> </androidx.cardview.widget.CardView>3. 适配器 (PlanAdapter.kt):RecyclerView.Adapter负责将数据列表绑定到每个Item视图上。
class PlanAdapter( private val onItemClick: (Plan) -> Unit, private val onItemLongClick: (Plan) -> Unit ) : RecyclerView.Adapter<PlanAdapter.PlanViewHolder>() { private var planList: List<Plan> = emptyList() inner class PlanViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { val nameTextView: TextView = itemView.findViewById(R.id.tv_plan_name) val descTextView: TextView = itemView.findViewById(R.id.tv_plan_desc) val timeTextView: TextView = itemView.findViewById(R.id.tv_create_time) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PlanViewHolder { val view = LayoutInflater.from(parent.context) .inflate(R.layout.item_plan, parent, false) return PlanViewHolder(view) } override fun onBindViewHolder(holder: PlanViewHolder, position: Int) { val plan = planList[position] holder.nameTextView.text = plan.name holder.descTextView.text = plan.description ?: "暂无描述" // 格式化时间显示 val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) holder.timeTextView.text = "创建于:${dateFormat.format(Date(plan.createTime))}" holder.itemView.setOnClickListener { onItemClick(plan) } holder.itemView.setOnLongClickListener { onItemLongClick(plan) true // 返回true表示消费了长按事件 } } override fun getItemCount() = planList.size fun submitList(newList: List<Plan>) { planList = newList notifyDataSetChanged() // 简单通知全部更新,对于复杂列表建议用DiffUtil优化 } }4. ViewModel (MainViewModel.kt):负责从数据库获取数据,并暴露给UI。
class MainViewModel(application: Application) : AndroidViewModel(application) { private val db = AppDatabase.getDatabase(application) val allPlans: LiveData<List<Plan>> = db.planDao().getAllPlans().asLiveData() fun insertPlan(plan: Plan) = viewModelScope.launch { db.planDao().insert(plan) } fun deletePlan(plan: Plan) = viewModelScope.launch { db.planDao().delete(plan) } }5. Activity (MainActivity.kt):连接View和ViewModel,设置监听器。
class MainActivity : AppCompatActivity() { private lateinit var viewModel: MainViewModel private lateinit var adapter: PlanAdapter override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) viewModel = ViewModelProvider(this).get(MainViewModel::class.java) adapter = PlanAdapter( onItemClick = { plan -> // 点击跳转到计划详情页 val intent = Intent(this, PlanDetailActivity::class.java).apply { putExtra("PLAN_ID", plan.id) } startActivity(intent) }, onItemLongClick = { plan -> // 长按删除计划 AlertDialog.Builder(this) .setTitle("删除计划") .setMessage("确定要删除计划“${plan.name}”吗?") .setPositiveButton("确定") { _, _ -> viewModel.deletePlan(plan) } .setNegativeButton("取消", null) .show() } ) val recyclerView = findViewById<RecyclerView>(R.id.rv_plans) recyclerView.adapter = adapter // 观察数据变化,自动更新UI viewModel.allPlans.observe(this) { plans -> adapter.submitList(plans) } // 添加计划按钮 findViewById<FloatingActionButton>(R.id.fab_add_plan).setOnClickListener { showAddPlanDialog() } } private fun showAddPlanDialog() { val dialogView = layoutInflater.inflate(R.layout.dialog_add_plan, null) val editName = dialogView.findViewById<EditText>(R.id.et_plan_name) val editDesc = dialogView.findViewById<EditText>(R.id.et_plan_desc) AlertDialog.Builder(this) .setTitle("新建健身计划") .setView(dialogView) .setPositiveButton("创建") { _, _ -> val name = editName.text.toString().trim() if (name.isNotEmpty()) { val newPlan = Plan(name = name, description = editDesc.text.toString()) viewModel.insertPlan(newPlan) } else { Toast.makeText(this, "计划名称不能为空", Toast.LENGTH_SHORT).show() } } .setNegativeButton("取消", null) .show() } }5.2 计划详情与训练记录界面
计划详情页(PlanDetailActivity)会复杂一些,它可能需要展示该计划下的所有训练日,并且能进入某个训练日去记录当天的训练。
技术要点:
- 数据获取:通过
Intent传递过来的planId,在ViewModel中查询该计划下的所有WorkoutDay。 - 界面布局:可以使用
ViewPager2+TabLayout来切换显示不同的训练日(每个训练日是一个Fragment)。 - 记录训练:在每个训练日的
Fragment里,有一个列表展示已记录的动作,一个按钮可以添加新动作。点击“添加”弹出一个对话框或跳转到新界面,让用户从动作库选择动作并输入组数、次数、重量,然后调用WorkoutRecordDao.insert保存。 - 数据更新:当在详情页添加了新的训练记录,数据库更新,
Flow或LiveData会自动通知观察者(详情页的ViewModel),然后UI自动刷新。这里体现了MVVM数据驱动的优势,我们不需要手动去刷新列表。
5.3 动作库管理
动作库可以是一个独立的Activity或Fragment,用于管理Exercise表。提供增删改查功能。为了用户体验,首次打开App时,可以预置一些常见的健身动作数据。这可以在数据库创建后,通过RoomDatabase.Callback的onCreate方法中执行一个协程任务来插入初始数据。
// 在AppDatabase的构建器中添加 .databaseBuilder(...) .addCallback(object : RoomDatabase.Callback() { override fun onCreate(db: SupportSQLiteDatabase) { super.onCreate(db) // 在后台线程执行初始化 CoroutineScope(Dispatchers.IO).launch { getDatabase(context).exerciseDao().insertAll(defaultExercises) } } })6. 数据展示、调试与常见问题
6.1 历史数据查看与简单图表
历史记录页面可以是一个简单的列表,按日期倒序排列所有WorkoutRecord。更进阶的功能是绘制某个动作的历史重量/次数曲线。这里可以引入一个轻量级的图表库,比如MPAndroidChart。
集成步骤:
- 在
build.gradle中添加依赖:implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0'。 - 在布局文件中加入
LineChart视图。 - 在代码中,从
ViewModel获取某个动作的历史记录(List<WorkoutRecord>),将时间和重量/次数转换为图表需要的List<Entry>数据集。 - 配置图表样式(如坐标轴、描述、颜色等),然后设置数据并刷新图表。
注意事项:图表库通常比较重,如果只是课程设计展示,用简单的列表和文字描述进步趋势也是完全可以的,评委更看重的是数据获取和展示的逻辑是否清晰。
6.2 数据库调试工具:Database Inspector
Android Studio自带的Database Inspector是调试数据库的神器。当App在模拟器或真机上运行时,你可以在Android Studio的View -> Tool Windows -> App Inspection中找到它。选择你的应用进程,就能直接看到应用数据库中的所有表和数据,并且可以实时执行SQL查询。这在检查数据是否正确插入、更新时非常方便,避免了到处打Log的麻烦。
6.3 常见问题与排查技巧
Room数据库升级迁移:如果你的应用后续版本需要修改表结构(比如增加字段),就必须处理数据库升级。需要在
@Database注解中增加version,并实现Migration对象。对于课程设计,如果初始版本设计得好,v1版本基本够用。但务必记住这个知识点。踩坑记录:千万不要在开发初期就设置
exportSchema = false然后疯狂修改实体类,这会导致每次修改都要卸载重装App才能更新数据库。初期可以先用fallbackToDestructiveMigration()临时允许破坏性迁移(清空数据),但定稿前一定要设计好稳定的表结构。RecyclerView列表不更新:确保你更新数据源(
planList)后,调用了adapter.notifyDataSetChanged()或更优的DiffUtil方法。如果使用了LiveData或Flow,并在observe中更新适配器数据,通常会自动触发。主线程操作数据库:Room默认不允许在主线程(UI线程)执行数据库操作,因为可能阻塞UI导致应用无响应(ANR)。所有Dao的
suspend函数都必须在协程作用域(如viewModelScope.launch)或后台线程中调用。如果你看到Cannot access database on the main thread的异常,就是因为这个。Intent传递复杂对象:
Intent的putExtra方法只能传递基本类型或可序列化对象。我们的Plan、Exercise等实体类需要实现Serializable或Parcelable接口。更推荐实现Parcelable,性能更高。或者,更MVVM的做法是只传递ID(如planId),然后在目标界面用这个ID去数据库查询完整数据。内存泄漏:在
Activity或Fragment中观察LiveData时,使用了viewLifecycleOwner(在Fragment中)或this(在Activity中)作为生命周期所有者,这能确保在界面销毁时自动移除观察,避免内存泄漏。在MainActivity的示例中,我们使用的是this。界面旋转数据丢失:如果你在
Activity的onCreate中通过Intent获取数据并直接赋值给局部变量,旋转屏幕后Activity重建,这些局部变量就没了。解决方案是将这些数据保存在ViewModel中,因为ViewModel的生命周期比Activity长,不受配置变化影响。这也是MVVM模式的核心优势之一。
把这个简单的健身系统做下来,你会发现它像一条线,把安卓开发的珠子(Activity、Fragment、RecyclerView、Room、LiveData、ViewModel)都串了起来。每个环节都有值得深挖的细节,比如RecyclerView的DiffUtil性能优化、Room数据库的关系查询、ViewModel的SavedStateHandle处理临时状态。课程设计的目的不是做一个多么华丽的应用,而是通过一个完整的项目,把知识体系打通。当你遇到问题、搜索解决、最终跑通的那一刻,才是成长最快的时候。希望这篇长文能成为你开发路上的一个实用参考,而不仅仅是另一份代码拷贝。
本文还有配套的精品资源,点击获取