首页 / 资讯中心 / 文章详情

安卓答题App完整工程解析:SQLite题库、倒计时与避坑指南

安卓答题App完整工程解析:SQLite题库、倒计时与避坑指南 ★ FEATURED ARTICLE
简介基于Android Studio开发的一款安卓答题App完整项目面向Android初学者、课程设计者及有测验类应用需求的学习者。内置限时答题、选择题作答、即时判断对错并反馈正确答案、答题结果统计、错题集自动收集与历史成绩保存覆盖从题库加载到成绩管理的主要流程。资源共50个文件压缩包大小8.81MB包含Java源码、XML布局、Gradle构建配置、PNG图标、properties属性文件及docx运行说明文档附有可直接安装的APK和演示视频还打包了题库数据。已有5888人学习目录结构清晰标准Android Studio工程布局便于定位与修改结合运行文档与视频既能快速部署体验也能深入理解答题状态管理、错题持久化和统计逻辑适合作为毕业设计、课程作业的参考。1. 安卓答题App一个能练手也能直接改的完整工程做课程设计、公司内部考核工具、或者给培训机构搭一个轻量考试系统最省事的路径不是从零写界面而是拿一份能跑的安卓答题工程来改。这份基于Android Studio的答题App就是把「题目展示、选项交互、计时、评分、错题回顾」这些核心环节全打通了的完整项目不是只给一个登录页面或者静态列表的demo。适合刚学完四大组件、想看看完整App怎么串起来的初学者也适合手里有现成题库、想快速做成App交付的开发者。你能直接改包名、换图标、替换题库发布成自己的应用。下面我会按工程结构、数据库设计、答题流程、坑点排查这个顺序把这套工程从里到外拆一遍方便你对照源码复现。2. 工程与技术选型Android Studio 工程结构、语言与依赖的取舍2.1 工程目录模块划分三个页面骨架与一个全局状态类打开这份工程首先看的是app/src/main/java下的包结构。按功能划分代码主要分成三块主界面MainActivity、答题页QuizActivity、结果页ResultActivity另外还有一个独立的档案类QuestionBank和全局的当前答题状态管理类QuizState。这种「三页面 一数据库 一状态类」的组合是小型答题 App 最常见的骨架好处是页面跳转逻辑清晰什么页面负责什么事一眼就能看出来后期要往里面加登录、排行榜也容易。Android Studio 打开工程的第一步先确认 Gradle 和 SDK 版本对不对。工程里build.gradle文件规定了编译依赖常见配置类似这样android { namespace com.example.quizapp compileSdk 34 defaultConfig { applicationId com.example.quizapp minSdk 21 targetSdk 34 versionCode 1 versionName 1.0 } } dependencies { implementation androidx.appcompat:appcompat:1.6.1 implementation com.google.android.material:material:1.11.0 implementation androidx.constraintlayout:constraintlayout:2.1.4 }这里面 minSdk 21 意味着 Android 5.0 以上都能跑这个覆盖范围在实际使用中基本够用。compileSdk 34 是当前主流编译版本如果你的 Android Studio 提示 SDK 缺失去 SDK Manager 里把对应版本装上就行。如果下载后打不开十有八九是 Gradle 版本和 Studio 版本不匹配这时候看 Android Studio 的版本要求AGP 8.x 对应 Gradle 8.x 以上别硬试旧版本直接升级 Studio 最省时间。2.2 语言选型Kotlin 还是 Java为什么推荐跟着工程默认走现在 Android Studio 新建项目默认语言是 Kotlin这份答题工程如果注释里出现lateinit var或by viewModels()这类写法就是 Kotlin 写的如果大量出现findViewById(R.id.xxx)和.setOnClickListener就是 Java 写的。不管哪种第一步都是先把工程跑起来不要上来就重写语言。我一般建议新手跟着工程原有语言走因为代码和布局文件是配套的你自己混着改很容易踩坑。Kotlin 在写异步和判空时更省事比如后台加载题库用协程代码会短一截。如果你用的是 Java 版本也别急着迁移核心逻辑是一样的读起来差异不大。这个工程的 UI 交互逻辑集中在答题页你改题目跳转或者加倒计时处理的核心是同一个方法链路语言只是语法差别。2.3 题库存储选型三种方案对比题库存哪是答题 App 绕不开的决定。这份工程到底用哪种需要你打开代码看一下QuestionBank类的实现存储方式适用场景优点缺点SharedPreferences 存 JSON题目量 100临时演示实现最简单几行代码就能读题目一多加载慢不便查询SQLite 原生题目几百到几千结构固定轻量、无额外依赖、查询灵活手写 SQL 容易犯错Room题库大、要频繁改查编译期校验 SQL和 LiveData 配合好需引入 kapt 或 ksp依赖多一点如果你拿到的工程里用了一整条建表 SQL 和SQLiteOpenHelper那就是原生 SQLite 方案这种写法最直观也最容易改造成 Room。我的习惯是题目在 500 道以内、以单选题为主、不需要在线更新题库时SQLite 原生写法完全够用等你要做后台推送题库、题目带图片解析、按分类精确查询时再考虑换 Room。别听别人说 Room 是趋势就硬换小项目用原生 SQLite 维护成本更低踩坑也少。3. 题库与数据层SQLite 建表、初始化导入与随机抽题的实现细节3.1 建表设计与字段语义题意、选项、答案、分类怎么装打开QuestionBank.java或QuizDatabaseHelper.java核心是一段建表语句。典型的答题表结构如下CREATE TABLE QUESTIONS ( _id INTEGER PRIMARY KEY AUTOINCREMENT, question_text TEXT NOT NULL, option_a TEXT NOT NULL, option_b TEXT NOT NULL, option_c TEXT, option_d TEXT, correct_answer TEXT NOT NULL, category TEXT DEFAULT 默认分类, difficulty INTEGER DEFAULT 1 );在这里option_c和option_d允许为空是为了兼容只有两个选项的判断题correct_answer存的是 ABCD 这种字母而不是具体选项文本这样后续判断对错只需要比对字母不用做字符串匹配。difficulty这个字段很多初学者不理解为什么要留其实它是给「按难度抽题」和「错题重练优先出简单题」预留的扩展点。假如你后面要做分级考试这个字段就能直接派上用场不用回头改表结构。很多工程还会单独建一张配置表EXAM_CONFIG存考试时长、每题分值、是否随机打乱选项这些参数。这样做的好处是改考试规则不用重新编译 App改数据库就行。如果你拿到的版本没有这张表建议自己加后面第 5 章我们会提到与它相关的一个坑。3.2 首次启动初始化题库assets 目录导入与自带题目的自动落库题库初始化是新手最容易卡住的地方——明明代码里有题目数据但安装到手机后就是查不到原因是「初始化只写了代码没写触发逻辑」。常见的触发方式有两种一种是onCreate里每次打开数据库都检查表是否为空为空则从assets目录读入另一种是用SharedPreferences记录一个「已初始化」标志只有第一次启动时才执行建表和数据导入。第二种更安全避免每次启动都查一次表的开销。Override public void onCreate(SQLiteDatabase db) { db.execSQL(CREATE_TABLE_SQL); boolean firstRun prefs.getBoolean(db_inited, false); if (!firstRun) { insertBuiltInQuestions(db); prefs.edit().putBoolean(db_inited, true).apply(); } }insertBuiltInQuestions函数内部就是反复执行INSERT语句。这里有个细节要注意insertBuiltInQuestions(db)如果你已经把题库改成从assets目录的 JSON 文件读取那这里就改成loadJsonFromAssets()再加parseAndInsert。不管怎么改都要记住在onCreate里先建表、再判断是否导入顺序反了会直接抛no such table异常日志里报错指向getWritableDatabase()那一行这个错误在下一章我们会展开说。3.3 随机抽题的三种写法SQL 随机、集合打乱与性能边界题目加载完下一个核心需求是乱序出题。最直接的写法是 SQL 里的ORDER BY RANDOM()但它的性能随着题量增大而下降500 道题没感觉5000 道题时下拉加载会有一点延迟。中间路线是查全部_id在应用层用Collections.shuffle打乱后按顺序取题。工程里如果用到 RecyclerView 逐题展示通常会先加载题目列表再打乱一个存放_id的数组按打乱后的顺序取题目。SQLiteDatabase db helper.getReadableDatabase(); Cursor cursor db.rawQuery( SELECT _id, question_text, option_a, option_b, option_c, option_d, correct_answer FROM QUESTIONS WHERE category ? ORDER BY RANDOM() LIMIT ?, new String[]{category, String.valueOf(limit)} );这段 SQL 的ORDER BY RANDOM()适合单次读题就决定好顺序的场景配合LIMIT控制题目数量。但要注意如果工程师想实现「同一套题不同人乱序」就必须把打乱结果存下来否则每次进页面顺序都不一样就会出现两个人明明做同一套题、实际题目顺序完全不同的问题。这里的取舍是演示用随机 SQL 没关系正式考试或用于评测就一定要在应用层固定种子再打乱。参数上category传空字符串查全表传具体分类则按分类过滤limit控制出题数常见设置是单选题 20 道、判断题 10 道。数据库这块的另一个常见问题是「真机上运行报unable to open database file」原因是手机存储空间不足或路径不对logcat 会打印详细路径照着路径去设备管理器里确认目录是否存在即可。我一般会在导入数据后立刻用db.query(...).getCount()打一条日志确认完成了再开下一段逻辑方便问题定位。4. 答题主流程UI 交互、倒计时判定、得分累计与错题回看4.1 答题页 UI 布局与选项点击逻辑题目展示、四个选项、上一题下一题答题页布局用 ConstraintLayout 或 LinearLayout 都行核心要素是顶部标题栏显示当前题号和剩余时间中间一块 CardView 显示题干下面四个按钮或 RadioButton 显示 A/B/C/D 选项。用 RadioButton 的好处是自带互斥点击但需要注意的是如果用户直接点击了某个选项跳转下一题下一题必须调用clearCheck()清空选中状态否则上一题的答案会带到这一题。optionsGroup.setOnCheckedChangeListener { group, checkedId - if (checkedId -1) returnsetOnCheckedChangeListener val answer when (checkedId) { R.id.radioA - A R.id.radioB - B R.id.radioC - C R.id.radioD - D else - } currentAnswer answer selectedAnswers[currentIndex] answer }这里用了一个selectedAnswers数组来存每道题用户选过的答案而不是仅仅存当前题目的选项。这样用户点「上一题」返回时能恢复之前选过的选项体验上不会出现「回去一看白选了」的翻车情况。代码中的currentIndex是当前题目在列表中的下标掉头改题时直接从数组还原 UI 状态。如果这段代码在点击时没有记录答案那么翻页后选项全空这在逻辑上是被允许的因为很多答题 App 支持「做完再回头检查」所以用数组冗余记录是标准做法。4.2 倒计时实现与边界处理全局计时器、退到后台、时间到自动交卷倒计时在答题 App 里是问题多发区。用CountDownTimer写起来最直接但它的坑在于手机休眠或切到后台时Timer 可能被系统挂起导致用户回来时时间还在却多出几分钟。更稳的方案是记录考试开始的时间戳System.currentTimeMillis()每次界面刷新时用当前时间减开始时间计算剩余时间这样就算 App 被切后台再切回来剩余时间也是准确的。long startTime System.currentTimeMillis(); long durationMillis 60 * 60 * 1000; // 60分钟 CountDownTimer timer new CountDownTimer(durationMillis, 1000) { Override public void onTick(long millisUntilFinished) { long elapsed System.currentTimeMillis() - startTime; long remain durationMillis - elapsed; if (remain 0) { showTimeUpDialog(); finishQuiz(); } textViewTime.setText(formatTime(remain)); } Override public void onFinish() { showTimeUpDialog(); finishQuiz(); } };这个写法的妙处在于onTick里以系统当前时间重新校准剩余时间即使用户通过某些方式暂停了 Timer下一次onTick到来时也会把时间纠正回来。formatTime(remain)通常格式化成mm:ss显示在标题栏右侧。这里的一个细节是当remain 0时要走到finishQuiz()里面调用结果页并传入答题数据否则会出现倒计时显示 00:00 还在答题的 bug。4.3 得分计算与错题收集答对累加、答错进错题本、结果页回显交卷后的逻辑分为两步遍历selectedAnswers与数据库的正确答案对比得满分值再筛选出错误项写入错题记录表。得分统计不要太复杂直接一个 for 循环逐题比对即可。int score 0; StringBuilder wrongIds new StringBuilder(); for (int i 0; i selectedAnswers.length; i) { if (selectedAnswers[i].equals(correctAnswers[i])) { score eachScore; } else { wrongIds.append(questionIds[i]).append(,); } } saveWrongQuestions(wrongIds.toString());saveWrongQuestions这个方法把错题 id 拼成 1,5,23, 这样的字符串存到新表里或SharedPreferences。拼成字符串而不是逐条插入多条记录是为了减少数据库操作次数代价是之后查错题时需要split(,)后逐条查询。工程里如果只能进一次考试、没有错题重练入口这个saveWrongQuestions方法可能被注释或省略。结果页ResultActivity接收总分、答对数、总题数三个值通过 Intent 传递最后用setText显示。这里需要注意的一个坑ResultActivity 必须标记成android:launchModesingleTask或在跳转时清掉之前的 Activity 栈否则用户按返回键会回到答题页出现「考完还能改答案再重新交卷」的漏洞。代码写法是加Intent.FLAG_ACTIVITY_CLEAR_TOP | FLAG_ACTIVITY_NEW_TASK这个细节我们下一章专门记录。5. 避坑与常见问题排查运行、打包、使用中的五个翻车点5.1 点击选项没反应radioGroup 监听不到现象界面显示正常点击四个选项按钮毫无反馈OnCheckedChangeListener里的 Log 也不打。原因选项不是 RadioButton 而是 Button或者 Button 没有设置setOnClickListener只在 RadioButton 上设了监听而布局模板里用的是 CheckBox。解决打开activity_quiz.xml确认控件的类型如果是 Button就改用setOnClickListener单独处理每次点击的判断。另一个可能原因是你在onCreate里执行了optionsGroup.clearCheck()该方法会触发一次checkedId -1的回调如果你的代码没有对-1做保护事件就被吞掉了。解决方法是像第 4.1 节那样在回调开头先判断if (checkedId -1) return;。5.2 横竖屏切换后答题进度清零现象答到第 18 题转一下手机屏幕页面回到第 1 题且之前选过的答案全丢。原因默认情况下屏幕旋转会销毁并重建 Activity答题页的数据没有被onSaveInstanceState保存。解决在QuizActivity里重写onSaveInstanceState存当前题号和selectedAnswers数组在onCreate的savedInstanceState分支恢复数据或者偷懒一点在 AndroidManifest 里给 Activity 加android:configChangesorientation|screenSize这样屏幕旋转时不会重建 Activity数据还在。两种方案都行但注意configChanges方式治标不治本如果系统字体大小变化或强制深色模式切换它仍然可能触发重建。5.3 倒计时在模拟器上正常真机上时间越跑越快现象模拟器倒计时 60 分钟正常装到手机上变成 45 分钟就交卷或是一小时才过了三十分钟。原因CountDownTimer的回调间隔不是绝对准确的受系统省电模式和应用进程调度影响回调会被延迟。如果在onTick里每次减 1 秒来计算剩余时间就会出现误差累积。解决改用我们在 4.2 节写的「记录开始时间戳 每次用当前时间反推剩余时间」误差只在于最后一次校准前的延迟不会累积。如果你已经用旧写法发布出去了遇到这种问题不要慌把计时逻辑替换成时间戳方案重打包一次即可。5.4 交卷后按返回键能回到答题页还能再次交卷现象结果页点返回回到答题界面此时改两个选项再点交卷分数变了错题列表也变了。原因没有清理 Activity 栈答题页和结果页之间存在返回链路。解决跳转结果页时用startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK))把整个栈清空或者在 ResultActivity 的onBackPressedOnBackPressedDispatcher里直接finish()并把答题页一并 finish。工程如果在跳转结果页前没有关闭答题页逻辑上就存在这个漏洞。从那次线上考试有人反复刷分之后我每次写交卷跳转都会强制把finish()放在触碰之前。5.5 打包 App 体积小但删除应用后重新安装题库数据丢失现象第一次安装后能答题卸载重装后打开 App 题库是空的或者提示「题库表不存在」。原因SQLiteOpenHelper的实例保存在应用私有目录卸载应用后数据会被一并清除而onCreate里的初始化只有在数据库文件不存在时才会执行第二次安装数据库文件当然不存在但你的代码没有触发重建。解决在getReadableDatabase()之前先判断文件是否存在或者每次启动时都执行一次「检查表是否有记录」的逻辑为空就再导入一次初始化数据。最常见的原因还是出在db_inited这个SharedPreferences标志没有在卸载时被清除所以你要把判断改成「数据库文件不存在」而不是「标志不存在」。6. 进阶玩法Excel 题库直接转 JSON 导入、增加图片解析、打包前的最后检查6.1 让非程序员同事也能维护题库Excel 转 JSON 的标准转换流程工程交付给别人后最常听到的需求是「题库怎么换」。如果每次都要改 Java 数组再重新打包更新一次考试内容就要等一个版本周期。解决思路把QuizActivity的数据源改成读assets/questions.json外部维护题库的人只需要维护一个 Excel 文件然后用脚本转成 JSON 再丢进 assets。Excel 的列结构固定成题目、A、B、C、D、正确答案、分类、难度导出成 CSV 后转 JSON。import csv import json questions [] with open(quiz.csv, r, encodingutf-8-sig) as f: reader csv.DictReader(f) for row in reader: questions.append({ question: row[题目], options: [row[A], row[B], row[C], row[D]], answer: row[正确答案], category: row[分类], difficulty: int(row[难度]) }) with open(questions.json, w, encodingutf-8) as f: json.dump(questions, f, ensure_asciiFalse, indent2)这个脚本的逻辑很直白读 CSV逐行组装字典最后整体写进 JSON。运行时确保 CSV 的表头和这里保持一致尤其是题目和A这些列名不能改。题库维护人员更新完 Excel 导出 CSV 再跑一次脚本就能生成新的题库文件替换 assets 下的旧文件。这么做以后换题目不需要碰代码只要重新打包或放在服务器上供 App 动态下载更新。注意 CSV 的编码Excel 另存为 CSV 时默认可能是 ANSI脚本里encodingutf-8-sig就是兼容带 BOM 的 UTF-8避免中文乱码导致解析失败。6.2 给题目绑定解析图片与音频的扩展思路题库表结构建议预留一个explain_content字段存文字解析再留一个explain_image的 URL 或资源路径。答案判定之后如果答错了弹出底部对话框显示解析文字和图片体验会比只告知「正确答案是 B」好很多。这个扩展改造点不复杂数据库加字段答题页加一个TextView和ImageView在showResult分支里填充。比较现实的问题是你拿到的这份工程可能不支持explain_*字段这时不要改表结构而是加一张辅助表QUESTION_EXPLAIN主键关联_id额外存图片路径。这样老数据不迁移新题目的解析也能正常显示。如果你不想动数据库退一步的做法是在assets下按题目 id 起名放图片比如explain_001.jpgJava 代码直接按 id 拼接路径加载也不复杂。6.3 打包前检查清单签名、版本兼容、真机测试与动态权限无论改了多少东西发布前我一定按这个清单走一遍节省大量「装上去闪退」的时间minSdk 21 的情况下低版本手机如果用了AndroidX的AppCompat确认主题继承自Theme.AppCompat。targetSdk如果 30注意 Android 11 的包可见性限制如果需要跳转到系统设置或检测其他应用要加queries声明如果不需要则不用管。动态权限如果答题 App 要读取本地导入题库文件需要申请存储权限读assets不需要权限但如果用户自己导入外部 JSON 文件就需要READ_EXTERNAL_STORAGE或分区存储下的MANAGE_EXTERNAL_STORAGE。签名用 Android Studio 自带的 Generate Signed Bundle/APK 生成 keystore千万别把 keystore 放进工程目录一起打包丢了就找不回来谁也没办法帮你恢复市场账号的签名。模拟器跑通的流程真机大概率要再调一遍。热词里有人问「安卓模拟器哪个最好」我的习惯是先用 Android Studio 自带模拟器跑逻辑再用自己手机真机调指纹、调权限弹窗。特别是倒计时、息屏再亮屏这种场景模拟器测不出来只能真机复现。从那次倒计时真机翻车之后我每次改完代码都会强制在真机上跑一遍「息屏、切后台、亮屏回来」的完整流程才敢发出去给测试。这个习惯救了我很多次也建议你用这份工程复现时把它当成固定步骤。希望这些拆解和避坑记录能帮到你少走几步我走过的弯路。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站