简介:针对高校教务选课与成绩管理场景,安卓Android教务选课成绩管理系统以Android+Java为客户端技术栈,结合Apache后台服务,围绕学生选课、成绩查询、课程信息展示、教师成绩录入等核心业务展开,适合移动应用开发学习者与教务系统设计人员作为完整项目参考。压缩包共收录200个文件,大小仅3.56MB,其中以91个Java源码文件与64个XML布局/配置资源为主,辅以10个frm数据表结构文件、1个SQL脚本及JAR依赖、properties配置等,构成一套便于导入工程前梳理的移动教务项目骨架。该项目已吸引571人浏览学习,对于如此精简的包体而言具备较高参考价值。包内含完整Java业务逻辑、XML界面布局、SQL数据库脚本及课程、学院、班级、学生、教师、成绩、选课等数据表设计,可帮助读者快速看清Android教务系统的分层结构与前后端交互方式,也能直接用于课程设计、毕业设计或二次开发。
1. 安卓Android教务选课成绩管理系统到底在解决谁的什么问题
拿到名字里带"(2)"的压缩包,多数是期末大作业的归档版本。教务选课成绩管理系统是安卓开发课里出现频率最高的综合题:学生登录后浏览课程、在容量内选课、按学期查成绩;教师维护课程、录入分数。整套流程用本地 SQLite 就能完整演示,不需要部署任何后端服务。
难点不在控件,而在"教务"两个字:同一张课程表,学生端看剩余名额,管理端看维护入口,操作的是同一份数据。安卓开发新手拿它练 Activity 流转和列表刷新,有经验的工程师能借它审视事务、并发和版本迁移上的习惯。下文按先表结构、再界面代码的顺序拆解,最后落到一条验证命令。
2. 数据层先行:安卓教务系统的 SQLite 表结构与外键关联
2.1 本地 SQLite 够用的边界在哪
把"系统"拆开看,教务选课成绩管理的核心就是五张表之间的插入、更新与联查。单机演示场景下,SQLite 是默认选择:随 APK 打包、零配置、SQL 语法和 MySQL 接近,答辩时能说清楚模型。只有出现"多台设备同时改同一份数据"的需求,才需要换成后端数据库。三档选型可以直接当判断标准:
| 场景 | 存储方案 | 理由 |
|---|---|---|
| 期末大作业 / 单机演示 | SQLite | 随应用打包,事务、索引、外键都支持 |
| 局域网多客户端选课 | 后端服务 + MySQL | 容量扣减需要集中控制 |
| 想省样板代码 | Room 封装 SQLite | 编译期校验 SQL,少写 Cursor 转换 |
对于标题里这种典型课设项目,SQLite 是性价比最高的起点。Android Studio 自带的 Database Inspector 能直接查看模拟器上的库内容,比反复拉文件省事;真机则需要配合开发者模式调试。对安卓开发来说,先把数据模型定住,后面写界面才不会反复返工。
2.2 五张核心表的建表 SQL 与字段释义
常见做法是先建 student、teacher、course 三张基础表,再建 select_record 中间表和 grade 成绩表。选课记录表承载学生和课程的多对多关系,成绩表单独存放分数,避免每次联查时把选课状态和成绩混在一起。建表语句如下:
-- 学生表:sno 作为登录账号,密码给默认值便于演示 CREATE TABLE IF NOT EXISTS student ( sid INTEGER PRIMARY KEY AUTOINCREMENT, sno TEXT NOT NULL UNIQUE, sname TEXT NOT NULL, password TEXT NOT NULL DEFAULT '123456', major TEXT ); -- 教师表:单独建表区分登录角色 CREATE TABLE IF NOT EXISTS teacher ( tid INTEGER PRIMARY KEY AUTOINCREMENT, tno TEXT NOT NULL UNIQUE, tname TEXT NOT NULL, password TEXT NOT NULL DEFAULT '123456' ); -- 课程表:容量上限与已选人数分开存 CREATE TABLE IF NOT EXISTS course ( cid INTEGER PRIMARY KEY AUTOINCREMENT, cname TEXT NOT NULL, credit REAL DEFAULT 2.0, teacher_name TEXT, capacity INTEGER DEFAULT 30, selected_count INTEGER DEFAULT 0 ); -- 选课记录表:唯一约束是防重复选课的底线 CREATE TABLE IF NOT EXISTS select_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, sid INTEGER NOT NULL, cid INTEGER NOT NULL, select_time TEXT DEFAULT (datetime('now','localtime')), UNIQUE(sid, cid) ); -- 成绩表:一门课一条记录,重复录入用覆盖 CREATE TABLE IF NOT EXISTS grade ( gid INTEGER PRIMARY KEY AUTOINCREMENT, sid INTEGER NOT NULL, cid INTEGER NOT NULL, score REAL, remark TEXT, UNIQUE(sid, cid) );这段 SQL 里三个细节值得较真。第一,UNIQUE(sid, cid) 是防重复选课的数据库级底线,即使 Java 层漏判,插入时也会抛约束异常,程序里 catch 住即可。第二,course 表的 selected_count 是冗余字段,好处是查列表不用对 select_record 做 count(*),坏处是每次选课必须和插入选课记录放在同一事务里更新,否则余量会漂移。第三,成绩被误删后重新录入的场景用 INSERT OR REPLACE 最顺手,先查再改反而多一次查询窗口期。
2.3 外键约束默认关闭:一个必须显式打开的开关
SQLite 为兼容旧库,外键约束默认是关闭的。Android 里通过 SQLiteOpenHelper 获取连接时,需要在 onConfigure 里显式打开:
@Override public void onConfigure(SQLiteDatabase db) { super.onConfigure(db); db.execSQL("PRAGMA foreign_keys = ON"); }打开之后,删除还有选课记录在案的课程时,数据库会抛外键冲突。程序里 catch SQLiteConstraintException,返回"该课程已有学生选课,不可删除",比在业务层自己数 count 可靠。这个开关不开,删除操作在演示到一半时爆出来的概率非常高。
3. 选课与成绩查询的安卓端实现:从登录态到列表刷新
3.1 登录模块与 SharedPreferences 会话保持
教务系统的登录做两件事:校验账号密码、记录当前身份。校验走 SQLite 查询,身份用 SharedPreferences 保存,App 重启不用重新登录。DbHelper 继承 SQLiteOpenHelper,把第 2 章的建表语句放进 onCreate,查询方法如下:
public long checkLogin(String sno, String pwd, String role) { SQLiteDatabase db = getReadableDatabase(); String table = "teacher".equals(role) ? "teacher" : "student"; String idCol = "teacher".equals(role) ? "tid" : "sid"; Cursor cursor = db.rawQuery( "SELECT " + idCol + " FROM " + table + " WHERE sno=? AND password=?", new String[]{sno, pwd}); long id = -1; if (cursor.moveToFirst()) { id = cursor.getLong(0); } cursor.close(); return id; }角色通过参数传进来,一个方法同时服务学生端和教师端,避免写两套重复代码。密码默认值统一为"123456",演示时不必逐个建账号。登录成功后把用户 id、角色写进 SharedPreferences,sp.edit().putLong("userId", id).putString("role", role).apply()即可。apply() 是异步落盘,commit() 同步等待,登录这种低频操作用哪个都行;但模式必须 MODE_PRIVATE,全局可读模式从 API 17 起已废弃,跑在旧版本模拟器上的项目尤其要注意。
3.2 课程列表的余量计算与刷新方式
列表页的核心是一条带余量计算的 SQL。剩余名额直接由 capacity 减 selected_count,不必关联选课记录表做子查询;只有"当前学生是否已选该课"才需要 join select_record:
SELECT c.cid, c.cname, c.credit, c.teacher_name, c.capacity, c.selected_count, c.capacity - c.selected_count AS remain, EXISTS(SELECT 1 FROM select_record r WHERE r.cid = c.cid AND r.sid = ?) AS selected FROM course c ORDER BY c.cid;这里把"当前学生是否已选"用 EXISTS 子查询带出来,Adapter 里根据 selected 字段决定按钮显示"退选"还是"选课",余量小于等于 0 的课直接置灰按钮。刷新时机上有一个常被忽略的细节:操作完成后回到列表页,必须重新执行查询,而不是在本地集合里改一个字段。列表数据一旦被多个界面持有,本地副本很容易和数据库不一致。刷新方式的选择参考这张表:
| 场景 | 刷新方式 | 原因 |
|---|---|---|
| 选课/退选成功后 | 重新查库 | 数据库才是一致性来源 |
| 管理端改完容量回列表 | 重新查库 | 列表页不持有写权限 |
| 本地筛选、排序 | notifyDataSetChanged | 数据未落库,无需重查 |
几十条课程的量级下,直接 notifyDataSetChanged() 就够了,不需要上 DiffUtil。
3.3 选课写入:容量校验与记录插入绑成一个事务
选课写入涉及两步:course.selected_count 加一,select_record 插入一行。两步必须在一个事务里,否则出现"名额扣了但记录没写"的半截状态。容量校验最稳的写法是把条件写进 UPDATE 的 WHERE,交给数据库行锁裁决,而不是先 SELECT 再在 Java 里比较:
public boolean selectCourse(long sid, long cid) { SQLiteDatabase db = getWritableDatabase(); db.beginTransaction(); try { // 容量条件放进 UPDATE,只有 selected_count < capacity 时才真正改行 SQLiteStatement stmt = db.compileStatement( "UPDATE course SET selected_count = selected_count + 1 " + "WHERE cid = ? AND selected_count < capacity"); stmt.bindLong(1, cid); int updated = stmt.executeUpdateDelete(); // 返回受影响行数 if (updated != 1) { return false; // 容量不足或课程不存在,事务回滚 } ContentValues rec = new ContentValues(); rec.put("sid", sid); rec.put("cid", cid); long rid = db.insertWithOnConflict("select_record", null, rec, SQLiteDatabase.CONFLICT_IGNORE); if (rid == -1) { return false; // 重复选课,回滚会撤销 capacity 的扣减 } db.setTransactionSuccessful(); return true; } finally { db.endTransaction(); } }关键点在两个返回值。executeUpdateDelete() 返回受影响行数,结果为 0 说明余量不足,finally 里的 endTransaction 走回滚,capacity 不会被改动。insertWithOnConflict 命中 UNIQUE(sid, cid) 时返回 -1,同样触发回滚,于是前一步的容量扣减自动撤销——这两处 return false 都是靠事务回滚保证数据一致的,绝不能提前调用 setTransactionSuccessful。
成绩查询是典型的两表 join,学生端显示课程名、学分、分数。SQL 里的别名不要省,Cursor 按列名取值时最不容易写错:
SELECT c.cname, c.credit, g.score, g.remark FROM grade g JOIN course c ON g.cid = c.cid WHERE g.sid = ? ORDER BY c.cid;4. 事务、并发与版本迁移:安卓选课系统最容易翻车的 3 个参数
4.1 先查再写有窗口期,容量校验要写进 UPDATE
第 3 章的写法能成立,是因为容量判断和扣减合并成一条 UPDATE,由数据库自己保证原子性。很多人的第一版是先 SELECT capacity、selected_count,再在 Java 里比大小,最后 UPDATE。两个线程同时读到 remain=1,两边都认为可以选,结果选课记录插了两条,容量被扣成负数。问题不在 UPDATE 本身,SQLite 是单写者,写锁会串行化;问题在"先读后写"的窗口期里,判断依据已经过期。
课设里常见的补救是给所有数据库操作套一个单线程 Executor 顺序执行,确实能挡住并发,但也掩盖了问题。更硬核的方案是条件 UPDATE 配合受影响行数判断,容量边界由数据库背书。答辩被问到"多人同时选最后一门课怎么办"时,能讲出这两层,说明真的理解并发写。
4.2 WAL 模式与单写线程的取舍
安卓开发里数据库读写放主线程会卡顿,几十毫秒的写操作在列表滚动时就能被感知。常见做法是自定义单线程 ExecutorService,所有 DB 操作排队执行:
private final ExecutorService dbExecutor = Executors.newSingleThreadExecutor();再配合 WAL 模式让读不阻塞写:
dbHelper.setWriteAheadLoggingEnabled(true);WAL 的两个代价要提前知道。第一,数据库目录下会多出 -wal 和 -shm 两个文件,备份数据必须三个文件一起拷,只拷 .db 会看到"数据丢了"。第二,多线程并发写时,即使底层是同一个 SQLiteDatabase 实例,也可能触发 SQLiteDatabaseLockedException,所以单写线程的 Executor 依然是课设项目最稳的调度方式。
提示:WAL 模式下从 /data/data/包名/databases/ 拉取数据做备份时,记得连同 .db、-wal、-shm 三个文件一起处理,否则可能拿到旧版本内容。
4.3 DB_VERSION 不是装饰,onUpgrade 只做增量迁移
SQLiteOpenHelper 构造函数里的版本号是最容易被无视的参数。表结构加一列时只改 onCreate 里的建表语句,对已安装的旧库完全不生效,因为数据库文件已经存在,不会重新执行 onCreate。正确做法是版本号加 1,在 onUpgrade 里写增量迁移:
private static final int DB_VERSION = 3; // 从 1 逐级升上来 @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { if (oldVersion < 2) { // 第一次升级:给课程表加学期字段 db.execSQL("ALTER TABLE course ADD COLUMN semester TEXT DEFAULT '2025-2026-1'"); } if (oldVersion < 3) { // 第二次升级:给成绩表加查询索引 db.execSQL("CREATE INDEX idx_grade_sid ON grade(sid)"); } }每加一次结构变更,版本号加一、onUpgrade 加一个 if 分支,老用户升级时从 oldVersion 逐级执行到 newVersion。最常见的错误写法是 DROP TABLE 后重建,数据全丢,演示升级时当场穿帮。答辩被问"升级后学生选课记录还在不在",答"用 ALTER TABLE 做增量迁移"就是得分点。
4.4 四个参数速查
| 参数 | 设置位置 | 作用 | 常见误用 |
|---|---|---|---|
| foreign_keys | onConfigure | 开启外键约束 | 不写 PRAGMA,删除冲突在运行期才暴露 |
| WAL | setWriteAheadLoggingEnabled | 读写并发 | 备份只拷 .db 不带 wal 文件 |
| DB_VERSION | SQLiteOpenHelper 构造参数 | 触发 onUpgrade | 永远写 1,改表后老版本崩溃 |
| CONFLICT_IGNORE | insertWithOnConflict | 防重复选课 | try-catch 吞掉 SQLiteConstraintException |
5. 打包前用一条指令验证数据落盘与选课主流程
界面显示正常不代表数据写进了数据库。开发期验证落盘,最直接的办法是 adb 进 shell 查库文件。数据库在 /data/data/包名/databases/ 下,debug 版可以用 run-as 直接读:
# 列出数据库文件 adb shell run-as com.example.eduadmin ls databases/ # 直接查课程表的真实数据 adb shell run-as com.example.eduadmin sqlite3 \ databases/edu_admin.db \ "SELECT cid, cname, capacity, selected_count FROM course;"模拟器自带 sqlite3,真机大部分没有这个二进制。真机调试时改用 exec-out 把库拉到本地再查,命令改成adb exec-out run-as com.example.eduadmin cat databases/edu_admin.db > /tmp/edu_admin.db,然后用本机 sqlite3 打开。注意 run-as 只对 debuggable 的包生效,打正式签名包后会被拒绝,这是正常的。
验证顺序跟着主流程走:学生账号登录 → 选一门课 → select_record 多出一条 → course 的 selected_count 加一 → 重复选同一门确认插入被忽略 → 管理端录成绩 → 切回学生账号查成绩一致。五步全部符合预期,再在 Android Studio 里执行 Build > Build APK(s) 出包。
最后一招是初始化数据的正确姿势。空数据库演示很尴尬,常见做法是在 onCreate 末尾调用 seedData(),插 10 门课程和几个带默认密码的学生账号。注意 onCreate 只在数据库首次创建时执行,库文件一旦存在,改了种子数据也不会生效。开发期想反复重置,一条命令删库最快:
adb shell run-as com.example.eduadmin rm databases/edu_admin.db下次启动 App 时 SQLiteOpenHelper 发现数据库文件不存在,会自动重走 onCreate,种子数据全部回来,不需要卸载重装,也不会丢 Android Studio 的调试设置。在改表结构、调种子数据、查重复选课的开发循环里,这条命令比设置里清数据快得多。
本文还有配套的精品资源,点击获取