Android答题系统开发实战:Room+ViewModel+RecyclerView

📅 发布时间:2026/9/19 8:52:09
Android答题系统开发实战:Room+ViewModel+RecyclerView
简介这是一份面向Android初学者与移动应用开发入门者的答题系统实战资料围绕Android平台下简单答题应用的完整实现展开帮助读者理解从数据存储到界面交互的基本开发流程。资源包共1个文件为PDF格式大小约209KB内容以图文与代码示例结合的方式呈现便于对照学习。目前已有5131人学习下载具备一定的参考热度。资料重点讲解答题系统的数据库设计思路采用SQLite轻量级关系型数据库存储题目数据并借助SQLite Database Browser工具进行管理通过Question类封装题干、选项A至D、答案、解释及用户选择等属性配合DBService类完成数据库连接与题目列表获取使用rawQuery()执行查询、Cursor遍历结果集并转换为对象。同时涉及assets目录存放试题数据库、应用启动时复制数据库文件等实用技巧以及ExamActivity中错题模式标志变量、RadioGroup选项处理等界面交互逻辑适合作为课程设计或练手项目的参考。1. 从一道选择题开始Android 答题系统到底该怎么做很多人第一次接到「答题系统」需求时脑子里蹦出来的方案是一个 Activity上面放题干 TextView下面放四个选项 Button点对了弹 Toast点错了标红。这个方案能跑但撑不过第二周——题目要换、要计分、要倒计时、要记录错题、要支持单选多选判断代码很快就变成一坨 if-else。Android 实现简单的答题系统核心不是 UI 有多花哨而是把「题库数据」「答题状态」「界面渲染」三件事拆开。题库用本地 SQLite 或 assets 里的 JSON 都行答题状态用一个 ViewModel 持有界面只负责把状态画出来。这样换题型、加计分规则、接后端接口都只动一层。这篇面向的是能写 Activity、用过 RecyclerView 的 Android 开发者也适合想拿答题系统练手 Jetpack 组件的人。下面从数据建模讲到可运行的代码再到倒计时、错题本这些容易翻车的点最后给一套能直接抄的排错和验证方法。2. 题库建模与数据层用 Room 存题、用 JSON 灌初始数据2.1 为什么答题系统优先选 Room 而不是裸 SQLite题目数量少的时候直接读 assets 里的 JSON 也能用。但答题系统通常要支持「错题本」「收藏」「做题记录」这些都需要增删改查。裸 SQLite 写SQLiteOpenHelper要手写建表语句、Cursor 解析、版本迁移代码量大且容易在字段变更时崩。Room 是 Jetpack 里的 ORM编译期校验 SQL字段改了编译就报错比运行时崩强得多。常见做法是题目本身用 Room 存初始题库用 JSON 放在 assets首次启动时批量插入。这样既保留了本地查询能力又不用把题目硬编码在 Java/Kotlin 里。2.2 题目实体与选项的字段设计一道题至少要有题干、题型单选/多选/判断、选项列表、正确答案、解析。选项不要用四个独立字段optionA/optionB/optionC/optionD那样加一个选项就要改表结构。正确做法是选项单独一张表用外键关联题目。// Question.kt Entity(tableName question) data class Question( PrimaryKey(autoGenerate true) val id: Long 0, val stem: String, // 题干 val type: Int, // 1单选 2多选 3判断 val answer: String, // 正确答案多选用逗号分隔如 A,C val analysis: String // 解析 ) // Option.kt Entity(tableName option, foreignKeys [ ForeignKey(entity Question::class, parentColumns [id], childColumns [questionId], onDelete ForeignKey.CASCADE) ]) data class Option( PrimaryKey(autoGenerate true) val id: Long 0, val questionId: Long, val label: String, // A/B/C/D val content: String )answer字段用字符串存多选答案是为了避免再建一张答案表。多选时按A,C格式存解析时split(,)即可。onDelete CASCADE保证删题时选项一起删不会留下孤儿数据。2.3 从 assets 灌入初始题库的完整流程首次启动判断可以用 SharedPreferences 存一个db_initialized标记也可以用 Room 的count()查询。后者更可靠因为用户清数据后标记可能还在。suspend fun initIfEmpty(db: AppDatabase, context: Context) { if (db.questionDao().count() 0) return val json context.assets.open(questions.json) .bufferedReader().use { it.readText() } val list Gson().fromJson(json, ArrayQuestionJson::class.java) list.forEach { q - val qid db.questionDao().insert(q.toEntity()) q.options.forEach { opt - db.optionDao().insert(opt.toEntity(qid)) } } }逻辑说明先查数量非空直接返回避免重复插入。bufferedReader().use保证流关闭。插入题目后拿到自增 id再插选项保证外键正确。参数上questions.json建议放在src/main/assets/下格式用数组每项包含stem/type/answer/analysis/options。注意批量插入要放在Dispatchers.IO里主线程插几百道题会 ANR。数据量大时用Insert返回ListLong或事务包裹减少 IO 次数。3. 答题界面与状态管理ViewModel 持有状态RecyclerView 渲染选项3.1 用 ViewModel 管住答题状态避免旋转丢数据答题过程中用户旋转屏幕Activity 重建如果状态存在 Activity 字段里就全丢了。用ViewModel持有当前题号、已选答案、得分重建时状态还在。这是答题系统最容易被忽略但最影响体验的一点。class QuizViewModel : ViewModel() { private val _state MutableLiveDataQuizState() val state: LiveDataQuizState _state private var questions: ListQuestion emptyList() private var index 0 private val selected mutableSetOfString() var score 0 private set fun load(qs: ListQuestion) { questions qs index 0 selected.clear() score 0 emit() } fun select(label: String, multi: Boolean) { if (multi) { if (!selected.add(label)) selected.remove(label) } else { selected.clear() selected.add(label) } emit() } fun submit() { val q questions[index] val correct q.answer.split(,).toSet() if (selected correct) score 10 if (index questions.size - 1) { index; selected.clear(); emit() } } private fun emit() { _state.value QuizState(questions.getOrNull(index), selected.toSet(), score, index) } }逻辑说明selected用Set存已选项多选时add返回 false 说明已存在就移除实现反选。单选时先清空再添加。submit里把正确答案split(,)成 Set 再比较避免顺序问题。score用private set防止外部乱改。参数上每题分值这里写死 10实际可以做成Question的字段。QuizState是一个 data class包含当前题、已选、得分、进度UI 只观察它。3.2 选项用 RecyclerView 而不是四个写死的 Button四个 Button 的方案在判断题两个选项和多选题选项数量不定时会很别扭。用 RecyclerView 渲染选项item 布局里放一个 TextView 加一个选中态背景点击回调把 label 传给 ViewModel。class OptionAdapter( private val onSelect: (String) - Unit ) : RecyclerView.AdapterOptionAdapter.VH() { private var options: ListOption emptyList() private var selected: SetString emptySet() fun submit(opts: ListOption, sel: SetString) { options opts; selected sel; notifyDataSetChanged() } override fun onBindViewHolder(holder: VH, position: Int) { val opt options[position] holder.tv.text ${opt.label}. ${opt.content} holder.itemView.isSelected selected.contains(opt.label) holder.itemView.setOnClickListener { onSelect(opt.label) } } // onCreateViewHolder 略 }逻辑说明submit同时更新选项和选中态notifyDataSetChanged简单粗暴但题目选项少性能没问题。itemView.isSelected配合 selector 背景实现选中高亮比手动改背景色干净。点击只回调 label具体选中逻辑交给 ViewModel保持 UI 无状态。3.3 单选、多选、判断三种题型的统一处理题型差异只在「能否多选」和「选项数量」。在 ViewModel 的select里根据question.type决定是否多选UI 层不用改。判断题本质是只有两个选项的单选数据层把选项设成「正确/错误」即可。题型type 值选项数选择逻辑单选12~6清空后添加多选22~6存在则移除否则添加判断32同单选这张表建议直接对应到select方法的分支判断避免在 Adapter 里写题型逻辑。4. 倒计时、计分与错题本三个最容易翻车的实现细节4.1 倒计时用 CountDownTimer 还是协程CountDownTimer是 Android 原生类用起来简单但在onTick里更新 UI 要注意 Activity 是否已销毁。协程方案更灵活配合lifecycleScope自动取消。// 协程方案 fun startTimer(seconds: Int, onTick: (Int) - Unit, onFinish: () - Unit) { lifecycleScope.launch { for (s in seconds downTo 1) { onTick(s) delay(1000) } onFinish() } }逻辑说明lifecycleScope绑定 Activity 生命周期页面销毁协程自动取消不会回调到已销毁的 UI。delay(1000)是挂起而非阻塞。参数seconds是总时长onTick每秒回调剩余秒数onFinish在归零时触发交卷。注意delay不保证精确 1000ms系统调度会有几毫秒偏差。如果要做「剩余时间精确到毫秒」的展示用System.currentTimeMillis()算差值别累加 tick 次数。4.2 计分规则要可配置别写死在 submit 里每题分值、多选漏选是否给半分、错选是否扣分这些规则产品随时会改。把计分抽成一个ScoreRule接口submit调用它。interface ScoreRule { fun calc(question: Question, selected: SetString): Int } class SimpleScoreRule(private val perQuestion: Int 10) : ScoreRule { override fun calc(question: Question, selected: SetString): Int { val correct question.answer.split(,).toSet() return if (selected correct) perQuestion else 0 } }逻辑说明SimpleScoreRule是全对才给分。要支持漏选给半分再写一个PartialScoreRule实现同一接口即可ViewModel 不用改。参数perQuestion默认 10构造时可传不同分值。4.3 错题本的数据落地与查询错题本本质是一张表记录做错的题目 id 和时间。交卷时把错题批量插入查询时 join 题目表。Entity(tableName wrong_record) data class WrongRecord( PrimaryKey(autoGenerate true) val id: Long 0, val questionId: Long, val timestamp: Long ) Query(SELECT q.* FROM question q INNER JOIN wrong_record w ON q.id w.questionId ORDER BY w.timestamp DESC) suspend fun getWrongQuestions(): ListQuestion逻辑说明INNER JOIN保证只返回还在题库里的错题。ORDER BY timestamp DESC让最近做错的排前面。插入前建议先查是否已存在避免同一题重复记录或者用OnConflictStrategy.REPLACE配合唯一索引。5. 调试与验证用 ADB 和日志把答题流程跑通5.1 用 ADB 快速验证题库是否灌入成功开发阶段不想每次都点进 App 看可以直接查数据库。Debug 包允许run-as。adb shell run-as com.example.quiz ls databases/ adb shell run-as com.example.quiz sqlite3 databases/quiz.db SELECT COUNT(*) FROM question;逻辑说明run-as以应用身份执行能访问私有目录。第一条列数据库文件确认quiz.db存在。第二条查题目数量返回 0 说明灌库逻辑没跑或 JSON 解析失败。参数com.example.quiz换成实际包名。注意部分设备没有sqlite3命令可以adb exec-out run-as 包名 cat databases/quiz.db local.db拉到本地用工具打开。5.2 答题流程的日志埋点与常见崩溃点在 ViewModel 的关键方法里打日志能快速定位是状态没更新还是 UI 没刷新。fun submit() { val q questions[index] Log.d(Quiz, submit index$index type${q.type} selected$selected answer${q.answer}) // ... }逻辑说明日志打出当前题号、题型、已选、正确答案对比就能看出是选择逻辑错还是答案格式错。常见崩溃点有三个questions为空时questions[index]越界answer为空字符串时split得到[]RecyclerView 的options和selected不同步导致高亮错乱。5.3 用单元测试覆盖计分和选择逻辑UI 不好测但 ViewModel 的逻辑可以纯 JVM 测试不依赖设备。Test fun multiSelect_toggle() { val vm QuizViewModel() vm.load(listOf(Question(1, 题干, 2, A,C, ))) vm.select(A, true) vm.select(C, true) vm.submit() assertEquals(10, vm.score) }逻辑说明构造一道多选答案A,C的题依次选 A、C交卷后断言得分 10。这个测试能覆盖「多选反选」「答案比较」两个易错点。参数上Question的构造顺序要和 data class 一致type2表示多选。5.4 性能与体验的收尾检查项题目多的时候RecyclerView 的选项列表要设setHasFixedSize(true)避免重复测量。题干过长要放在NestedScrollView里防止小屏截断。交卷后跳转结果页用ActivityResultContracts而不是startActivityForResult后者已废弃。检查项做法原因选项点击抖动item 加foregroundselector提升点击反馈题干超长外层 NestedScrollView小屏可滚动页面跳转ActivityResultContracts替代废弃 API列表性能setHasFixedSize(true)减少测量最后一行技术内容把ScoreRule做成可注入的构造参数测试时传一个固定返回值的假实现就能在不改 ViewModel 的前提下验证各种计分边界。本文还有配套的精品资源点击获取