从零构建Android答题App:MVVM架构与Kotlin实战指南

发布时间:2026/9/5 13:32:50
从零构建Android答题App:MVVM架构与Kotlin实战指南 简介这是一款基于Android Studio开发的轻量级安卓答题应用面向移动开发初学者与课程设计实践者解决日常知识测验、错题归集与成绩追踪等教学场景需求。资源包共50个文件包含12个Java业务逻辑文件、14个XML界面布局文件、10个PNG图标资源以及Gradle构建脚本、APK安装包、MP4演示视频和DOCX说明文档等整体压缩后仅8.81MB结构清晰、模块解耦明确便于快速编译运行与二次开发。已有5710人学习下载覆盖从环境配置到功能验证的完整链路。用户可直接安装APK体验限时答题、实时对错反馈、答题结果统计、错题自动收录及历史成绩保存等核心功能配套提供详细运行文档、开发环境说明与实操演示视频显著降低上手门槛是Android基础组件如Activity、Handler、SharedPreferences综合应用的典型范例。1. 项目缘起为什么从零开始做一个答题App如果你是一名Android开发者或者正在学习移动开发那么“做一个App”这个想法大概率在你的待办清单里躺了很久。一个答题App听起来功能明确、结构清晰似乎是入门实战的绝佳选择。但当你真正打开Android Studio新建一个项目面对空白的MainActivity时那种“从何下手”的茫然感就会立刻袭来。市面上的教程要么过于零散只讲一个按钮点击要么过于庞大直接上架构让初学者望而却步。我最初想做这个答题App就是想解决这个痛点。它不是一个简单的“Hello World”也不是一个复杂的企业级应用而是一个能串联起Android开发核心知识链的“教学项目”。通过它你可以系统地实践UI布局、数据存储、网络请求、事件处理、乃至简单的架构设计。更重要的是你会遇到并解决那些教程里不会写的“坑”比如列表项复用导致的状态错乱、异步加载数据时的界面卡顿、不同设备上的适配问题等等。这些经验远比照抄代码更有价值。接下来我将带你从零开始一步步构建一个功能完整的答题App。我们会涵盖从环境搭建、UI设计、逻辑实现到数据管理的全过程并重点分享那些我踩过坑、流过汗才总结出的实战技巧。无论你是刚学完基础语法的新手还是想通过一个完整项目巩固技能的开发者这篇内容都将是一份可以直接“抄作业”的详细指南。2. 环境准备与项目初始化避开第一个坑万事开头难而Android开发的开头往往就“难”在环境上。很多新手满怀热情地下载了Android Studio却卡在了Gradle下载、SDK配置或者虚拟机启动上热情瞬间被浇灭大半。这一章我们就来把这条路彻底铺平。2.1 Android Studio安装与“科学”配置首先前往Android开发者官网下载最新稳定版的Android Studio。这里有一个关键点不要使用任何第三方修改版或绿色版。官方安装包能最大程度保证组件完整性和后续更新的稳定性。安装过程基本是“下一步”到底但有几个选项需要注意安装类型选择“Standard”标准即可它会自动配置好JDK和必要的组件。SDK组件安装位置建议将Android SDK安装到一个英文路径、且没有空格和特殊字符的目录下例如D:\Android\Sdk。这是为了避免后续Gradle构建时可能出现的各种路径解析错误。安装完成后首次启动才是真正的挑战开始——Gradle和SDK的下载。由于网络环境问题这里很容易卡住或失败。注意国内开发者通常会配置镜像源来加速。你可以在Android Studio的Settings或Preferenceson Mac Appearance BehaviorSystem SettingsHTTP Proxy中设置代理。但更推荐一劳永逸的方法配置Gradle全局镜像和Android SDK镜像。配置Gradle全局镜像找到你的用户目录下的.gradle文件夹Windows在C:\Users\你的用户名\.gradle新建一个init.gradle文件内容如下allprojects { repositories { maven { url https://maven.aliyun.com/repository/public/ } maven { url https://maven.aliyun.com/repository/google/ } maven { url https://maven.aliyun.com/repository/gradle-plugin/ } mavenLocal() mavenCentral() } }这会将项目的依赖库下载源指向阿里云镜像速度会有质的提升。配置Android SDK镜像打开Android Studio的SDK Manager工具栏小机器人图标在SDK Tools选项卡中勾选Android SDK Command-line Tools并安装。然后打开SDK Manager的SDK Update Sites标签页点击右下角Add添加国内镜像源例如腾讯的名称Tencent地址https://mirrors.cloud.tencent.com/android/repository/添加后可以勾选并移到顶部优先使用。完成这些配置后再尝试创建或打开项目构建速度会快很多。2.2 新建项目与核心依赖引入打开Android Studio选择“New Project”。在模板选择页面为了更贴近我们答题App的需求我建议选择“Empty Views Activity”空视图Activity而不是更复杂的模板。这样项目结构最干净我们可以完全掌控。在配置页面有几个字段需要填写Name:QuizApp(你的应用名称)Package name:com.yourname.quizapp(包名一般是域名倒写)Save location: 选择一个合适的项目路径同样建议英文无空格。Language: 选择Kotlin。虽然你可能从Java入门但Kotlin已是Android开发的官方首选语言其空安全、扩展函数等特性能让你写出更简洁、健壮的代码。本项目将全程使用Kotlin。Minimum SDK: 选择API 24: Android 7.0 (Nougat)。这是一个比较折中的选择既能覆盖绝大多数现有设备又能使用很多现代API。无需追求过低版本会徒增适配工作量。点击“Finish”后Android Studio会自动开始构建项目。第一次构建会下载Gradle和项目依赖根据你的网络和镜像配置可能需要几分钟。构建成功后我们来看看项目结构并添加一些核心依赖。打开项目根目录下的build.gradle.kts (Module: app)文件在dependencies块中添加我们需要的库dependencies { // ... 默认依赖 // 核心Kotlin扩展库提供对Android组件的便捷扩展 implementation(androidx.core:core-ktx:1.12.0) // 生命周期感知组件用于管理UI生命周期 implementation(androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.0) implementation(androidx.lifecycle:lifecycle-livedata-ktx:2.8.0) // 用于在Fragment、Activity等之间传递数据替代已废弃的Bundle implementation(androidx.navigation:navigation-fragment-ktx:2.7.7) implementation(androidx.navigation:navigation-ui-ktx:2.7.7) // 网络请求库用于从网络加载题库可选后续扩展用 implementation(com.squareup.retrofit2:retrofit:2.9.0) implementation(com.squareup.retrofit2:converter-gson:2.9.0) // 图片加载库如果题目包含图片 implementation(io.coil-kt:coil:2.6.0) // 本地数据库如果题库存储在本地 implementation(androidx.room:room-runtime:2.6.1) ksp(androidx.room:room-compiler:2.6.1) // 使用KSP注解处理器比kapt更快 }添加后点击右上角的“Sync Now”同步项目。这些库构成了我们App的现代化开发基石。你可能注意到我用了ksp而不是传统的kapt这是Kotlin Symbol Processing的缩写是新一代的注解处理器速度更快与Kotlin的兼容性更好。3. 应用架构与UI设计不止是画界面一个可维护的应用需要一个清晰的架构。对于答题App我们采用经典的Model-View-ViewModel (MVVM)模式。这能很好地将界面逻辑View与业务数据Model分离ViewModel作为中间人负责处理和管理数据。3.1 定义数据模型Model答题App的核心数据是“题目”。我们首先在com.yourname.quizapp.model包下创建一个Question.kt数据类。// Question.kt data class Question( val id: String, // 题目唯一ID val category: String, // 分类如“历史”、“科学” val type: QuestionType, // 题型单选、多选、判断 val content: String, // 题干文本 val options: ListString, // 选项列表 val correctAnswers: SetInt, // 正确答案的索引集合支持多选 val explanation: String? null, // 答案解析可选 val imageUrl: String? null // 题目配图URL可选 ) // 枚举类定义题型 enum class QuestionType { SINGLE_CHOICE, MULTIPLE_CHOICE, TRUE_FALSE }这里有几个设计考量correctAnswers使用SetInt而不是Int是为了同时兼容单选题Set里只有一个元素和多选题。explanation和imageUrl设为可空?因为不是所有题目都有解析或图片。这是Kotlin空安全特性的直接应用。使用data class因为它自动为我们生成equals(),hashCode(),toString()和copy()方法非常方便。3.2 设计用户界面ViewUI部分我们主要需要两个界面答题界面和结果界面。我们使用Android推荐的Navigation Component来管理页面跳转。首先在res/navigation/目录下创建nav_graph.xml导航图。右键res- New - Android Resource File类型选Navigation。在导航图中添加两个fragment目的地分别对应答题和结果页。然后我们来设计答题界面fragment_quiz.xml。答题界面需要包含一个TextView显示题目分类和当前进度如“历史 - 第1/10题”。一个TextView或ImageView如果题目有图显示题干。一个RecyclerView来动态显示选项列表。这是最佳实践比用多个写死的RadioButton或CheckBox灵活得多易于适配不同数量的选项。一个“上一题”、“下一题”或“提交”按钮。fragment_quiz.xml的核心部分可能如下使用ConstraintLayoutandroidx.constraintlayout.widget.ConstraintLayout android:layout_widthmatch_parent android:layout_heightmatch_parent TextView android:idid/tv_progress app:layout_constraintTop_toTopOfparent app:layout_constraintStart_toStartOfparent app:layout_constraintEnd_toEndOfparent/ TextView android:idid/tv_question_content app:layout_constraintTop_toBottomOfid/tv_progress/ androidx.recyclerview.widget.RecyclerView android:idid/rv_options android:layout_width0dp android:layout_height0dp app:layout_constraintTop_toBottomOfid/tv_question_content app:layout_constraintBottom_toTopOfid/btn_control app:layout_constraintStart_toStartOfparent app:layout_constraintEnd_toEndOfparent/ Button android:idid/btn_previous android:text上一题/ Button android:idid/btn_next android:text下一题/ !-- 或者一个提交按钮 -- Button android:idid/btn_submit android:text提交答案 android:visibilitygone/ !-- 将按钮放入一个水平链式布局中 -- /androidx.constraintlayout.widget.ConstraintLayout这里的关键是RecyclerView。我们需要为它创建一个适配器OptionAdapter和一个选项项的布局item_option.xml。item_option.xml可能包含一个CheckBox用于多选或一个RadioButton用于单选但需要配合RadioGroup在RecyclerView中处理单选状态更复杂更通用的做法是使用一个CardView包裹TextView通过点击项的背景色变化来模拟选中状态由适配器内部管理选中逻辑。这给了我们最大的灵活性。3.3 实现ViewModel与数据绑定创建QuizViewModel.kt它继承自ViewModel()。ViewModel负责持有和管理与UI相关的数据并在配置变更如屏幕旋转时存活。// QuizViewModel.kt import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch class QuizViewModel(private val repository: QuizRepository) : ViewModel() { // 当前题目列表 private val _questionList MutableLiveDataListQuestion() val questionList: LiveDataListQuestion _questionList // 当前题目索引 private val _currentQuestionIndex MutableLiveData(0) val currentQuestionIndex: LiveDataInt _currentQuestionIndex // 用户答案映射题目ID - 选中选项索引集合 private val _userAnswers mutableMapOfString, SetInt() val userAnswers: MapString, SetInt get() _userAnswers // 加载题库 fun loadQuestions(category: String) { viewModelScope.launch { val questions repository.getQuestionsByCategory(category) _questionList.value questions } } // 记录用户答案 fun submitAnswerForCurrentQuestion(selectedIndices: SetInt) { _questionList.value?.get(currentQuestionIndex.value ?: 0)?.let { currentQuestion - _userAnswers[currentQuestion.id] selectedIndices } } // 切换到下一题 fun goToNextQuestion() { if ((currentQuestionIndex.value ?: 0) (_questionList.value?.size?.minus(1) ?: 0)) { _currentQuestionIndex.value (currentQuestionIndex.value ?: 0) 1 } } // 切换到上一题 fun goToPreviousQuestion() { if ((currentQuestionIndex.value ?: 0) 0) { _currentQuestionIndex.value (currentQuestionIndex.value ?: 0) - 1 } } // 计算得分 fun calculateScore(): Int { var score 0 _userAnswers.forEach { (questionId, userAnswer) - val question _questionList.value?.find { it.id questionId } question?.let { if (userAnswer it.correctAnswers) { score } } } return score } }QuizRepository是一个数据仓库抽象它决定数据是来自本地如JSON文件、数据库还是网络。这里我们先实现一个本地版本。在QuizFragment中我们通过by viewModels()委托来获取ViewModel实例并观察其LiveData。// QuizFragment.kt class QuizFragment : Fragment() { private val viewModel: QuizViewModel by viewModels() private lateinit var binding: FragmentQuizBinding private lateinit var optionAdapter: OptionAdapter override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { binding FragmentQuizBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setupRecyclerView() setupObservers() setupClickListeners() // 加载题目例如从参数获取分类 val category arguments?.getString(category) ?: default viewModel.loadQuestions(category) } private fun setupObservers() { viewModel.questionList.observe(viewLifecycleOwner) { questions - questions?.let { updateUIWithQuestion(it) } } viewModel.currentQuestionIndex.observe(viewLifecycleOwner) { index - updateProgress(index) } } private fun updateUIWithQuestion(questions: ListQuestion) { val currentIndex viewModel.currentQuestionIndex.value ?: 0 if (currentIndex questions.size) { val question questions[currentIndex] binding.tvQuestionContent.text question.content // 更新适配器数据 optionAdapter.submitList(question.options) // 恢复用户已选答案如果有 val selectedForThisQuestion viewModel.userAnswers[question.id] ?: emptySet() optionAdapter.setSelectedIndices(selectedForThisQuestion) } } // ... 其他方法如 setupRecyclerView, setupClickListeners }这里有一个非常重要的细节observe时使用了viewLifecycleOwner而不是thisFragment本身。这是因为Fragment的视图View有自己的生命周期可能比Fragment本身销毁得更早例如在跳转到其他Fragment时。使用viewLifecycleOwner可以确保LiveData的观察只在视图存活时生效避免内存泄漏和更新一个不存在的视图导致的崩溃。这是Fragment开发中一个非常经典的坑。4. 核心功能实现答题逻辑与状态管理UI和数据绑定好后最复杂的部分来了——如何优雅地处理答题交互和状态尤其是RecyclerView中选项的选中状态管理以及不同题型单选/多选的切换。4.1 构建灵活的选项适配器OptionAdapter不仅要显示选项文本还要处理点击事件并正确反映选中状态。关键在于选中状态必须由适配器内部管理并与ViewModel中的用户答案同步。// OptionAdapter.kt class OptionAdapter( private val onOptionSelected: (SetInt) - Unit // 回调将选中状态告知Fragment/ViewModel ) : ListAdapterString, OptionAdapter.ViewHolder(DiffCallback()) { // 当前选中项的索引集合 private var selectedIndices: MutableSetInt mutableSetOf() private var questionType: QuestionType QuestionType.SINGLE_CHOICE // 默认单选 fun setQuestionType(type: QuestionType) { questionType type if (type QuestionType.SINGLE_CHOICE) { // 如果是单选确保selectedIndices最多只有一个元素 if (selectedIndices.size 1) { val first selectedIndices.first() selectedIndices.clear() selectedIndices.add(first) notifyDataSetChanged() // 通知UI更新 } } } fun setSelectedIndices(indices: SetInt) { selectedIndices.clear() selectedIndices.addAll(indices) notifyDataSetChanged() } inner class ViewHolder(private val binding: ItemOptionBinding) : RecyclerView.ViewHolder(binding.root) { fun bind(optionText: String, position: Int) { binding.tvOptionText.text optionText // 根据当前position是否在selectedIndices中更新UI状态例如改变背景色 val isSelected position in selectedIndices binding.root.isSelected isSelected // 设置点击监听器 binding.root.setOnClickListener { handleOptionClick(position) } } } private fun handleOptionClick(clickedPosition: Int) { when (questionType) { QuestionType.SINGLE_CHOICE - { if (clickedPosition in selectedIndices) { // 如果点击已选中的项在单选模式下通常不取消选择保持至少一个选中 // 但也可以设计为允许取消。这里我们设计为不能取消必须选一个。 // selectedIndices.clear() // 如果允许取消则清除 } else { selectedIndices.clear() selectedIndices.add(clickedPosition) } } QuestionType.MULTIPLE_CHOICE - { if (clickedPosition in selectedIndices) { selectedIndices.remove(clickedPosition) // 取消选择 } else { selectedIndices.add(clickedPosition) // 选择 } } QuestionType.TRUE_FALSE - { // 判断题只有两个选项逻辑可同单选 selectedIndices.clear() selectedIndices.add(clickedPosition) } } notifyDataSetChanged() // 通知UI更新选中状态 onOptionSelected(selectedIndices.toSet()) // 将最终选中集合回调出去 } // DiffCallback 用于高效更新列表省略... }这个适配器的设计有几个要点状态内聚选中状态selectedIndices由适配器自己管理外部如ViewModel通过setSelectedIndices方法来设置初始状态或恢复状态。题型感知通过setQuestionType方法适配器能动态改变其交互逻辑单选/多选。回调通信通过onOptionSelected高阶函数将用户的最新选择实时通知给父组件Fragment父组件再调用ViewModel的submitAnswerForCurrentQuestion方法保存答案。性能优化使用ListAdapter配合DiffUtil.ItemCallback来更新列表这样当选项列表内容变化时比如切换到下一题只有真正变化的项会重新绑定效率更高。在QuizFragment的setupRecyclerView中我们这样设置适配器private fun setupRecyclerView() { optionAdapter OptionAdapter { selectedIndices - // 用户选择发生变化时立即保存到ViewModel viewModel.submitAnswerForCurrentQuestion(selectedIndices) } binding.rvOptions.adapter optionAdapter binding.rvOptions.layoutManager LinearLayoutManager(requireContext()) }4.2 实现题目切换与进度保存“上一题”、“下一题”按钮的逻辑在ViewModel中已经实现。在Fragment中绑定点击事件即可private fun setupClickListeners() { binding.btnNext.setOnClickListener { // 在切换到下一题前确保当前题目的答案已保存已在OptionAdapter回调中保存 viewModel.goToNextQuestion() } binding.btnPrevious.setOnClickListener { viewModel.goToPreviousQuestion() } binding.btnSubmit.setOnClickListener { // 跳转到结果页面传递ViewModel或计算好的结果 val score viewModel.calculateScore() val total viewModel.questionList.value?.size ?: 0 val direction QuizFragmentDirections.actionQuizFragmentToResultFragment(score, total) findNavController().navigate(direction) } }这里有一个关键细节当用户点击“下一题”时当前题目的答案理论上已经通过适配器的回调实时保存了。但是如果用户没有做任何选择就直接点“下一题”呢这时selectedIndices是空集也会被保存。这符合逻辑代表用户未作答。我们需要在结果计算时处理这种情况。进度保存得益于ViewModel的特性。因为ViewModel的生命周期比Fragment长与Activity的Lifecycle关联或在Fragment间通过by viewModels()共享时作用域更大所以当屏幕旋转时questionList、currentQuestionIndex和userAnswers这些存储在ViewModel中的数据不会丢失UI会自动恢复。这是MVVM架构带来的巨大便利。4.3 结果页与数据传递结果页面ResultFragment相对简单主要显示得分和每道题的对错回顾。我们使用Safe Args来安全地在Fragment间传递数据。首先在nav_graph.xml中为action添加参数action android:idid/action_quizFragment_to_resultFragment app:destinationid/resultFragment argument android:namescore app:argTypeinteger / argument android:nametotal app:argTypeinteger / /actionRebuild项目后会生成QuizFragmentDirections类用于创建导航动作如上文所示。在ResultFragment中通过by navArgs()来获取参数class ResultFragment : Fragment() { private val args: ResultFragmentArgs by navArgs() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val score args.score val total args.total binding.tvScore.text getString(R.string.score_format, score, total) // 可以在这里显示更详细的结果回顾例如从共享的ViewModel中获取userAnswers和questionList进行对比 } }对于更复杂的数据如整个答题回顾列表不建议通过导航参数传递因为参数大小可能受限。更好的做法是共享ViewModel。在QuizFragment和ResultFragment中通过指定相同的ViewModelStoreOwner例如共享的Activity来获取同一个ViewModel实例从而直接访问答题数据。5. 数据持久化与高级功能展望一个基础的答题App已经成型。但要让其更实用我们还需要考虑数据从哪里来以及如何持久化用户的答题记录、收藏等。5.1 本地数据源实现Room数据库如果题库不大且固定可以打包在App内。我们可以使用Room数据库来存储题目和记录。首先定义Entity、DAO和Database。// QuestionEntity.kt Entity(tableName questions) data class QuestionEntity( PrimaryKey val id: String, val category: String, val type: String, // 存储为字符串如SINGLE_CHOICE val content: String, val options: String, // 将ListString序列化为JSON字符串存储 val correctAnswers: String, // 将SetInt序列化为JSON字符串存储 val explanation: String?, val imageUrl: String? ) // UserAnswerEntity.kt Entity(tableName user_answers) data class UserAnswerEntity( PrimaryKey(autoGenerate true) val recordId: Long 0, val questionId: String, val selectedAnswers: String, // JSON字符串 val isCorrect: Boolean, val timestamp: Long )这里我们遇到了一个Room的常见问题它不能直接存储复杂对象ListString,SetInt。我们需要使用TypeConverter将它们转换为数据库友好的格式如JSON字符串。// Converters.kt class Converters { TypeConverter fun fromList(list: ListString): String Gson().toJson(list) TypeConverter fun toList(json: String): ListString Gson().fromJson(json, object : TypeTokenListString() {}.type) TypeConverter fun fromSet(set: SetInt): String Gson().toJson(set) TypeConverter fun toSet(json: String): SetInt Gson().fromJson(json, object : TypeTokenSetInt() {}.type) }然后在Database注解中注册这个转换器类。接着实现QuizRepository的具体类LocalQuizRepository它从Room数据库中获取数据。在ViewModel中通过依赖注入手动构造或使用Hilt/Dagger传入Repository实例。5.2 网络数据源与缓存策略如果题库需要从网络动态加载我们可以引入Retrofit。在QuizRepository中实现一个RemoteQuizRepository或一个同时支持本地和网络的QuizRepositoryImpl。一个常见的模式是优先从本地数据库Room读取如果为空或过期则从网络获取并更新到本地数据库。这涉及到更复杂的逻辑如定义数据源优先级、缓存过期时间等。这里可以引入Flow来构建一个响应式数据流。class QuizRepositoryImpl( private val localDataSource: QuestionLocalDataSource, private val remoteDataSource: QuestionRemoteDataSource ) : QuizRepository { override fun getQuestionsByCategory(category: String): FlowListQuestion flow { // 1. 先发射本地数据如果有 val localQuestions localDataSource.getQuestions(category) emit(localQuestions.map { it.toDomain() }) // 2. 同时从网络获取最新数据 try { val remoteQuestions remoteDataSource.fetchQuestions(category) // 3. 将网络数据保存到本地 localDataSource.saveQuestions(remoteQuestions) // 4. 再次发射更新后的本地数据 emit(localDataSource.getQuestions(category).map { it.toDomain() }) } catch (e: Exception) { // 网络请求失败只使用本地数据可以记录日志或通知UI e.printStackTrace() } } }在ViewModel中我们可以收集这个Flow并更新LiveData。5.3 可能遇到的坑与优化点RecyclerView 的闪烁问题在OptionAdapter的notifyDataSetChanged()被频繁调用时可能会看到选项闪烁。优化方法是使用DiffUtil并只更新变化的项或者使用ListAdapter内部已集成DiffUtil并确保DiffCallback正确实现。对于选中状态如果只是改变背景色可以考虑在ViewHolder中直接更新视图属性而不是每次都notifyDataSetChanged()但这需要更精细的状态管理。ViewModel 的初始化如果ViewModel的构造函数需要参数如Repository不能直接使用by viewModels()。你需要实现一个ViewModelProvider.Factory。或者使用Hilt依赖注入框架它能极大地简化这个过程。对于新手项目手动实现Factory也是一个很好的学习过程。内存泄漏在Fragment中观察LiveData时务必使用viewLifecycleOwner。在协程中启动长时间运行的任务时使用viewModelScope它会在ViewModel清除时自动取消避免泄漏。配置变更虽然ViewModel能保存数据但一些与视图强相关的状态如滚动位置、输入框焦点等ViewModel不负责保存。对于RecyclerView的滚动位置可以在onSaveInstanceState中保存LayoutManager的状态并在onViewStateRestored中恢复。UI 测试与单元测试为QuizViewModel编写单元测试非常直接因为它不依赖Android框架。你可以使用TestCoroutineDispatcher来测试协程。对于Fragment的UI测试可以使用Espresso和FragmentScenario。走到这一步你的答题App已经从一个简单的想法变成了一个结构清晰、功能完整、具备一定可扩展性的项目。它涵盖了现代Android开发的核心概念Kotlin、MVVM、LiveData/Flow、Room、Navigation Component、RecyclerView等。你可以在此基础上继续扩展比如添加题目收藏、错题本、每日挑战、多种主题切换甚至集成语音读题等功能。每个功能的添加都是对已有架构的一次考验和提升也是你技术成长的绝佳路径。本文还有配套的精品资源点击获取