ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Android Studio开发离线答题App:题库设计、判分逻辑与数据存储实战

Android Studio开发离线答题App:题库设计、判分逻辑与数据存储实战 简介面向Android开发者与移动应用初学者的安卓答题App完整源码包基于Android Studio开发覆盖限时答题、选择题作答、实时判断正误并反馈、答题结果统计、错题集收集与历史成绩保存等典型功能模块。整份资源包内共包含50个文件压缩包约8.81MB其中Java与XML源代码分别承载业务逻辑与界面布局Gradle配置用于管理工程构建PNG图片资源提供界面素材另含可直接安装的APK、演示视频、运行文档及基本安装环境说明。目前已有5888人学习下载便于快速上手体验完整答题流程。配套内容不仅包含全部源代码还提供配套题库数据可供直接运行测试或作为课程设计、毕业设计的参考也能帮助读者理解答题判定、数据统计与本地记录等实现思路。1. 答题App从哪里动手Android Studio里的三个前置决定有朋友要做内部培训考核用的答题小工具需求很具体装到安卓手机上能刷题、能模拟考、能看错题。用Android Studio开发安卓答题App这件事技术难点其实不多真正要提前想明白的是三个问题题库文件放哪、答题进度存哪、页面怎么切换。这三件事定了剩下就是照着流程往下写。这套方案面向两类人准备交课设的在校生和要快速交付内部工具的团队。我把做这类离线题库App的方案、参数和踩过的坑一次讲完。2. 题库JSON与工程骨架先把数据立住再碰界面一个答题App最怕的不是界面丑而是题库结构设计得不行。后期要加题型、加解析、加章节分类就要改好几个文件改完还得做兼容。我一般先把题库抽象成一份JSON放在assets目录里App启动时读到内存。这样做有一个直接好处换题不用改代码重新编译替换资源文件再打包发布就行。2.1 创建工程时把minSdk和targetSdk一次选对用Android Studio新建工程模板选“Empty Views Activity”就够不用跟风选Compose。答题页用传统View写更直观调试成本低。工程创建页面里有几个下拉框默认的通常不用动但有两个一定要主动确认。minSdk建议选26Android 8.0。这一档覆盖了目前主流在用的设备同时可以放心用StandardCharsets、java.nio这些API不用为老版本写一堆兼容层。targetSdk建议选34也就是Android 13/14这一代。不要因为自己手机系统老就把targetSdk往下压现在应用市场对新上架应用的targetSdk有最低要求。targetSdk高一些还会强制做分区存储适配但对只读写自己应用内部目录的答题App来说没有额外负担。包名我习惯用com.example.quiz这种三段式。如果后面要上架包名定了就不要再改——市场上会把它当成另一个应用历史用户全丢。这是我在别的项目上吃过的亏这次直接长教训了。工程创建完第一件事是把Gradle同步跑通。首次同步要下载依赖耗时长短和网络关系很大。卡住不动先看Event Log和Build Output里报的错是什么类型不要反复点Sync。依赖下载超时是常见问题最常见也最稳妥的解决办法是把repositories里配置的仓库换成国内可用的镜像具体换哪个要看日志里超时的域名。这个问题在第5章会展开讲。2.2 题库JSON的字段设计选项、答案和解析怎么放题库JSON建议按“试卷”结构组织外层是试卷信息内层是题目数组。这样既支持单题练习也支持按章节组卷。下面是我实际项目里在用的最小结构。{ paperTitle: Android基础测试, questionCount: 3, questions: [ { id: 1, type: single, question: Activity的启动模式中singleTask的作用是什么, options: [ 每次启动都会创建新实例, 栈内若已存在实例则复用并清空其之上的所有Activity, 全局只保留一个实例不置于栈中, 和standard启动模式行为一致 ], answerIndex: 1, explain: singleTask会检查任务栈中是否已有该Activity实例若存在则将其之上的Activity全部出栈再把该实例带到栈顶。 } ] }三个字段设计值得说清楚。第一answerIndex用数字索引而不用字母A/B/C/D。代码里拿到的是选项列表的下标用索引判分省掉字母换算也避免“答案是B还是索引1”这种混乱。第二explain字段必须保留。哪怕现在不做解析功能先留空字符串占位后面的错题解析功能才能直接基于旧题库升级不用重新整理几百道题。第三type字段先用字符串single不要用0、1这种魔法数字。等要加多选题、判断题时字符串的可读性和扩展性都比数字好得多。还有一个容易被忽略的点JSON文件里不要写注释。JSON规范不允许注释有人习惯在文件里加//说明解析时直接报错还说见鬼。题库字段的说明写到项目README里不要写进JSON。2.3 Assets目录加载题库InputStream与UTF-8编码新建assets目录把quiz_data.json放进去。加载方法如下public String loadQuizJson(Context context) { StringBuilder sb new StringBuilder(); try (InputStream is context.getAssets().open(quiz_data.json); BufferedReader reader new BufferedReader( new InputStreamReader(is, StandardCharsets.UTF_8))) { String line; while ((line reader.readLine()) ! null) { sb.append(line); // 逐行读入避免一次性读大文件 } } catch (IOException e) { Log.e(QuizJson, assets文件读取失败, e); return null; } return sb.toString(); }这段代码有两个容易踩的细节。第一个必须显式指定StandardCharsets.UTF_8。不指定的话InputStreamReader在某些ROM上会用系统默认编码而不少国产ROM默认不是UTF-8结果就是题库里的中文全部变乱码。第二个用getAssets()拿资源不是new File()。assets目录打进APK后是应用自带的资源在/data/data/包名/目录下没有对应的独立文件路径按File方式读必然失败。拿到原始JSON字符串之后解析逻辑如下。数据量小的时候用org.json自带的API就够上万道题再上Gson或Moshi也不迟。JSONObject root new JSONObject(jsonString); JSONArray arr root.getJSONArray(questions); ListQuestion questions new ArrayList(); for (int i 0; i arr.length(); i) { JSONObject obj arr.getJSONObject(i); JSONArray opts obj.getJSONArray(options); String[] options new String[opts.length()]; for (int j 0; j opts.length(); j) { options[j] opts.getString(j); } // optString优于getString字段不存在时给默认值不抛异常 questions.add(new Question( obj.getInt(id), obj.getString(type), obj.getString(question), options, obj.getInt(answerIndex), obj.optString(explain, ) )); }解析出来的List 建议存在一个全局对象里Application子类或者Activity的成员变量都行。提示题库是只读数据放assets最简单可靠。错题记录这种运行期产生的数据才用SQLite。题库和用户记录分开管理代码结构会清晰很多。3. 答题主流程的代码实现状态流转、判分与进度回显页面结构定了最核心的代码就是三块题目切换、选项判分、进度回显。这里先把状态模型立起来——答题页在任何时刻只处于三种状态之一正在答题、本题已提交、整卷已完成。用枚举表达状态机所有逻辑都以状态为中心不容易写乱。3.1 单Activity多Fragment还是单Activity单布局答题页的选型答题App有常见的两种结构。第一种是单Activity加多Fragment答题页一个Fragment结果页一个Fragment错题本一个Fragment。第二种是单Activity加单布局用View的visibility来控制页面显隐。我的建议是题目量在100以内、页面类型不超过三个的工具型答题App直接用单Activity单布局不引入Fragment。原因是三个省去Fragment事务的提交时机坑省去Fragment被系统重建后状态恢复的麻烦所有逻辑集中在Activity里新手排查问题更直观。布局上答题区域用垂直的LinearLayout选项用TextView动态添加不要用RadioGroup。RadioGroup在“做完一题再切下一题”时要手动clearCheck()清理时机偶尔会出问题。改成TextView后每个选项独立监听点击选中态和判分态都能用代码控制自由度大很多。选项的选中态背景可以用selector在res/drawable下描述这份XML是黑白底、视觉朴素但业务上够用的样式selector 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#F5F5F5/ stroke android:width1dp android:color#BDBDBD/ corners android:radius8dp/ /shape /item /selector选项View绑定的核心逻辑private void bindQuestion(Question question, int index) { textQuestion.setText((index 1) . question.getQuestionText()); optionContainer.removeAllViews(); for (int i 0; i question.getOptions().length; i) { TextView optionView new TextView(this); optionView.setText(question.getOptions()[i]); optionView.setPadding(dp(12), dp(10), dp(12), dp(10)); optionView.setBackgroundResource(R.drawable.option_bg); optionView.setTextSize(16); final int pos i; optionView.setOnClickListener(v - { // 题目已提交后不可再改答案 if (currentState QuizState.ANSWERED) { return; } selectedIndex pos; updateOptionHighlight(pos); }); optionContainer.addView(optionView); } }关键点在于用selectedIndex记录当前选中项用currentState防止一题多答。setOnClickListener里的if拦截不是多写的——如果当前题已经提交、解析文字还留在屏幕上用户又点了其他选项这个判断会拦截掉避免已经锁定的答案被覆盖。3.2 切题与判分的核心代码onClick里做了什么“下一题”按钮的点击处理里要同时完成判分、记录错题、更新进度、切换题目四个动作。集中在一个方法里管理比拆到四五个回调里到处找好维护得多private void submitAndNext() { Question current paper.getQuestions().get(currentIndex); // 判分比较用户选项与答案索引 if (selectedIndex current.getAnswerIndex()) { correctCount; } else { wrongQuestionIds.add(current.getId()); } // 跳下一题或进入结果页 if (currentIndex paper.getQuestions().size() - 1) { currentIndex; selectedIndex -1; resetOptionHighlight(); bindQuestion(paper.getQuestions().get(currentIndex), currentIndex); } else { showResultPage(); } updateProgressBar(); }为什么判分放在“下一题”而不是“选项点击”因为用户可能反悔改选项。点选项时立刻判分会把临时选的错误答案也算进去。提交后再判分答案已锁定判出来的是终态。所有状态字段在切题时都要重置其中selectedIndex-1最关键——不重置的话下一题还没选就带着上一题的下标点“下一题”时会把旧答案判给新题这是典型的前后题串答案bug。resetOptionHighlight的实现也简单遍历optionContainer的所有子View把isSelected统一设为false即可。这段逻辑直白不单独贴代码了。3.3 进度条与得分答题过程中的两个显示点进度显示分两个层面。顶部ProgressBar显示“第x题/共n题”的总体进度这是过程态考试结束后的正确率显示在结果页这是终态。过程态的更新代码progressBar.setMax(paper.getQuestions().size()); progressBar.setProgress(currentIndex 1); textProgress.setText( String.format(Locale.CHINA, 第 %d / %d 题, currentIndex 1, totalCount) );setProgress的时机要在bindQuestion之后不要在选项点击时更新进度。用户正在思考这一题时进度条跳动会干扰注意力等“下一题”动作发生再更新才合理。终态的结果页在showResultPage里触发需要传递的参数是correctCount、wrongQuestionIds、切题过程中记录的各题耗时。用Intent跳转还是Activity内换布局都行。如果是单Activity单布局方案直接在Activity里切换可见View即可private void showResultPage() { setContentView(R.layout.activity_result); TextView text findViewById(R.id.text_score); text.setText(String.format(Locale.CHINA, 正确率 %d%%, correctCount * 100 / paper.getQuestions().size())); }注意setContentView在同一个Activity里切换布局会丢掉之前的View状态所以顺序上不要让这行代码和执行中的异步任务混在一起。结果页展示完用户再返回时直接返回桌面或回题库列表逻辑比较简单。4. 错题本与历史记录数据持久化的两种方案与落地代码主流程跑通紧接着要做的是决定错题和历史记录往哪存。见过不少项目把错题放在一个全局静态List里App进程一被杀全没了。用户第二次打开找不到错题直接认为产品是坏的。数据持久化这层不能省。4.1 SharedPreferences和SQLite怎么选一张对比表错题本和考试历史的持久化方案一般是二选一直接看表方案适用数据量查询复杂度实现成本典型场景SharedPreferencesJSON字符串千条以内写入需全量覆盖低断点续答、最近一次考试结果SQLite数千条以上支持条件查询、分页中错题累计、历史榜单、按题ID检索我的建议是最近一次得分、断点续答信息用SharedPreferences错题本用SQLite。错题只会越攒越多后续大概率还会加“按章节筛选错题”“统计某题错了多少次”之类的查询。SharedPreferences里存JSON字符串做这种查询要全量解析遍历性能和代码可读性都会拖后腿。4.2 错题本的建表与插入SQLiteOpenHelper的最小实现核心建表逻辑public class WrongDbHelper extends SQLiteOpenHelper { public WrongDbHelper(Context context) { super(context, wrong_quiz.db, null, 1); } Override public void onCreate(SQLiteDatabase db) { db.execSQL( CREATE TABLE wrong_questions ( id INTEGER PRIMARY KEY AUTOINCREMENT, question_id INTEGER NOT NULL, question_text TEXT NOT NULL, options_json TEXT, answer_index INTEGER, user_answer_index INTEGER, wrong_count INTEGER DEFAULT 1, last_wrong_time TEXT) ); } Override public void onUpgrade(SQLiteDatabase db, int oldV, int newV) { if (oldV 2) { // 增量加列不删表保住用户已有错题 db.execSQL(ALTER TABLE wrong_questions ADD COLUMN is_reviewed INTEGER DEFAULT 0); } } }wrong_count和last_wrong_time这两个字段要特别说明。错题本不只是存下来就完用户反复错的题应该在列表里排更前wrong_count就是为这个排序服务的。last_wrong_time记录最近一次答错时间之后做“按时间倒序查看”时用得上。onUpgrade里刻意用增量升级而不是DROP重建。很多项目在升级数据库版本时直接删表用户的错题全部清空这几乎等于产品事故。数据库版本从1升到2时只加一列就好。插入错题时也不要无脑INSERT。同一道题如果之前已经错过应该把wrong_count加一否则是新增记录public void upsertWrongQuestion(Question q, int userAnswerIndex) { SQLiteDatabase db getWritableDatabase(); ContentValues values new ContentValues(); values.put(question_id, q.getId()); values.put(question_text, q.getQuestionText()); values.put(options_json, new JSONArray(Arrays.asList(q.getOptions())).toString()); values.put(answer_index, q.getAnswerIndex()); values.put(user_answer_index, userAnswerIndex); String time new SimpleDateFormat(yyyy-MM-dd HH:mm:ss, Locale.CHINA) .format(new Date()); int updated db.update(wrong_questions, values, question_id ?, new String[]{String.valueOf(q.getId())}); if (updated 0) { values.put(wrong_count, 1); values.put(last_wrong_time, time); db.insert(wrong_questions, null, values); } else { db.execSQL(UPDATE wrong_questions SET wrong_count wrong_count 1, last_wrong_time ? WHERE question_id ?, new Object[]{time, q.getId()}); } }先update判断影响行数为0再insert这种写法数据量不大时够用也避免依赖UNIQUE约束的冲突处理读代码的人不容易绕晕。注意SimpleDateFormat的创建比较重如果被频繁调用建议提到成员变量复用。错题本的读取就是一个带排序的querypublic ListWrongQuestion loadWrongQuestions() { SQLiteDatabase db getReadableDatabase(); Cursor cursor db.rawQuery( SELECT * FROM wrong_questions ORDER BY wrong_count DESC, last_wrong_time DESC, null); // 遍历cursor封装成ListWrongQuestion用完后一定要close() }ORDER BY先按错误次数降序再按时间降序这样错得最多的自然排在最上面。4.3 历史记录别无限膨胀清理策略与一个坑考试历史表建议只保留最近50条。做法是每次插入新记录后执行一次清理db.execSQL( DELETE FROM exam_history WHERE id NOT IN (SELECT id FROM exam_history ORDER BY id DESC LIMIT 50) );这条SQL保留按id倒序的前50条其余删除单条语句搞定全部清理不依赖外部任务。有个小坑这要求exam_history的主键必须是自增INTEGER id。如果用了UUID字符串做主键ORDER BY id DESC的语义就完全不对删除的可能是最新的记录而不是最旧的。所以建表时主键就用自增id。5. 答题App开发里最常见的5个坑现象、原因和解决办法这章写踩坑记录每一条都按“现象、原因、解决”来展开。都是我实际遇到过或帮别人排查过的直接拿去对比自己项目就行。5.1 Gradle同步“玄学”失败先分清楚是网络还是配置问题现象新建工程后Gradle同步一直转圈最后报“Could not resolve com.android.tools.build:gradle”或者“ConnectTimeout”Sync按钮反复点都一个样。原因同步要下载Gradle分发包和大量依赖下载源不通就卡在这里。还有一类原因跟网络无关是JDK版本和Gradle版本不匹配Android Studio内置的JBR一般没问题但自己改过JDK路径的机器比较容易踩到。解决先看Event Log和Build Output的具体报错再决定动哪里。报网络相关的常见做法是把仓库地址换成国内可用的镜像仓库报版本相关的打开gradle-wrapper.properties看distributionUrl改成与当前Android Studio匹配的版本即可。不要每次失败都去改distributionUrl瞎试确认错误类型比换版本重要得多。换镜像也不是越多越好同时挂三四个源反而会拖慢解析速度保留一两个可用的就行。5.2 题库中文乱码或JSON解析报错assets文件编码是黑匣子现象题库里的中文在界面上显示成乱码或者JSON解析器报“Unexpected character”打开文件看又是正常的。原因assets里的json文件很可能不是UTF-8编码。Windows下某些编辑器默认存成GBK打包进APK时编码不会被自动转换运行时按UTF-8读取就会出问题。还有一种情况是文件带BOM头BOM会让JSON解析器在第一行就报错。解决在Android Studio里把文件编码转成UTF-8。转完不要急着关用十六进制方式打开文件头检查UTF-8无BOM的JSON文件应从左花括号开始如果开头出现EF BB BF三个字节说明BOM还在。去掉BOM的最简单方法是在AS的File Encoding设置里选择带“No BOM”的选项重新保存。这道工序很玄学很多乱码问题查来查去最后都是这个原因。代码层面如果怀疑乱码就先Log.d打印loadQuizJson返回的第一个中文字符看看是原文本还是“锟斤拷”能快速缩小范围。5.3 答题答到一半屏幕旋转题目跳回第一题现象用户转一下屏幕正在答的第五题变回第一题刚选的选项全没了。原因配置变更configuration change会默认触发Activity销毁重建内存里的currentIndex、selectedIndex全部丢失。解决有两个方向。最简单的是锁死竖屏在AndroidManifest.xml的Activity节点加一行android:screenOrientationportrait。答题场景不需要横屏锁掉不亏省掉序列化代码。如果还想支持平板横屏就需要在onSaveInstanceState里保存currentIndex、selectedIndex、correctCount在onCreate的savedInstanceState参数里恢复。两个方向选一个即可不要两个都写维护起来重复。锁竖屏的代价是平板用户拿不到横屏大视野但如果题库工具的核心目标是“答完题看到结果”这个取舍完全值得。5.4 选项快速连点导致串题点击回调没做状态防护现象用户点“下一题”的同时手指还留在旧选项上新题目加载出来之前旧点击事件又被触发一次。结果就是新题还没看就被带上了旧答案甚至出现跳题。原因布局刷新前快速点击多个位置点击事件会按队列分发给对应的View。绑定新题目时optionContainer会removeAllViews再添加新View但如果旧View的点击在移除前已经触发回调里的代码会按旧数据执行。解决在submitAndNext()开头把currentState置为ANSWERED在bindQuestion里重置回ANSWERING。所有点击回调第一行都用if (currentState ! QuizState.ANSWERING) return;把非答题态请求全部拦掉。这比单纯依赖View.isClickable更可靠因为状态机在逻辑层面把整个页面锁住了。顺手也把“下一题”按钮在提交后先setEnabled(false)等新题bind完再恢复视觉上给用户一个“已提交”的反馈。5.5 Handler内存泄漏答题倒计时把Activity一起带走了现象加了答题倒计时功能的App退出答题页后使用LeakCanary会报警或者页面销毁后倒计时仍在走。原因用Handler.postDelayed发消息后如果Handler是内部类会隐式持有外部Activity引用。Activity销毁了队列里延迟消息还未执行Activity无法被回收成为内存泄漏。解决两个方案。一是把Handler改成静态内部类加WeakReference持有Activity。二是在onDestroy里调用handler.removeCallbacksAndMessages(null)把所有未执行回调清掉。答题倒计时用CountDownTimer也行但要注意cancel()并不保证所有回调立刻失效还是要在onDestroy里先cancel再手动清空。内存泄漏不会让App立刻崩但页面反复进出之后就是卡顿和OOM线上反馈“卡顿”时查这类问题最耗时间。6. 让答题App像正式产品验证、升级与几个进阶设计这一步做三件事真机冒烟测试、覆盖安装验证、加防丢答题状态的进阶设计。不少人把App跑在模拟器上就宣布完成但答题App涉及的屏幕适配、输入法弹起、返回键行为模拟器和真机差异很大。我自己的习惯是每个迭代都拿一台主力真机做覆盖安装测试。6.1 真机冒烟测试的10步检查清单手动按固定步骤走一遍启动App能看到试卷列表进入第一题显示正常选一个选项看高亮是否正确连续下一题直到最后一题提交后结果页的得分与预期一致错题本里能看到刚才答错的题返回键退出再重进错题仍在屏幕旋转后题目不跳已锁屏的机型则直接确认锁屏生效杀进程重进App断点续答如果做了要能弹窗覆盖安装新APK后题库能正常更新。测试过程中重点观察两件事错题写入是否成功新旧APK覆盖安装后SQLite数据库版本升级是否会触发onUpgrade。6.2 断点续答与答题倒计时两个最值得加的进阶设计第一个值得加的是断点续答。做法是把currentIndex、selectedIndex、考试开始时间存进SharedPreferences每次进答题页前检查有没有未完成的考试有就弹窗询问是否继续public void saveExamSnapshot() { SharedPreferences sp getSharedPreferences(exam_state, MODE_PRIVATE); sp.edit() .putInt(current_index, currentIndex) .putInt(selected_index, selectedIndex) .putLong(start_time, System.currentTimeMillis()) .apply(); }恢复时注意要校验currentIndex不能超过题库总数否则题库换过之后旧快照会越界。第二个是单题倒计时。每道题固定时间时间用完自动判错进下一题。实现时结合5.5的防泄漏习惯在onDestroy里把CountDownTimer的cancel和Handler的removeCallbacks全写上。倒计时归零事件里也要判断当前页面是否还处于答题态防止时间到了却跳到了结果页出现重复弹窗。最后说一个我从错误里学到的习惯每次用Android Studio改完代码先Build Clean Project再打包Debug APK。不要直接Build APK因为Gradle增量编译有时不会把assets目录里更新过的文件重新打进去表现就是换了题库但App里数据没变化排查半天才发现APK里装的还是旧文件。所以换题不改代码时切记Clean后打新包。这个细节帮我省了大量无效排查时间希望帮到你。本文还有配套的精品资源点击获取
返回列表