简介:面向Android课程设计的一套选课系统完整源码,采用前后台分离思路,覆盖学生端与管理员端。学生通过账号密码登录后可查看个人课表、浏览选课列表并提交选课,还支持修改个人信息;管理员使用固定账号登录后既可管理全部课程信息,也能删除或新增课程,可修改学生每门课的成绩,并能添加新的学生账号。项目中借助SQLiteDatabase完成数据持久化,使用底部导航栏组织核心页面,配合Activity跳转实现常用流程,UI与交互逻辑都比较常规,适合安卓初学者作为课设模板或二次开发起点。压缩包大小29.52MB,当前详情页显示文件总数为0、文件类型明细暂无,实际内容请以解压后为准。该资源已有3921人学习,适用于Android Studio 3.6.1及以上版本,配置Gradle 5.6.4即可运行。代码带注释,便于对照理解选课模块、角色权限和数据表设计。
1. 这套选课系统的价值,在于把“数据边界”画在手机之外
课程设计里“选课系统”出镜率极高,但多数交上去的版本,是把课程数据直接塞进 SQLite,界面和逻辑全挤在一个 Activity 里。能打开、能点按钮、能查几条记录,本质上是个单机演示程序。答辩时老师只要问“课程数据换一台手机怎么办”“两个人同时选最后一门课会不会超选”,当场就卡住。标题里的“前后台分离”就是把数据与业务逻辑放到独立服务端,Android Studio 端只负责展示与交互,双方通过 HTTP 接口交换 JSON。这套结构的优势不是“看着高级”,而是界面可以单独调试、数据可以单独验证、选课竞争条件可以在服务端统一处理,正好覆盖课程设计最看重的几个评分点:架构清晰、功能完整、有测试支撑。
2. 前后台分离的架构怎么切:Android Studio 负责 UI,服务端负责数据
2.1 前后台分离的分界点:接口、协议、状态各管各的
前后台分离不是简单地把代码分成两个工程,而是要分清三种边界:接口边界、协议边界、状态边界。
接口边界指客户端只依赖服务端暴露的 REST API,不会直接操作数据库;协议边界指双方约定 JSON 结构,字段名、类型、错误码都统一;状态边界指的是“选课是否成功”这类业务状态由服务端判定,Android 端只负责把判定结果渲染出来,不能自己在本地把剩余名额减一。
这个划分对课设尤其关键。很多同学会在 Android 端做一个“本地课程缓存”,选课时先改本地数据再同步服务端,一旦同步失败,两边数据就对不上。前后台分离的写法应该是:服务端是唯一事实来源,Android 端每次进入页面都重新拉取课程列表,选课动作只提交请求,界面状态跟着响应刷新。这样排查问题的时候,只需要问“服务端返回了什么”,而不是“客户端哪里算错了”。
2.2 选型依据:Retrofit + OkHttp + JSON 为什么是课设首选
实现前后台分离的 Android 端,常见方案是 Retrofit 加 OkHttp 加 Gson。Retrofit 负责把接口方法声明成 Kotlin/Java 函数,OkHttp 负责真正的网络请求与连接管理,Gson 负责 JSON 和实体类的互转。这三样都是 Android Studio 默认工程里可以直接引入的库,资料多、报错容易搜、课程设计答辩时也经得起问。
| 技术 | 职责 | 为什么选它 | 常见替代 |
|---|---|---|---|
| Retrofit | 接口定义与请求装配 | 注解声明式写法,代码量少,泛型解析完善 | Ktor Client、Volley |
| OkHttp | HTTP 连接、超时、日志 | 连接复用好,拦截器方便调试,稳定 | HttpURLConnection |
| Gson | JSON 序列化/反序列化 | 与 Retrofit 集成简单,字段映射灵活 | Moshi、kotlinx.serialization |
| Spring Boot | 服务端接口与事务 | 起步快,内置 Tomcat,课设环境友好 | Flask、Node.js Express |
我在课设里推荐 Spring Boot 做服务端:不需要额外配 Tomcat,一个 main 方法就能启动,MyBatis 可以直接写 SQL,对“数据库查询”这个考点的展示非常直观。如果你的电脑跑不动 Spring Boot,用 Flask 写同样接口也行,Android 端代码完全不受影响,这正是前后台分离的好处。
2.3 统一接口规范:ApiResp 包裹返回数据,省掉一半查询调试
客户端和服务端约定一个统一响应结构,我用一个泛型包装类,所有接口都返回这个格式,前端只解析一次。
{ "code": 0, "message": "success", "data": {} }code 为 0 表示成功,非 0 表示业务失败,message 给用户提示,data 放具体数据。有人会直接用 HTTP 状态码表示业务状态,我一般不建议:HTTP 200 只代表请求到达服务器,不代表选课成功。比如“课程已满”是预期内的业务结果,HTTP 状态码仍应返回 200,由 code 字段区分。
| code | 含义 | 客户端处理 |
|---|---|---|
| 0 | 成功 | 解析 data |
| 1001 | 课程不存在或已满 | Toast message |
| 1002 | 重复选课 | Toast message 并刷新列表 |
| 1003 | 参数缺失 | 检查请求参数 |
Android 端对应的 Kotlin 数据类长这样:
data class ApiResp<T>( @SerializedName("code") val code: Int, @SerializedName("message") val message: String, @SerializedName("data") val data: T? )注意两个点:第一,Gson 反序列化时只看@SerializedName注解,接口字段名和 Kotlin 字段名不一致很容易踩坑,后面排错章节会细说;第二,data 字段的泛型 T 在 Retrofit 声明返回类型时要写具体类型,比如ApiResp<List<CourseDto>>,否则泛型擦除会直接解析失败。
3. 服务端选课系统的数据模型与查询接口
3.1 数据库三表设计:student、course、selected_course 及唯一索引
选课系统最经典的库表结构是三张表:学生表、课程表、选课关系表。先看建表 SQL,这套结构在 MySQL 8 和 MariaDB 上都直接可用。
CREATE TABLE student ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL ); CREATE TABLE course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, teacher VARCHAR(50), credit INT DEFAULT 2, total INT NOT NULL, remaining INT NOT NULL ); CREATE TABLE selected_course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, course_id BIGINT NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_course (student_id, course_id) );课程表保留了一个remaining冗余字段,专门用来表示剩余名额。这不是数据库设计课说的“反范式错误”,而是针对“选课”这种高频读、低频写场景的常见做法:列表页直接读 remaining 展示“剩余 X 人”,不用实时 count 选课关系表。
selected_course上唯一的联合索引uk_student_course是防重复选课的第一道防线:即使业务代码漏判断,数据库层面也不允许同一个学生对同一门课插入两条记录。这个索引在答辩时一定要主动提,它能直接引出第五章的并发验证。
3.2 查询接口的 SQL 与实现:关联查询的取舍
课程列表接口要做的是“查所有课程,并标记当前学生是否已选”。常见实现是在 SQL 里用LEFT JOIN一次查出结果,避免在 Android 端用循环比对。
@Select(""" SELECT c.id, c.name, c.teacher, c.credit, c.total, c.remaining, IF(s.id IS NULL, 0, 1) AS selected FROM course c LEFT JOIN selected_course s ON c.id = s.course_id AND s.student_id = #{studentId} ORDER BY c.id """) List<Map<String, Object>> listCourses(Long studentId);注意 JOIN 的条件里带上了s.student_id = #{studentId},这是关键。如果把这个条件写进 WHERE,就变成了内连接,没选过的课程会被过滤掉;写在 JOIN 条件里,LEFT JOIN 才能保留未选课程,并通过IF(s.id IS NULL, 0, 1)判断出是否已选。这条 SQL 是课设报告里可以直接截图分析的内容,把“为什么放 ON 不放 WHERE”讲清楚,数据库查询这一项得分就不会低。
接口层暴露为GET /api/courses?studentId=1,返回的就是之前约定的ApiResp结构。实际调用时,MyBatis 返回的 Map 里字段名是下划线风格,而客户端用的是驼峰,我一般在 SQL 里用别名直接转换,比如把student_id写成studentId,避免在 Java 里再做一层转换。
3.3 选课操作的防超卖处理:事务、先查后改与唯一索引的兜底
选课是这个系统的核心事务,逻辑不能只是“insert 一条记录”。两个学生同时请求最后一门课时,如果只做“先查剩余,再插入”,中间隔着网络延迟,两个请求都可能读到 remaining=1,导致超卖。服务端处理我一般这样写:
@Transactional public void selectCourse(Long studentId, Long courseId) { // 带行锁查询,阻塞并发的其他选课请求 Course course = courseMapper.selectByIdForUpdate(courseId); if (course == null || course.getRemaining() <= 0) { throw new BusinessException(1001, "课程已满或不存在"); } int count = selectedMapper.countByStudentAndCourse(studentId, courseId); if (count > 0) { throw new BusinessException(1002, "不能重复选课"); } selectedMapper.insert(studentId, courseId); courseMapper.decreaseRemaining(courseId); }selectByIdForUpdate对应的 SQL 是SELECT * FROM course WHERE id = #{courseId} FOR UPDATE,这是悲观锁的写法:事务提交前,其他事务要更新同一行必须等待。对课设场景,选课并发量远没有电商秒杀那么大,悲观锁简单可靠、容易解释。如果你想在报告里体现进阶思考,可以补一句:高并发场景会改用乐观锁或 Redis 原子扣减,但课程设计用行锁已经足够。
@Transactional保证这三步在同一事务里:插入成功但扣减失败时整体回滚,不会出现“票扣了但没选上”的数据不一致。注意事务要加在 Service 方法上,而不是 Controller 方法上,加错了位置会导致事务完全不生效。
4. Android Studio 客户端实现:Retrofit 到 RecyclerView 的完整链路
4.1 定义 Retrofit 接口和网络层:baseUrl、超时、日志拦截器
先在build.gradle里加依赖,然后写网络层。依赖版本不用刻意追新,我用的是一套非常稳定的组合:
implementation 'com.squareup.retrofit2:retrofit:2.9.0' implementation 'com.squareup.retrofit2:converter-gson:2.9.0' implementation 'com.squareup.okhttp3:okhttp:4.12.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'网络层我习惯用一个单例对象封装,避免每个页面都 new 一个 Retrofit:
object ApiClient { private const val BASE_URL = "http://10.0.2.2:8080/" private val okHttpClient = OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(15, TimeUnit.SECONDS) .addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY }) .build() val courseApi: CourseApi = Retrofit.Builder() .baseUrl(BASE_URL) .client(okHttpClient) .addConverterFactory(GsonConverterFactory.create()) .build() .create(CourseApi::class.java) }baseUrl 有两个坑必须说。第一,必须以/结尾,否则和@GET("api/courses")拼起来会少掉斜杠;第二,10.0.2.2是 Android 模拟器访问宿主机的专用地址,真机调试要改成电脑的局域网 IP。connectTimeout 指建立连接的超时,readTimeout 指等待响应的超时,课设环境里服务端第一次启动往往比较慢,readTimeout 给到 15 秒比较保险。日志拦截器只建议在 debug 环境开,输出所有请求和响应体,这是排查接口问题最快的工具。
接口定义用协程的 suspend 写法,比传统 enqueue 回调简洁很多,Retrofit 2.6 以上原生支持:
interface CourseApi { @GET("api/courses") suspend fun getCourses(@Query("studentId") studentId: Long): ApiResp<List<CourseDto>> @POST("api/select") suspend fun selectCourse(@Body SelectRequest request): ApiResp<Any> }@Query会把 studentId 拼到 URL 上,@Body会把对象序列化成 JSON 放进请求体。这两个注解是 Retrofit 最常用的两个参数注解,考试和面试都爱问。
4.2 ViewModel + LiveData 管理课程列表与选课状态
页面直接用 Activity 发请求也能跑,但旋转屏幕时请求会中断、数据会丢。用 ViewModel 把网络请求和界面状态放进生命周期感知的容器里,是“优秀课设”和普通课设的明显分界线。
class CourseViewModel : ViewModel() { private val api = ApiClient.courseApi private val _courseList = MutableLiveData<List<CourseDto>>() val courseList: LiveData<List<CourseDto>> = _courseList private val _tip = MutableLiveData<String>() val tip: LiveData<String> = _tip fun loadCourses(studentId: Long) { viewModelScope.launch { try { val resp = api.getCourses(studentId) if (resp.code == 0) { _courseList.value = resp.data } else { _tip.value = resp.message } } catch (e: Exception) { _tip.value = "网络异常:${e.message}" } } } fun selectCourse(studentId: Long, courseId: Long) { viewModelScope.launch { try { val resp = api.selectCourse(SelectRequest(studentId, courseId)) _tip.value = if (resp.code == 0) "选课成功" else resp.message } catch (e: Exception) { _tip.value = "请求失败:${e.message}" } } } }viewModelScope 是 lifecycle-viewmodel-ktx 提供的协程作用域,ViewModel 销毁时自动取消协程,避免请求还没回来页面已经关了导致的泄漏。LiveData 的value赋值只能在主线程,上面代码没有切换线程,所以安全;如果网络请求被放到了Dispatchers.IO,就必须改用postValue,这是新手最常掉的坑。
界面侧通过observe监听courseList,数据到位就刷新 RecyclerView;监听tip,弹一个 Toast。整个 Activity 里不会出现任何网络代码,这就是前后台分离在客户端层面的体现:网络层、数据层、界面层各管各的。
4.3 RecyclerView 的选课按钮逻辑:防重复点击与失败回滚
选课按钮的交互逻辑比想象中容易写崩。常见问题是:用户手快双击按钮,一个学生发出两个选课请求;或者请求失败后按钮一直保持禁用,用户以为点过了。我一般会维护一个selecting集合记录正在提交中的课程。
class CourseAdapter( private val onSelect: (CourseDto) -> Unit ) : RecyclerView.Adapter<CourseAdapter.VH>() { private val selecting = mutableSetOf<Long>() fun setSelecting(courseId: Long, selecting: Boolean) { if (selecting) this.selecting.add(courseId) else this.selecting.remove(courseId) notifyDataSetChanged() } // 在 onBindViewHolder 里 holder.btnSelect.setOnClickListener { val course = item if (selecting.contains(course.id)) return@setOnClickListener if (course.selected == 1) { Toast.makeText(context, "已选过这门课", Toast.LENGTH_SHORT).show() } else { setSelecting(course.id, true) onSelect(course) } } }在 ViewModel 返回响应后,调用setSelecting(courseId, false)重置按钮状态,同时刷新课程列表更新剩余名额和已选标记。这样双击提交被拦截在网络层之前,失败也不影响后续重试。选课前再弹一个AlertDialog让用户确认,这一条交互细节放到课设演示里会非常显眼,属于低成本高印象分的功能。
5. 联调排错与并发验证:优秀课设的加分项
5.1 联调阶段最常见的 5 个报错,从 Android Studio 到 JSON 解析
前后台分离的项目第一次把两端连起来跑,报错几乎是必然的。下面这 5 个是我在课设指导里见到最频繁的问题,按检查顺序列出来。
| 现象 | 原因 | 处理 |
|---|---|---|
| Connection refused | 模拟器用了 localhost,指到自己 | 服务端保持启动,baseUrl 改成http://10.0.2.2:8080/ |
| CLEARTEXT communication not permitted | Android 9+ 默认禁止明文 HTTP | 在 AndroidManifest.xml 的 application 上加android:usesCleartextTraffic="true" |
| JSON 解析失败 Expected BEGIN_OBJECT | 后端 data 字段返回结构不一致 | 用日志拦截器看原始 JSON,对照@SerializedName逐个字段核对 |
| 中文乱码 | 服务端和数据库编码不一致 | MySQL 建库用utf8mb4,服务端连接串加characterEncoding=UTF-8 |
| Gradle 导入慢或失败 | 首次需下载依赖 | 用阿里云镜像仓库,或打开 Android Studio 的 Offline Work 模式 |
其中明文 HTTP 的问题只在调试阶段建议打开,课程设计不涉及上线场景,可以用这个方案;如果你的服务端配了 HTTPS,就不需要这项配置。
5.2 用一个并发脚本证明“不超卖”,答辩时直接演示
选课系统最重要的验证是并发下不超卖。我把数据库里课程 ID 为 1 的课程余量设为 8,然后用一条循环命令模拟 20 个学生同时抢课:
COURSE_ID=1 for i in $(seq 1 20); do curl -s -X POST "http://10.0.2.2:8080/api/select" \ -H "Content-Type: application/json" \ -d "{\"studentId\": $i, \"courseId\": $COURSE_ID}" & done wait&让 20 个请求并发执行,wait等全部结束。跑完后查询数据库:
SELECT COUNT(*) FROM selected_course WHERE course_id = 1; SELECT remaining FROM course WHERE id = 1;正确的预期是COUNT(*) = 8,remaining = 0:8 个请求成功,其余 12 个返回课程已满,不允许插入第 9 条记录。如果结果出现大于 8 的记录数,说明事务或行锁没有生效,回到 3.3 节检查@Transactional是否加在了 Service 层。建议把命令和查询结果截图放进课设报告,同时把这组 curl 参数改成你自己的课程 ID 和端口,跑通后存档,这就是整个项目最有力的数据一致性证据。
本文还有配套的精品资源,点击获取