简介:这是一套面向安卓初学者与课程设计需求者的完整健身系统源码,基于Android Studio开发,适合作为移动应用开发大作业或练手项目。项目覆盖登录注册、个人信息管理、运动项目列表与锻炼计时等模块,并融入MD5加密、Sqlite数据库、VideoView视频播放、OkHttp网络通信、RecycleView列表、ViewPager轮播图、Glide图片加载及Handler异步消息等十余个核心知识点,能帮助读者系统串联安卓开发技能。压缩包共626个文件,约85.28MB,包含java源码、xml布局、png与jpg图片资源、json数据、jar依赖及apk安装包等,结构完整可直接导入运行。目前已有1728人学习下载,适合需要完整赛题方案、排错思路与模块化代码参考的读者,便于快速理解项目架构并二次修改。
1. 从课程设计到能跑通的健身 App:Android Studio 里到底要写多少东西
每年期末,总有一批人打开 Android Studio 准备做健身系统课程设计,然后卡在第一个 Activity 上。这个标题看着像作业,实际拆开是一套完整的安卓开发最小闭环:多页面导航、SQLite 本地存储、列表适配器、计时器逻辑、图表展示。健身系统的好处是业务逻辑不复杂,但该有的模块一个不少,拿来练手刚好。适合两类人:一是第一次做安卓课程设计、不知道从哪下手的新手;二是做过记账本或计算器,想加个计时和数据统计功能的人。下面按我实际做过的路径,从建工程到打包,把每一步的参数和坑都写清楚。
2. 工程搭建与数据库设计:先把骨架立住
2.1 新建工程时选哪个模板
打开 Android Studio,选 Empty Views Activity,不要选 Compose 那个。课程设计场景下,XML 布局加 Java 或 Kotlin 的写法资料最多,出问题好搜。语言选 Java 还是 Kotlin 看课程要求,如果老师没指定,我一般用 Java,因为网上课程设计案例源码大部分是 Java 的,抄改方便。
最低 SDK 选 API 24(Android 7.0),覆盖率高,而且 SQLite 的 API 在这个版本上很稳定。Gradle 配置用默认的 Groovy DSL 就行,别折腾 Kotlin DSL,新手容易在 build.gradle.kts 的语法上翻车。
# 新建工程后先跑一次,确认环境没问题 # 在 Android Studio 底部 Terminal 里执行 ./gradlew assembleDebug这一步的目的是确认 Gradle 能正常下载依赖并编译。如果卡在 Download 阶段,检查 Settings 里的 HTTP Proxy 是否配了镜像。国内环境建议在项目根目录的 settings.gradle 里加上阿里云镜像:
// settings.gradle dependencyResolutionManagement { repositories { maven { url 'https://maven.aliyun.com/repository/google' } maven { url 'https://maven.aliyun.com/repository/public' } google() mavenCentral() } }参数说明:google()和mavenCentral()是官方源,阿里云镜像放在前面可以加速。注意顺序,镜像在前、官方在后,这样找不到的包还能回落到官方源。
2.2 数据库表结构怎么定
健身系统核心就三张表:用户表、运动记录表、训练计划表。课程设计不用搞太复杂,但字段类型要选对。
| 表名 | 字段 | 类型 | 说明 |
|---|---|---|---|
| user | id | INTEGER PRIMARY KEY AUTOINCREMENT | 主键自增 |
| user | username | TEXT NOT NULL | 用户名 |
| user | password | TEXT NOT NULL | 密码,存明文即可,课程设计不要求加密 |
| user | height | REAL | 身高 cm |
| user | weight | REAL | 体重 kg |
| record | id | INTEGER PRIMARY KEY AUTOINCREMENT | 主键 |
| record | user_id | INTEGER | 外键关联 user.id |
| record | sport_type | TEXT | 运动类型,如跑步、骑行 |
| record | duration | INTEGER | 时长,单位秒 |
| record | calories | REAL | 消耗卡路里 |
| record | record_date | TEXT | 日期,格式 yyyy-MM-dd |
| plan | id | INTEGER PRIMARY KEY AUTOINCREMENT | 主键 |
| plan | user_id | INTEGER | 外键 |
| plan | plan_name | TEXT | 计划名称 |
| plan | target_duration | INTEGER | 目标时长,秒 |
| plan | is_completed | INTEGER | 0 未完成,1 已完成 |
建表 SQL 写在 SQLiteOpenHelper 的 onCreate 里:
// DBHelper.java public class DBHelper extends SQLiteOpenHelper { private static final String DB_NAME = "fitness.db"; private static final int DB_VERSION = 1; public DBHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } @Override public void onCreate(SQLiteDatabase db) { // 用户表 db.execSQL("CREATE TABLE user (" + "id INTEGER PRIMARY KEY AUTOINCREMENT," + "username TEXT NOT NULL," + "password TEXT NOT NULL," + "height REAL," + "weight REAL)"); // 运动记录表 db.execSQL("CREATE TABLE record (" + "id INTEGER PRIMARY KEY AUTOINCREMENT," + "user_id INTEGER," + "sport_type TEXT," + "duration INTEGER," + "calories REAL," + "record_date TEXT)"); // 训练计划表 db.execSQL("CREATE TABLE plan (" + "id INTEGER PRIMARY KEY AUTOINCREMENT," + "user_id INTEGER," + "plan_name TEXT," + "target_duration INTEGER," + "is_completed INTEGER DEFAULT 0)"); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { // 课程设计阶段直接删表重建,生产环境不能这么干 db.execSQL("DROP TABLE IF EXISTS user"); db.execSQL("DROP TABLE IF EXISTS record"); db.execSQL("DROP TABLE IF EXISTS plan"); onCreate(db); } }逻辑说明:onCreate 只在数据库第一次创建时调用,onUpgrade 在版本号增加时调用。参数 DB_VERSION 从 1 开始,改表结构时加 1,否则新表不会生效。注意 onUpgrade 里直接 DROP 会丢数据,课程设计可以接受,但你要知道这是偷懒做法。
2.3 封装一个数据库操作类
不要在每个 Activity 里直接写 SQL,抽一个 DAO 类出来。以运动记录为例:
// RecordDao.java public class RecordDao { private DBHelper helper; public RecordDao(Context context) { helper = new DBHelper(context); } // 插入一条记录 public long insert(int userId, String sportType, int duration, double calories, String date) { SQLiteDatabase db = helper.getWritableDatabase(); ContentValues values = new ContentValues(); values.put("user_id", userId); values.put("sport_type", sportType); values.put("duration", duration); values.put("calories", calories); values.put("record_date", date); long id = db.insert("record", null, values); db.close(); return id; } // 查询某用户某天的总时长 public int getTotalDuration(int userId, String date) { SQLiteDatabase db = helper.getReadableDatabase(); Cursor cursor = db.rawQuery( "SELECT SUM(duration) FROM record WHERE user_id=? AND record_date=?", new String[]{String.valueOf(userId), date}); int total = 0; if (cursor.moveToFirst()) { total = cursor.getInt(0); } cursor.close(); db.close(); return total; } }参数说明:db.insert返回新插入行的 id,失败返回 -1。rawQuery的第二个参数是占位符的值数组,顺序要和 SQL 里的?对应。注意 Cursor 用完必须 close,否则反复查询会报 "CursorWindowAllocationException"。
3. 界面与交互:把功能串起来
3.1 登录注册页怎么写才不崩
登录页是第一个坑最多的地方。常见翻车点:空输入没判断、数据库查询在主线程、密码框类型没设对。
// LoginActivity.java 核心逻辑 public void onLoginClick(View view) { String username = etUsername.getText().toString().trim(); String password = etPassword.getText().toString().trim(); if (username.isEmpty() || password.isEmpty()) { Toast.makeText(this, "用户名和密码不能为空", Toast.LENGTH_SHORT).show(); return; } UserDao userDao = new UserDao(this); boolean success = userDao.checkLogin(username, password); if (success) { // 保存当前登录用户 id,后续页面要用 SharedPreferences sp = getSharedPreferences("user", MODE_PRIVATE); sp.edit().putInt("user_id", userDao.getUserId(username)).apply(); startActivity(new Intent(this, MainActivity.class)); finish(); } else { Toast.makeText(this, "用户名或密码错误", Toast.LENGTH_SHORT).show(); } }逻辑说明:先做非空校验,再查数据库。SharedPreferences 用来存登录态,key 是 "user_id"。注意apply()是异步写,commit()是同步写,登录场景用 apply 就行。
布局文件里密码框要设android:inputType="textPassword",否则密码明文显示。这个细节很多新手会漏。
3.2 运动记录列表用 RecyclerView 还是 ListView
2024 年了,用 RecyclerView。ListView 虽然简单,但复用机制不如 RecyclerView 灵活,而且面试问起来 RecyclerView 是必考。课程设计用 RecyclerView 还能多写点代码,显得工作量足。
// RecordAdapter.java public class RecordAdapter extends RecyclerView.Adapter<RecordAdapter.ViewHolder> { private List<Record> dataList; public RecordAdapter(List<Record> dataList) { this.dataList = dataList; } static class ViewHolder extends RecyclerView.ViewHolder { TextView tvType, tvDuration, tvCalories, tvDate; ViewHolder(View itemView) { super(itemView); tvType = itemView.findViewById(R.id.tv_type); tvDuration = itemView.findViewById(R.id.tv_duration); tvCalories = itemView.findViewById(R.id.tv_calories); tvDate = itemView.findViewById(R.id.tv_date); } } @Override public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { View view = LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_record, parent, false); return new ViewHolder(view); } @Override public void onBindViewHolder(ViewHolder holder, int position) { Record record = dataList.get(position); holder.tvType.setText(record.getSportType()); holder.tvDuration.setText(record.getDuration() + " 秒"); holder.tvCalories.setText(String.format("%.1f 千卡", record.getCalories())); holder.tvDate.setText(record.getRecordDate()); } @Override public int getItemCount() { return dataList == null ? 0 : dataList.size(); } }参数说明:onCreateViewHolder负责创建 ViewHolder,onBindViewHolder负责绑定数据。注意getItemCount要判空,否则数据没加载完就 setAdapter 会崩。String.format("%.1f", ...)保留一位小数,卡路里显示更自然。
3.3 计时器功能:Handler 还是 CountDownTimer
运动计时用 CountDownTimer 最省事,但如果你要做正计时(比如跑步计时),用 Handler 发消息更灵活。
// 正计时实现 private Handler handler = new Handler(Looper.getMainLooper()); private int seconds = 0; private boolean isRunning = false; private Runnable timerRunnable = new Runnable() { @Override public void run() { if (isRunning) { seconds++; tvTimer.setText(formatTime(seconds)); handler.postDelayed(this, 1000); } } }; // 开始计时 public void startTimer() { if (!isRunning) { isRunning = true; handler.post(timerRunnable); } } // 停止计时 public void stopTimer() { isRunning = false; handler.removeCallbacks(timerRunnable); } // 格式化时间 private String formatTime(int totalSeconds) { int min = totalSeconds / 60; int sec = totalSeconds % 60; return String.format("%02d:%02d", min, sec); }逻辑说明:handler.postDelayed每隔 1000 毫秒执行一次,isRunning标志位控制是否继续。注意在 Activity 的onDestroy里调用handler.removeCallbacks,否则页面销毁后计时器还在跑,内存泄漏。
提示:CountDownTimer 的
onTick回调间隔不精确,做倒计时够用,做精确计时不行。课程设计用 Handler 方案更稳。
4. 避坑与排查:课程设计里最容易翻车的 5 个地方
4.1 数据库查询报 no such table
现象:App 一运行就崩,Logcat 显示android.database.sqlite.SQLiteException: no such table: user。
原因:DBHelper 的 onCreate 没执行,或者数据库版本号没改。常见情况是你改了建表 SQL,但 DB_VERSION 还是 1,系统认为数据库已经存在,不会重新调用 onCreate。
解决:把 DB_VERSION 加 1,或者在手机设置里清除应用数据。开发阶段我一般直接卸载重装,比改版本号快。
4.2 RecyclerView 数据更新后列表不刷新
现象:插入一条新记录后返回列表页,列表还是旧的。
原因:Adapter 的 dataList 是同一个引用,但你没有调用notifyDataSetChanged()。
解决:在 Activity 的onResume里重新查询数据库并更新 dataList,然后调adapter.notifyDataSetChanged()。注意不要用notifyItemInserted除非你能确定插入位置。
4.3 模拟器上时间不对导致日期查询为空
现象:查询当天记录返回空,但数据库里明明有数据。
原因:模拟器时区或系统时间和宿主机不一致,SimpleDateFormat格式化出来的日期和插入时不一致。
解决:统一用Calendar.getInstance()获取日期,或者直接用 SQLite 的date('now')函数。我一般用new SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()),Locale 一定要指定,否则在某些系统上会出问题。
4.4 打包 release 版安装后闪退
现象:debug 版正常,release 版一打开就闪退。
原因:release 版默认开启混淆,SQLiteOpenHelper 或实体类被混淆后反射失败。
解决:在 proguard-rules.pro 里加保留规则:
-keep class com.example.fitness.model.** { *; } -keep class com.example.fitness.db.** { *; }或者课程设计阶段直接在 build.gradle 里把 minifyEnabled 设为 false。
4.5 Gradle 同步报资源重复错误
现象:Duplicate resources或Resource entry already exists。
原因:图片资源重名,或者 values 目录下 strings.xml 里定义了重复的 key。
解决:在 Android Studio 里按 Ctrl+Shift+F 全局搜索报错的资源名,找到重复定义的地方删掉。图片资源建议加前缀,比如ic_sport_run.png、ic_sport_cycle.png。
5. 进阶技巧:让课程设计多拿 10 分的三个细节
5.1 用 MPAndroidChart 画一周运动趋势
课程设计如果只做增删改查,分数不会太高。加一个柱状图展示最近七天的运动时长,工作量上去了,答辩也好讲。
在 build.gradle 的 dependencies 里加:
implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0'然后在 settings.gradle 里加 jitpack 仓库:
maven { url 'https://jitpack.io' }画图代码:
BarChart barChart = findViewById(R.id.bar_chart); List<BarEntry> entries = new ArrayList<>(); // 假设 queryLast7Days 返回长度为 7 的数组,单位分钟 int[] minutes = recordDao.getLast7DaysMinutes(userId); for (int i = 0; i < 7; i++) { entries.add(new BarEntry(i, minutes[i])); } BarDataSet dataSet = new BarDataSet(entries, "每日运动时长(分钟)"); BarData barData = new BarData(dataSet); barChart.setData(barData); barChart.invalidate(); // 刷新参数说明:BarEntry的第一个参数是 X 轴索引,第二个是 Y 值。invalidate()触发重绘。X 轴的日期标签可以用XAxis的setValueFormatter自定义。
5.2 用 SharedPreferences 记住登录状态
每次打开 App 都要重新登录,体验很差。在启动页判断 SharedPreferences 里有没有 user_id,有就直接跳主页面。
// SplashActivity.java SharedPreferences sp = getSharedPreferences("user", MODE_PRIVATE); int userId = sp.getInt("user_id", -1); if (userId != -1) { startActivity(new Intent(this, MainActivity.class)); } else { startActivity(new Intent(this, LoginActivity.class)); } finish();注意退出登录时要sp.edit().remove("user_id").apply(),否则下次启动还是自动登录。
5.3 导出数据到 CSV 作为加分项
答辩时老师问“数据能不能导出”,你说能,当场演示,效果很好。实现思路:查询所有记录,拼成 CSV 字符串,写到getExternalFilesDir目录。
public void exportCsv(int userId) { List<Record> list = recordDao.getAllByUser(userId); StringBuilder sb = new StringBuilder("类型,时长(秒),卡路里,日期\n"); for (Record r : list) { sb.append(r.getSportType()).append(",") .append(r.getDuration()).append(",") .append(r.getCalories()).append(",") .append(r.getRecordDate()).append("\n"); } File file = new File(getExternalFilesDir(null), "fitness_export.csv"); try (FileWriter writer = new FileWriter(file)) { writer.write(sb.toString()); Toast.makeText(this, "已导出到 " + file.getPath(), Toast.LENGTH_LONG).show(); } catch (IOException e) { e.printStackTrace(); } }这个功能代码量不大,但答辩时能体现你考虑了数据可移植性。注意 Android 10 以上要动态申请存储权限,或者直接用getExternalFilesDir免权限。
我做了三个学期的课程设计指导,发现最后拿高分的不是功能最多的,而是每个功能都能讲清楚为什么这么做的。数据库为什么用 SQLite 不用 Room,因为课程设计环境不固定,SQLite 零依赖;列表为什么用 RecyclerView,因为要练 ViewHolder 复用。这些理由在答辩时说出来,比多写两个页面管用。希望帮到你。
本文还有配套的精品资源,点击获取