简介:这份资源面向Android初学者与移动开发课程学习者,提供一套以SQLite为数据核心的学生成绩管理完整项目,可用于课程设计参考或CRUD与本地存储的实战练习。压缩包共1641个文件,约18.93MB,以class、dex等编译产物和json、xml配置为主,另有40个java源码、53个jar依赖及apk安装包,便于直接运行与对照源码阅读。项目覆盖学生与成绩数据模型设计、SQLiteOpenHelper建表与版本管理、增删改查与按学号姓名检索、ORDER BY成绩排序,以及RecyclerView界面布局和按钮事件处理,并涉及CursorLoader异步加载与异常处理思路。已有4243人学习,适合按模块拆解、理解Android数据层与UI层协作方式。
1. 从一次期末查分说起:这个 Android 成绩管理项目到底解决什么问题
期末周,教务老师抱着一摞 Excel 挨个班发成绩,学生挤在办公室门口问分数,改一个错分要重发整张表——这个场景几乎每个学校都遇到过。这个基于 Android 的学生成绩管理系统,就是把这套流程搬到手机端:教师端录入和修改成绩,学生端查询自己的各科分数,数据落在本机 SQLite 里,不依赖服务器也能跑。技术栈很朴素——Java + Android 原生 + SQLite,没有花哨的框架,恰恰适合拿来练手 Android 四大组件、数据库 CRUD 和界面跳转。适合谁?刚学完 Java 和 Android 基础、想找一个能完整跑通「登录—列表—增删改查」闭环的在校生,或者需要给课程设计交一份能演示的成品的人。下面我按「能干什么 → 怎么跑 → 怎么改」拆开讲。
2. 项目结构与技术选型:为什么是 Java + SQLite 这套组合
2.1 目录结构长什么样
拿到源码包,第一件事是认清目录。Android 原生项目的骨架基本固定,这个项目也不例外,核心代码集中在app/src/main下:
app/ ├── src/main/ │ ├── java/com/example/scoremanager/ │ │ ├── LoginActivity.java // 登录入口,区分教师/学生 │ │ ├── MainActivity.java // 教师端主界面,成绩列表 │ │ ├── StudentActivity.java // 学生端查询界面 │ │ ├── AddScoreActivity.java // 新增/编辑成绩表单 │ │ ├── db/ │ │ │ ├── DBHelper.java // 继承 SQLiteOpenHelper,建表与升级 │ │ │ └── ScoreDao.java // 成绩增删改查封装 │ │ └── bean/ │ │ ├── Student.java // 学生实体 │ │ └── Score.java // 成绩实体 │ ├── res/ │ │ ├── layout/ // 各界面 XML 布局 │ │ └── values/ // strings、colors、styles │ └── AndroidManifest.xml // 四大组件注册 └── build.gradle // 模块级依赖与编译配置这个分层很典型:bean放实体,db放数据库访问,Activity 只负责界面和交互。新手容易把所有逻辑塞进 Activity,这个项目至少在结构上做了分离,二次开发时改起来不至于牵一发动全身。
2.2 技术选型背后的取舍
为什么不用 Room 或者网络数据库?因为这是个单机演示型项目,SQLite 是 Android 系统自带的,零配置、零依赖,装上就能用。Room 虽然更现代,但会引入注解处理器和额外依赖,对课程设计来说反而增加理解成本。Java 而非 Kotlin 也是同理——大部分教材和课程还在用 Java,源码可读性对新手更友好。
build.gradle里通常只需要最基础的配置,常见做法是这样:
android { compileSdk 33 defaultConfig { applicationId "com.example.scoremanager" minSdk 21 // 覆盖绝大多数在用设备 targetSdk 33 versionCode 1 versionName "1.0" } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } } dependencies { implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'com.google.android.material:material:1.9.0' implementation 'androidx.recyclerview:recyclerview:1.3.0' }minSdk 21是血泪经验——设太高会挡掉一批旧测试机,设太低又要处理兼容分支,21 是个平衡点。compileSdk和targetSdk保持一致,避免运行时权限行为不一致导致的玄学问题。RecyclerView 用来渲染成绩列表,比 ListView 灵活,也是现在的主流做法。
2.3 数据库表设计:两张表撑起整个业务
整个系统的数据模型就两张表:学生表和成绩表。建表逻辑写在DBHelper的onCreate里:
public class DBHelper extends SQLiteOpenHelper { private static final String DB_NAME = "score.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 student (" + "stu_no TEXT PRIMARY KEY, " + "name TEXT NOT NULL, " + "class_name TEXT)"); // 成绩表:自增 id,通过 stu_no 关联学生 db.execSQL("CREATE TABLE score (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "stu_no TEXT NOT NULL, " + "course TEXT NOT NULL, " + "score REAL, " + "FOREIGN KEY(stu_no) REFERENCES student(stu_no))"); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { // 简单粗暴:版本升级时重建表,演示项目够用 db.execSQL("DROP TABLE IF EXISTS score"); db.execSQL("DROP TABLE IF EXISTS student"); onCreate(db); } }这里有两个设计点值得说。第一,学号用TEXT而不是INTEGER,因为真实学号常带字母或前导零,用整型会丢信息。第二,score用REAL存分数,支持小数。外键约束在 SQLite 里默认不生效,需要setForeignKeyConstraintsEnabled(true),演示项目里通常不强制,靠业务层保证一致性——这是取舍,不是疏忽。
onUpgrade直接删表重建,对演示项目够用,但真上线会丢数据。我一般会在注释里标一句「生产环境请写迁移脚本」,避免后来人踩坑。
3. 核心模块拆解:从登录到成绩增删改查的完整链路
3.1 登录与角色分流
登录界面是整个系统的入口,逻辑不复杂但容易写乱。核心是根据账号判断角色,然后跳不同界面:
public class LoginActivity extends AppCompatActivity { private EditText etAccount, etPwd; private RadioGroup rgRole; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_login); etAccount = findViewById(R.id.et_account); etPwd = findViewById(R.id.et_pwd); rgRole = findViewById(R.id.rg_role); findViewById(R.id.btn_login).setOnClickListener(v -> { String account = etAccount.getText().toString().trim(); String pwd = etPwd.getText().toString().trim(); if (account.isEmpty() || pwd.isEmpty()) { Toast.makeText(this, "账号或密码不能为空", Toast.LENGTH_SHORT).show(); return; } int checkedId = rgRole.getCheckedRadioButtonId(); if (checkedId == R.id.rb_teacher) { // 教师端:校验固定账号后进入主界面 if ("teacher".equals(account) && "123456".equals(pwd)) { startActivity(new Intent(this, MainActivity.class)); finish(); } else { Toast.makeText(this, "教师账号错误", Toast.LENGTH_SHORT).show(); } } else { // 学生端:把学号带过去,查询时用 Intent intent = new Intent(this, StudentActivity.class); intent.putExtra("stu_no", account); startActivity(intent); finish(); } }); } }逻辑说明:先做非空校验,再按 RadioGroup 选中的角色分流。教师端用硬编码账号,学生端直接把输入的学号通过Intent传给查询界面。参数上,putExtra("stu_no", account)的 key 要和StudentActivity里getStringExtra的 key 严格一致,这是新手最常翻车的地方——拼错一个字母,查出来永远是空。
硬编码账号当然不是好做法,但演示项目里能省掉用户表。二次开发时我建议至少把账号抽到strings.xml或一个常量类里,别散落在代码中。
3.2 成绩列表:RecyclerView 适配器怎么写
教师端主界面用 RecyclerView 展示所有成绩。适配器是核心,负责把List<Score>绑到 item 布局上:
public class ScoreAdapter extends RecyclerView.Adapter<ScoreAdapter.VH> { private List<Score> data; private OnItemClickListener listener; public interface OnItemClickListener { void onEdit(Score score); void onDelete(Score score); } public ScoreAdapter(List<Score> data, OnItemClickListener listener) { this.data = data; this.listener = listener; } static class VH extends RecyclerView.ViewHolder { TextView tvStuNo, tvName, tvCourse, tvScore; Button btnEdit, btnDelete; VH(View itemView) { super(itemView); tvStuNo = itemView.findViewById(R.id.tv_stu_no); tvName = itemView.findViewById(R.id.tv_name); tvCourse = itemView.findViewById(R.id.tv_course); tvScore = itemView.findViewById(R.id.tv_score); btnEdit = itemView.findViewById(R.id.btn_edit); btnDelete = itemView.findViewById(R.id.btn_delete); } } @Override public VH onCreateViewHolder(ViewGroup parent, int viewType) { View v = LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_score, parent, false); return new VH(v); } @Override public void onBindViewHolder(VH holder, int position) { Score s = data.get(position); holder.tvStuNo.setText(s.getStuNo()); holder.tvName.setText(s.getName()); holder.tvCourse.setText(s.getCourse()); holder.tvScore.setText(String.valueOf(s.getScore())); holder.btnEdit.setOnClickListener(v -> listener.onEdit(s)); holder.btnDelete.setOnClickListener(v -> listener.onDelete(s)); } @Override public int getItemCount() { return data == null ? 0 : data.size(); } }关键点:ViewHolder 缓存控件引用,避免findViewById在滑动时反复调用;点击事件通过接口回调抛给 Activity,适配器不直接操作数据库,职责清晰。getItemCount里判空是防御性写法,防止数据还没加载完就渲染导致空指针。
3.3 DAO 层:增删改查的封装
所有数据库操作收在ScoreDao里,Activity 不碰SQLiteDatabase:
public class ScoreDao { private DBHelper helper; public ScoreDao(Context context) { helper = new DBHelper(context); } // 查询:支持按学号过滤,学生端和教师端共用 public List<Score> query(String stuNo) { List<Score> list = new ArrayList<>(); SQLiteDatabase db = helper.getReadableDatabase(); String sql = "SELECT s.stu_no, st.name, s.course, s.score " + "FROM score s LEFT JOIN student st ON s.stu_no = st.stu_no"; String[] args = null; if (stuNo != null && !stuNo.isEmpty()) { sql += " WHERE s.stu_no = ?"; args = new String[]{stuNo}; } Cursor c = db.rawQuery(sql, args); while (c.moveToNext()) { Score s = new Score(); s.setStuNo(c.getString(0)); s.setName(c.getString(1)); s.setCourse(c.getString(2)); s.setScore(c.getDouble(3)); list.add(s); } c.close(); db.close(); return list; } // 新增:先确保学生存在,再插成绩 public long insert(Score s) { SQLiteDatabase db = helper.getWritableDatabase(); ContentValues stu = new ContentValues(); stu.put("stu_no", s.getStuNo()); stu.put("name", s.getName()); db.insertWithOnConflict("student", null, stu, SQLiteDatabase.CONFLICT_IGNORE); ContentValues cv = new ContentValues(); cv.put("stu_no", s.getStuNo()); cv.put("course", s.getCourse()); cv.put("score", s.getScore()); long id = db.insert("score", null, cv); db.close(); return id; } public int delete(int id) { SQLiteDatabase db = helper.getWritableDatabase(); int rows = db.delete("score", "id = ?", new String[]{String.valueOf(id)}); db.close(); return rows; } }逻辑说明:query用LEFT JOIN把学生姓名带出来,避免界面上只显示学号;insert用insertWithOnConflict配合CONFLICT_IGNORE,学生已存在时不报错也不重复插,这是常见做法。参数上,rawQuery的?占位符必须用String[]传参,别用字符串拼接——拼接会有 SQL 注入风险,虽然单机项目风险低,但习惯要养好。
Cursor用完必须close(),数据库连接也要关,否则反复查询会泄漏。这是 Android 数据库操作的基本纪律。
3.4 新增与编辑:一个 Activity 两种模式
新增和编辑共用AddScoreActivity,靠 Intent 里有没有传 id 来区分:
public class AddScoreActivity extends AppCompatActivity { private EditText etStuNo, etName, etCourse, etScore; private ScoreDao dao; private int editId = -1; // -1 表示新增 @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_add_score); dao = new ScoreDao(this); etStuNo = findViewById(R.id.et_stu_no); etName = findViewById(R.id.et_name); etCourse = findViewById(R.id.et_course); etScore = findViewById(R.id.et_score); editId = getIntent().getIntExtra("id", -1); if (editId != -1) { // 编辑模式:回填已有数据 etStuNo.setText(getIntent().getStringExtra("stu_no")); etName.setText(getIntent().getStringExtra("name")); etCourse.setText(getIntent().getStringExtra("course")); etScore.setText(String.valueOf(getIntent().getDoubleExtra("score", 0))); } findViewById(R.id.btn_save).setOnClickListener(v -> save()); } private void save() { String stuNo = etStuNo.getText().toString().trim(); String course = etCourse.getText().toString().trim(); String scoreStr = etScore.getText().toString().trim(); if (stuNo.isEmpty() || course.isEmpty() || scoreStr.isEmpty()) { Toast.makeText(this, "请填写完整", Toast.LENGTH_SHORT).show(); return; } double score = Double.parseDouble(scoreStr); if (score < 0 || score > 100) { Toast.makeText(this, "分数需在 0-100 之间", Toast.LENGTH_SHORT).show(); return; } Score s = new Score(stuNo, etName.getText().toString().trim(), course, score); if (editId == -1) { dao.insert(s); } else { dao.update(editId, s); } setResult(RESULT_OK); finish(); } }这里的关键是editId这个哨兵值:-1代表新增,其他代表编辑。setResult(RESULT_OK)配合主界面的onActivityResult或ActivityResultLauncher刷新列表。分数范围校验放在保存前,比数据库约束更早拦截,用户体验更好。
4. 跑起来:环境准备、编译与真机调试
4.1 环境与导入
需要 Android Studio(建议 Hedgehog 及以上版本)和 JDK 17。导入步骤:File → Open选项目根目录,等 Gradle Sync 完成。如果卡在下载依赖,检查gradle-wrapper.properties里的 distributionUrl 是否可访问,国内常见做法是配镜像源。
首次 Sync 失败最常见的原因是 SDK 版本不匹配。打开build.gradle看compileSdk,再到Settings → SDK Manager确认对应平台已安装。缺哪个装哪个,别硬改版本号——改低了可能用到新 API 编译不过。
4.2 编译与运行
命令行编译:
# 在项目根目录执行,生成 debug 包 ./gradlew assembleDebug # 产物路径 # app/build/outputs/apk/debug/app-debug.apk装到设备:
# 确认设备已连接 adb devices # 安装 adb install -r app/build/outputs/apk/debug/app-debug.apk-r表示覆盖安装,保留数据。第一次装建议不加-r,避免旧数据干扰。运行后先用教师账号teacher / 123456登录,录几条成绩,再退出用学生学号登录查——这条链路走通,说明数据库和界面都正常。
4.3 数据库怎么看
调试时想直接看 SQLite 数据,两个办法。一是 Android Studio 的 App Inspection → Database Inspector,运行中直接查表,改数据即时生效。二是把数据库文件导出来:
adb shell "run-as com.example.scoremanager cat databases/score.db" > score.db导出的score.db用任意 SQLite 工具打开即可。run-as只对 debug 包有效,release 包会拒绝。这个技巧排查「数据到底插没插进去」特别管用,比在代码里打一堆 Log 高效。
5. 避坑与排查:几个我实际踩过的坑
5.1 查询结果永远为空
现象:学生端登录后列表空白,但教师端能看到数据。原因:Intent传学号的 key 和接收端getStringExtra的 key 不一致,或者学生端查询时传了空字符串走了全量分支但 SQL 拼错。解决:在StudentActivity里先把拿到的stuNo打 Log 确认非空,再检查 DAO 里WHERE条件拼接的字段名和表结构是否一致。我一般会在 DAO 的 query 入口加一行Log.d("DAO", "query stuNo=" + stuNo),一眼看出参数对不对。
5.2 新增后列表不刷新
现象:保存成功返回主界面,列表还是旧的。原因:AddScoreActivity里finish()了但主界面没重新查库,或者用了startActivity而不是startActivityForResult。解决:主界面在onResume里重新调dao.query(null)并notifyDataSetChanged(),这是最省事的做法;或者用ActivityResultLauncher在回调里刷新。前者简单但每次回前台都查一次,数据量大时有性能开销,演示项目无所谓。
5.3 分数输入非数字直接崩溃
现象:输入框里敲了字母,点保存 App 闪退。原因:Double.parseDouble抛NumberFormatException没捕获。解决:解析前用正则或try-catch包住:
double score; try { score = Double.parseDouble(scoreStr); } catch (NumberFormatException e) { Toast.makeText(this, "分数格式不正确", Toast.LENGTH_SHORT).show(); return; }输入框本身也可以设android:inputType="numberDecimal",从源头限制键盘类型,但别完全依赖它——粘贴进来的内容不受键盘限制。
5.4 数据库升级后旧数据全没了
现象:改了表结构、升了DB_VERSION,重装后之前录的成绩消失。原因:onUpgrade里DROP TABLE重建。解决:演示阶段可以接受,但要在升级前用adb把score.db备份出来;真要保留数据,得写ALTER TABLE迁移脚本,按版本号逐级升级。这是我在课程设计答辩时被问过的问题,提前想好怎么答。
5.5 真机上界面错位
现象:模拟器上布局正常,真机上按钮被键盘顶飞或文字截断。原因:布局用了固定dp高度,没考虑不同屏幕密度和软键盘。解决:根布局用ScrollView包裹表单,EditText外层加adjustResize,AndroidManifest对应 Activity 设android:windowSoftInputMode="adjustResize"。列表项高度用wrap_content,别写死。
6. 二次开发:从演示项目到能用的成绩工具
拿到这个项目,多数人不会止步于跑通,而是想改成自己能用的东西。改造方向我按投入产出比排一下。
第一,把硬编码账号换成用户表。在DBHelper里加一张user表,字段account、password、role,登录时查库比对。密码至少做个 MD5 或 SHA-256 哈希,别明文存——虽然是练手项目,但习惯从第一天养起。改造时注意onUpgrade要加迁移逻辑,不能直接删表。
第二,成绩列表加搜索和排序。RecyclerView 上方加一个SearchView,输入学号或课程名时调 DAO 的带条件查询;排序用ORDER BY score DESC在 SQL 层做,别在 Java 里Collections.sort,数据量大时差别明显。DAO 的query方法签名改成query(String stuNo, String keyword, String orderBy),参数多了用 Builder 或直接传个查询条件对象,别堆一长串String。
第三,导出成绩为 CSV。Android 写文件到公共目录要走MediaStore或SAF,别直接往/sdcard写——Android 10 以后分区存储会拦。简单做法是用ACTION_CREATE_DOCUMENT让用户选保存位置,拿到Uri后用ContentResolver.openOutputStream写入。CSV 每行学号,姓名,课程,分数,注意字段里有逗号时加引号转义。
第四,加统计图表。各科平均分、及格率用 MPAndroidChart 画个柱状图,数据从 DAO 聚合查询来:SELECT course, AVG(score) FROM score GROUP BY course。图表库引入后注意混淆规则,release 包别把图表类混淆掉导致运行时找不到。
验证改造是否成功,我的习惯是每次改完 DAO 层就跑一遍「新增 → 查询 → 编辑 → 删除 → 再查询」的完整链路,用 Database Inspector 对照每步的表数据。光看界面刷新不够,界面可能骗人,库里的数据不会。
改造时有个坑我踩过:给score表加字段后忘了升DB_VERSION,结果新代码查旧库直接崩,报no such column。从那以后我每次动表结构,都强制走一遍「改onCreate→ 升版本号 → 写onUpgrade迁移 → 卸载重装验证」的流程,一步不省。希望这个习惯也能帮到你。
本文还有配套的精品资源,点击获取