ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

安卓教务选课成绩管理系统:SQLite与Kotlin实战

安卓教务选课成绩管理系统:SQLite与Kotlin实战 简介面向高校教务场景的AndroidJavaApache技术栈移动应用源码包适合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): ListCourse { 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 mutableListOfCourse() 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_id和student_name登录成功后写入退出登录时清空。3.3 用RecyclerView展示课程与选课状态课程列表是系统的主界面我用RecyclerView加一个CourseAdapter。每一个列表项显示课程名、学分、教师、已选人数/容量以及一个选课按钮。按钮的显示状态由Course.selected决定已选则置灰。Adapter的关键部分如下class CourseAdapter( private val onSelect: (Course) - Unit ) : RecyclerView.AdapterCourseAdapter.ViewHolder() { class ViewHolder(val binding: ItemCourseBinding) : RecyclerView.ViewHolder(binding.root) private val items mutableListOfCourse() fun submitList(list: ListCourse) { 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 true。submitList使用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(thisMainActivity, 选课成功, Toast.LENGTH_SHORT).show() refreshList() } else { Toast.makeText(thisMainActivity, 选课失败容量不足或已选过, 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?): ListEnrollment { 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.080-89分3.070-79分2.060-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.kts里dependencyResolutionManagement需要有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值是对应人数barWidth0.6f让柱子之间留出空隙避免视觉拥挤granularity1f保证x轴标签不会出现小数刻度。成绩分布的数据来源可以用单条SQLGROUP BY scoreRange查出然后在Kotlin里映射到四个区间。5. 期末答辩前的自检清单与稳定化技巧5.1 边界输入与重复选课我见过太多系统在“快速连点选课按钮”时崩溃原因是点击后没有立刻把按钮isEnabled置为false。正确姿势是在点击回调里第一行就禁用按钮等事务结束后再恢复。另外学号输入框要过滤非数字字符密码字段要设置inputTypetextPassword。数据库层已经加了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 数据库版本升级与进度条卡顿后续如果改了表结构记得提升SQLiteOpenHelper的DATABASE_VERSION在onUpgrade里做表迁移不要直接DROP TABLE。进度条方面如果用老式的AsyncTask在Activity销毁后回调会崩溃所以务必改用协程的lifecycleScope。最后一个小技巧在课程列表滑动时关闭RecyclerView的ItemAnimator也就是recyclerView.itemAnimator null这样大量数据刷新时不会因为动画导致掉帧这是答辩演示时最容易给老师留下好印象的细节。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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