资讯动态

Android学生成绩管理系统开发:SQLite与RecyclerView的完整实践

发布时间:2026/9/16 13:06:30 来源:尧图企业网站定制
简介面向高校与中学教学场景的安卓学生成绩管理系统设计资料包适合用作毕业设计、课程设计或期末大作业参考。内容覆盖安卓客户端与服务器端整体实现包含用户登录注册、成绩录入、成绩查询、成绩统计分析等核心功能模块项目采用分模块化设计职责清晰便于阅读与二次开发。压缩包共四百四十五个文件主要包括一百三十二个Java源文件、八十二个XML布局与配置、四十个JSP页面、三十八个Jar依赖库并附带GIF演示图、PNG/JPG界面资源和前端CSS、JS等素材整体约17.33MB。资源内附详细的MySQL数据库说明文档对数据表结构、字段含义、约束与索引进行梳理有助于理解数据层设计。系统在安卓客户端使用本地数据库存储并通过HTTP与后台通信兼顾多客户端并发访问与异常处理可为Android开发、Java后台及数据库应用提供完整实战参考。已有八十人学习/下载适合需要快速完成课程设计和毕业设计并进行项目复现与功能扩展的同学。1. 一个成绩管理系统为什么值得用 Android 做现实中这样的活并不少见班主任要把 50 个学生、6 门课、4 次考试的 1200 条成绩汇总记账靠 Excel 来回传漏一条、错一位数月末就要返工。把这个活搬到手机上就成了标题里说的基于 Android 的学生成绩管理系统。它要做的事情很具体用关系型数据库存学生、课程和分数三张表用 RecyclerView 做录入和查询界面再用 SQL 算平均分、及格率。系统真正的价值不在界面多炫而在于离线可用、数据不出手机是 Android 原生开发里最适合完整走一遍的项目也常被选作课程设计题目。2. 成绩数据层按三张表设计SQLite 与 DAO 这样搭系统要离线运行先得决定成绩数据放哪。最稳妥的答案是 SQLiteAndroid 内置、不依赖外部组件写起来比 Room 透明正好能把数据库课上学到的索引、事务、JOIN 原样用上。本章先把表结构定死再用一个 DAO 把增删改查全部收拢避免业务代码里到处散落 SQL 字符串。2.1 为什么用 SQLite而不是 JSON 文件或 Room把成绩存成 JSON 文件的做法其实很常见一个数组塞进/storage/emulated/0/Android/data/包名/files/下的 txt读取时用JSONArray整体解析。数据量小的时候它跑得不慢但一旦出现按课程查分数段按学期分组算平均就得把全部数据拉出来在 Java 里遍历代码越写越长。更麻烦的是用户随手清理缓存文件就没了。SQLite 是关系型数据库查询交给 SQL应用层只关心业务对象。Room 是它的上层封装能用注解生成 DAO 实现减少样板代码但代价是引入编译期注解处理器第一次构建要等 annotation processing 跑完。对课程设计或单机小工具来说手写 SQL 反而更容易讲清楚索引、事务和 JOIN 的行为下面的示例全部基于原生 SQLite API。2.2 学生、课程、成绩三张表的建表 SQL 与字段约束成绩表常见两种设计错误一种把学生姓名、课程名、考试名各加一列每行重复存储数据冗余另一种把所有分数拼成一个文本字段查询时没法走 SQL。正确做法是拆成三张表用外键关联。-- 学生表 CREATE TABLE IF NOT EXISTS student ( id INTEGER PRIMARY KEY AUTOINCREMENT, student_no TEXT NOT NULL UNIQUE, -- 学号业务上唯一 name TEXT NOT NULL, class_name TEXT NOT NULL DEFAULT ); -- 课程表 CREATE TABLE IF NOT EXISTS course ( id INTEGER PRIMARY KEY AUTOINCREMENT, course_name TEXT NOT NULL UNIQUE ); -- 成绩表 CREATE TABLE IF NOT EXISTS score ( id INTEGER PRIMARY KEY AUTOINCREMENT, student_id INTEGER NOT NULL, course_id INTEGER NOT NULL, score REAL NOT NULL CHECK(score BETWEEN 0 AND 100), exam_type TEXT NOT NULL, -- 平时/期中/期末 semester TEXT NOT NULL, -- 比如 2024-2025-1 created_at INTEGER NOT NULL DEFAULT (strftime(%s,now)), UNIQUE(student_id, course_id, exam_type, semester), FOREIGN KEY(student_id) REFERENCES student(id) ON DELETE CASCADE, FOREIGN KEY(course_id) REFERENCES course(id) ON DELETE CASCADE ); CREATE INDEX idx_score_course_semester ON score(course_id, semester);score字段用 REAL 不用 INTEGER是因为可能有 89.5 的平时分CHECK约束把脏数据挡在数据库层UNIQUE(student_id, course_id, exam_type, semester)保证同一名学生同一门课同一场考试不会重复录入。外键ON DELETE CASCADE让删除学生时成绩同步删除不会留下孤儿记录。最后一个索引对应最常见的查询条件按课程和学期查成绩。注意SQLite 默认不启用外键约束需要在 SQLiteOpenHelper 的onConfigure里执行setForeignKeyConstraintsEnabled(true)否则 CASCADE 不会生效。2.3 用 SQLiteOpenHelper 和 DAO 把 SQL 收拢起来如果每个 Activity 都自己 open 数据库表结构变更时改动会散落一地。我一般只留一个SQLiteOpenHelper子类外加一个 DAO 类Activity 只跟 DAO 打交道。public class ScoreDbHelper extends SQLiteOpenHelper { private static final String DB_NAME score_manager.db; private static final int DB_VERSION 1; public ScoreDbHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } Override public void onConfigure(SQLiteDatabase db) { super.onConfigure(db); db.setForeignKeyConstraintsEnabled(true); // 打开外键 } Override public void onCreate(SQLiteDatabase db) { db.execSQL(CREATE_STUDENT_SQL); db.execSQL(CREATE_COURSE_SQL); db.execSQL(CREATE_SCORE_SQL); db.execSQL(CREATE_INDEX_SQL); } Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { // 课程设计阶段直接重建生产环境需要 ALTER 迁移 for (String sql : DROP_SQL_LIST) { db.execSQL(sql); } onCreate(db); } }DB_VERSION是数据库的版本号不是 Android 系统版本号。第一次安装为 1以后每次改表结构都要 1并在onUpgrade里补对应迁移。onConfigure在onCreate之前执行外键开关放这里最合适。查询方法的写法上推荐用rawQuery加占位符例如按课程名和分数段查成绩public ListScoreVO queryScoreRange(String courseName, double min, double max) { SQLiteDatabase db helper.getReadableDatabase(); String sql SELECT s.student_no, s.name, c.course_name, sc.score FROM score sc JOIN student s ON s.id sc.student_id JOIN course c ON c.id sc.course_id WHERE c.course_name ? AND sc.score BETWEEN ? AND ? ORDER BY sc.score DESC; Cursor cursor db.rawQuery(sql, new String[]{courseName, String.valueOf(min), String.valueOf(max)}); ListScoreVO list new ArrayList(); while (cursor.moveToNext()) { ScoreVO vo new ScoreVO(); vo.studentNo cursor.getString(0); vo.name cursor.getString(1); vo.courseName cursor.getString(2); vo.score cursor.getDouble(3); list.add(vo); } cursor.close(); return list; }?占位符的作用是避免 SQL 注入参数按顺序填进selectionArgs。用列号cursor.getXxx(0)更快但如果查询列有调整建议换成getColumnIndexOrThrow(student_no)可读性更好。Cursor用完后必须关闭这是 Android 内存泄漏的重灾区。把 DAO 方法整理成接口方便以后替换实现DAO 方法涉及表关键点addStudent()student利用 student_no UNIQUE 做幂等插入addScore()score, course先查课程 id不存在则新建queryScoreRange()score, student, courseJOIN 三表 BETWEEN 范围getCourseStats()score, courseGROUP BY 聚合统计3. 成绩录入、多条件查询与统计的落地写法数据库层搭好之后业务层主要解决三件事录入不重复、查询能组合、统计不出错。这一章按这个顺序把三个场景的代码和参数讲清楚。3.1 成绩录入多表关联写操作要包在事务里一次保存成绩的动作往往要写多张表课程表里可能没有这门课学生表里可能没有这个学号。常见做法是逐个判断再插入但这几步之间一旦失败库里就会留下只有课程没有成绩的中间态所以要把它们绑在一个事务里。SQLiteDatabase db dbHelper.getWritableDatabase(); db.beginTransaction(); try { long courseId queryCourseId(db, courseName); // 不存在返回 -1 if (courseId -1) { courseId addCourse(db, courseName); } long studentId queryStudentId(db, studentNo); if (studentId -1) { studentId addStudent(db, studentNo, name, className); } addScoreRecord(db, studentId, courseId, score, examType, semester); db.setTransactionSuccessful(); // 只有走到这一行才提交 } finally { db.endTransaction(); // 没调用 setTransactionSuccessful 就回滚 }重点是finally里的endTransaction不管中间是否抛异常数据库都会被正确释放。setTransactionSuccessful()必须在endTransaction()之前调用否则整个事务回滚这是避免写下半个成绩的保险闸。批量初始化 1200 条成绩时同样用一个事务包住循环比逐条提交快得多因为每次提交都要触发一次磁盘同步。3.2 多条件查询的动态 WHERE 与 selectionArgs查询界面常见三个条件学号、课程、分数段用户可能填也可能不填所以 WHERE 是动态拼出来的。拼接本身不难难的是selection里的?和selectionArgs数组一一对应。StringBuilder selection new StringBuilder(11); ListString args new ArrayList(); if (!TextUtils.isEmpty(studentNo)) { selection.append( AND s.student_no ?); args.add(studentNo); } if (!TextUtils.isEmpty(courseName)) { selection.append( AND c.course_name ?); args.add(courseName); } if (hasScoreFilter) { selection.append( AND sc.score ? AND sc.score ?); args.add(String.valueOf(min)); args.add(String.valueOf(max)); } Cursor cursor db.query(score sc, null, selection.toString(), args.toArray(new String[0]), null, null, sc.score DESC);db.query的参数顺序是 table, columns, selection, selectionArgs, groupBy, having, orderBy。columns 传 null 表示查所有列但一旦 JOIN 了多张表建议显式写列名否则后续表结构变动会导致 Cursor 下标错位。args的顺序必须和?出现的顺序一致这个错误很难用肉眼发现我一般会把最终拼好的 selection 和 args 打日志核对。另外moveToNext()只有在第一次返回 true 后getXxx才有意义否则读到的都是默认值。用完的 Cursor 要及时 close。下面几个是调试时最容易遇到的情况表象原因检查方式查询结果一直是空selectionArgs 与 selection 顺序不一致打印 selection 和 args 逐项比对及格率全是 0 或 1整数除法没有浮点化检查聚合 SQL 是否 ×1.0删除学生后成绩还在外键约束未启用确认 onConfigure 开了开关3.3 平均分、最高分、及格率一次查出来统计界面不必在 Java 里写循环用一条 GROUP BY 就能同时拿到平均分、最高分、及格率SELECT c.course_name, COUNT(*) AS cnt, AVG(sc.score) AS avg_score, MAX(sc.score) AS max_score, SUM(CASE WHEN sc.score 60 THEN 1 ELSE 0 END) * 1.0 / COUNT(*) AS pass_rate FROM score sc JOIN course c ON c.id sc.course_id WHERE sc.exam_type 期末 AND sc.semester ? GROUP BY c.course_id;注意pass_rate的计算SUM(CASE...)是整数COUNT(*)也是整数两者相除会得到整数所以必须乘1.0把结果变成浮点数否则及格率永远返回 0 或 1。这是统计结果最容易翻车的地方。AVG(score)返回的 double 不要在 DAO 里格式化保留原始值由 UI 层用String.format(%.1f, value)处理避免统计页和导出页显示格式不一致。4. 成绩界面的四个关键点列表、Adapter、校验和存储界面层决定这个系统能不能真的用起来。下面四个点是我在 Android Studio 里实现这类管理界面时一定会处理的列表骨架、Adapter 刷新、输入校验与进度反馈、存储路径。任何一个处理不当功能都能跑但会出现滚动卡顿、数据错位、ANR 或导出文件打不开。4.1 主界面用 CoordinatorLayout 搭骨架把 Toolbar、内容和底部操作区组织好的常见方案是 CoordinatorLayout AppBarLayout RecyclerViewandroidx.coordinatorlayout.widget.CoordinatorLayout com.google.android.material.appbar.AppBarLayout com.google.android.material.appbar.MaterialToolbar / /com.google.android.material.appbar.AppBarLayout androidx.recyclerview.widget.RecyclerView app:layout_behaviorstring/appbar_scrolling_view_behavior / /androidx.coordinatorlayout.widget.CoordinatorLayoutlayout_behavior让 Toolbar 在列表上滑时收起RecyclerView 自动顶上去竖屏录入成绩时可视区更大。新建项目选 Empty Views Activity 模板时Android Studio 会自动带上 Material 依赖不需要额外引入。有一点容易忽略如果 AppBar 里还放了横向滚动的 Tab 或 Banner要给它们配app:layout_scrollFlagsscroll|enterAlways否则列表下滑时会卡住整个 Toolbar。4.2 RecyclerView 的 Adapter 与列表刷新Adapter 的核心问题是刷新时机。数据量 200 条以内时notifyDataSetChanged()足够简单但如果列表已经滚到中间用户修改一条成绩后刷新这个调用会让列表闪烁还会丢滚动位置。更稳的做法是更新单条时用notifyItemChanged(position)。public class ScoreAdapter extends RecyclerView.AdapterScoreAdapter.VH { private ListScoreVO data new ArrayList(); public void setData(ListScoreVO newData) { this.data newData; notifyDataSetChanged(); } Override public void onBindViewHolder(VH holder, int position) { ScoreVO vo data.get(position); holder.studentName.setText(vo.name); holder.scoreText.setText(String.valueOf(vo.score)); // 不及格标红及格保持默认色 holder.scoreText.setTextColor(vo.score 60 ? 0xFFD32F2F : 0xFF212121); } // VH 和 getItemCount 省略 }设置 item 点击事件时有个经典坑如果 item 根布局设了onClick而 item 内部又有 EditTextACTION_DOWN 会被 EditText 消费根布局的 CLICK 永远不触发。调试这类问题时打印dispatchTouchEvent日志确认事件被谁消费再决定把点击监听放到根布局还是子控件上。4.3 成绩输入校验与进度条反馈成绩输入框用numberDecimal键盘但有的输入法不弹小数点所以我会在代码层再拦一道InputFilterEditText scoreInput findViewById(R.id.et_score); scoreInput.setFilters(new InputFilter[]{ (source, start, end, dest, dstart, dend) - { StringBuilder sb new StringBuilder(dest); sb.replace(dstart, dend, source.toString()); String value sb.toString(); // 允许 0~3 位整数和 1 位小数 return value.matches(\\d{0,3}(\\.\\d?)?) ? source : ; } });InputFilter返回空串等于拒绝本次输入不管是手工敲还是粘贴进来的都会被正则拦住。保存时再判断一次数值是否在 0~100 之间数据库的 CHECK 约束作最后兜底。批量导入 1200 条成绩时不能直接在 UI 线程跑事务否则可能 ANR。常见做法是开子线程执行配合 ProgressBar 反馈进度new Thread(() - { int count importData(scoreList); runOnUiThread(() - { progressBar.setVisibility(View.GONE); adapter.setData(loadAll()); }); }).start();runOnUiThread把 UI 更新切回主线程千万不要在子线程里直接操作 RecyclerView会抛CalledFromWrongThreadException。进度条在导入开始前setVisibility(View.VISIBLE)结束后隐藏。4.4 存储路径与分享避开 Android/data 的坑Android 10 之后分区存储收紧/storage/emulated/0/Android/data/包名/里的文件虽然可以读写但其他应用访问这个目录会被拒绝用户用文件管理器根本看不到导出的文件。继续往公共 Download 目录写又会碰到动态权限申请不同 targetSdkVersion 行为还不一样targetSdkVersion写公共 DownloadFileProvider 分享28 及以下需 READ/WRITE_EXTERNAL_STORAGE推荐29可开 requestLegacyExternalStorage推荐30 及以上需 MANAGE_EXTERNAL_STORAGE推荐最省事的方案是用系统分享把文件放进应用私有目录再用 FileProvider 生成 content:// URI 发出去。Intent send new Intent(Intent.ACTION_SEND); Uri uri FileProvider.getUriForFile(context, context.getPackageName() .fileprovider, csvFile); send.setType(text/csv); send.putExtra(Intent.EXTRA_STREAM, uri); send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); context.startActivity(Intent.createChooser(send, 导出成绩));getUriForFile生成的 URI 只授予接收方一次性读取权限不需要申请外部存储权限。Manifest 里注册 FileProvider在res/xml/file_paths.xml里配置files-path nameexports pathexports//别再用file:///storage/emulated/0/...这种路径去拼 IntenttargetSdkVersion 30 以上会直接抛FileUriExposedException。5. 给成绩系统加上 CSV 导出和数据库备份两个能力5.1 一键导出 CSV 并解决中文乱码导出到时给班主任在电脑上看CSV 是最通用的格式。生成文件不难难在两个细节中文乱码和字段转义。public File exportCsv(Context context) { File dir new File(context.getFilesDir(), exports); if (!dir.exists()) dir.mkdirs(); File file new File(dir, scores_ System.currentTimeMillis() .csv); try (BufferedWriter writer new BufferedWriter(new OutputStreamWriter( new FileOutputStream(file), StandardCharsets.UTF_8))) { writer.write(\uFEFF); // UTF-8 BOM防止 Excel 打开乱码 writer.write(学号,姓名,课程,考试类型,成绩); writer.newLine(); Cursor cursor queryAllScores(); while (cursor.moveToNext()) { writer.write(cursor.getString(0) , cursor.getString(1) , cursor.getString(2) , cursor.getString(3) , cursor.getDouble(4)); writer.newLine(); } cursor.close(); } catch (IOException e) { Log.e(ScoreExport, export failed, e); } return file; }\uFEFF是 UTF-8 BOM用 Excel 打开 CSV 时没有它就会把中文识别成乱码。文件名带时间戳避免覆盖上次导出。简化版本没有处理字段里的逗号和引号如果学号、姓名可能出现这类字符需要用双引号包住字段、内部引号翻倍这是 CSV 的转义规则。5.2 数据库备份用 SQLiteDatabase.backup 更稳直接把 .db 文件复制一份的做法有个隐患数据库处于 WAL 模式时主文件里可能还没合并最近提交的事务备份出来是旧数据。用系统 API 做一致性备份File backup new File(context.getFilesDir(), backup_ System.currentTimeMillis() .db); SQLiteDatabase source helper.getReadableDatabase(); source.backup(backup);backup()会处理 WAL 和 checkpoint导出的文件是完整快照。恢复时把备份文件复制回数据库目录同时删除同名的-wal和-shm文件再重新打开数据库避免残留的 WAL 重放导致版本冲突。导出报表和备份这两个入口可以放在同一个菜单里用 MaterialAlertDialog 让用户二选一文件名里带上当前学期参数归档时按学期区分比事后手动改名省事得多。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价