Android Studio学生选课系统实战:从课程设计到二次开发

发布时间:2026/10/24 9:07:08
Android Studio学生选课系统实战:从课程设计到二次开发

简介:这份资源是面向Android初学者与进阶开发者的学生选课系统完整项目源码,基于Android Studio构建,适合作为课程设计、毕业设计或Android技能练手的实战案例。压缩包共620个文件,约56.75MB,涵盖java源代码、xml布局与清单、gradle构建脚本、properties配置、dex与class编译产物、png与jpg图片资源及json数据文件等,完整呈现了一个安卓应用的工程结构与构建链路。项目围绕选课场景展开,涉及RecyclerView课程列表展示、SQLite选课数据持久化、网络通信与多线程处理等关键技术点,并包含Gradle依赖管理、SDK本地配置等工程化细节。已有2086人学习下载,读者可借此熟悉Android Studio项目目录组织、理解build.gradle与清单文件的作用,并对照源码掌握UI设计、数据存储与模块协作的实现思路,对提升安卓开发实践能力具有较高参考价值。

1. 从一次课程设计答辩说起:这个 Android 选课系统到底能干什么

去年帮一个学弟看课程设计,他打开 Android Studio 模拟器,登录、选课、退课、查课表一气呵成,老师当场问了一句「并发选同一门课怎么处理」,他愣了三秒。这个场景基本概括了「androidstdio学生选课系统」这类项目的真实价值:它不是一个玩具 Demo,而是一个把学生端、课程数据、选课状态流转串起来的最小可用闭环。用 Android Studio 做客户端,后端通常配一个轻量服务或本地数据库,覆盖登录鉴权、课程列表、选课退课、课表查看这几条主线。适合谁?正在做课程设计的学生、想练手 Android 网络请求与本地存储的初级开发者、需要一套能改能扩的选课原型做二次开发的人。下面我按「能干什么 → 怎么跑 → 怎么改」的顺序,把这个项目的骨架拆开讲清楚。

2. 技术栈与工程结构:一个选课 App 的骨架长什么样

2.1 客户端技术选型与依赖关系

这类项目在 Android Studio 里落地,最常见的组合是 Java 或 Kotlin + 原生 View 体系,网络层用 OkHttp 或 Retrofit,本地缓存用 SQLite 或 Room,列表展示用 RecyclerView。为什么不用 Compose?不是不能用,而是课程设计场景下,RecyclerView + Adapter 的写法资料多、报错好查,指导老师也看得懂。我一般会建议保持这个组合,把精力放在业务逻辑上,而不是新框架的玄学问题上。

依赖层面,build.gradle里通常会有这几块:网络请求库、JSON 解析库(Gson 或 Moshi)、数据库组件、以及 Material Design 组件库。下面是一个典型的模块级配置片段,注意版本号要和你本地 Android Studio 的 Gradle 插件版本对齐,否则同步阶段就会翻车。

// app/build.gradle android { compileSdk 34 defaultConfig { applicationId "com.example.courseselect" minSdk 24 // 覆盖绝大多数教学用模拟器 targetSdk 34 versionCode 1 versionName "1.0" } } dependencies { implementation 'androidx.recyclerview:recyclerview:1.3.2' implementation 'com.squareup.retrofit2:retrofit:2.9.0' implementation 'com.squareup.retrofit2:converter-gson:2.9.0' implementation 'androidx.room:room-runtime:2.6.1' annotationProcessor 'androidx.room:room-compiler:2.6.1' implementation 'com.google.android.material:material:1.11.0' }

这段配置的逻辑很直白:minSdk 24是为了兼容教学机房里那些老模拟器;Retrofit 负责把 HTTP 接口转成 Java 方法调用;Room 负责把课表和选课记录落到本地,断网时还能看到已选课程。参数上最容易被忽略的是compileSdk和 Gradle 插件版本的匹配关系,两者差一个大版本,同步就会报「Unsupported class file major version」,这是新手最高频的翻车点之一。

2.2 目录结构与模块职责划分

一个能跑起来的选课系统,包结构通常按职责切分,而不是按页面堆。下面是我拆过的项目里比较清爽的一种组织方式:

app/src/main/java/com/example/courseselect/ ├── ui/ # 页面层 │ ├── LoginActivity.java │ ├── CourseListActivity.java │ └── ScheduleActivity.java ├── adapter/ # RecyclerView 适配器 │ └── CourseAdapter.java ├── model/ # 数据实体 │ ├── Course.java │ └── Student.java ├── network/ # 网络层 │ ├── ApiService.java │ └── RetrofitClient.java ├── db/ # 本地数据库 │ ├── AppDatabase.java │ └── CourseDao.java └── util/ # 工具类 └── SessionManager.java

这个结构的核心思想是「页面只管展示和交互,数据从 network 或 db 来,实体在 model 里统一」。SessionManager单独拎出来存登录态,用 SharedPreferences 实现,避免每个页面都去读一遍用户信息。很多同学把网络请求直接写在 Activity 里,短期能跑,一旦要加「选课成功后刷新列表」这种联动,代码就开始互相纠缠,这是血泪经验。

2.3 数据流:一次选课请求经过哪些层

把一次「点击选课按钮」的完整链路走一遍,你就理解了这个项目的架构。用户在CourseListActivity点击某门课的「选课」按钮,Adapter 通过接口回调把课程 ID 传给 Activity,Activity 调用ApiService.selectCourse(studentId, courseId),Retrofit 发出 POST 请求,服务端返回选课结果,回调里根据成功或失败更新本地 Room 数据库并刷新列表。这条链路里,任何一层出问题都会表现为「点了没反应」,所以排查时要按层定位,而不是盯着按钮看。

// CourseListActivity 中的选课回调 private void onSelectClicked(Course course) { String studentId = SessionManager.getStudentId(this); apiService.selectCourse(studentId, course.getId()) .enqueue(new Callback<ApiResponse>() { @Override public void onResponse(Call<ApiResponse> call, Response<ApiResponse> response) { if (response.isSuccessful() && response.body() != null && response.body().isSuccess()) { // 选课成功:写本地库 + 刷新列表 courseDao.insert(new SelectedCourse(course.getId(), studentId)); loadCourses(); } else { Toast.makeText(CourseListActivity.this, "选课失败:" + response.body().getMessage(), Toast.LENGTH_SHORT).show(); } } @Override public void onFailure(Call<ApiResponse> call, Throwable t) { Toast.makeText(CourseListActivity.this, "网络异常,请稍后重试", Toast.LENGTH_SHORT).show(); } }); }

这段代码的关键点在enqueue的异步回调:网络请求不能放在主线程,否则直接抛NetworkOnMainThreadException。response.isSuccessful()判断的是 HTTP 状态码 2xx,而body().isSuccess()判断的是业务层返回的 success 字段,两者要分开看,很多项目只判断前者,结果服务端返回 200 但业务失败时界面还提示成功,这是典型的黑匣子问题。参数上studentId从 SessionManager 取,courseId从列表项带过来,两个 ID 缺一不可。

3. 跑起来:从导入工程到选课成功的完整步骤

3.1 环境准备与工程导入

拿到源码后第一件事不是急着点运行,而是对齐环境。Android Studio 版本、Gradle 版本、JDK 版本三者要匹配。我一般会先看项目根目录的gradle/wrapper/gradle-wrapper.properties,里面写着这个项目期望的 Gradle 版本,然后确认本地 JDK 是 17 还是 11。如果 Gradle 版本和 JDK 不匹配,同步阶段就会报错,这时候改distributionUrl比升级 Android Studio 更省事。

导入步骤按顺序来:打开 Android Studio,选择 Open 而不是 Import,定位到项目根目录(有settings.gradle的那一层),等待 Gradle 同步。同步过程中如果卡在「Downloading」很久,多半是仓库地址访问慢,可以在settings.gradle里把google()和mavenCentral()的顺序调整一下,或者配置国内镜像。同步成功后,检查local.properties里是否有sdk.dir,没有的话手动指向本地 SDK 路径。

3.2 后端服务与数据库初始化

选课系统离不开数据。如果项目自带一个本地服务端(常见的是 Node.js 或 Spring Boot 写的简易接口),先把它跑起来。以 Node 为例,进入server目录,执行依赖安装和启动:

# 进入服务端目录 cd server # 安装依赖 npm install # 初始化数据库(建表 + 插入测试课程数据) npm run init-db # 启动服务,默认监听 3000 端口 npm start

启动后先用浏览器或 curl 访问一下http://localhost:3000/api/courses,确认能返回课程 JSON 列表。这一步很关键,因为 Android 端连不上服务时,报错信息往往只有一句「连接失败」,你无法判断是服务没起、端口不对还是 IP 写错。先验证服务端独立可用,再排查客户端,能省一半时间。

如果项目没有独立服务端,而是用本地 SQLite 模拟数据,那就跳过这一步,直接看db包下的初始化逻辑,确认首次启动时会插入测试课程。常见做法是在AppDatabase的onCreate回调里执行建表和种子数据插入。

3.3 模拟器网络配置与首次登录

Android 模拟器访问宿主机服务有个坑:不能用localhost或127.0.0.1,因为那是模拟器自己的回环地址。要用10.0.2.2指向宿主机。所以RetrofitClient里的 BASE_URL 通常写成http://10.0.2.2:3000/。如果你用真机调试,则要改成电脑在局域网里的 IP,比如http://192.168.1.100:3000/,并且手机和电脑要在同一个网络下。

// RetrofitClient.java private static final String BASE_URL = "http://10.0.2.2:3000/"; public static ApiService getApiService() { OkHttpClient client = new OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) // 连接超时 .readTimeout(10, TimeUnit.SECONDS) // 读取超时 .build(); Retrofit retrofit = new Retrofit.Builder() .baseUrl(BASE_URL) .client(client) .addConverterFactory(GsonConverterFactory.create()) .build(); return retrofit.create(ApiService.class); }

超时时间设 10 秒是折中值,太短在慢网络下容易误判失败,太长用户等得焦躁。另外 Android 9 以后默认禁止明文 HTTP 请求,如果 BASE_URL 是http://开头,需要在AndroidManifest.xml的application标签里加android:usesCleartextTraffic="true",否则请求直接失败,日志里只有一句Cleartext HTTP traffic not permitted,这是新手最容易卡住的地方。

配置好之后,用测试账号登录(通常服务端种子数据里会有admin/123456或student1/123456这类账号),进入课程列表,点一门课选上,再去课表页确认。整条链路通了,说明环境没问题,可以开始改代码了。

4. 核心模块拆解:登录、选课、课表三条主线的实现细节

4.1 登录鉴权与 Session 管理

登录模块看起来简单,但它是整个系统的入口,做不好后面全是麻烦。典型实现是:用户输入学号和密码,客户端 POST 到/api/login,服务端校验后返回一个 token 或直接返回学生信息,客户端把学生 ID 和 token 存到 SharedPreferences,后续请求带上。

// LoginActivity 中的登录逻辑 private void doLogin(String studentId, String password) { apiService.login(studentId, password) .enqueue(new Callback<LoginResponse>() { @Override public void onResponse(Call<LoginResponse> call, Response<LoginResponse> response) { if (response.isSuccessful() && response.body() != null && response.body().getToken() != null) { // 持久化登录态 SessionManager.saveSession(LoginActivity.this, response.body().getStudentId(), response.body().getToken()); startActivity(new Intent(LoginActivity.this, CourseListActivity.class)); finish(); // 避免返回键回到登录页 } else { Toast.makeText(LoginActivity.this, "账号或密码错误", Toast.LENGTH_SHORT).show(); } } @Override public void onFailure(Call<LoginResponse> call, Throwable t) { Toast.makeText(LoginActivity.this, "网络异常", Toast.LENGTH_SHORT).show(); } }); }

这里有个细节:登录成功后调用finish(),否则用户按返回键会回到登录页,体验很怪。SessionManager内部用SharedPreferences的apply()异步写入,避免主线程阻塞。token 的作用是后续请求的鉴权凭证,常见做法是在 OkHttp 里加一个 Interceptor,统一往请求头里塞Authorization: Bearer <token>,这样每个接口方法不用重复传 token。

4.2 课程列表与 RecyclerView 适配器

课程列表是用户停留最久的页面,性能和数据准确性都要顾。RecyclerView 的 Adapter 负责把List<Course>绑定到 item 布局上,每个 item 显示课程名、教师、学分、余量和一个选课按钮。余量为 0 时按钮置灰,这个判断要放在onBindViewHolder里做。

// CourseAdapter.java 核心绑定逻辑 @Override public void onBindViewHolder(@NonNull CourseViewHolder holder, int position) { Course course = courseList.get(position); holder.tvName.setText(course.getName()); holder.tvTeacher.setText(course.getTeacher()); holder.tvCredit.setText("学分:" + course.getCredit()); holder.tvRemain.setText("余量:" + course.getRemain()); // 余量为 0 或已选过,禁用按钮 boolean canSelect = course.getRemain() > 0 && !course.isSelected(); holder.btnSelect.setEnabled(canSelect); holder.btnSelect.setText(course.isSelected() ? "已选" : "选课"); holder.btnSelect.setOnClickListener(v -> { if (listener != null) { listener.onSelectClicked(course); } }); }

canSelect这个判断把「余量」和「是否已选」两个条件合并,避免用户重复选同一门课。listener是 Activity 传进来的回调接口,Adapter 不直接持有 Activity 引用,防止内存泄漏。列表数据刷新用notifyDataSetChanged()简单粗暴,数据量大时换成DiffUtil更平滑,但课程数量一般几十条,前者够用。

4.3 选课状态流转与本地课表落库

选课成功后,除了刷新列表,还要把记录写到本地数据库,这样课表页和断网场景都能用。Room 的实体和 DAO 定义如下:

// SelectedCourse.java 实体 @Entity(tableName = "selected_course") public class SelectedCourse { @PrimaryKey(autoGenerate = true) public int id; public String studentId; public String courseId; public String courseName; public String teacher; } // CourseDao.java @Dao public interface CourseDao { @Insert(onConflict = OnConflictStrategy.REPLACE) void insert(SelectedCourse course); @Query("SELECT * FROM selected_course WHERE studentId = :sid") List<SelectedCourse> getByStudent(String sid); @Query("DELETE FROM selected_course WHERE studentId = :sid AND courseId = :cid") void delete(String sid, String cid); }

OnConflictStrategy.REPLACE保证同一门课重复插入时覆盖而不是报错。退课走delete方法,删完再刷新课表。课表页按星期和节次排列,常见做法是在SelectedCourse里加weekday和section字段,查询后按这两个字段排序,再用 GridLayout 或自定义 View 画出来。状态流转的核心是「服务端为准、本地为辅」:选课结果以接口返回为准,本地库只是缓存,避免两端不一致时用户看到错误信息。

5. 避坑与排查:选课系统最容易翻车的五个地方

5.1 模拟器连不上后端

现象:登录页一直提示网络异常,Logcat 里是ConnectException。原因多半是 BASE_URL 写成了localhost或127.0.0.1,模拟器访问的是自己。解决:改成10.0.2.2,真机则用电脑局域网 IP,并确认服务端监听的是0.0.0.0而不是仅127.0.0.1。

5.2 明文 HTTP 被拦截

现象:请求发出但立刻失败,日志提示Cleartext HTTP traffic not permitted。原因是 Android 9 以后默认禁止明文流量。解决:在AndroidManifest.xml的application标签加android:usesCleartextTraffic="true",或者把服务端换成 HTTPS。教学项目用前者更快。

5.3 主线程网络请求崩溃

现象:点击按钮后 App 直接闪退,日志是NetworkOnMainThreadException。原因是有同学把 Retrofit 的execute()同步方法直接写在按钮回调里。解决:一律用enqueue()异步方式,回调里再更新 UI;如果确实需要同步,放到子线程或协程里。

5.4 选课成功但列表不刷新

现象:Toast 提示选课成功,但课程余量和按钮状态没变。原因是只更新了服务端,没有重新拉取列表或更新本地数据源。解决:在成功回调里调用loadCourses()重新请求,或者直接修改courseList中对应对象的remain和selected字段后notifyItemChanged(position)。

5.5 数据库版本升级导致崩溃

现象:改了实体字段后重新运行,App 启动即崩,日志是IllegalStateException: Room cannot verify the data integrity。原因是 Room 检测到 schema 变化但版本号没升。解决:把@Database的version加 1,并提供Migration,或者开发阶段直接用fallbackToDestructiveMigration()重建表。生产环境别用后者,会丢数据。

6. 二次开发:把课程设计改成能用的选课工具

拿到这套代码后,最值得做的改造不是换 UI,而是把「选课冲突检测」补上。原始项目通常只判断余量,不判断时间冲突,用户能选上两门同一时段的课,这在真实场景里是硬伤。改造思路:在SelectedCourse里补weekday和section字段,选课前先查本地库,判断新课程的时间段是否与已选课程重叠。

// 选课前的冲突检测 private boolean hasConflict(Course newCourse, List<SelectedCourse> selected) { for (SelectedCourse sc : selected) { if (sc.weekday == newCourse.getWeekday() && sc.section == newCourse.getSection()) { return true; // 同一时段已有课 } } return false; }

调用时机放在onSelectClicked里、发网络请求之前,冲突就直接 Toast 提示,不发请求。这样既减少无效请求,也让交互更跟手。验证方法很简单:选一门周一第 1-2 节的课,再选另一门同样时段的课,应该被拦住;选不同时段的课则正常通过。

第二个改造点是登录态过期处理。真实使用中 token 会失效,接口返回 401 时应该跳回登录页而不是弹一个看不懂的错误。常见做法是在 OkHttp 的 Interceptor 里统一拦截 401,清空 Session 并启动 LoginActivity。这个改动不大,但能让项目从「能演示」变成「能交付」。

第三个点是列表分页。课程多了以后一次性拉全量会慢,给接口加page和size参数,RecyclerView 滚动到底部时加载下一页。改造时注意维护一个currentPage变量,加载中要加锁防止重复请求,这是分页最常见的翻车点。

我自己的习惯是,每次改完一个模块,先在模拟器上把「登录 → 选课 → 退课 → 查课表」这条主链路完整走一遍,再提交代码。有一次偷懒只测了选课没测退课,结果退课后本地库删了但列表没刷新,演示时被当场问住。从那以后我每次改完选课逻辑,都强制把主链路走一遍再收工。希望这套拆解能帮你少走几个弯路,把这份源码真正跑起来、改下去。

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