
简介基于Android Studio打造的安卓答题App完整项目包面向正在学习安卓开发、需要完成课程设计或毕业设计的开发者也可作为初级工程师巩固安卓组件与数据存储的练手素材。项目实现了限时答题、选择题作答、自动判断正确性并即时反馈、答题结果统计、错题本收集以及历史成绩保存等完整功能界面状态与数据管理均有参考价值。压缩包整体仅8.81MB共含50个文件以Java业务源码、XML界面布局、Gradle工程配置、可直接安装的APK、演示视频、Word运行说明及环境说明为主结构清晰便于按功能模块查阅。目前已有5905人学习使用适合作为安卓交互与数据存储综合练手项目。除完整源代码外还提供安装包、录屏演示、题库文件与运行文档可帮助使用者快速部署应用、梳理答题判分流程与错题统计逻辑减少踩坑成本。1. 用 Android Studio 从零搭一个答题 App先别急着写代码把这三个问题想清楚很多人拿到“基于 Android Studio 开发的安卓答题 app”这个需求第一反应是打开 Android Studio 新建项目然后找一套题库 JSON 就开始堆界面。我见过不少新手在这个阶段翻车原因不是不会写 Java 或 Kotlin而是没想清楚一个答题 App 的核心到底是什么它本质上是一个“题库展示 答题交互 结果反馈”的闭环数据结构和状态管理才是地基界面反而不是最难的。尤其是当你打算做一套能真正上架、能应对几百道题甚至后续扩展分类、错题本、计时功能的 App 时一开始的数据模型如果拍脑袋定后面每加一个功能都要动一堆代码那是真痛苦。这篇文章按我自己的实操路径来写先说清楚做这个标题最常见的技术选型然后给出一套能从零跑通的最小工程结构再讲答题状态机的设计、界面搭建、以及那些不跑一遍根本发现不了的坑。适合两类人一类是刚学 Android 没多久、想用这个项目练手的学生另一类是已经在做但总觉得代码越写越乱、想重构的从业者。我默认你已经装好了 Android Studio能新建空白项目知道 Activity 和 Layout 的基本概念。如果你连 SDK 都还没配好建议先去把环境搞定再回来看因为后面每一步都是建立在能编译运行的基础上的。接下来我直接进入正题按照“数据层 → 状态层 → 界面层 → 打包验证”这个顺序来推进。你会发现这个顺序和大多数教程正好反过来——但这也是我最想纠正的一点思维习惯先让数据结构稳定再让界面服务于数据而不是界面写完了再回来改数据。2. 选型与工程结构为什么我建议用 Kotlin Room 单 Activity 架构2.1 语言和持久化方案怎么选别再用 SQLiteOpenHelper 手写数据库了如果你去搜老教程多半会看到 Java SQLiteOpenHelper 的写法每张表都自己写 create table、insert、query代码冗长还容易写错。我自己刚开始做的时候也是这么干的结果题库一多SQL 语句拼接出错、字段类型对不上调了大半天才发现是建表语句里少了一个逗号。后来换了 Room这类问题基本不再出现。常见做法是语言用 Kotlin数据库用 Room这样做的理由很简单——Kotlin 的空安全和非空判断能帮你少写很多 if null 的模板代码而 Room 在编译期就会检查 SQL 语句写错了直接编译报错不会留到运行期才崩。2.2 工程目录怎么分按功能分包别按类型分包很多新手喜欢建一堆包什么 activity、adapter、bean 全部分开结果一个类文件里塞了五个功能的东西。我推荐的做法是按照功能模块分包一个答题模块就是一个包里面放这个模块的 Activity、ViewModel、Adapter、数据类。这个习惯对单机题库 App 来说尤其重要因为你后面大概率会加“错题本”“收藏”“模拟考试”这些模块如果每个模块都独立成包加功能的时候几乎不动旧代码。以下是我自己常用的一套最小工程结构你新建项目后直接照着建包就行com.example.quizapp/ ├── data/ │ ├── database/ # Room 数据库相关 │ │ ├── AppDatabase.kt │ │ ├── QuestionDao.kt │ │ └── Converters.kt │ ├── model/ # 数据模型 │ │ ├── Question.kt │ │ └── QuizResult.kt │ └── repository/ # 数据仓库统一管理数据来源 │ └── QuestionRepository.kt ├── ui/ │ ├── quiz/ # 答题界面相关 │ │ ├── QuizActivity.kt │ │ ├── QuizViewModel.kt │ │ └── QuestionAdapter.kt │ └── result/ # 结果展示 │ └── ResultActivity.kt └── utils/ └── Constants.kt # 常量比如每题分值这个结构的好处是数据层只负责数据的读写和转换不关心界面UI 层只负责显示和交互不直接操作数据库中间用 Repository 做缓冲以后要换网络题库的时候只改 Repository 的实现底层就够了。新手最容易犯的错就是 Activity 里直接写数据库操作一开始没问题等界面逻辑复杂了一个 Activity 动辄上千行维护起来非常痛苦。2.3 数据模型是关键一道题到底需要哪些字段我先定义一个最精简的 Question 数据类支持单选和多选也支持后续扩展。这个设计决定了你题库的格式所以值得多花几分钟想清楚Entity(tableName questions) data class Question( PrimaryKey(autoGenerate true) val id: Int 0, val type: Int, // 1-单选 2-多选 3-判断 val question: String, // 题干 val optionA: String?, val optionB: String?, val optionC: String?, val optionD: String?, val answer: String, // 正确选项如 A 或 AB val analysis: String?, // 答案解析可选 val categoryId: Int // 分类ID方便后续做分类刷题 )这段代码逻辑很简单但关键在 answer 字段的格式单选存单个字母多选存多个字母按字典序排列比如 “ABD”。我见过有人把多选答案存成 “BD A” 这种带空格的格式解析的时候还得 split纯属给自己挖坑。另外选项字段我用了可空类型因为判断题只有题干和答案不需要 ABCD 四个选项。2.4 DAO 和数据库实例的写法一次性把基础代码写对接下来是 DAO也就是数据访问对象。这一步几乎是你每天都要打交道的地方我直接给出最常用的几个查询方法Dao interface QuestionDao { Query(SELECT * FROM questions WHERE categoryId :categoryId) suspend fun getQuestionsByCategory(categoryId: Int): ListQuestion Query(SELECT * FROM questions ORDER BY RANDOM() LIMIT :limit) suspend fun getRandomQuestions(limit: Int): ListQuestion Insert suspend fun insertAll(questions: ListQuestion) Query(SELECT COUNT(*) FROM questions) suspend fun getQuestionCount(): Int }这里的 suspend 关键字意味着这些操作要放在协程里执行不阻塞主线程。Room 默认不允许在主线程访问数据库这是它的保护机制强制你养成异步操作的习惯。ORDER BY RANDOM() 是 SQLite 自带的随机排序适合在本地题库里抽取随机题但如果题量超过几万条这个写法的性能会明显下降需要换成先查 id 列表再随机取条的方案。数据库实例我建议写成单例模式整个 App 只创建一个实例。网上很多教程直接在 Application 里建对象然后到处传其实没必要那么复杂。常见做法是这样的Database(entities [Question::class], version 1) abstract class AppDatabase : RoomDatabase() { abstract fun questionDao(): QuestionDao companion object { Volatile private var INSTANCE: AppDatabase? null fun getDatabase(context: Context): AppDatabase { return INSTANCE ?: synchronized(this) { val instance Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, quiz.db ).build() INSTANCE instance instance } } } }这里要提醒一句如果你的题库是提前准备好的建议用 Room 的 prepackagedDatabase 方法直接打包 assets 目录下的 db 文件而不是启动的时候用 insertAll 逐条插入。我一开始图省事在 Application 里判断数据库为空就往里插 500 道题结果每次冷启动都要卡一两秒用户感知非常明显。打包预置数据库的方法只需要在 databaseBuilder 后面加一句.createFromAsset(questions.db)就够了但你需要先用 SQLite 工具生成好这个数据库文件具体的导出方法后面会讲。3. 模拟考试功能怎么实现一个状态机管住所有按钮和倒计时3.1 答题状态机的核心用户在做题过程中的五种状态答题 App 最容易写乱的地方就是界面状态。你想想看用户在答题过程中可能处于哪些状态正在作答第 N 题、已经选了选项但没提交、提交后看解析、倒计时归零、交卷之后看成绩。如果不做状态管理完全靠临时变量加 if else 去控制按钮是否可点、答案是否正确代码很快会被各种边界条件绕晕。我采用的办法是在 ViewModel 里维护一个 sealed class把状态显式地表示出来。这里用 Kotlin 的密封类真是再合适不过了sealed class QuizUiState { object Loading : QuizUiState() data class InProgress( val question: Question, val currentIndex: Int, val totalCount: Int, val selectedAnswer: String, val isAnswered: Boolean, val timeLeft: Long ) : QuizUiState() data class Finished( val score: Int, val correctCount: Int, val wrongCount: Int, val detailList: ListAnswerRecord ) : QuizUiState() }这里的 InProgress 状态把当前题目、进度、已选答案、是否已提交、剩余时间都打包在一起Activity 只需要观察这一个状态并渲染 UI 就行。你可能觉得一个状态类带这么多字段有点重但好处是界面永远不会因为某几个变量之间不一致而出现“状态错乱”的 bug。比如用户还没选答案时点击下一题selectedAnswer 是空字符串逻辑上就走不到提交分支也就不会误判。3.2 倒计时和自动交卷的实现方式一个协程管到底计时器是模拟考试功能里绕不开的东西。很多人用 Handler 或者 Timer 写定时器改来改去经常出现 Activity 销毁后还在跑的问题。我的做法是在 ViewModel 里开一个协程无限循环调用 delay每次减一秒时间归零就自动进入交卷逻辑fun startTimer(totalTimeSeconds: Long) { viewModelScope.launch { timeLeft totalTimeSeconds while (timeLeft 0) { delay(1000) timeLeft-- _uiState.update { state - if (state is QuizUiState.InProgress) { state.copy(timeLeft timeLeft) } else state } } submitQuiz() // 自动交卷 } }这里有个关键细节协程是在 viewModelScope 里启动的ViewModel 被销毁时协程会自动取消所以不会出现“Activity 已经关了计时器还在后台走、还试图更新 UI”的崩溃。还有一点需要注意倒计时的时间源用的是挂起函数 delay它并不是严格精确的如果你需要跟服务器对时那得用系统时钟计算差值不过本地题库场景 delay 完全够用。3.3 交卷逻辑的隐藏难点多选题的答案比对和判分规则交卷的时候最典型的翻车点是多选题的判分。常见做法是把用户答案和正确答案都当成字符串直接比较但如果用户选了 B 和 A你存的用户答案是 “AB” 还是 “BA”如果你在点击选项的时候没有做排序存成 “BA”然后和正确答案的 “AB” 比较这道题就会莫名奇妙被判定错。解决办法是在提交前统一标准化对用户答案的字符串按字母序排序或者干脆在点击选项时强制按顺序插入。我推荐前者因为代码只在提交时写一次不容易漏改。下面是交卷判分的核心代码private fun normalizeAnswer(answer: String): String { return answer.toCharArray().sorted().joinToString() } private fun submitQuiz() { val state _uiState.value as? QuizUiState.InProgress ?: return val normalizedUserAnswer normalizeAnswer(state.selectedAnswer) val normalizedCorrectAnswer normalizeAnswer(state.question.answer) val isCorrect (normalizedUserAnswer normalizedCorrectAnswer) normalizedUserAnswer.isNotEmpty() // 记录单题结果 val record AnswerRecord( questionId state.question.id, userAnswer state.selectedAnswer, correctAnswer state.question.answer, isCorrect isCorrect ) answerRecords.add(record) // 更新分数和进度 val correctCount answerRecords.count { it.isCorrect } val totalScore correctCount * Constants.SCORE_PER_QUESTION if (state.currentIndex state.totalCount - 1) { _uiState.value QuizUiState.Finished(...) } else { // 进入下一题重置已选答案 } }注意这段代码里处理了两种典型边界一是用户来不及选就自动交卷selectedAnswer 是空字符串normalizeAnswer 之后也是空不会误判为正确二是多选中用户选了 “BD”标准化后是 “BD”正确答案也是 “BD”比较结果才可靠。我见过更复杂的判分规则比如多选漏选得一半分、错选不得分那个就需要额外定义计分策略了这里先不展开因为本地模拟考试场景一般用最简单的全对才得分。4. 用 RecyclerView 做答题滑卡界面布局复用和选项状态恢复4.1 答题 App 需要用哪种列表ScrollView 还是 RecyclerView很多人做答题界面时第一反应是 ScrollView因为一题就是一个整个页面上下滑动看题干选项在下方。但如果你要做的是“一页多题”的试卷模式或者支持左右滑动的答题卡导航ScrollView 就明显吃力了。我建议直接用 RecyclerView把每一道题当作一个 item用 LinearSnapHelper 实现单页滑动效果这样每道题都复用了 ViewHolder内存占用非常稳定不会因为题目数量多而卡顿。4.2 选项按钮的选中逻辑如何避免 RecyclerView 复用导致的选项状态错乱这里是 RecyclerView 最著名的坑当你滑动列表时item 会被回收和复用如果你在选项按钮的点击回调里直接修改按钮的选中状态而不保存一份“当前题目的用户答案”到数据模型里那么滑回去的时候之前选中的选项会变成未选中甚至出现在另一道题上。解决办法非常简单每次 onBindViewHolder 的时候从当前题目对应的已选答案里去读取状态再渲染到按钮上。假设你已经有了一个MapInt, Stringkey 是题目的 idvalue 是用户这道题已选的答案字符串那么在 Adapter 里的绑定逻辑应该是这样override fun onBindViewHolder(holder: ViewHolder, position: Int) { val question questions[position] ?: return val userAnswer answerMap[question.id] ?: // 先重置所有选项的背景再根据 userAnswer 重新设置 listOf( holder.optionA, holder.optionB, holder.optionC, holder.optionD ).forEach { it.setBackgroundResource(0) } if (question.type 1) { // 单选题只有存入的答案对应选项才高亮 highlightOption(holder, userAnswer) } else { // 多选题遍历 userAnswer 的每个字符分别高亮 userAnswer.forEach { letter - highlightOption(holder, letter.toString()) } } }这段代码的逻辑说明每次绑定 item 时先无条件把所有选项背景清掉再根据 map 中已保存的答案重新高亮。这样不管 item 怎么复用只要 answerMap 数据是对的界面上显示就对。最容易犯的错误是只在点击回调里更新按钮背景那样滑动后必然会错乱这是 RecyclerView 新手必踩的坑。4.3 选项的高亮样式用 selector 而不是手动改背景色关于选项选中后的视觉效果我不建议在代码里直接 setBackgroundColor因为按下和选中的反馈需要不同的颜色纯代码写死了很难调。更稳的做法是定义两个 drawable selector一个对应未选中态一个对应选中态。你可以在 res/drawable 下建一个option_bg.xmlselector xmlns:androidhttp://schemas.android.com/apk/res/android item android:state_selectedtrue shape solid android:color#E3F2FD / stroke android:width2dp android:color#2196F3 / corners android:radius8dp / /shape /item item shape solid android:color#FAFAFA / stroke android:width1dp android:color#BDBDBD / corners android:radius8dp / /shape /item /selector然后在 Activity 里做选项点击时把当前选中的 item 调成 selected true其余选项 selected false。很多教程会教你把选中状态记在一个“选项 ID”变量里但如果你用了 RecyclerView这个变量必须拆成上面提到的 answerMap 才能跟题目一一对应否则滑动就会串。5. 冷启动加载题库的优化与避坑SQLite 预置库和版本升级三件事5.1 为什么启动时逐条 insert 是灾难一次 App 启动卡 3 秒的教训我在前面的章节提到过用 insertAll 逐条插入作为题库初始化这里展开说一下为什么这是不可行的长线方案。假设你有 1000 道题每道题加上四个选项字符串总数据量可能只有几百 KB看起来不大。但如果你在 Application 的 onCreate 里异步插入用户打开 App 会先看到一片白屏甚至因为数据库写入和界面首帧渲染同时竞争 IO几秒之内点击任何按钮都会无响应。更麻烦的是每次升级题库版本你还得考虑要不要清掉旧数据重新插一遍这中间只要哪一道题的 SQL 语句类型对不上就直接崩溃。所以正确做法是把题库数据在开发期就整理成一个 SQLite 数据库文件放到 assets 目录App 第一次启动时直接复制到私有目录之后每次打开都是秒开。5.2 如何用 SQLite 工具生成预置数据库文件三步导出可用的 .db常见做法是用 SQLite 命令行工具在你的电脑上先建好表、导入数据然后导出 .db 文件。这个过程中最需要留意的是表结构必须和 Room 的实体定义一致包括表名、字段名、字段类型一个字符都不能差。具体操作步骤可以这样拆第一步写一个 SQL 脚本文件创建 questions 表并插入几条测试数据用 SQLite 命令行执行sqlite3 questions.db create_quiz.sql其中 create_quiz.sql 内容大致是DROP TABLE IF EXISTS questions; CREATE TABLE questions ( id INTEGER PRIMARY KEY AUTOINCREMENT, type INTEGER NOT NULL, question TEXT NOT NULL, optionA TEXT, optionB TEXT, optionC TEXT, optionD TEXT, answer TEXT NOT NULL, analysis TEXT, categoryId INTEGER NOT NULL ); INSERT INTO questions (type, question, optionA, optionB, optionC, optionD, answer, analysis, categoryId) VALUES (1, Android 中 Activity 启动模式不包括以下哪个, standard, singleTop, singleTask, singleInstance, D, 解析除这三种外还有 singleInstancePerTask 等扩展模式。, 1);第二步打开 Android 项目的 app/src/main/assets 目录把 questions.db 放进去。如果你的项目没有 assets 目录手动新建一个就行。第三步在 Room 数据库构建时加上 createFromAssetRoom.databaseBuilder(context, AppDatabase::class.java, quiz.db) .createFromAsset(questions.db) .build()这一步的逻辑说明createFromAsset 会在数据库文件不存在的时候从 assets 里复制文件到 App 的私有数据库目录。这个过程只在首次启动时发生后续启动 Room 会直接检测到数据库文件已存在不再复制所以对性能几乎无影响。这里还要注意 Room 的版本号必须和你 assets 里的表结构版本匹配不然会抛 IllegalStateException。5.3 通过 assets 更新题库时为什么改了表结构还是提示数据库版本不匹配这是 Room 最容易让人困惑的地方也是我踩过最久的一个坑。现象是我改了 Question 实体比如多加了一个 origin 字段也改了 assets 里的 questions.db 文件然后重新装到手机上App 一启动就崩报错提示数据库版本从 1 到 2 的迁移路径缺失。原因很简单Room 的版本号并不是数据库文件本身的版本号而是你在 Database 注解里写的 version 参数。只要表结构变了就必须把 version 加 1同时提供 Migration 或者允许破坏性重建。我通常这样处理开发阶段数据库版本还没上线直接允许破坏性重建最简单。在构建数据库时加一句.fallbackToDestructiveMigration()但真正上架后这招就不能随便用了因为用户的本地数据可能包括收藏记录、错题本一旦重建就全部清空。上架后的更新我应该写一个 Migration把新的字段用 ALTER TABLE 加进去比如val MIGRATION_1_2 object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL(ALTER TABLE questions ADD COLUMN origin TEXT DEFAULT ) } }然后构建时传入addMigrations(MIGRATION_1_2)。这里的执行经验是千万别在 Migration 里写复杂的数据搬运逻辑因为 Migration 的执行时机可能是在用户升级完 App 第一次进页面时耗时长了会造成明显卡顿所有跨版本的数据整理都应该放到下一版发布的延迟任务里做。5.4 本地题库和预置题库的关系开发期用什么工具编辑数据最高效有人问预置题库数据库能不能直接用 Android Studio 的 Device File Explorer 导出手机里的 db 回来编辑答案是可以但不推荐因为导出的文件是 SQLite 格式普通编辑器看不了。我常用流程是开发期先在 App 里做一个隐藏的调试选项允许把 assets 题库复制到外部存储然后在电脑上用 DB Browser for SQLite 直接编辑改完再放回 assets。这里有个玄学问题DB Browser 保存文件后Room 有可能因为表结构里多了一些自动索引而识别失败所以保存时记得勾选“不压缩”的兼容模式或者导出的类型选 SQLite 格式并保留原有的表 schema。一般我遇到这种问题就重新执行一遍建表脚本插入数据后再导出基本能避开。6. 答题结果页与错题本闭环把记录存成一张表的三种验证方法6.1 结果页不仅显示分数还要能回看答题记录答题完成之后的页面如果只显示一个数字用户下次再遇到同类型的题还是不知道错在哪。比较稳妥的方案是在提交完所有题目后生成一个 AnswerRecord 列表每一项包含题目 id、用户选的答案、正确答案、是否答对。然后结果页通过 RecyclerView 展示这些记录答对的题目标绿、答错的标红点击还能展开查看解析。这个功能如果要从 Activity 传数据我一般不建议直接传整个对象列表因为 Android 的 Intent 传 Parcelable 列表虽然支持但数据量大时序列化开销不小。更稳的做法是把结果存进 Room 的 answer_records 表里结果页直接用 ViewModel 查询展示。这么做还有一个额外好处——以后做错题本从这张表里过滤 isCorrect 0 的记录就能拿到错题不用再另建一张表。6.2 答题记录表的建表语句与写入时机防止同一题重复记录为了方便你扩展我这里给一个 AnswerRecord 数据类的定义Entity(tableName answer_records) data class AnswerRecord( PrimaryKey(autoGenerate true) val id: Long 0, val quizId: Long, // 一场考试/一次练习的批次ID val questionId: Int, val userAnswer: String, val correctAnswer: String, val isCorrect: Boolean, val timestamp: Long )这里的 quizId 值得解释一下它标识的是“某一次答题会话”不是题目 id。有了它你可以在结果页按 quizId 查出一整场的所有答题明细之后如果要做历史成绩曲线直接按 quizId 分组聚合就行。我见过有人只在表里存题目 id 和是否答对结果想反查某一场考试时没法区分哪些记录是同一场的还得反过来去建关联表很被动。写入时机有两种常见做法一种是每答完一题立即 insert好处是就算用户在交卷前强行杀掉 App答题记录也保存了一部分另一种是整场交卷后统一 insert好处是写库次数少性能更好。我通常选择前者因为本地数据库插入一条记录的耗时几乎可以忽略而崩溃时数据的完整性更重要。6.3 如何验证自己的答题逻辑是否有 bug三种快速自测方法代码写完不能只在模拟器上点一遍就觉得没问题。以下三种方法是我每次改完逻辑必跑的能发现大多数隐性 bug。第一种边界题号测试。只放 1 道题进题库跑完整个流程从答题到交卷重点看“最后一题”分支是否正确。如果你在 Activity 里判断 currentIndex totalCount - 1 时交卷那么这里最容易出现越界或者重复提交。再放 2 道题跑一遍看第一题跳到第二题时上一题的答案是否被正确记录。第二种乱序点击测试。用户不一定会按顺序答题先把所有题目浏览一遍再回头改第一题的答案最后直接提交。这时候如果你在滑动过程中修改了 answerMap 但没更新 adapter 的绑定数据源页面会显示正确、实际记录却是旧答案。这个坑只要把 answerMap 放在 ViewModel 里而不是 Activity 的局部变量就能解决不过多一次测试更稳妥。第三种模拟器上开启调试模式直接杀进程。在交卷之前把 App 从后台划掉然后再点开看数据是否还在。如果用的是内存变量保存 answers那肯定丢。如果你按 6.2 写了逐题写入那么重新打开后至少能看到已经答过的题目的记录这个行为符合用户预期是合格的。我平时还有一个习惯给 Question 表加一个 isTrueOrFalse 类型的判断题然后故意把答案写成 “T” 或 “F”检查判分逻辑是不是只把 A/B/C/D 视为合法。这道题能暴露出一些新手写死选项字母导致判断题判不了分的尴尬场景我当年就栽过后来把 option 字段全部改成可空、判断题只显示“正确/错误”按钮之后才彻底缓解。最后一章也是我真正想分享的一个手感层面的经验当你把核心逻辑跑通后请务必在目标手机上做一次真机测试不要只在模拟器上点。模拟器的触摸精度和流畅度跟真机差很多尤其是多选题连续点选时的手感只有真机才能看出问题。把上面三种测试方法跑一遍再让身边人拿着你的手机实际答一遍题观察他们会不会在某一步卡住。在 Android 开发这条路上数据结构和状态管理永远比界面动画值钱把代码写成可以随时加功能的样子比一口气加满功能更值得投入。希望这些经验能帮到你。本文还有配套的精品资源点击获取