简介这是一份基于Android Studio开发的选课系统完整项目源码包面向移动应用开发初学者与课程设计实践者解决高校场景下移动端选课功能从UI设计、登录鉴权到课程管理的全流程实现问题。压缩包共1486个文件涵盖611个XML布局与资源定义文件、472个PNG图标资源、150个编译后class字节码、125个JSON配置与接口模拟数据以及11个核心Java业务逻辑文件和1个可直接安装的app-debug.apk整体大小25.47MB结构清晰体现典型Android MVVM雏形与模块化组织特征。已有2504人学习下载资源包含完整的登录界面支持用户名记忆、密码明暗文切换、课程列表RecyclerView展示、本地SharedPreferences持久化、基础网络请求集成及安全加密示意开箱即用适合用于Android课程设计、毕设参考或碎片化技能验证。1. 一个能跑在真机上的 Android 选课系统不是 Demo是带登录、课程列表、选退课、缓存和本地 SQLite 的完整闭环你搜“Android 设计选课系统.rar”大概率点进一个压缩包——解压后是app/src/main/java下零散的 Activity 和res/layout里几个 XML 布局AndroidManifest.xml里注册了三个 Activitybuild.gradle用的是com.android.tools.build:gradle:3.5.0连androidx都没迁。这种项目扔进 Android Studio Dolphin 或 Iguana 会直接报错Manifest merger failed、No resource found for string/app_name、Cannot resolve symbol R……它不是不能跑而是缺了现代 Android 开发的四根支柱依赖管理、资源命名规范、Activity 生命周期适配、以及最关键的——数据持久化与网络请求的真实链路。本文讲的就是把这个.rar里沉睡的“选课系统”骨架真正焊上电源、装好轮子、接通教务系统模拟接口让它能在 Android 12 真机上完成一次从登录 → 查课 → 选课 → 查已选 → 退课的全流程闭环。适合刚学完 Activity 和 RecyclerView、正卡在“写完代码却连不上数据”的 Android 初学者也适合需要快速交付教学实训项目的带队老师——所有代码可复制粘贴所有配置项标清版本号和路径所有报错有对应排查路径。不讲抽象架构只拆真实.rar解压后第一眼看到的那堆文件怎么救活。2. 从 .rar 解压到可编译工程四步重建项目结构绕过 Gradle 同步地狱拿到Android设计选课系统.rar别急着双击打开。先解压到一个全英文无空格路径下比如D:\android_project\course_selection这是后续所有操作不出岔的前提。解压后你会看到典型的旧式 Android 项目结构src/、res/、assets/、libs/、AndroidManifest.xml但没有gradle/目录也没有settings.gradle。这意味着它大概率是 Eclipse ADT 时代或早期 Android Studio 项目导出的。现在要把它变成 Android Studio 2022.3.1Iguana及以上能识别的现代工程。2.1 手动补全 Gradle 脚手架三文件定乾坤进入解压目录在根目录下新建三个文件settings.gradle、build.gradle项目级、gradle/wrapper/gradle-wrapper.properties。内容如下// settings.gradle include :app rootProject.name CourseSelection// build.gradle项目级放在根目录 // 注意此为项目级 build.gradle不是 app/build.gradle buildscript { ext.kotlin_version 1.8.22 repositories { google() mavenCentral() } dependencies { classpath com.android.tools.build:gradle:8.2.2 classpath org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version } } task clean(type: Delete) { delete rootProject.buildDir }// gradle/wrapper/gradle-wrapper.properties distributionBaseGRADLE_USER_HOME distributionPathwrapper/dists distributionUrlhttps\://services.gradle.org/distributions/gradle-8.2-bin.zip zipStoreBaseGRADLE_USER_HOME zipStorePathwrapper/dists提示gradle-8.2-bin.zip是 Android Gradle Plugin 8.2.2 对应的 Gradle 版本。若你本地已安装其他 Gradle务必统一否则 Android Studio 会自动下载耗时较长但安全。2.2 迁移源码到 androidx Jetifier 兼容层旧项目几乎必然使用android.support.*包。Android Studio 提供一键迁移工具但需先让项目能被识别。将原src/目录下的所有 Java 文件整体剪切到app/src/main/java/下若无app/目录则手动创建。同理res/整体移入app/src/main/res/。此时打开 Android Studio选择Open an existing Android Studio project指向你刚整理好的根目录。首次同步会失败因为app/build.gradle不存在——现在我们来写它。// app/build.gradle plugins { id com.android.application id org.jetbrains.kotlin.android // 若原项目无 Kotlin可删此行及 kotlin 依赖 } android { namespace com.example.courseselection // 必须与 AndroidManifest.xml 中 package 一致 compileSdk 34 defaultConfig { applicationId com.example.courseselection minSdk 21 targetSdk 34 versionCode 1 versionName 1.0 testInstrumentationRunner androidx.test.runner.AndroidJUnitRunner } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile(proguard-android-optimize.txt), proguard-rules.pro } } compileOptions { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { jvmTarget 17 } } dependencies { implementation androidx.core:core-ktx:1.12.0 implementation androidx.appcompat:appcompat:1.6.1 implementation com.google.android.material:material:1.10.0 implementation androidx.constraintlayout:constraintlayout:2.1.4 implementation androidx.recyclerview:recyclerview:1.3.2 // 添加网络与数据库支持 implementation androidx.room:room-runtime:2.6.1 implementation androidx.room:room-ktx:2.6.1 implementation com.squareup.retrofit2:retrofit:2.9.0 implementation com.squareup.retrofit2:converter-gson:2.9.0 implementation androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0 implementation androidx.lifecycle:lifecycle-livedata-ktx:2.7.0 testImplementation junit:junit:4.13.2 androidTestImplementation androidx.test.ext:junit:1.1.5 }参数说明namespace必须与AndroidManifest.xml中manifest package...完全一致否则 R 文件无法生成minSdk 21是为兼容大部分教学机如华为畅享系列、小米 Redmi Note 系列room和retrofit是选课系统数据流的基石——课程列表来自网络已选课存本地退课需双向同步。2.3 修复 AndroidManifest.xml四大必改项原始AndroidManifest.xml通常存在四个致命问题①package值为空或错误如com.example②application标签缺少android:usesCleartextTraffictrue调试期允许 HTTP③activity的android:name写成.LoginActivity但实际类名是Login④ 缺少android.permission.INTERNET和android.permission.ACCESS_NETWORK_STATE。修正后关键片段如下manifest xmlns:androidhttp://schemas.android.com/apk/res/android packagecom.example.courseselection uses-permission android:nameandroid.permission.INTERNET / uses-permission android:nameandroid.permission.ACCESS_NETWORK_STATE / application android:allowBackuptrue android:iconmipmap/ic_launcher android:labelstring/app_name android:themestyle/Theme.CourseSelection android:usesCleartextTraffictrue !-- 调试期必须加 -- activity android:name.LoginActivity android:exportedtrue intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity activity android:name.CourseListActivity android:exportedfalse / activity android:name.SelectedCoursesActivity android:exportedfalse / /application /manifest逻辑说明android:usesCleartextTraffictrue是 Android 9.0 默认禁止明文 HTTP 的开关教学环境多用本地 mock 接口如http://192.168.1.100:8080/api/courses不加此属性OkHttp会直接抛Cleartext HTTP traffic not permitted异常且无明确日志提示极易卡死。3. 数据层重构用 Room 替代裸 SQL用 Retrofit 统一网络请求入口原.rar项目中你大概率会看到DBHelper.java里满屏SQLiteDatabase.execSQL(CREATE TABLE ...)和Cursor手动解析。这种写法在 Android 12 上不仅维护困难更因query()方法弃用警告导致编译失败。我们必须用 Room 实现数据持久化并用 Retrofit 封装网络请求形成“网络 ↔ ViewModel ↔ UI”的标准链路。3.1 定义 Course 实体与 DAORoom 的最小可行单元在app/src/main/java/com/example/courseselection/data/下新建Course.kt和CourseDao.kt// Course.kt Entity(tableName course_table) data class Course( PrimaryKey val id: Int, val name: String, val teacher: String, val credit: Double, val time: String, val location: String, val capacity: Int, val selectedCount: Int, val isSelected: Boolean false // 本地标记是否已选 ) // CourseDao.kt Dao interface CourseDao { Insert(onConflict OnConflictStrategy.REPLACE) suspend fun insertAll(courses: ListCourse) Query(SELECT * FROM course_table WHERE isSelected 1) suspend fun getSelectedCourses(): ListCourse Query(SELECT * FROM course_table ORDER BY id ASC) suspend fun getAllCourses(): ListCourse Query(UPDATE course_table SET isSelected :selected WHERE id :id) suspend fun updateSelection(id: Int, selected: Boolean) Query(UPDATE course_table SET selectedCount selectedCount 1 WHERE id :id AND selectedCount capacity) suspend fun incrementSelectedCount(id: Int): Int Query(UPDATE course_table SET selectedCount selectedCount - 1 WHERE id :id AND selectedCount 0) suspend fun decrementSelectedCount(id: Int): Int }参数说明onConflict OnConflictStrategy.REPLACE是为应对“重复插入同一门课”场景如刷新课程列表isSelected字段是本地选课状态快照避免每次都要查 DBincrementSelectedCount返回影响行数用于判断是否超限——这是选课业务的核心校验点。3.2 构建 Database 类与 Repository连接 Room 与 Retrofit// AppDatabase.kt Database( entities [Course::class], version 1, exportSchema false ) abstract class AppDatabase : RoomDatabase() { abstract fun courseDao(): CourseDao companion object { Volatile private var INSTANCE: AppDatabase? null fun getDatabase(context: Context): AppDatabase { return INSTANCE ?: synchronized(this) { INSTANCE ?: buildDatabase(context).also { INSTANCE it } } } private fun buildDatabase(context: Context): AppDatabase { return Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, course_database ).build() } } } // CourseRepository.kt class CourseRepository(private val courseDao: CourseDao, private val apiService: ApiService) { suspend fun refreshCourses() { val remoteCourses apiService.getCourses().await() withContext(Dispatchers.IO) { courseDao.insertAll(remoteCourses) } } suspend fun getSelectedCourses(): ListCourse courseDao.getSelectedCourses() suspend fun selectCourse(courseId: Int): ResultUnit { return try { // 1. 尝试增加选课人数 val affected courseDao.incrementSelectedCount(courseId) if (affected 0) return Result.failure(Exception(课程已满)) // 2. 更新本地选中状态 courseDao.updateSelection(courseId, true) Result.success(Unit) } catch (e: Exception) { Result.failure(e) } } suspend fun unselectCourse(courseId: Int): ResultUnit { return try { courseDao.decrementSelectedCount(courseId) courseDao.updateSelection(courseId, false) Result.success(Unit) } catch (e: Exception) { Result.failure(e) } } }逻辑说明refreshCourses()是启动页或下拉刷新的入口它先调远程 API 拉取最新课程再全量写入本地 DB——这是离线可用的关键selectCourse()采用“先更新计数再标记状态”的原子顺序避免网络请求成功但本地状态未更新的不一致ResultUnit是 Kotlin 推荐的错误处理方式比try-catch更易组合。3.3 Retrofit 接口定义与 Mock Server 配置教学环境无需真实教务系统用MockWebServer搭建本地 API 即可// ApiService.kt interface ApiService { GET(courses) fun getCourses(): DeferredListCourse } // RetrofitClient.kt object RetrofitClient { private const val BASE_URL http://10.0.2.2:8080/ // Android 模拟器访问宿主机用 10.0.2.2 val apiService: ApiService by lazy { Retrofit.Builder() .baseUrl(BASE_URL) .addConverterFactory(GsonConverterFactory.create()) .addCallAdapterFactory(CoroutineCallAdapterFactory()) .build() .create(ApiService::class.java) } }参数说明10.0.2.2是 Android 模拟器访问本机的固定 IP若用真机调试需将BASE_URL改为电脑局域网 IP如http://192.168.1.100:8080/并关闭电脑防火墙CoroutineCallAdapterFactory是让 Retrofit 支持Deferred的关键避免回调嵌套。4. UI 层落地RecyclerView ViewBinding Navigation Component 三件套原.rar项目中的ListView和findViewById()已彻底过时。我们必须用RecyclerView展示课程列表用ViewBinding替代findViewById用Navigation Component管理LoginActivity → CourseListActivity → SelectedCoursesActivity的跳转。4.1 创建 CourseAdapter用 ListAdapter 实现高效 Diff// CourseAdapter.kt class CourseAdapter( private val onCourseClick: (Course) - Unit, private val onToggleSelect: (Course) - Unit ) : ListAdapterCourse, CourseAdapter.ViewHolder(CourseDiffCallback()) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val binding ItemCourseBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return ViewHolder(binding) } override fun onBindViewHolder(holder: ViewHolder, position: Int) { holder.bind(getItem(position), onCourseClick, onToggleSelect) } inner class ViewHolder(private val binding: ItemCourseBinding) : RecyclerView.ViewHolder(binding.root) : RecyclerView.ViewHolder(binding.root) { fun bind( course: Course, onCourseClick: (Course) - Unit, onToggleSelect: (Course) - Unit ) { binding.apply { tvCourseName.text course.name tvTeacher.text 教师${course.teacher} tvTime.text 时间${course.time} tvLocation.text 地点${course.location} tvCapacity.text 容量${course.capacity} / ${course.selectedCount} ivSelect.setImageResource( if (course.isSelected) R.drawable.ic_selected else R.drawable.ic_unselected ) root.setOnClickListener { onCourseClick(course) } ivSelect.setOnClickListener { onToggleSelect(course) } } } } } class CourseDiffCallback : DiffUtil.Callback() { private var oldList emptyListCourse() private var newList emptyListCourse() override fun getOldListSize(): Int oldList.size override fun getNewListSize(): Int newList.size override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { return oldList[oldItemPosition].id newList[newItemPosition].id } override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { return oldList[oldItemPosition] newList[newItemPosition] } fun setLists(old: ListCourse, new: ListCourse) { oldList old newList new } }逻辑说明ListAdapter自动处理DiffUtil比手动notifyDataSetChanged()流畅十倍ItemCourseBinding是由item_course.xml自动生成的绑定类ivSelect点击事件直接触发选课/退课逻辑无需在 Activity 中写setOnClickListenerCourseDiffCallback的setLists方法在submitList()前被调用确保 Diff 正确。4.2 CourseListActivityViewModel ViewBinding Lifecycle 绑定// CourseListActivity.kt class CourseListActivity : AppCompatActivity() { private lateinit var binding: ActivityCourseListBinding private lateinit var viewModel: CourseViewModel override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding ActivityCourseListBinding.inflate(layoutInflater) setContentView(binding.root) viewModel ViewModelProvider( this, CourseViewModelFactory( (application as MyApplication).database.courseDao(), RetrofitClient.apiService ) )[CourseViewModel::class.java] setupRecyclerView() observeViewModel() viewModel.refreshCourses() // 首次加载 } private fun setupRecyclerView() { val adapter CourseAdapter( onCourseClick { course - Toast.makeText(this, 查看课程${course.name}, Toast.LENGTH_SHORT).show() }, onToggleSelect { course - if (course.isSelected) { viewModel.unselectCourse(course.id) } else { viewModel.selectCourse(course.id) } } ) binding.recyclerView.apply { adapter adapter layoutManager LinearLayoutManager(thisCourseListActivity) } } private fun observeViewModel() { viewModel.courses.observe(this) { courses - (binding.recyclerView.adapter as? CourseAdapter)?.submitList(courses) } viewModel.uiState.observe(this) { state - when (state) { is UiState.Loading - binding.progressBar.visibility View.VISIBLE is UiState.Success - binding.progressBar.visibility View.GONE is UiState.Error - { binding.progressBar.visibility View.GONE Toast.makeText(this, 错误${state.message}, Toast.LENGTH_LONG).show() } } } } }参数说明MyApplication是自定义 Application 类用于持有AppDatabase实例UiState是密封类sealed interface UiState { object Loading : UiState; data class Success(val data: ListCourse) : UiState; data class Error(val message: String) : UiState }比LiveDataBoolean更语义化observe(this)中的this是LifecycleOwner确保观察者随 Activity 销毁自动解注册杜绝内存泄漏。4.3 Navigation Component 配置告别 startActivity 硬编码在app/src/main/res/navigation/nav_graph.xml中定义导航图?xml version1.0 encodingutf-8? navigation xmlns:androidhttp://schemas.android.com/apk/res/android xmlns:apphttp://schemas.android.com/apk/res-auto android:idid/nav_graph app:startDestinationid/loginFragment fragment android:idid/loginFragment android:namecom.example.courseselection.ui.login.LoginFragment android:labelLoginFragment action android:idid/action_loginFragment_to_courseListFragment app:destinationid/courseListFragment / /fragment fragment android:idid/courseListFragment android:namecom.example.courseselection.ui.course.CourseListFragment android:labelCourseListFragment action android:idid/action_courseListFragment_to_selectedCoursesFragment app:destinationid/selectedCoursesFragment / /fragment fragment android:idid/selectedCoursesFragment android:namecom.example.courseselection.ui.selected.SelectedCoursesFragment android:labelSelectedCoursesFragment / /navigation逻辑说明Fragment 替代 Activity 是现代 Android 的推荐做法NavHostFragment自动管理 Fragment 栈action标签定义跳转路径findNavController().navigate(R.id.action_loginFragment_to_courseListFragment)比startActivity(Intent(...))更安全且支持参数传递Safe Args插件app:startDestination指定启动页避免AndroidManifest.xml中多个LAUNCHER导致图标重复。5. 避坑指南从 .rar 解压到真机运行的 5 个血泪现场这些坑是我帮 12 所高校实训班救活选课系统时学生问得最多、重装 Studio 最频繁的五个点。每一条都对应一个具体现象、根本原因和可立即执行的解决命令。5.1 现象Cannot resolve symbol R且Build → Make Project无报错原因AndroidManifest.xml中package与app/build.gradle中namespace不一致或res/目录下存在非法命名的资源文件如ic_back.png里有大写字母或横杠。解决① 统一package和namespace② 进入app/src/main/res/执行命令行批量重命名Windowsfor /r %i in (*.png *.xml) do ren %i %~ni.xml然后手动将文件名改为全小写下划线如ic_back.png→ic_back.png保留IC_Back.png→ic_back.png③File → Invalidate Caches and Restart → Invalidate and Restart。5.2 现象App 安装后闪退Logcat 显示java.lang.RuntimeException: Unable to instantiate activity ComponentInfo原因AndroidManifest.xml中activity android:name.LoginActivity的类名与实际 Java/Kotlin 文件名不匹配如文件是Login.java但声明为.LoginActivity或LoginActivity没有public修饰符。解决① 检查LoginActivity.java或LoginActivity.kt文件首行是否为public class LoginActivity extends AppCompatActivityJava或class LoginActivity : AppCompatActivity()Kotlin② 在AndroidManifest.xml中确认android:name与类名完全一致包括大小写③ 若用 Kotlin确保app/build.gradle中已启用viewBindingbuildFeatures { viewBinding true }。5.3 现象课程列表空白Logcat 无网络错误但Retrofit的onFailure被调用原因AndroidManifest.xml缺少android:usesCleartextTraffictrue且接口地址为http://非https://。解决① 在application标签下添加android:usesCleartextTraffictrue② 确认RetrofitClient.BASE_URL以http://开头③ 若用真机检查电脑是否开启热点共享并在手机 Wi-Fi 设置中手动配置 DNS 为192.168.1.1电脑 IP。5.4 现象点击选课按钮无反应Logcat显示java.lang.IllegalStateException: Cannot access database on the main thread原因Room 的 DAO 方法未加suspend关键字或在主线程直接调用courseDao.insertAll()。解决① 确保所有 DAO 方法签名含suspend如suspend fun insertAll(...)② 在 ViewModel 中调用时必须用viewModelScope.launch { ... }或lifecycleScope.launch { ... }包裹③ 删除所有Thread { ... }.start()手动开线程的写法统一用协程。5.5 现象真机上图片不显示ImageView一片空白但模拟器正常原因res/drawable/下的图片资源未适配高分辨率屏幕真机读取drawable-xxhdpi但该目录为空回退到drawable又因图片尺寸过大 OOM。解决① 将所有图片放入res/drawable-anydpi/矢量图或res/drawable/位图② 用 Android Studio 的Image Asset Studio右键res→New → Image Asset生成多密度图标③ 在ImageView的 XML 中显式设置android:scaleTypecenterCrop避免拉伸失真。6. 真机验证与性能加固让选课系统扛住 200 人并发点击最后一步不是写完就结束而是用真机跑通全流程并做三项关键加固启动速度优化、OOM 防御、离线兜底。这决定了你的系统是“能跑”还是“敢交”。6.1 真机全流程验证清单必须逐项打钩步骤操作预期结果验证命令ADB1连接真机USB 调试开启adb devices显示设备号adb devices2Build → Build Bundle(s) and APK(s) → Build APK(s)生成app-debug.apkls app/build/outputs/apk/debug/3adb install app/build/outputs/apk/debug/app-debug.apk安装成功桌面出现图标adb install -r ...覆盖安装4点击图标启动输入账号密码可设默认admin/123456进入课程列表页显示 10 门课adb logcat | grep CourseListActivity5点击一门课的选课按钮Toast 提示“选课成功”该课右侧图标变实心adb shell dumpsys activity activities | grep mResumedActivity6返回首页进入“已选课程”页显示刚选的课程且selectedCount1adb shell run-as com.example.courseselection cat databases/course_database需 root提示第 6 项若无 root可用adb backup -f course.ab com.example.courseselection导出数据包用dd ifcourse.ab bs24 skip1 \| python3 -c import zlib,sys;print(zlib.decompress(sys.stdin.buffer.read()).decode(utf-8))解压查看 JSON 数据。6.2 启动速度优化SplashScreen 延迟初始化原项目启动慢主因是Application.onCreate()中做了耗时 DB 初始化。改造如下// MyApplication.kt class MyApplication : Application() { override fun onCreate() { super.onCreate() // 移除 DB 初始化改用懒加载 // AppDatabase.getDatabase(this) // ← 删除这一行 } } // SplashActivity.kt新增 class SplashActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { SplashScreen.installSplashScreen(this) super.onCreate(savedInstanceState) // 延迟 1s 启动主界面同时预热 DB lifecycleScope.launch { delay(1000) val db AppDatabase.getDatabase(thisSplashActivity) // 预热常用 DAO db.courseDao() startActivity(Intent(thisSplashActivity, LoginActivity::class.java)) finish() } } }参数说明SplashScreen.installSplashScreen()是 Android 12 官方启动画面 API比自定义Theme.Splash更稳定delay(1000)既提供视觉缓冲又为 DB 预热留出时间lifecycleScope确保 Activity 销毁时协程自动取消。6.3 OOM 防御Bitmap 大图加载策略课程列表中若有封面图极易 OOM。必须用Glide替代setImageResource// 在 CourseAdapter.ViewHolder.bind() 中 Glide.with(binding.root.context) .load(course.imageUrl) // 假设 Course 类有 imageUrl 字段 .placeholder(R.drawable.ic_placeholder) .error(R.drawable.ic_error) .override(200, 120) // 强制缩放到指定尺寸减少内存占用 .into(binding.ivCover)逻辑说明override(200, 120)比centerCrop()更激进直接降低像素数placeholder和error避免加载失败时 ImageView 空白Glide自动管理 Bitmap 内存无需手动recycle()。6.4 离线兜底Room LiveData 实现“断网可用”当网络断开时用户仍应能查看已选课程。只需在 ViewModel 中加一层判断// CourseViewModel.kt fun loadCourses() { viewModelScope.launch { // 1. 先尝试网络加载 val result withContext(Dispatchers.IO) { try { repository.refreshCourses() true } catch (e: Exception) { false } } // 2. 无论网络成败都从本地读取 val localCourses repository.getSelectedCourses() _courses.value localCourses _uiState.value if (result) UiState.Success(localCourses) else UiState.Error(网络不可用显示本地数据) } }教训我带的第一届学生交作业时90% 的“选课系统”在食堂 Wi-Fi 断掉后直接白屏。后来我强制要求所有列表页必须有本地数据兜底且兜底逻辑写在 ViewModel 层不在 UI 层做if (isNetworkAvailable) ... else ...。这样既符合 Clean Architecture又让离线体验无缝。现在我的学生答辩时评委随手关掉手机数据系统依然流畅展示已选课——这比任何 PPT 都有说服力。希望帮到你。本文还有配套的精品资源点击获取
