去年接了个活儿,要帮一个历史知识竞赛的备赛团队做一款安卓Android平台历史知识在线考试系统题库App。当时我第一反应是:题库App嘛,无非就是题目列表加一个答题页面,套个现成的网页壳子都能应付。等到真动手做起来才发现,这类App表面上看着简单,实际把功夫全藏在数据模型、考试状态管理和历史内容的呈现方式上。如果你也正准备上手做类似的题库类项目,这篇文章应该能帮你少走不少弯路。我会把需求边界、表结构设计、考试流程的工程化处理、界面架构、历史知识特有展示方式再到真机实测中踩过的坑,都按我实际开发时的顺序讲一遍。
1. 需求边界:不是刷题工具,而是带考试现场感的备考系统
1.1 用户到底在抱怨什么
备赛团队拿到手里的时候,用的还是旧版做题页面:纯文本题目、没有计时、没有判分、错题得自己拿本子抄。我调研了一圈发现,普通刷题App的弱点非常一致——题目随机,但随机没有章法,经常连着出同一个朝代的内容,答题过程没有时间压力,考生真正上了考场就崩。这其实就是典型的需求信号:用户要的不是“能做题”,而是“能模拟真实考试”。
基于这个判断,我把产品定位从“刷题工具”悄悄换成了“带考试现场感的备考系统”。做题之外必须有完整的考试流程——抽题、计时、交卷、自动判分、错题沉淀、知识点掌握度统计。后续所有技术方案都是围绕这个定位展开的,这也决定了我在架构上不能只做静态页面,得把“一次考试会话”当成一个有状态的生命周期来管理。
1.2 MVP功能取舍:哪些必须做,哪些先砍掉
这是我自己吃过亏的地方。一开始需求文档写得特别全,账号登录、排行榜、好友PK、社区讨论都有。后来我按“考试闭环”重新理了一遍,把功能分成三个优先级:
| 优先级 | 功能 | 理由 |
|---|---|---|
| P0 | 题库浏览、按知识点筛选题目 | 学习备考的入口,必须最先可用 |
| P0 | 模拟考试、自动判分、交卷回看 | 项目核心价值,没有这个等于没做 |
| P0 | 错题本、答题记录 | 备考提分最实用的沉淀 |
| P1 | 成绩趋势、知识点掌握度图表 | 有价值,但要等基础数据积累后才能体现 |
| P2 | 排行榜、社区、账号体系 | 短期不影响考试核心体验,全部延后 |
事实也证明这个排序是对的。开发中期团队临时要加一场全真模拟考试,因为P0链路已经完整,我两天内就接上了。如果当时先做了登录和社区,估计要加班一整周。
1.3 “在线”二字的落地解释
标题里带了“在线考试系统”,这里必须说清楚我最后怎么定义这个“在线”:题库的更新走云端接口,考试答题过程完全本地化,交卷后成绩再同步上报。这样设计的原因很现实——考场这种场景网络状况不可控,万一答到一半断网,整个考试会话直接作废,那这个App就没法用了。把考试核心流程放在本地,由本地SQLite做持久化,网络只负责题库增量更新和成绩上报,稳定性会好很多。
当然,这个方案的代价是首次启动必须做一次题库初始化,涉及网络请求时还要处理版本增量。这一块我在第二章详细讲。
2. 历史题库建模:三张核心表与一套增量更新机制的由来
2.1 题目表、选项表、知识点表到底怎么设计
题库是整个系统的地基,前面篇章再怎么吹,表结构设计错了后期全得返工。我最开始用的是最笨的方案:把题干、选项、答案、解析全部塞进一张大宽表。结果引入多选题之后立刻暴露问题——一道题四个选项,其中正确答案我不得不用逗号分隔符拼在一个字段里,查询和判分都别扭。
参考实际开发中的常规做法,我最终拆成了三张核心表:
@Entity(tableName = "question") data class Question( @PrimaryKey val id: Long, val type: Int, // 1单选 2多选 3判断 4填空 val content: String, // 题干正文 val analysis: String, // 答案解析 val difficulty: Int, // 1-3,三档 val dynastyId: Long, // 关联朝代/分期 val imagePath: String?, // 题目配图(本地路径或URL) val sortOrder: Int // 用于控制展示顺序 ) @Entity(tableName = "question_option") data class QuestionOption( @PrimaryKey val id: Long, val questionId: Long, // 外键 val optionTag: String, // A/B/C/D val content: String, val isCorrect: Boolean ) @Entity(tableName = "knowledge_point") data class KnowledgePoint( @PrimaryKey val id: Long, val name: String, // 如“唐朝”“工业革命”“某历史人物” val parentId: Long?, // 支持多级知识点树 val describeText: String )题目和选项拆开之后,多选、单选、判断都能统一用question_option表存答案,判断的答案仍然走选项,只是固定A代表“正确”、B代表“错误”,这样判分逻辑可以统一处理。题干里有配图的历史题目,比如地图、文物照片、战役示意图,就用imagePath字段存相对路径,图片文件放asset目录或远端,避免把二进制图片塞进数据库导致包体膨胀。
2.2 知识点关联:用多对多而不是字符串
我把知识点的关联做成了中间表,也就是一道题可以挂多个知识点,一个知识点下也可以有多道题。这张表在设计时要注意,备考团队后期要做“薄弱知识点分析”,如果当初图省事在题目表里存了“知识点名称”这种冗余字符串,后面联表统计一定会疯掉。多对多的中间表虽然一开始写起来多几步,但对“按知识点出题”“按知识点统计正确率”这些功能非常友好,是值得的。
@Entity(tableName = "question_knowledge_relation", primaryKeys = ["questionId", "knowledgeId"] ) data class QuestionKnowledgeRelation( val questionId: Long, val knowledgeId: Long )2.3 预置题库方案:assets放数据库文件加Room增量更新
题库数据的初始化我选了“预置SQLite文件放assets”的方式,而不是启动时一条条执行SQL插入。关键原因是题目数据量大,几千道题加解析文本,逐条插入在低端机上可能要卡十几秒。提前用工具把数据生成好做成一个history_question.db文件放进assets目录,首启时用Room.databaseBuilder的createFromAsset方法直接拷贝到私有目录,几乎秒开。
后续题库更新则采用版本增量策略:每次新版App发版时,把需要更新的题目做成带版本号的增量SQL补丁,在Room的Migration里执行。这样做的好处是,老用户升级时不用重新下载整套数据库,只需要执行几百条插入语句。
提示:
createFromAsset和Migration不能混用同一个版本号。如果数据库已经发布过,后续版本必须走Migration链,不能指望再走createFromAsset,这是Room的官方限制,踩过你就记住了。
3. 考试流程的工程化设计:抽题、计时、判分的细节打磨
3.1 抽题策略:不能让一次考试变成“某个朝代的专场”
很多刷题App随机抽题就是ORDER BY RANDOM(),但这会导致一个现实问题——你明明想做一套覆盖“先秦到近代”的综合卷,结果系统随机给了你八道同一时期的题。这种考完没有任何备考参考价值。我实现抽题用的是分层随机:
// 按历史分期分组,numList为每个阶段期望的题数 fun assemblePaper( questionPool: List<Question>, stageIds: List<Long>, numList: List<Int>, totalCount: Int ): List<Question> { val result = mutableListOf<Question>() require(stageIds.size == numList.size) stageIds.forEachIndexed { index, stageId -> val candidate = questionPool.filter { it.dynastyId == stageId } result.addAll(candidate.shuffled().take(numList[index])) } // 如果某阶段题目不够,从其他阶段随机补足 if (result.size < totalCount) { val remain = questionPool.filter { it.id !in result.map { q -> q.id } } result.addAll(remain.shuffled().take(totalCount - result.size)) } return result.shuffled() }实际操作中还加了一条“同一知识点连续出现不超过2次”的约束,做法很简单,抽完一遍之后遍历结果,如果连续出现相同知识点的题目数超过阈值,就把后面的题往前提并再次打乱。整体随机性会有一些损失,但是对备考来说,覆盖面远比随机性重要。
3.2 倒计时处理:不能因为切后台就“考试作弊警告”
在线考试的计时器有一个很隐蔽的坑:用户答到一半接了电话,或者不小心锁屏再回来,倒计时要么重头开始,要么直接清零。我最后的方案是考试开始时间戳存本地数据库,倒计时由当前时间反推,而不是启动一个内存里的倒计时器。这样哪怕进程被系统回收,重新进入考试页时也能把剩余时间算出来:
val remainingTime = examTotalSeconds - (System.currentTimeMillis() - examStartTime) / 1000只有当剩余时间为负时,才触发自动交卷流程。至于“切后台超过30秒标记异常”这种东西,我最终没做太强硬的限制,只是记录了一次行为日志,因为判断题App强行限制后台切换,最受伤的往往是正经用户。
3.3 判分的边界情况:多选题漏选、填空题近似答案
判分逻辑比看起来复杂。单选题直接比对选项ID,多选题则是“选项集合完全一致”才算对,漏选、错选都不得分。判断题的答案也是走A/B选项,和单选一样的处理方式。填空题是最麻烦的,用户填“洋务运动”,答案可能是“洋务运动(自强求富)”,怎么判都要尽量宽容:
fun evaluateFillAnswer(userInput: String, correctAnswer: String): Boolean { val normalize = { s: String -> s.trim().replace(" ", "").replace("(", "(").replace(")", ")").toLowerCase() } val u = normalize(userInput) val c = normalize(correctAnswer) if (u == c) return true // 允许答案中包含括号备注时,用户只写主干的场景 return c.contains("(") && u == c.substringBefore("(") }另外,为了避免用户靠选项位置猜答案,我在生成题目时会把选项顺序打乱一次,让固定位置的“正确选项”失去参考意义。这个策略要放在“出题组装”阶段而不是数据库存储阶段,否则同一道题每次出现的选项顺序都一样,多练习题的人很容易形成记忆惯性。
4. Android端落地选型:Compose、Room与导航状态的技术组合
4.1 为什么选了Kotlin + Compose而不是传统XML
做技术选型时我对比了传统的XML加ViewBinding方案,最后选了Jetpack Compose。原因不是潮流,而是这个项目的界面状态太适合Compose这套模型了——考试中当前选中项、已做题号、剩余时间、答题卡状态,全部都是会根据用户操作实时刷新的状态流。用Compose的StateFlow加collectAsState做驱动,界面更新的心智负担比手动notifyDataSetChanged低很多。
还有一点是历史知识展示里躲不开的自定义绘制,时间轴、知识点关系小图、题目配图上的标注图层,Compose的Canvas API比自定义View的老路子好写得多。我在第五章会额外展开。
4.2 考试页面状态建模:用sealed class管住会话状态
考试页面是整个App里状态最复杂的页面。我给它单独建了一个ExamViewModel,核心状态用一个sealed class加StateFlow表达:
sealed class ExamUiState { object Loading : ExamUiState() data class Ready(val paper: ExamPaper) : ExamUiState() data class Running( val currentIndex: Int, val answeredMap: Map<Long, List<String>>, // 题号 -> 用户选项 val remainingSeconds: Long ) : ExamUiState() data class Submitted( val score: Int, val correctCount: Int, val wrongList: List<WrongDetail> ) : ExamUiState() }Running里存的是用户作答快照,每切换一题或点击选项都通过viewModelScope里的函数更新。这个设计最大的好处是状态单一,答题卡、顶部倒计时、底部切换按钮全部依赖同一份数据,不会出现某个组件里的选中状态和试卷记录不一致的情况。
4.3 Room联表查询在错题本里的应用
错题本是考试之后最高频使用的页面。它需要展示题目原文、用户当时填的答案、正确答案和解析,也就是说要一次性拿题、选项、作答记录三张表的数据。直接用原生SQL加返回自定义数据类的映射会比较麻烦。Room支持返回一对多的POJO,我用了一个拆开查询再合并的做法:
@Query("SELECT * FROM question WHERE id IN (:ids)") suspend fun getQuestionsByIds(ids: List<Long>): List<Question> @Query("SELECT * FROM question_option WHERE questionId IN (:ids)") suspend fun getOptionsByQuestionIds(ids: List<Long>): List<QuestionOption>拿到两个列表后在内存里按questionId分组拼接。数据量在几千题的情况下这样做完全够用,不用为每个错题单独发SQL。错题列表用Flow包装,每次从错题表插入或移除数据时,列表自动刷新,UI层直接观察即可。
这里要特别提醒:不要把判分之后的错题明细直接拼好JSON存到一张表里。后面一旦要加“按知识点看错题分布”“按时间趋势看正确率”之类的统计,宽表JSON查起来会非常痛苦。规范的三表设计虽然写起来稍慢,但扩展性好太多。
5. 历史知识可视化:时间轴、图文混排与相关知识联想的实现思路
5.1 时间轴组件:把朝代变迁画成一条可滚动的线
历史知识的App和其他题库最大的区别在于“时间感”。单纯给用户展示“唐朝是公元618年建立”没有任何体感,但如果把朝代更迭画成一条横向时间轴,每个朝代占据对应的宽度,重要事件用节点标注,记忆效率会提升很多。我用Compose的Canvas实现了一个简单的横向时间轴:
@Composable fun DynastyTimeline( periods: List<PeriodNode>, modifier: Modifier = Modifier ) { Canvas(modifier = modifier) { val totalWidth = size.width val startX = 40f val endX = totalWidth - 40f // 画主轴线 drawLine( color = Color.Gray, start = Offset(startX, size.height / 2), end = Offset(endX, size.height / 2), strokeWidth = 4f ) periods.forEachIndexed { index, period -> val x = startX + (endX - startX) * period.startYear / maxYear drawCircle(color = Color(0xFF4A6FA5), radius = 6f, center = Offset(x, size.height / 2)) } } }时间轴上方可以叠放朝代名称的横向列表,点击节点会跳到该朝代下的题目列表。这个功能我开发只用了小半天,但用户反馈是所有界面里最受欢迎的一个——很多人考完试之后还会回来专门翻时间轴复习。
5.2 图文混排的题目:题图加载与内存控制
历史题目里常有“根据下图回答”的题型,图可能是地图、文物照片或者某个事件的新闻图片。我在答题页做图片展示的时候只用一个简单的原则:小图走Coil加内存缓存,大图用全屏预览页再按需加载,绝不在答题列表里同时加载多张高分辨率图。实际实现是题目详情页用SubcomposeAsyncImage,点击图片再进全屏页。
题库数据里有少量题目是多图,比如“对比两张地图找出变化”,这种我直接用LazyRow实现横向滑动,但要控制预加载数量,默认只预加载当前页和相邻页。多图题的内存峰值比单图高不少,低端机上要防OOM。
5.3 相关知识点联想:让备考从“做题”变成“织网”
做完基础功能后我加了一个“此题相关”区域,展示同知识点、同朝代、相关人物的题目入口。原理非常简单:根据当前题目的knowledgeId从question_knowledge_relation表反查其他题目的id,再按当时作答正确率排序,优先推荐“你大概率会错”的题目。
这个功能在数据闭环里非常有用,因为它解释了“为什么我刷了100题但还是没记住”——因为题目之间是孤立的,做完就忘;而联想推荐会把同一知识点的不同题目串起来,形成复习网络。技术上不复杂,但用户粘性提升明显。
6. 真机实测阶段的坑:从启动白屏到考试途中被电话打断
6.1 冷启动白屏:问题在主题而不是代码
第一版测试时,App启动总会先闪一下白屏再进主页。这不是Android标准启动流程的问题,主要是因为默认主题背景色是白色,而主页是深色系,启动时系统还没加载完界面,就会暴露主题底色。修复方式是单独建一个SplashTheme,设置和主页接近的背景色,然后启动后在业务层切回正常主题。实测这个改动对主观体验的提升非常明显。
6.2 题库升级后老用户数据丢失的兼容处理
有次压测我模拟从1.0升级到1.1,发现老用户的错题本数据全部丢失了。排查下来是数据库版本从1升到2时,我编写了增加新表的Migration,但同一个事务里不小心改了question表的主键约束,SQLite旧数据无法迁移到新结构,Room直接把整个数据库重建了。这里没有捷径,只能老老实实把每次发布版本的增删改都提前设计好,Migration里的SQL要保证老版本一版一版逐级升级都能通过,而不是只测试“从1直接跳到最新”。我把这个测试场景补齐后,连续两个版本升级数据都很平稳。
6.3 考试中切后台再回来,状态恢复还是丢了
状态丢失是我在开发里踩得最深的坑。一开始我以为把考试剩余时间和作答记录放在ViewModel里就没事,结果发现用户按Home键回桌面,系统在内存紧张时会直接把进程杀掉,ViewModel也救不回来。最终方案是双保险:剩余时间在SavedStateHandle里存一份,作答记录每答一题就写入Room的exam_answer_snapshot表。进程被杀重启后,先读取本地快照恢复Running状态,再校准剩余时间。
测过几轮发现不是所有崩溃都能恢复,极端情况下快照表也会损坏,所以我还做了“放弃当前考试重新开始”的兜底按钮,同时保留提交失败的历史成绩记录,避免用户一次崩溃就丢了整套考试数据。
6.4 发布前必查:混淆规则、权限最小化与断网行为
发布前有一堆小问题值得提前查,否则上架后很尴尬。比如混淆规则,如果用到了Gson反射,必须把题目实体类加@Keep注解,否则Release包一运行就是字段全为null。权限这块只申请了网络和读取存储空间两个权限,不写不用的权限,应用商店审核也更顺利。
断网行为也要专门测:题库更新接口失败时不能阻塞考试流程,成绩上报失败要本地保存重发。我的做法是把上报任务放WorkManager,网络恢复后自动重试,用户完全无感。这类细节测试时注意不到,但上架后一定会被用户遇到。
6.5 一个容易被忽略的细节:考试中途来电
最后分享一个在真机上反复测出来的问题,来电会导致onStop触发,页面暂时不可见,但如果不做处理,倒计时依然由时间戳反推,来电十分钟用户发现考试时间已经结束了。这里我的处理方式是:在onResume时重新进入App,如果当前处于考试中,弹一个“考试时间仍在计时”的提示,并显示剩余时间,让用户自己决定是否继续。本质上考试时间不会因为来电暂停,这样既符合真实考试规则,也避免用户产生“App偷了我时间”的误解。
7. 阶段性复盘与后续扩展方向
7.1 这套架构能沉淀出什么东西
做到这里,整个考试系统已经可以完整跑通:题库初始化、模拟考试、交卷判分、错题复盘、知识点统计、题库更新。当初定下的“答题本地化、同步在线化”路线经受住了真机验证,低端机上首启初始化不超过1秒,考试过程中无网络也能完成全部流程,这对我来说是最关键的成绩。
7.2 想做在线考试系统,最值得先动手的环节
如果你正打算做一个类似的题库App,我的建议是先动手做“一次考试的完整闭环”,也就是抽题、答题、交卷、判分、错题记录。这个环节是最能暴露技术难点的部分,同时也是产品真正的价值侧重点。等闭环跑通了,再考虑加社区、排行榜、多端同步这些外围功能,节奏会舒服很多。
7.3 我又给这套系统补了什么
最近我给系统补了两个小功能,一个是按“历年易错题”维度的专项练习卷,依据是所有用户的作答统计数据生成,不再只靠随机出题。另一个是给题目解析补充了“相关知识卡”,把一些背景知识做成卡片形式直接放在解析下方展平。目前看来这两个小功能都比预期受欢迎,也为后面做更精细的知识图谱推荐打下了基础。你如果也做到这一步,可以往这个方向试试。