ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android SQLite学生成绩管理:Java源码实现与避坑指南

Android SQLite学生成绩管理:Java源码实现与避坑指南

简介:这份资源面向Android初学者与移动开发课程学习者,提供一套可直接运行的「学生成绩管理系统」完整工程,用于练习SQLite本地存储与Java业务逻辑的配合。系统围绕学生信息与科目成绩的增删改查展开,涵盖SQLiteOpenHelper建表与版本管理、RecyclerView列表展示、按学号姓名条件查询及按总分排序等典型功能,并涉及异常处理与CursorLoader异步加载等性能优化思路,适合作为课程设计或毕业设计的参考原型。压缩包共1641个文件,约18.93MB,以class与dex编译产物、json与xml配置、java源码及jar依赖为主,另含apk安装包、gradle构建脚本与图片资源,完整保留了工程结构与构建痕迹。目前已有4243人学习。读者可从中获得数据模型设计、UI布局与数据库交互的完整实现路径,对照源码理解CRUD落地方式,并借鉴查询排序与数据安全的处理思路。

1. 从一份能跑通的 Android 成绩管理源码说起

期末周前一周,某高校实验室的 A 同学抱着一台旧笔记本找我,说他课程设计选了「学生成绩管理系统」,导师要求必须能装到真机上演示,还要能说清楚数据是怎么存的。他手里有一份从网上扒下来的 Android 工程,打开一看,Activity 里直接拼 SQL 字符串,成绩表连主键都没设,删一条记录能把整个班的成绩带走。这不是个例——Android SQLite Java 这套组合看起来门槛低,真正落地时翻车的地方全在细节里。这份资源就是围绕「学生成绩管理系统」这个经典课程设计场景,用 Java + SQLite 原生 API 搭一套能编译、能演示、能讲清原理的完整工程,适合正在做课程设计的学生、需要快速交付一个本地数据管理 Demo 的开发者,以及想搞明白 SQLiteOpenHelper 到底该怎么用的人。它解决的不是「能不能跑」,而是「跑起来之后数据对不对、删改会不会误伤、换台机器还能不能复现」。

2. 拆开工程看骨架:Activity、SQLiteOpenHelper 与数据模型怎么分工

拿到一份 Android 成绩管理源码,第一件事不是急着点运行,而是先看目录结构,判断它是不是「能改得动」的工程。很多网上流传的版本把所有逻辑塞进一个 MainActivity,几千行代码里既画界面又写 SQL,这种结构演示能过,但导师一问「你的数据层在哪」就露馅。合理的分层应该是:实体类负责字段定义,SQLiteOpenHelper 子类负责建库建表和版本迁移,DAO 层封装增删改查,Activity 只做界面绑定和事件响应。这套分工不是学院派洁癖,而是因为 SQLite 在 Android 上是进程内嵌数据库,没有独立服务进程,任何一处 SQL 写错都会直接抛异常到 UI 线程,分层能让你在 DAO 层就把错误拦住。

2.1 实体类与表结构的字段映射

先看实体类。学生成绩这个场景,核心就两张表:学生表和成绩表。学生表存学号、姓名、班级,成绩表存学号、课程名、分数。这里有个新手常犯的错——把学生姓名直接冗余进成绩表,觉得查询方便,结果改个名字要同步更新几十条成绩记录,一旦漏改就出现「同一个人两个名字」。正确做法是成绩表只存学号作为外键关联,姓名通过 JOIN 或二次查询拿。实体类字段和表字段一一对应,类型也要对齐:SQLite 没有严格的类型约束,你声明 INTEGER 它也能塞字符串进去,所以 Java 侧的类型转换必须在 DAO 层做掉,不能指望数据库帮你兜底。

// Student.java —— 实体类只做字段承载,不掺业务逻辑 public class Student { private String studentId; // 学号,业务主键,用 TEXT 存避免前导零丢失 private String name; // 姓名 private String className; // 班级 public Student(String studentId, String name, String className) { this.studentId = studentId; this.name = name; this.className = className; } // getter/setter 省略,实际工程里建议用 IDE 生成 }

这段代码没什么花哨的,但学号用 String 而不是 int 是关键决策。很多学校的学号带前导零或者字母前缀,用 int 存会在插入时被截断,查询时对不上号,这种坑在演示现场被导师随手输一个「001」就能触发。字段命名用驼峰,建表时转下划线,这是 Java 侧和 SQL 侧的约定,后面写 DAO 时能少一层心智负担。

2.2 SQLiteOpenHelper 的建表与版本管理

SQLiteOpenHelper 是整个工程的基石,它管两件事:第一次打开时建库建表,以及数据库版本升级时做迁移。onCreate 只在数据库文件不存在时调用一次,onUpgrade 在版本号变大时调用。我见过太多工程把建表语句写在 Activity 的 onCreate 里,每次启动都执行一遍 CREATE TABLE IF NOT EXISTS,看似无害,实则把版本管理彻底废掉了——将来加字段时你没有任何钩子可用。

public class DBHelper extends SQLiteOpenHelper { private static final String DB_NAME = "grade.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) { // 学生表:学号为主键,TEXT 类型保留前导零 db.execSQL("CREATE TABLE student (" + "student_id TEXT PRIMARY KEY, " + "name TEXT NOT NULL, " + "class_name TEXT)"); // 成绩表:自增主键 + 学号外键,分数限制 0-100 db.execSQL("CREATE TABLE score (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "student_id TEXT NOT NULL, " + "course TEXT NOT NULL, " + "score REAL CHECK(score >= 0 AND score <= 100), " + "FOREIGN KEY(student_id) REFERENCES student(student_id))"); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { // 版本 1 到 2 的迁移示例:给成绩表加备注字段 if (oldVersion < 2) { db.execSQL("ALTER TABLE score ADD COLUMN remark TEXT"); } } }

建表语句里有两个值得说的点。第一,成绩表用了自增主键 id,而不是拿「学号+课程」做联合主键,原因是同一学生同一课程可能存在补考记录,联合主键会挡住第二次插入,自增 id 留出了余地。第二,score 字段加了 CHECK 约束,把 0 到 100 的范围卡在数据库层,这样即使 UI 层校验漏了,脏数据也进不来。注意 SQLite 的外键约束默认是关闭的,需要在 onOpen 里执行db.execSQL("PRAGMA foreign_keys = ON")才生效,这一点后面避坑章节会展开。onUpgrade 里用 oldVersion 判断而不是直接 DROP 重建,是因为真实场景下用户数据不能丢,课程设计演示时导师很可能问你「加个字段怎么办」,这段代码就是答案。

2.3 DAO 层封装:把 SQL 关进笼子

DAO 层的价值在于让 Activity 永远不碰 SQL 字符串。每个方法对应一个业务动作,参数用实体类或基本类型传,返回值用 List 或单个对象。这样做的直接好处是:SQL 注入风险被收敛到一处,Cursor 的关闭有统一出口,将来换 Room 或换网络存储时只改这一层。

public class ScoreDao { private final DBHelper helper; public ScoreDao(Context context) { this.helper = new DBHelper(context); } // 插入成绩,返回新行 id,-1 表示失败 public long insertScore(String studentId, String course, double score) { SQLiteDatabase db = helper.getWritableDatabase(); ContentValues cv = new ContentValues(); cv.put("student_id", studentId); cv.put("course", course); cv.put("score", score); // 用 insert 而非 execSQL 拼串,参数由框架转义 return db.insert("score", null, cv); } // 按学号查所有成绩,游标用完必须关 public List<String> queryByStudent(String studentId) { List<String> result = new ArrayList<>(); SQLiteDatabase db = helper.getReadableDatabase(); Cursor cursor = db.query("score", new String[]{"course", "score"}, "student_id = ?", new String[]{studentId}, null, null, "course ASC"); try { while (cursor.moveToNext()) { result.add(cursor.getString(0) + " : " + cursor.getDouble(1)); } } finally { cursor.close(); // 不关游标会泄漏,长时间运行后查询变慢 } return result; } }

insertScore 用 ContentValues 而不是字符串拼接,这是防注入的基本功,也是 SQLite 官方推荐的写法。queryByStudent 里 Cursor 放在 try-finally 中关闭,是因为 Cursor 持有底层文件句柄,忘记关在低端机上跑几十次查询就会触发「Too many open files」。查询条件用占位符?配合 selectionArgs,而不是把 studentId 直接拼进 where 子句,同样是为了安全和转义。排序用course ASC让结果稳定,演示时列表顺序不会每次刷新都变,这种细节导师不一定夸,但乱序一定会被问。

3. 从建库到界面联动:一套可复现的落地步骤

骨架看清之后,真正动手要把工程跑起来,还得走一遍环境配置、数据初始化和界面绑定的完整链路。这一章按「新建工程 → 建库 → 灌数据 → 列表展示 → 增删改」的顺序推进,每一步都给出可抄的配置和代码,重点讲清楚参数为什么这么设、失败时先看哪里。Android 的版本兼容是个绕不开的话题,minSdk 设太低用不了新 API,设太高旧设备装不上,课程设计场景下建议 minSdk 21、targetSdk 按当前主流走,这样模拟器和真机都能覆盖。

3.1 工程配置与依赖的最小集

用 Android Studio 新建 Empty Activity 工程,语言选 Java,包名自己定。build.gradle 里不需要引入任何第三方库,SQLite 是系统内置的,这也是这套方案在课程设计里吃香的原因——不用配仓库、不用等下载、断网也能编译。唯一要确认的是 compileSdk 和 targetSdk 保持一致,避免运行时权限和行为变更带来的意外。

// app/build.gradle 关键片段 android { compileSdk 34 defaultConfig { applicationId "com.example.grademanager" minSdk 21 // 覆盖绝大多数在用设备 targetSdk 34 versionCode 1 versionName "1.0" } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } }

minSdk 21 是个务实的选择,再低会碰到一些 API 缺失,再高会挡掉一部分旧模拟器镜像。Java 8 的语法支持在 AGP 新版本里默认开启,但显式写出来能避免换机器后编译报错。这里没有加任何依赖,意味着工程体积小、构建快,答辩现场用别人的电脑 clone 下来也能直接跑,这种「零依赖可复现」在演示场景里比任何花哨功能都值钱。

3.2 首次启动的数据初始化策略

空数据库打开界面是一片空白,演示效果很差。常见做法是在 DBHelper 的 onCreate 里插入几条种子数据,但要注意 onCreate 只在建库时触发一次,如果你调试时反复卸载重装没问题,可一旦只清数据不删库,种子就不会再灌。更稳妥的方式是单独写一个初始化方法,在 Application 或首个 Activity 启动时判断表是否为空,为空才插入。

// 在 MainActivity.onCreate 中调用 private void initSeedDataIfEmpty() { ScoreDao dao = new ScoreDao(this); if (dao.countAll() == 0) { // 表空才灌,避免重复插入 dao.insertScore("2023001", "高等数学", 88.5); dao.insertScore("2023001", "大学英语", 76.0); dao.insertScore("2023002", "高等数学", 92.0); } }

countAll 的实现就是一条SELECT COUNT(*) FROM score,返回 int。判断为 0 才插入,保证多次启动不会累积重复记录。种子数据选两三个学生、两三门课,覆盖「同一学生多门课」和「同一课程多人」两种查询场景,演示时切换学号就能看到不同结果,比只放一条数据有说服力。分数故意用 88.5 这种带小数的值,是为了验证 REAL 类型和显示格式,整数分数容易掩盖精度问题。

3.3 ListView 绑定与增删改的界面联动

界面层用 ListView + SimpleAdapter 就够,不必上 RecyclerView,课程设计的重点是数据链路不是 UI 框架。关键在于每次数据变更后要重新查询并 notifyDataSetChanged,否则界面和数据库会脱节——这是新手最常见的「改了没反应」问题。

private void refreshList() { List<Map<String, String>> items = new ArrayList<>(); for (String line : scoreDao.queryAllFormatted()) { Map<String, String> row = new HashMap<>(); row.put("line", line); items.add(row); } SimpleAdapter adapter = new SimpleAdapter(this, items, android.R.layout.simple_list_item_1, new String[]{"line"}, new int[]{android.R.id.text1}); listView.setAdapter(adapter); } // 删除按钮回调里 int rows = scoreDao.deleteById(selectedId); if (rows > 0) { refreshList(); // 删除成功后必须刷新,否则列表还显示旧数据 }

refreshList 每次都重建 adapter,数据量小的时候没问题,数据上千条时会有卡顿,进阶做法是复用 adapter 只更新数据源,这一点在最后一章会提。deleteById 返回受影响行数,大于 0 才刷新,这样删除失败时界面不会误报成功。selectedId 来自 ListView 的 onItemClick 回调里记录的 position 对应的数据库 id,注意是数据库主键不是列表下标,两者在排序变化后会错位,这个坑后面单独说。

4. 避坑与排查:五个让演示当场翻车的细节

代码能编译不代表能演示,SQLite 在 Android 上的很多行为跟桌面版不一样,下面这几条都是我在帮人看工程时反复遇到的真实翻车点,按「现象 → 原因 → 解决」拆开写,照着排查能省掉大量试错时间。

4.1 外键约束写了却没生效

现象:成绩表里插入了不存在的学号,数据库照单全收,查询时出现「孤儿成绩」。原因:SQLite 的外键约束默认关闭,建表时写了 FOREIGN KEY 只是声明,不执行PRAGMA foreign_keys = ON就不会真正校验。解决:在 DBHelper 的 onOpen 方法里执行该 PRAGMA,注意 onOpen 每次打开数据库都会调用,适合放这种会话级设置。

@Override public void onOpen(SQLiteDatabase db) { super.onOpen(db); if (!db.isReadOnly()) { db.execSQL("PRAGMA foreign_keys = ON"); } }

4.2 主线程查询导致界面卡顿甚至 ANR

现象:数据量到几千条后,点查询按钮界面冻结一两秒,严重时弹「应用无响应」。原因:SQLite 查询是同步 IO,放在主线程会阻塞 UI 渲染。解决:小数据量课程设计可以接受,但要心里有数;数据量上去后用 ExecutorService 或 AsyncTask(已废弃但课程设计仍常见)把查询挪到子线程,查完再回主线程更新 UI。演示前先用几千条数据压一遍,能提前暴露这个问题。

4.3 用列表下标当数据库 id 删除

现象:按课程名排序后删第二条,结果删掉的是另一条记录。原因:ListView 的 position 是当前显示顺序的下标,排序或过滤后与数据库主键不再对应。解决:adapter 的数据项里带上数据库 id,onItemClick 时取 id 而不是 position,删除用 id 定位。这个错误在演示时特别隐蔽,因为第一次点往往是对的,换个排序就错位。

4.4 数据库版本号改了但没写迁移逻辑

现象:给实体加了字段、改了 DB_VERSION,装上新版后一查询就崩,报「no such column」。原因:版本号变大触发 onUpgrade,但方法体是空的或者只写了 DROP TABLE,旧数据被清空或表结构没更新。解决:onUpgrade 里按 oldVersion 逐版本写 ALTER TABLE,绝不 DROP 重建;调试阶段可以卸载重装绕过,但交付版本必须写对迁移。

4.5 忘记关闭 Cursor 和数据库连接

现象:反复查询几十次后应用变慢,Logcat 出现资源相关警告。原因:Cursor 和 SQLiteDatabase 都持有底层资源,不关闭会累积泄漏。解决:Cursor 用 try-finally 关闭;SQLiteDatabase 一般由 SQLiteOpenHelper 统一管理,不要每次手动 close,否则下次 getWritableDatabase 又要重开,反而更慢。记住「Cursor 必关、Database 交给 Helper」这条原则即可。

5. 进阶验证:用事务和批量操作把数据一致性钉死

基础功能跑通之后,真正区分「能演示」和「敢交付」的是数据一致性。成绩管理里最典型的场景是批量导入:一个班几十条成绩,逐条 insert 中途失败会留下半截数据,查也不是删也不是。SQLite 的事务机制就是为这种场景准备的,把一批操作包在 beginTransaction 和 setTransactionSuccessful 之间,任何一条抛异常就整体回滚,要么全进要么全不进。下面这段是批量导入的写法,注意 endTransaction 必须放在 finally 里,否则异常路径下事务不会结束,后续操作会一直卡在未提交状态。

public int batchInsert(List<double[]> records, String studentId) { SQLiteDatabase db = helper.getWritableDatabase(); db.beginTransaction(); int success = 0; try { for (double[] r : records) { ContentValues cv = new ContentValues(); cv.put("student_id", studentId); cv.put("course", "课程" + (int) r[0]); cv.put("score", r[1]); if (db.insert("score", null, cv) != -1) success++; } db.setTransactionSuccessful(); // 只有走到这里才真正提交 } finally { db.endTransaction(); // 无论成败都要结束事务 } return success; }

批量插入用事务的另一个好处是速度。SQLite 默认每次 insert 都走一次磁盘同步,几十条数据能明显感觉到卡;包进事务后只在提交时同步一次,实测能快一个数量级。验证事务是否生效有个土办法:故意在循环中间抛个异常,看数据库里是不是一条都没进去,进了就说明事务没包对。这个验证动作我每次交付前都会做一遍,比看代码可靠。

再往上一层是查询性能。成绩管理常见的「按班级查平均分」「按课程排名」这类聚合查询,用 SQL 的 GROUP BY 和 AVG 在数据库层算完再返回,比把全表捞到 Java 里循环快得多,也省内存。索引方面,student_id 和 course 上各建一个普通索引,查询计划会从全表扫描变成索引查找,几千条数据下差异肉眼可见。建索引的语句放在 onCreate 里,跟建表一起执行:

db.execSQL("CREATE INDEX idx_score_student ON score(student_id)"); db.execSQL("CREATE INDEX idx_score_course ON score(course)");

索引不是越多越好,每个索引都会拖慢写入并占空间,成绩表这种读多写少的场景加这两个足够。验证索引是否被用上,可以在查询前执行EXPLAIN QUERY PLAN,看输出里有没有USING INDEX,没有就说明条件写法让索引失效了,比如对字段做了函数运算或者用了 LIKE '%xx' 前缀通配。

最后说一个演示技巧:答辩前把数据库文件从设备里 pull 出来,用任意 SQLite 客户端打开,当着导师的面查一遍表结构和数据,比在手机上点来点去更有说服力。命令是adb shell run-as 包名 cat databases/grade.db > local.db,前提是应用是 debuggable 的。这一步能证明数据真的落盘了,不是内存里造的假象。从那以后我每次交付带本地存储的工程,都会强制走一遍「pull 库 → 外部工具打开 → 核对表结构」的流程,翻车次数直线下降。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进