安卓教务选课成绩管理系统:SQLite与Kotlin实战
2026/9/14 4:10:00 网站建设 项目流程

简介:面向高校教务场景的Android+Java+Apache技术栈移动应用源码包,适合Android学习者、毕业设计选题者以及教务系统二次开发人员。压缩包共200个文件,大小3.56MB,构成上以91个Java源文件、64个XML布局/配置资源为核心,另含SQL数据库脚本、10个窗体描述文件、JAR依赖及图片素材,从数据层、界面层到逻辑层均有覆盖。系统模块包括学生选课、成绩查询、教师录入成绩、课程信息展示,并采用HTTPS与数据库加密保障数据安全。已有571人学习下载,可藉此掌握Android项目工程结构、MVC架构拆分、SQLite/MySQL数据交互、数据表之间的关系以及教务业务功能的设计实现,是快速上手移动教务系统开发的实用参考。

1. 安卓教务选课成绩管理系统:先想清楚“移动端能做什么”

很多人拿到“安卓Android教务选课成绩管理系统”这个题目时,第一反应是照着Web管理后台做一套功能全移植:教师管理、课程管理、选课审批、成绩录入、报表导出……结果安卓端越写越重,最后变成一个塞满Activity的怪物。实际这类系统在移动端的核心价值只有一个:让学生能随时查课表、选课、看成绩。后端管理功能应该留在Web端或者干脆用SQLite本地模拟。反直觉的结论是:越简单的CRUD越容易在细节上翻车。选课时的并发控制、成绩聚合的SQL、列表滑动的卡顿,才是真正的分水岭。这篇博文会以本地SQLite为数据底座,用Android Studio + Kotlin从数据模型一步步搭起一个可以交付的教务选课成绩管理系统,适合安卓开发新人、期末大作业选手,也适合想理顺移动端数据层逻辑的熟手。

2. 数据模型先行:设计选课与成绩的SQLite表结构

2.1 实体关系:学生、课程、选课记录的三张表联动

教务选课成绩管理系统最经典的误区是把“选课”和“成绩”拆成两张独立的表。在真实教务场景里,一名学生选了一门课,最终只会产生一个成绩记录。与其让选课表、成绩表通过外键反复JOIN,不如把成绩作为选课记录的可空字段,这样查询成绩和查询选课状态可以在同一条SQL里完成。我们只需要三张核心表:学生表student、课程表course、选课成绩表enrollment

学生和课程之间是多对多关系,enrollment就是中间表,同时承载了选课状态和成绩结果。这里有一个容易忽略的设计点:课程表里维护一个selected_count字段,表示当前已选人数。选课事务里先检查selected_count < capacity,再更新这个计数器。虽然这不是严格意义上的数据库事务,但在SQLite默认的串行化写操作下,可以避免超选。

表结构设计如下:

表名关键字段用途
studentid, student_no, password, name, major学生登录与身份
courseid, course_no, name, credit, teacher, capacity, selected_count课程信息与选课容量
enrollmentid, student_id, course_id, selected_at, score, gpa选课记录,成绩与绩点

2.2 SQLite建表语句与字段设计

在Android里使用SQLite,我一般让SQLiteOpenHelper负责建库建表。下面这段建表SQL可以直接放到onCreate里执行:

CREATE TABLE student ( id INTEGER PRIMARY KEY AUTOINCREMENT, student_no TEXT NOT NULL UNIQUE, password TEXT NOT NULL, name TEXT NOT NULL, major TEXT ); CREATE TABLE course ( id INTEGER PRIMARY KEY AUTOINCREMENT, course_no TEXT NOT NULL UNIQUE, name TEXT NOT NULL, credit REAL NOT NULL, teacher TEXT, capacity INTEGER NOT NULL DEFAULT 50, selected_count INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE enrollment ( id INTEGER PRIMARY KEY AUTOINCREMENT, student_id INTEGER NOT NULL, course_id INTEGER NOT NULL, selected_at INTEGER NOT NULL, score REAL, gpa REAL, UNIQUE(student_id, course_id), FOREIGN KEY(student_id) REFERENCES student(id), FOREIGN KEY(course_id) REFERENCES course(id) ); CREATE INDEX idx_enrollment_student ON enrollment(student_id);

参数说明:student_no设置UNIQUE,避免同一学号重复注册;selected_at使用INTEGER存毫秒时间戳,方便排序和格式化;score允许为空,表示选课后还没录入成绩;gpa用于存放通过成绩换算出的绩点。UNIQUE(student_id, course_id)是防止重复选课的底线约束,即便应用层漏了判断,数据库也会拒绝插入。

2.3 数据访问层:SQLiteOpenHelper与DAO模式

不要把SQL语句散落在Activity里。我习惯写一个AppDatabaseHelper继承SQLiteOpenHelper,再为每个业务对象建一个DAO类。这样在后期改造ContentProvider或者迁移到Room时,只需要动数据访问层。

下面是CourseDao里查询课程列表的方法,包含选课状态:

fun getCourseList(studentId: Long): List<Course> { val db = helper.readableDatabase val sql = """ SELECT c.*, CASE WHEN e.id IS NULL THEN 0 ELSE 1 END AS selected FROM course c LEFT JOIN enrollment e ON e.course_id = c.id AND e.student_id = ? ORDER BY c.course_no """.trimIndent() val cursor = db.rawQuery(sql, arrayOf(studentId.toString())) return cursor.use { val list = mutableListOf<Course>() while (it.moveToNext()) { list.add(Course( id = it.getLong(it.getColumnIndexOrThrow("id")), name = it.getString(it.getColumnIndexOrThrow("name")), selectedCount = it.getInt(it.getColumnIndexOrThrow("selected_count")), capacity = it.getInt(it.getColumnIndexOrThrow("capacity")), selected = it.getInt(it.getColumnIndexOrThrow("selected")) == 1 )) } list } }

这段代码用LEFT JOIN一次性查出课程和当前学生是否已选,避免在循环里发第二条SQL。cursor.use确保游标关闭,防止内存泄漏。需要注意CASE WHEN e.id IS NULL判断的是选课记录是否存在,而不是成绩是否为空,因为选课成功但成绩未录入时e.id依然有值。

3. 用Android Studio搭客户端骨架:登录、课程列表与选课事务

3.1 项目结构与Gradle配置要点

在Android Studio里新建项目时,我建议选择“Empty Views Activity”,语言选Kotlin。这类教务系统不需要复杂的Jetpack Compose,用传统的View体系配合RecyclerView更稳妥,也方便调试。build.gradle.kts里需要添加至少两个依赖:

dependencies { implementation("androidx.recyclerview:recyclerview:1.3.2") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") }

recyclerview负责课程列表展示,kotlinx-coroutines用来把数据库操作放到IO线程,避免在主线程查库导致ANR。如果你的Android Studio版本较新,可能需要手动点击Sync Now。热词里很多人遇到“android studio sdk无法勾选”的问题,那是SDK Manager里平台版本没下载完整,和这里无关。项目结构上,我会把包名拆成model/dao/ui三层,MainActivity只做页面跳转和登录验证。

3.2 登录模块与Session保持

登录页的常见做法是拿学号和密码去SQLite的student表里比对。不要在UI层直接用rawQuery,而是放在UserDao.login()里。登录成功后,用SharedPreferences保存学生ID,后续所有页面都从这个ID出发:

class UserDao(private val helper: AppDatabaseHelper) { fun login(studentNo: String, password: String): Student? { val db = helper.readableDatabase val cursor = db.rawQuery( "SELECT * FROM student WHERE student_no = ? AND password = ?", arrayOf(studentNo, password) ) return cursor.use { if (it.moveToFirst()) { Student( id = it.getLong(it.getColumnIndexOrThrow("id")), name = it.getString(it.getColumnIndexOrThrow("name")) ) } else null } } }

这段查询参数化了输入,有效避免SQL注入。很多期末大作业为了省事直接字符串拼接学号,这里特别提醒:即便本地数据库,也应当养成参数化查询的习惯。Session保存时用getSharedPreferences("session", Context.MODE_PRIVATE),键名用student_idstudent_name,登录成功后写入,退出登录时清空。

3.3 用RecyclerView展示课程与选课状态

课程列表是系统的主界面,我用RecyclerView加一个CourseAdapter。每一个列表项显示课程名、学分、教师、已选人数/容量,以及一个选课按钮。按钮的显示状态由Course.selected决定,已选则置灰。

Adapter的关键部分如下:

class CourseAdapter( private val onSelect: (Course) -> Unit ) : RecyclerView.Adapter<CourseAdapter.ViewHolder>() { class ViewHolder(val binding: ItemCourseBinding) : RecyclerView.ViewHolder(binding.root) private val items = mutableListOf<Course>() fun submitList(list: List<Course>) { items.clear() items.addAll(list) notifyDataSetChanged() } override fun onBindViewHolder(holder: ViewHolder, position: Int) { val course = items[position] holder.binding.tvCourseName.text = course.name holder.binding.tvCredit.text = "学分:${course.credit}" holder.binding.tvCount.text = "已选 ${course.selectedCount}/${course.capacity}" holder.binding.btnSelect.isEnabled = !course.selected holder.binding.btnSelect.text = if (course.selected) "已选" else "选课" holder.binding.btnSelect.setOnClickListener { onSelect(course) } } }

这里用binding替代findViewById,需要开启viewBinding = truesubmitList使用notifyDataSetChanged()简单直接,虽然性能不如DiffUtil,但对于几十门课程的规模完全够用。如果你用ListAdapter配合DiffUtil,记得在areItemsTheSame里比较course.id,避免刷新时列表闪烁。

3.4 进度条与选课事务:异步操作不能抢主线程

选课不是把数据插进enrollment就结束,还得检查容量、更新计数器。这一串操作必须在事务里执行,并且在UI上给出进度反馈。我用协程的withContext(Dispatchers.IO)做数据库操作,主线程弹一个带进度条的对话框。

lifecycleScope.launch { progressDialog.show() val result = withContext(Dispatchers.IO) { enrollmentDao.enroll(studentId, course.id) } progressDialog.dismiss() if (result) { Toast.makeText(this@MainActivity, "选课成功", Toast.LENGTH_SHORT).show() refreshList() } else { Toast.makeText(this@MainActivity, "选课失败:容量不足或已选过", Toast.LENGTH_LONG).show() } }

enrollmentDao.enroll内部包含事务:

fun enroll(studentId: Long, courseId: Long): Boolean { val db = helper.writableDatabase return try { db.beginTransaction() val cursor = db.rawQuery( "SELECT capacity, selected_count FROM course WHERE id = ?", arrayOf(courseId.toString()) ) cursor.use { if (!it.moveToFirst()) return false val capacity = it.getInt(0) val selectedCount = it.getInt(1) if (selectedCount >= capacity) return false } db.execSQL( "UPDATE course SET selected_count = selected_count + 1 WHERE id = ?", arrayOf(courseId.toString()) ) db.execSQL( "INSERT INTO enrollment(student_id, course_id, selected_at) VALUES(?,?,?)", arrayOf(studentId.toString(), courseId.toString(), System.currentTimeMillis()) ) db.setTransactionSuccessful() true } catch (e: Exception) { false } finally { db.endTransaction() } }

注意:return false在事务块里会触发finally回滚,但beginTransaction之后如果有提前返回,endTransaction会因为没有setTransactionSuccessful而回滚,这是正确行为。容量检查放在事务内部,避免两个线程同时读到剩余1个位置造成超选。

4. 成绩查询与统计:SQL聚合、GPA与图表展示

4.1 多条件查询:按学期、课程名过滤成绩

成绩查询页需要一个查询入口,常见做法是放一个EditText输入课程名关键字,再加一个Spinner选择学期。enrollment表里可以加一个semester字段,比如“23-24-1”。查询时通过QueryBuilder动态拼接条件:

fun getScores(studentId: Long, semester: String?, keyword: String?): List<Enrollment> { val db = helper.readableDatabase val where = StringBuilder("e.student_id = ?") val args = mutableListOf(studentId.toString()) if (!semester.isNullOrEmpty()) { where.append(" AND e.semester = ?") args.add(semester) } if (!keyword.isNullOrEmpty()) { where.append(" AND c.name LIKE ?") args.add("%$keyword%") } val sql = """ SELECT c.name, c.credit, e.score, e.gpa, e.semester FROM enrollment e JOIN course c ON e.course_id = c.id WHERE $where ORDER BY e.semester DESC, c.course_no ASC """.trimIndent() ... }

这里的LIKE ?配合%keyword%做模糊匹配,因为参数化了,所以包含%_的输入不会被当作通配符解读,这一点比直接拼接更安全。排序使用semester DESC让最近一学期排在最前面。

4.2 用SQL算平均分、绩点与排名

成绩统计不能光靠Java/Kotlin在内存里算,SQLite的聚合函数够用。平均学分绩点(GPA)需要先按学分加权。假设成绩与绩点换算规则是:90分以上4.0,80-89分3.0,70-79分2.0,60-69分1.0,否则0。我们可以在SQL里用CASE WHEN直接映射,也可以先查询后在应用层计算。我习惯在应用层算,因为换算规则经常变。但平均分可以用SQL一次查出:

SELECT COUNT(*) AS total_count, ROUND(AVG(score), 1) AS avg_score, SUM(credit) AS total_credit FROM enrollment e JOIN course c ON e.course_id = c.id WHERE e.student_id = ? AND e.score IS NOT NULL;

排名在SQLite里可以用窗口函数RANK() OVER (ORDER BY avg_score DESC)。但Android内置的SQLite版本可能低于3.25,不支持窗口函数。稳妥的做法是子查询:先查出所有学生的平均分,再数一数比自己高的人数:

SELECT (SELECT COUNT(*) + 1 FROM (SELECT student_id, AVG(score) AS avg_score FROM enrollment WHERE score IS NOT NULL GROUP BY student_id) AS a1 WHERE a1.avg_score > a2.avg_score) AS rank FROM (SELECT student_id, AVG(score) AS avg_score FROM enrollment WHERE score IS NOT NULL GROUP BY student_id) AS a2 WHERE a2.student_id = ?;

这个语句在低版本SQLite上也能跑通,缺点是数据量大时性能一般,但几百个学生的测试数据完全没问题。在页面展示时,把平均分和排名放进一个CardView,下方用列表列出每门课的成绩和学分。

4.3 引入MPAndroidChart展示成绩分布

如果想让期末大作业的答辩更有说服力,加一个成绩分布柱状图是性价比最高的操作。在build.gradle.kts里加入:

implementation("com.github.PhilJay:MPAndroidChart:v3.1.0")

这里要注意仓库配置,项目的settings.gradle.ktsdependencyResolutionManagement需要有maven("https://jitpack.io")。图表配置的代码通常放在Fragment或者Activity里:

val entries = listOf( BarEntry(0f, goodCount), BarEntry(1f, middleCount), BarEntry(2f, passCount), BarEntry(3f, failCount) ) val labels = listOf("优秀", "良好", "及格", "不及格") val barDataSet = BarDataSet(entries, "成绩分布").apply { color = Color.parseColor("#3F51B5") valueTextColor = Color.BLACK valueTextSize = 12f } val barData = BarData(barDataSet).apply { barWidth = 0.6f } barChart.apply { data = barData description.isEnabled = false xAxis.valueFormatter = IndexAxisValueFormatter(labels) xAxis.granularity = 1f axisRight.isEnabled = false invalidate() }

参数说明:BarEntry的x值是柱状图的索引,y值是对应人数;barWidth=0.6f让柱子之间留出空隙,避免视觉拥挤;granularity=1f保证x轴标签不会出现小数刻度。成绩分布的数据来源可以用单条SQLGROUP BY scoreRange查出,然后在Kotlin里映射到四个区间。

5. 期末答辩前的自检清单与稳定化技巧

5.1 边界输入与重复选课

我见过太多系统在“快速连点选课按钮”时崩溃,原因是点击后没有立刻把按钮isEnabled置为false。正确姿势是在点击回调里第一行就禁用按钮,等事务结束后再恢复。另外,学号输入框要过滤非数字字符,密码字段要设置inputType="textPassword"。数据库层已经加了UNIQUE(student_id, course_id),即使UI漏判断,也会抛异常,所以DAO的enroll方法要捕获SQLiteConstraintException并返回false

5.2 用adb导出SQLite数据库做真机验证

强烈建议在考试或答辩前用模拟器和真机各跑一遍。很多学生只在模拟器上测试,真机上因为Android版本不同出现路径或SQLite版本问题。验证数据最直接的方法是:

adb exec-out run-as com.example.education db/mydb.db > C:/backup/mydb.db

这条命令会把应用私有目录下的数据库文件导到电脑上,然后用DB Browser for SQLite打开检查。注意run-as仅对debug包有效。导出后重点看enrollment表是否有重复记录、selected_count是否与enrollment实际记录数一致。

5.3 数据库版本升级与进度条卡顿

后续如果改了表结构,记得提升SQLiteOpenHelperDATABASE_VERSION,在onUpgrade里做表迁移,不要直接DROP TABLE。进度条方面,如果用老式的AsyncTask,在Activity销毁后回调会崩溃,所以务必改用协程的lifecycleScope。最后一个小技巧:在课程列表滑动时,关闭RecyclerView的ItemAnimator,也就是recyclerView.itemAnimator = null,这样大量数据刷新时不会因为动画导致掉帧,这是答辩演示时最容易给老师留下好印象的细节。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询