
简介一份基于Android Studio开发的选课系统完整工程包面向Android初学者、移动应用课程设计者以及需要参考完整项目结构的学习者。项目围绕登录与选课两大核心流程涵盖环境配置、界面布局、数据持久化、网络请求、基础安全处理等完整开发链路。资源包共1486个文件以611个xml布局与配置、472张png界面素材、150个class编译文件为主同时包含dex、jar、aidl等构建产物并附有app-debug.apk可直接安装体验压缩包大小25.47MB。目前已有2504人下载学习。工程实现了记住用户名与密码、密码明文密文切换、SharedPreferences持久化登录信息、RecyclerView课程列表展示、选课确认对话框等关键功能并涉及Retrofit/OkHttp异步请求与异常处理目录结构清晰便于对照分析Android项目从UI搭建到业务逻辑落地的完整过程适合作为课程设计、实训项目或毕业设计的参考蓝本。1. 从登录页到选课闭环这份 Android 选课系统工程能复现什么Android 选课系统这类课设项目网上打包的源码不少但解压后能直接跑出 app-debug.apk 的不多。这份工程的核心价值在于登录、记住密码、密码隐藏显示、课程列表、选课确认、已选管理这一整套流程都能对上号编译产物也完整。工程内部是标准的 Android Studio 项目结构Java 源码、res 资源、Gradle 配置都在适合正在做 Android 课设、需要登录列表持久化模板的学生也适合刚接触 Android Studio 使用、想找一份能完整复现的工程的初级开发者。我完整拆了一遍把初始化、登录、列表、网络、排错、进阶这些环节的细节都记在下面照着走能省不少排查时间。2. 登录模块拆解SharedPreferences 记住密码的三个正确姿势2.1 解压之后先别慌那些 aidl 与 ap_ 文件到底是什么解压 rar 后很多人会被一堆 .aidl 文件吓到MediaSessionCompat.aidl、ParcelableVolumeInfo.aidl、PlaybackStateCompat.aidl、MediaMetadataCompat.aidl、ResultReceiver.aidl、RatingCompat.aidl。这些不是你的业务代码而是 AndroidX MediaCompat 库在编译时带进来的 AIDL 接口声明负责跨进程传递 MediaSession 相关的 Parcelable 对象。选课系统用不到它们直接无视别删也别改删了反而可能破坏依赖。同样容易让人误会的是 resources-debug.ap_、resources-debug-ir.ap_、resources-debug-androidTest.ap_ 这几个文件。ap_ 是 AAPT 把 res 资源编译后生成的中间产物debug 变体下每个依赖模块都会生成一个相当于资源的「半成品」状态。整个包里真正要关注的是 app-debug.apk这是打包完整的调试安装包可以直接 adb install 装到模拟器或真机上。导入方式没有什么玄学Android Studio 里 File → New → Import Project选解压后的根目录等 Gradle sync 跑完。如果 sync 报错九成是 SDK 版本、Gradle 版本和本机环境不一致改一下 compileSdk 或者降 Gradle 版本就能过别急着重写工程。要是清理资源时误删了 app/src/main 下的 res 目录构建会直接报 AAPT: error: resource style/AppTheme not found这种属于自伤跟工程本身没关系。2.2 记住用户名与密码SharedPreferences 封装类的标准写法这个系统记住密码的功能核心是 SharedPreferences数据以 XML 形式存在 app 私有目录。直接在 Activity 里散落 sp.edit() 是课设最常见的坏味道我一般会抽一个 PrefsManager 封装类登录页、注册页、设置页共用一份读写逻辑以后要换 DataStore 也只改这一个类。public class PrefsManager { private static final String FILE_NAME course_system_prefs; private static final String KEY_USERNAME username; private static final String KEY_PASSWORD password; private static final String KEY_REMEMBER remember_login; private SharedPreferences sp; public PrefsManager(Context context) { sp context.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE); } public void saveLoginInfo(String username, String password, boolean remember) { SharedPreferences.Editor editor sp.edit(); if (remember) { editor.putString(KEY_USERNAME, username); editor.putString(KEY_PASSWORD, EncryptUtil.md5(password)); } else { // 用户取消勾选「记住我」历史记录必须清掉否则下次还会自动填充 editor.remove(KEY_USERNAME); editor.remove(KEY_PASSWORD); } editor.putBoolean(KEY_REMEMBER, remember); editor.apply(); } public String getSavedUsername() { return sp.getString(KEY_USERNAME, ); } public String getSavedPassword() { return sp.getString(KEY_PASSWORD, ); } public boolean isRememberEnabled() { return sp.getBoolean(KEY_REMEMBER, false); } }几个容易忽略的参数点文件模式必须用 MODE_PRIVATE早期那种 MODE_WORLD_READABLE 在 Android 7.0 之后全部废弃强行使用直接抛 SecurityExceptioncommit() 是同步写磁盘apply() 是异步落盘登录这种低频写入用 apply 完全够还能避免在主线程卡顿密码存的是 MD5 只是「不是明文」严格场景要加盐或换 BCrypt课设答辩时能说出这条分数会不一样。布局上记住密码的复选框建议和密码框放在同一行用 Horizontal 的 LinearLayout 包一层。界面不是重点重点是登录页 onCreate 里要回填if (prefsManager.isRememberEnabled()) { etUsername.setText(prefsManager.getSavedUsername()); etPassword.setText(prefsManager.getSavedPassword()); cbRemember.setChecked(true); }这段逻辑要在 setContentView 之后立即执行同时别在 onSaveInstanceState 里覆盖用户正在输入的新内容。如果你用 KotlinSharedPreferences 可以写成属性委托但底层还是同一套东西。2.3 密码隐藏与显示inputType 切换与光标回退的坑密码框的默认配置是 android:inputTypetextPassword输入内容显示为圆点。「显示/隐藏」按钮常见做法是用一个 ImageButton 放在 EditText 右侧点击时切换 inputType同时换眼睛图标。EditText android:idid/etPassword android:layout_widthmatch_parent android:layout_heightwrap_content android:hint请输入密码 android:inputTypetextPassword android:drawableEnddrawable/ic_eye_close android:backgrounddrawable/bg_edit_text /btnTogglePwd.setOnClickListener(new View.OnClickListener() { Override public void onClick(View v) { int type etPassword.getInputType(); if ((type InputType.TYPE_TEXT_VARIATION_PASSWORD) ! 0) { etPassword.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD); btnTogglePwd.setIconResource(R.drawable.ic_eye_open); } else { etPassword.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD); btnTogglePwd.setIconResource(R.drawable.ic_eye_close); } // setInputType 会让光标跳回开头必须手动移回末尾 etPassword.setSelection(etPassword.length()); } });两个坑必须记住。第一判断当前类型不要用 比较整个 inputType因为 TYPE_CLASS_TEXT 和 TYPE_TEXT_VARIATION_PASSWORD 是位标记组合值会带其它 flag用位与 判断才可靠第二setInputType 会重置 EditText 内部状态光标直接跳到 0 位置用户输到一半切一下显示再打字就全插到最前面了所以 setSelection(etPassword.length()) 是每次切换都必要的一行。这个细节我在实际项目里见过不止一次翻车属于血泪经验。3. 课程列表与选课交互RecyclerView 复用与状态回写3.1 课程数据模型字段怎么定才不会返工课程对象是选课系统的中心数据。拆这份工程时我建议字段至少包含 courseId、name、teacher、credit、capacity、selected 六个。courseId 是唯一标识后面做已选去重、网络同步都靠它selected 表示当前登录用户是否已选这门课这个字段必须有因为 RecyclerView 的 item 是复用的你永远不能靠 View 的视觉状态去判断选课结果。public class Course { private String courseId; // 课程编号例如 CS101 private String name; // 课程名称 private String teacher; // 授课教师 private double credit; // 学分 private int capacity; // 课程容量 private boolean selected; // 当前用户是否已选 public Course(String courseId, String name, String teacher, double credit, int capacity) { this.courseId courseId; this.name name; this.teacher teacher; this.credit credit; this.capacity capacity; this.selected false; } public boolean isSelected() { return selected; } public void setSelected(boolean selected) { this.selected selected; } // 其余 getter / setter 省略 }如果你用 Kotlindata class 一行就能定义这些字段Java 写起来啰嗦但结构清楚。建议在 Course 里基于 courseId 重写 equals 和 hashCode这样 ArrayList.contains、remove 这类操作才可靠否则两个字段相同的课程会被当成不同对象去重逻辑写起来非常别扭。3.2 RecyclerView 适配器onBindViewHolder 必须重置选中态课程列表用 RecyclerView LinearLayoutManager 是最常规的组合。适配器的关键在 onBindViewHolder每次绑定都要根据 course.isSelected() 无条件重置按钮的文本和 enable 状态而不是只在点击回调里改。static class CourseViewHolder extends RecyclerView.ViewHolder { TextView tvName, tvTeacher, tvCredit; Button btnSelect; CourseViewHolder(View itemView) { super(itemView); tvName itemView.findViewById(R.id.tvCourseName); tvTeacher itemView.findViewById(R.id.tvTeacher); tvCredit itemView.findViewById(R.id.tvCredit); btnSelect itemView.findViewById(R.id.btnSelect); } } Override public void onBindViewHolder(CourseViewHolder holder, int position) { Course course courseList.get(position); holder.tvName.setText(course.getName()); holder.tvTeacher.setText(教师 course.getTeacher()); holder.tvCredit.setText(学分 course.getCredit()); // 每次 bind 都先重置状态否则 item 复用时按钮状态会串 if (course.isSelected()) { holder.btnSelect.setText(已选); holder.btnSelect.setEnabled(false); } else { holder.btnSelect.setText(选课); holder.btnSelect.setEnabled(true); } holder.itemView.setOnClickListener(v - showConfirmDialog(course, holder)); }这里有个容易困惑的点为什么已经 setOnClickListener 了还要在 bind 里反复 setText因为 RecyclerView 会把滚出屏幕的 item 的 View 交给新位置的 item 复用onBindViewHolder 每次都会执行你不在里面重置状态就会出现「上一屏选了计算机基础滚下去再滚回来高等数学的位置上也显示已选」的灵异现象。解决办法就是「bind 时无条件按照数据刷 UI」的写法跟界面刷新逻辑完全解耦。3.3 点击选课与确认对话框数据模型和 View 一起改点击课程条目弹出确认对话框正逻辑很简单但有两个同步问题要注意private void showConfirmDialog(Course course, CourseViewHolder holder) { new AlertDialog.Builder(context) .setTitle(确认选课) .setMessage(确定选择《 course.getName() 》吗) .setPositiveButton(确定, (dialog, which) - { course.setSelected(true); SelectedCourseManager.getInstance().add(course); holder.btnSelect.setText(已选); holder.btnSelect.setEnabled(false); }) .setNegativeButton(取消, null) .show(); }第一数据模型和 View 必须同步更新只改 View 不改数据下次 notifyDataSetChanged 或者列表刷新选中状态就丢了只改数据不改 View用户看到的还是「选课」按钮。第二直接操作 holder 和调用 notifyItemChanged(holder.getAdapterPosition()) 二选一两个都做会导致 item 闪烁一下体验很怪。getAdapterPosition() 在局部刷新时可能返回 NO_POSITION所以直接改 holder 反而更稳。另外确认按钮的回调里最好顺手做一次容量判断课程容量满时就不要弹确认框了直接 Toast 提示「课程已满」。课设最常见的做法是课程数据先在本地写死容量判断逻辑先留着后面接后端时能直接复用。3.4 已选课程页单例内存表还是本地数据库已选课程页的数据源有两种典型做法。课设最简单的是用单例持有一个 ArrayList选课时 add已选页用一个新 Adapter 展示同一份数据public class SelectedCourseManager { private static final SelectedCourseManager INSTANCE new SelectedCourseManager(); private final ArrayListCourse selectedCourses new ArrayList(); private SelectedCourseManager() {} public static SelectedCourseManager getInstance() { return INSTANCE; } public void add(Course course) { if (!selectedCourses.contains(course)) { selectedCourses.add(course); } } public ArrayListCourse getSelectedCourses() { return selectedCourses; } }单例的好处是跨页面共享零成本坏处是进程被杀数据就没了。如果你希望已选课程能持久化就用 Room 建一张 course 表把 selected 字段存进去登录后重新加载。Room 的写法比原生 SQLiteOpenHelper 省事很多Entity Dao Database 三个文件就能跑属于课设加分项。我一般建议先跑通单例版本再花半小时换 Room因为两个页面的 Adapter 完全不用动只换数据源。4. 登录逻辑与网络层Retrofit 异步请求的接口定义与坑4.1 接口定义与 Retrofit 单例baseUrl 结尾斜杠的坑选课系统的登录是典型的表单提交。用 Retrofit 定义接口逻辑清晰也方便后续加课程列表接口。public interface ApiService { POST(api/login) FormUrlEncoded CallLoginResponse login( Field(username) String username, Field(password) String password ); GET(api/courses) CallListCourse getCourseList(); }Retrofit 实例用单例管理避免每次请求都重新创建 OkHttpClient连接池和线程池没法复用public class RetrofitClient { private static Retrofit retrofit; private static final String BASE_URL http://10.0.2.2:8080/; public static Retrofit getInstance() { if (retrofit null) { OkHttpClient client new OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(15, TimeUnit.SECONDS) .build(); retrofit new Retrofit.Builder() .baseUrl(BASE_URL) .client(client) .addConverterFactory(GsonConverterFactory.create()) .build(); } return retrofit; } }两个参数细节baseUrl 必须以 / 结尾写成 http://10.0.2.2:8080 会在运行时直接抛 IllegalArgumentException这是 Retrofit 最经典的报错之一模拟器访问宿主机要写 10.0.2.2真机调试要改成电脑的局域网 IP且手机和电脑必须在同一网段。超时时间也别省connectTimeout 管连接readTimeout 管读数据选课接口一般 10 到 15 秒足够。4.2 登录按钮空校验、enqueue 异步与回调线程登录按钮的完整逻辑要覆盖三种情况输入为空、服务器返回业务失败、网络异常。空校验用 trim() 之后判断避免用户输入空格导致请求白发网络请求必须用 enqueue回调已经切到主线程可以直接更新 UI。btnLogin.setOnClickListener(v - { String username etUsername.getText().toString().trim(); String password etPassword.getText().toString().trim(); if (username.isEmpty() || password.isEmpty()) { Toast.makeText(LoginActivity.this, 用户名和密码不能为空, Toast.LENGTH_SHORT).show(); return; } ApiService api RetrofitClient.getInstance().create(ApiService.class); api.login(username, password).enqueue(new CallbackLoginResponse() { Override public void onResponse(CallLoginResponse call, ResponseLoginResponse response) { if (response.isSuccessful() response.body() ! null) { if (response.body().isSuccess()) { prefsManager.saveLoginInfo(username, password, cbRemember.isChecked()); startActivity(new Intent(LoginActivity.this, MainActivity.class)); finish(); } else { Toast.makeText(LoginActivity.this, response.body().getMsg(), Toast.LENGTH_SHORT).show(); } } else { Toast.makeText(LoginActivity.this, 服务器返回异常 response.code(), Toast.LENGTH_SHORT).show(); } } Override public void onFailure(CallLoginResponse call, Throwable t) { Toast.makeText(LoginActivity.this, 网络请求失败 t.getMessage(), Toast.LENGTH_SHORT).show(); } }); });提醒一个常见误用不要用 execute() 做同步请求在 Android 4.0 之后主线程里一调 execute() 就直接抛 NetworkOnMainThreadException应用秒崩。enqueue 内部是 OkHttp 的异步线程回调自动通过 Handler 切回主线程UI 操作天然安全。另外onResponse 不代表业务成功HTTP 200 只是传输层成功业务上的成败要看 body 里的字段所以 LoginResponse 里要有 isSuccess() 和 getMsg()。4.3 权限声明与登录态保持别只存一个 boolean网络请求必须在 AndroidManifest.xml 里声明权限uses-permission android:nameandroid.permission.INTERNET / uses-permission android:nameandroid.permission.ACCESS_NETWORK_STATE /INTERNET 权限漏了请求会直接失败ACCESS_NETWORK_STATE 用于判断网络是否可用登录前检查一次可以避免无网时让用户干等超时。另外debug 包默认允许明文 HTTPrelease 包必须给 application 加 android:usesCleartextTraffictrue否则 http:// 请求全部被系统拦截这个坑在课设后端没有 https 证书时尤其常见。登录态保持也不要只存一个 isLogin 布尔值然后写死 true。规范的课设做法是登录成功后服务器返回 token把 token 存到 SharedPreferences后续请求在 OkHttp 拦截器里统一加到 Authorization 头OkHttpClient client new OkHttpClient.Builder() .addInterceptor(chain - { String token prefsManager.getToken(); if (!token.isEmpty()) { return chain.proceed(chain.request().newBuilder() .header(Authorization, Bearer token) .build()); } return chain.proceed(chain.request()); }) .build();这样后端以后要加鉴权你不需要改任何业务代码只在拦截器里补逻辑就够了。这份工程里登录后的主界面就是课程列表页跳转前记得 finish() 掉登录页避免用户按返回键又退回登录界面。5. 避坑与排查拆这份工程时踩过的六个真实坑下面这六个坑是我拆工程、以及帮别人调课设时实际遇到过的按现象、原因、解决的思路列出来每个都能在半小时内定位。不要把这堆报错当黑匣子多数都是几行配置的事。5.1 满屏 aidl 文件以为源码是坏的现象解压后看到 MediaSessionCompat.aidl、RatingCompat.aidl 等一堆 aidl 文件怀疑资源不完整、工程损坏。 原因这是 AndroidX MediaCompat 库编译时自动带入的接口声明不是你的业务代码。正常工程的源码都在 app/src/main/java 下这些 aidl 只出现在 build 目录和依赖库里。 解决直接无视。如果你没用到媒体相关功能这堆文件对你没有任何影响也别去删删了依赖校验会失败。5.2 密码框右侧图标怎么点都没反应现象用 android:drawableEnd 给 EditText 加了眼睛图标点击图标不切换密码可见性。 原因EditText 的 drawable 默认不消费点击事件你没法通过 OnClickListener 直接监听 drawable 区域。 解决两种方案。一是把图标换成独立的 TextView 或 ImageButton 放在 EditText 右侧点击事件走这个按钮二是给 EditText 设置 OnTouchListener判断触摸点是否落在 drawableEnd 的矩形范围内event.getX() etPassword.getWidth() - etPassword.getTotalPaddingEnd()。实测第二种要处理 padding 和 RTL不如第一种省心。5.3 勾了「记住我」重启应用还是没回填现象勾选记住密码并登录成功后杀掉应用重新打开用户名和密码框是空的。 原因saveLoginInfo 里忘记分支没写或者 remove 分支无条件执行把刚存的值清掉了另一个常见原因是 onCreate 回填代码没执行。 解决按 2.2 的封装类写记住和取消是互斥分支回填放在 setContentView 之后立即执行不要再在 onResume 里覆盖否则用户正在修改输入时容易被系统状态打断。5.4 点击登录直接崩NetworkOnMainThreadException现象Logcat 抛出 android.os.NetworkOnMainThreadException应用秒退。 原因用了 execute() 同步请求或者直接把 OkHttp 的同步调用写在了主线程。 解决一律改 enqueue如果你自己开了子线程做请求记住子线程不能直接更新 UI要用 runOnUiThread 或 Handler 切回主线程。这个问题在课设答辩现场出现率极高属于典型翻车点写代码时就要避开。5.5 滑动列表后选课按钮状态串了现象第一屏选了「计算机基础」往下滑再滑回来「高等数学」的位置显示已选。 原因RecyclerView item 复用onBindViewHolder 没有根据 course.isSelected() 重置按钮。 解决bind 方法开头无条件按数据刷一遍按钮文本和 enable 状态不要只在点击回调里改 UI。这条和 3.2 讲的是同一件事但我在不同人的代码里见到太多次了单独拎出来提醒。5.6 想把课程数据写到外部存储结果 permission denied现象把课程列表导出成 json 写到 /storage/emulated/0/Android/data/ 下的目录或者公共目录Android 11 及以上直接报 Operation not permitted文件根本写不进去。 原因Android 10 开始分区存储app 对外部公共目录的写权限受限Android 11 强制分区存储WRITE_EXTERNAL_STORAGE 权限只对特定文件类型有效很多老代码里的配置直接失效。 解决课设场景不要把数据写到外部存储SharedPreferences 和 Room 都在私有目录天然不需要存储权限如果一定要导出文件用 MediaStore API 或者 Storage Access Framework 让用户自己选目录。这个坑特别隐蔽因为模拟器上可能正常换到 Android 13 的真机就复现。6. 进阶给登录选课加进度反馈并养成 aapt 核验 APK 的习惯6.1 网络请求的进度条用起来简单漏 dismiss 才致命裸的 Retrofit 请求体验很差点击登录后界面没反应用户以为没点中又点一次两个重复请求就发出去了。课设阶段最直观的反馈就是加载进度条Android 自带 ProgressDialog 虽然标记过时但胜在零依赖ProgressDialog dialog new ProgressDialog(LoginActivity.this); dialog.setMessage(正在登录...); dialog.setCancelable(false); dialog.show();关键是 dismiss 时机。onResponse 里多个分支每个都可能提前 return漏一个 dismiss进度条就永远卡在屏幕上。我一般封装一个 LoadingCallback 把 show 和 dismiss 成对管理业务代码只写成功和失败逻辑这样不会出现进度条卡死的低级事故。选课按钮同理点击后先置灰防止双击重复提交。6.2 拿到工程先核验 APKaapt dump badging拿到别人的 Android 工程别急着改代码先看编译产物。Android SDK 的 build-tools 里有 aapt在工程根目录执行aapt dump badging app-debug.apk输出包含包名、版本号、minSdk、targetSdk、launchable-activity 和权限列表。拆这份工程时我第一步就跑这条命令入口指向登录页、INTERNET 权限都在说明资源编译和打包链路完整假如 aapt 报错或者清单异常基本可以判断资源缺损。想顺带验稳定性可以再跑adb shell monkey -p 包名 100一百个随机点击页面不崩再继续改代码。从那以后我养成了个习惯网上下载的 Android 工程导入 Android Studio 之前先 aapt dump badging 一遍确认入口和权限再谈改代码。这个习惯帮我躲过了不少缺文件、改包名的坑这份工程下载后也建议照这个流程先验证再走导入步骤。希望帮到你。本文还有配套的精品资源点击获取