1. 题库软件看着像“套壳背题工具”,真正费功夫的是把题变成结构化数据
二级Java题库软件这类东西,第一次接触的人往往会低估它的工作量。界面无非就是左侧目录、中间题干、右侧答题卡,看着跟十几年前的电子书阅读器差不多,很多人第一反应是“这不就是个壳嘛,题目塞进去就完了”。可你真动手做一遍就会发现,代码量的大头从来不在界面上,而在“一道题到底该怎么存”这件事上。题目里嵌着代码片段、选项里带着缩进、解析里混着表格和运行结果,这些东西用一句varchar(500)是绝对装不下的。我见过不少自己攒的小工具,第一版图省事,题干直接当纯文本存,跑到第三套卷子的时候排版就彻底乱掉,最后不得不把整张表推倒重来。
我下面讲的内容,全部围绕“一款能在本机跑起来的二级Java模拟题库软件”来展开:它怎么建模题目、怎么选桌面技术栈、怎么把限时模拟考试的计时和判分做对、学习记录怎么落库、以及打包分发和实际备考时的一些经验。适合两类读者看:一类是想自己写一个类似工具练手或者自用的开发者,另一类是手上已经有一套二级Java题库软件、但用起来总觉得别扭、想知道问题出在哪的备考者。原文给的描述比较简略,所以下面的表结构、参数、命令都是按这类软件的常见做法补充的,不是某一份具体产品的实现,你按自己的情况取舍就行。
先说说我对这类软件的基本判断:它本质上是一个带状态的题目渲染器 + 一个带时钟的判分引擎。渲染器负责把题目里的代码、选项、解析以人能读的方式摆出来;判分引擎负责在限定时间内收集答案、计算分数、给出反馈。剩下所有的功能,错题本、收藏、模拟考、知识点统计,都是围绕这两件事做出来的外围。你把这两件事想清楚了,剩下的都是体力活;反过来,如果题目模型一开始就设计歪了,后面每加一个功能都是在补窟窿。
1.1 一道二级Java选择题,到底要拆成多少个字段
很多人设计题目表的时候,脑子里想的是“题干、A、B、C、D、答案”六个字段。这个模型在第一套卷子上能跑,从第二套开始就出问题,因为你会发现:有的题是四选一,有的是多选,有的题光标在选项前面、有的选项本身就是一段代码,还有的题根本没有选项、是要你补全代码的空。这些差异如果全部用“只有四个选项”的表结构硬扛,最后一定是靠if (optionE != null && !optionE.isEmpty())这种判断堆出来的意大利面。
我的做法是把题目表拆成两层:一层是题目主体,一层是选项明细。主体只关心“这是一道什么题”,选项明细关心“这道题的候选项长什么样”。这样多选、判断、填空都能落到同一套结构里,只是选项数量不同。下面这张表是我实际用下来比较稳的一组字段,你可以对着调:
| 字段名 | 类型 | 作用 | 容易被忽略的点 |
|---|---|---|---|
id | 自增主键 | 内部唯一标识 | 不要用题号当主键,题号会变 |
code | 字符串 | 稳定业务编号 | 导入导出、错题本关联都靠它 |
type | 枚举 | 单选/多选/判断/填空/编程 | 决定渲染器和判分器走哪条分支 |
chapter_id | 外键 | 知识点归属 | 一个题只挂一个主知识点,别挂多个 |
stem | 富文本 | 题干正文 | 代码块要用标记包起来 |
stem_lang | 字符串 | 题干里的代码语言 | 基本都是java,但留个口子不吃亏 |
score | 小数 | 该题分值 | 综合应用题和选择题分值差很多 |
difficulty | 整数 1-5 | 难度 | 用于抽题配比,不是为了显示星星 |
source | 字符串 | 来源标注 | 自己整理的就写自己整理的批次号 |
status | 枚举 | 启用/停用/待校对 | 导入的题一律先进待校对 |
这里我要特别强调status这个字段。凡是批量导入题库的工具,第一版几乎都会犯同一个错误:导入完立刻全部启用。结果就是某个批次里混进了几十道答案标错的题,学员刷到之后开始怀疑人生,你再去一道一道翻,工作量比重新导入还大。正确做法是导入后默认全部进“待校对”,先跑一遍自检(比如答案不在选项里、选项为空、题干长度为 0),把可疑题拎出来人工过一遍,再批量启用。这一步花不了十分钟,但能省下后面几小时的排查。
1.2 题干里的代码片段:纯文本、富文本还是带结构的存法
这是这类软件最磨人的地方。二级Java的题目里,代码片段出现的频率极高:一段for循环问输出、一段继承问结果、一段try-catch问异常类型。这些代码必须保留缩进、保留换行、用等宽字体渲染,否则{和}对不齐,读题的人得先做一遍括号匹配才能开始思考。
我试过三种存法,各有各的代价:
第一种是纯文本加自定义标记,比如用三个反引号把代码块包起来,渲染时正则切出来套等宽字体。优点是简单,数据库里一个字段搞定,迁移方便;缺点是嵌套标记容易出错,比如代码里本身就有反引号或者特殊符号,正则就会失配。规避办法是换一个几乎不会在 Java 代码里连续出现的标记,并且在导入时做一次转义检查。
第二种是HTML 富文本,直接用<pre><code>包裹。优点是渲染能力强,表格、加粗、换行都能表达;缺点是你得防注入,而且回退到纯文本的地方(比如搜索、导出、剪贴板)会带一堆标签,很难看。
第三种是结构化 JSON,题干存成[{"t":"text","v":"下列代码的输出是:"},{"t":"code","lang":"java","v":"int i=0;\n..."}]这样的数组。优点是前后端分离干净,搜索时只取text节点,渲染时按节点类型分派;缺点是编辑体验差,手改题库的时候很痛苦,必须配一个可视化编辑器才好用。
我最后的折中是:存储用纯文本加标记,编辑用富文本,导出转成结构化。存储层保持简单,任何文本工具都能打开看;编辑器里给个“插入代码块”的按钮,自动补齐标记;导出和导入走结构化 JSON,方便跟别的工具对接。这个方案不算优雅,但足够扛住几千道题的规模。
// 渲染层的一个小工具:把题干按代码块切分 public static List<Segment> split(String stem) { List<Segment> out = new ArrayList<>(); int i = 0; while (i < stem.length()) { int s = stem.indexOf("```", i); if (s < 0) { out.add(Segment.text(stem.substring(i))); break; } if (s > i) out.add(Segment.text(stem.substring(i, s))); int e = stem.indexOf("```", s + 3); if (e < 0) { out.add(Segment.text(stem.substring(s))); break; } out.add(Segment.code(stem.substring(s + 3, e).trim())); i = e + 3; } return out; }这段代码没什么技术含量,但它体现了我的一个偏好:渲染逻辑要独立成一个小函数,不要在界面代码里随手写正则。因为代码高亮、行号、字体缩放、复制这些需求后面会一个接一个加进来,逻辑散在 UI 里,加第三个功能的时候你就改不动了。
1.3 题库清洗:重复题、变体题、以及“回忆版”的坑
题库一旦超过几百道,重复就成了绕不过去的问题。同一道题在 A 批次里叫“下列关于 String 的说法正确的是”,在 B 批次里变成“以下关于 String 类的描述,正确的一项是”,题干只差几个字,但答案、解析、选项顺序都可能不一样。学员刷到两遍以为是两道题,实际上是在同一个知识点上原地打转。
我的处理分三步。第一步做标准化哈希:把题干去掉所有空白、标点、全角半角差异、数字编号,统一小写,然后取 SHA-256。这一步能捞掉绝大部分完全重复的题,成本几乎为零。第二步做相似度筛查:对哈希不同的题,用简单的字符级相似度(比如编辑距离归一化,或者按三元组做 Jaccard)算一遍,超过 0.85 的进人工复核队列。这一步会有误报,比如“下列哪个是接口”和“下列哪个不是接口”这种只差一个字的反向题,相似度会非常接近 1,但它们确实是两道题,所以必须人工过。第三步是答案一致性检查:同一知识点下,如果两道题表述几乎相同但答案不同,直接标红。
| 清洗手段 | 能解决什么 | 误伤风险 | 处理方式 |
|---|---|---|---|
| 标准化哈希 | 完全重复 | 极低 | 自动去重,保留最早一条 |
| 相似度阈值 | 换皮重复 | 中 | 进复核队列,人工确认 |
| 答案冲突检测 | 表述相同答案不同 | 低 | 标红,人工判定哪个对 |
| 空选项/答案越界 | 导入格式错 | 无 | 自动隔离到待校对 |
关于“回忆版”我要多说一句。这类题库里经常混着学员考完凭记忆写出来的题,特点是题干口语化、选项不完整、代码片段有语法错误。这类内容不是不能用,但必须单独打标签,和正式整理的题分开管理,否则你会在自检的时候被一堆int a = ;这种代码折磨。我的做法是给source字段定个前缀规则,回忆版统一用recall-开头,抽题时默认排除,只在“真题回顾”这个模块里展示。
2. 桌面端怎么选:Swing、JavaFX,还是直接套个浏览器
这是个每年都要吵一遍的问题。我先把结论摆出来:如果你是一个人或者两三个人的小团队,做的是本机运行的题库工具,Swing 大概率还是最省事的选择。这话不好听,因为 Swing 的界面确实显老,写起来也啰嗦,但它在这个特定场景下的优势太明显了。下面我把三条路各自的真实代价摊开讲。
2.1 为什么这类工具最后大多还是落回 Swing
第一个原因是运行时依赖。Swing 是 JDK 自带的,javax.swing.*一行 import 就能用,打出来的包里只需要一个精简过的 JRE。而 JavaFX 从 JDK 11 开始就不在标准发行版里了,要么单独引模块,要么用第三方打包的发行版,多一层依赖就多一层“在别人电脑上跑不起来”的概率。
第二个原因,说出来有点微妙:二级Java的学习环境本身就在讲 AWT 和 Swing。学员在题目里看到JFrame、JButton、ActionListener,回头打开你的软件,发现也是这套东西,心理上是连贯的。这不是技术理由,但对一个备考工具来说,一致的语境确实有价值。
第三个原因是启动速度和内存。Swing 应用冷启动通常在一秒以内,内存占用几十兆。同样的功能用内嵌浏览器做,光是内核初始化就要一两秒,内存起步就是一百多兆。题库软件的使用场景是“打开、刷十道题、关掉”,一天可能开关十几次,启动慢一秒,用起来就是钝的。
Swing 的缺点也很明确:布局管理器反人类、默认外观停留在上个时代、高分屏适配要手动调。但这些缺点是可以靠少量封装缓解的,比如统一定义一套颜色和间距常量、把常用的“卡片 + 标题 + 正文”封成一个组件类,界面代码的重复量能压下去一大半。
2.2 JavaFX 和内嵌浏览器方案的真实成本
JavaFX 不是不能用,它在两个地方明显强于 Swing:一是 CSS 支持,界面美化成本低很多;二是WebView组件,渲染题干里的富文本和表格几乎不用自己写。如果你的题库里大量题目带流程图、带表格、带复杂的排版,JavaFX 的优势会被放大。
但代价要算清楚:jlink打出来的运行时,即便只引必要的模块,体积通常也在 40MB 到 60MB 之间,比精简 JRE 加 Swing 应用大。第三方控件生态弱,遇到一个奇怪的渲染问题,能搜到的答案可能只有三条,而且都是八年前的。团队里没人熟悉的话,学习曲线比想象中陡。
内嵌浏览器(不管是 Electron 之类还是 Java 里嵌一个轻量内核)的优势是排版能力无敌。题干用 HTML 写,代码高亮用现成的库,公式、表格、图片全都不是问题。用 Markdown 写题库、渲染成 HTML、塞进浏览器组件里,这套流程的写题体验是所有方案里最好的。缺点就是前面说的体积和内存,以及一个很现实的麻烦:打包出来的东西在低配机器上打开会明显卡顿,而这恰恰是很多备考机的真实配置。
| 方案 | 打包体积 | 冷启动 | 富文本渲染 | 团队上手成本 | 适合什么情况 |
|---|---|---|---|---|---|
| Swing + 精简 JRE | 约 25-40MB | 快 | 要自己写 | 低 | 题量中等、排版简单、追求小体积 |
| JavaFX + jlink | 约 45-70MB | 较快 | 中等(支持 CSS) | 中 | 有美化需求、需要 WebView |
| 内嵌浏览器 | 约 80-150MB | 慢 | 强 | 中高 | 题干复杂、团队有前端经验 |
我自己的选择是 Swing,但做了一个折中:代码块用 JTextPane 加样式渲染,普通文本用 JLabel 的 HTML 模式。JLabel 支持一小部分 HTML 标签,用来做加粗、换行、简单表格足够,代码块交给等宽字体的 JTextPane,配上行号和语法着色。这套组合能覆盖 95% 的题目排版需求,剩下的 5% 就用图片解决,反正题库里的图本来也不多。
2.3 打包分发:绿色版 zip 还是安装包
这个环节是很多人第一次做桌面软件时最容易翻车的地方。你在 IDE 里跑得好好的,打成 jar 发给别人,对方双击没反应,或者弹一个“找不到主类”,然后你开始怀疑人生。
我的建议是两条腿走路:一条是绿色版 zip,解压即用,配一个start.bat,适合大部分场景;另一条是安装包,用 JDK 14 以后自带的jpackage生成,适合需要写注册表、建快捷方式的场合。绿色版优先,因为它的故障面小得多。
关键在 JRE 的裁剪。完整的 JDK 有三百多兆,带上它分发是不现实的。用jdeps分析依赖,再用jlink生成一个只含必要模块的运行时,体积能压到三四十兆:
# 1. 先把项目打成模块化的 jar,或用 --module-path 指定 # 2. 分析依赖模块 jdeps --multi-release 17 --ignore-missing-deps \ --print-module-deps target/question-bank.jar # 3. 按分析结果裁剪运行时 jlink --add-modules java.base,java.desktop,java.sql,java.logging \ --strip-debug --no-man-pages --no-header-files \ --compress=2 --output runtime # 4. 组装目录:runtime/ + 应用 jar + 启动脚本启动脚本里有个坑值得单独说:不要写死相对路径。很多人这么写java -jar question-bank.jar,双击 bat 的时候当前工作目录不一定在脚本所在目录,尤其是从压缩包里直接双击运行、或者通过快捷方式启动的时候,工作目录经常是C:\Windows\System32。正确的写法是用%~dp0拿到脚本自身所在目录:
@echo off chcp 65001 >nul set BASE=%~dp0 "%BASE%runtime\bin\java.exe" -Dfile.encoding=UTF-8 ^ -Dsun.java2d.uiScale=1.25 ^ -jar "%BASE%question-bank.jar"chcp 65001这行不是必须的,但加上它能避免一部分中文路径和中文日志乱码的问题,成本为零。
3. 限时模拟考的计时与判分引擎,是这类软件最不能出错的地方
界面丑一点用户能忍,功能少一点用户也能忍,唯独两件事不能错:时间算错和分数判错。这两件事一旦出问题,用户对整个软件的信任就没了,哪怕你其他功能做得再好,他也会觉得“这玩意儿不准”。偏偏这两块又最容易出微妙的 bug,而且都是在特定操作顺序下才复现。
3.1 抽题策略:随机、按知识点配比,还是还原卷面结构
最省事的做法是ORDER BY RANDOM() LIMIT 40,从题库里随机抽 40 道。这个做法在第一天就会被用户投诉,因为抽出来的卷子可能 30 道全是基础语法、一道集合都没有,难度分布完全失控。
我采用的是按卷面结构配比:先定义一份“卷面模板”,规定每个知识点抽几道、每种题型抽几道、难度怎么分布,然后按模板去抽。模板本身存在配置文件里,改起来不用碰代码:
{ "name": "标准模拟卷", "durationSeconds": 5400, "sections": [ { "chapter": "基础语法", "type": "single", "count": 8, "difficulty": [1, 2] }, { "chapter": "面向对象", "type": "single", "count": 10, "difficulty": [2, 4] }, { "chapter": "异常与集合", "type": "single", "count": 8, "difficulty": [2, 4] }, { "chapter": "IO与多线程", "type": "single", "count": 6, "difficulty": [3, 5] }, { "chapter": "上机编程", "type": "program", "count": 3, "difficulty": [3, 5] } ] }抽题时有个细节要处理:某个知识点题量不够怎么办。比如“多线程”这个章节在题库里只有 4 道题,模板要求抽 6 道。这时候不能报错退出,也不能静默少抽,正确做法是先从该章节全抽,缺的份额按“同难度、其他章节”补足,并在卷子生成后记一条警告日志。用户看不到这条日志,但你自己排查“为什么这卷子只有 37 题”的时候会用上。
还有一个反直觉的经验:题目顺序要固定,选项顺序可以打乱。题目顺序固定是因为编程题的上下文有时候依赖前面题目的作答状态(虽然罕见,但存在);选项顺序打乱是为了防止背题号。但打乱选项有个副作用,错题本里记录的“用户选了 B”在下次抽题时可能对应不同的选项,所以答案记录必须存选项内容或者选项的稳定标识,不能只存字母。这个坑我在第一版就踩了,用户反馈“错题本里我明明选对了却标红”,查了半天就是选项被打乱导致的。
3.2 计时器:为什么不能用 Thread.sleep 轮询累加
新手写倒计时最常见的写法是这样:
// 不要这么写 int remaining = 5400; while (remaining > 0) { Thread.sleep(1000); remaining--; label.setText(format(remaining)); }这段代码有四个问题。第一,Thread.sleep(1000)不保证精确睡一秒,实际可能是 1003 毫秒,五分钟下来就偏了将近一秒,一小时偏十几秒。第二,界面刷新必须在事件分发线程(EDT)上做,在后台线程直接setText会偶发界面卡死或者渲染错乱。第三,机器休眠或者被挂起再恢复,sleep的时间计算会完全失真。第四,用户手动改系统时间,如果倒计时依赖System.currentTimeMillis(),一改就乱。
正确的思路是只记一个截止时刻,剩余时间永远现算:
// 开始考试时记下截止点,用单调时钟 private volatile long deadlineNanos; private final AtomicBoolean submitted = new AtomicBoolean(false); public void start(int durationSeconds) { deadlineNanos = System.nanoTime() + durationSeconds * 1_000_000_000L; ScheduledExecutorService ses = Executors.newSingleThreadScheduledExecutor(r -> { Thread t = new Thread(r, "exam-clock"); t.setDaemon(true); return t; }); ses.scheduleAtFixedRate(() -> { long left = deadlineNanos - System.nanoTime(); if (left <= 0) { if (submitted.compareAndSet(false, true)) { SwingUtilities.invokeLater(this::autoSubmit); } ses.shutdown(); return; } SwingUtilities.invokeLater(() -> label.setText(format(left))); }, 0, 200, TimeUnit.MILLISECONDS); }几个关键点解释一下。用System.nanoTime()而不是currentTimeMillis(),因为前者是单调时钟,不受系统时间调整影响,精度也更高。刷新频率设为 200 毫秒而不是 1000 毫秒,因为界面只需要显示到秒,200 毫秒刷新能保证秒数跳变不会漏拍,同时开销可以忽略。AtomicBoolean的作用是保证自动交卷只触发一次——这是最容易出 bug 的地方,时钟线程在left <= 0之后如果没有立刻shutdown,下一拍还会进来,就会交两次卷,第二次可能弹出“已经交卷”的提示框,用户体验极差。
3.3 判分与交卷:那些只在边界情况下才出现的错误
判分逻辑本身不复杂,单选比对选项标识,多选比对集合相等,判断比对布尔值。真正麻烦的是交卷这个动作的边界情况。
第一种是空题。用户没作答的题,是算零分还是不算入总分?我的做法是算零分,但单独统计“未作答数量”,在成绩页明确显示。因为如果从总分里剔除,用户会看到“做了 30 题得了 70 分”,实际卷面是 40 题,这个分数就没有参考意义了。
第二种是重复交卷。除了自动交卷和手动交卷可能撞车,还有一个场景是用户点了交卷,确认框弹出来,他犹豫了十秒才点确定,这十秒里倒计时归零了,自动交卷先执行了一次。解决办法还是那个AtomicBoolean,把“提交”这个状态收敛到一个原子操作上,谁先抢到谁执行,另一个直接返回。
第三种是交卷过程中的异常。写数据库的时候磁盘满了、文件被占用、进程被杀,都会导致答案只写了一半。我的做法是先写临时表,再整体迁移:
-- 交卷时先写入暂存区 INSERT INTO attempt_answer_tmp (session_id, question_code, chosen, elapsed_ms) VALUES (?, ?, ?, ?); -- 全部写完后一次性落正式表,事务包裹 BEGIN; INSERT INTO attempt_answer (session_id, question_code, chosen, elapsed_ms) SELECT session_id, question_code, chosen, elapsed_ms FROM attempt_answer_tmp WHERE session_id = ?; DELETE FROM attempt_answer_tmp WHERE session_id = ?; COMMIT;这套流程的价值在于:即使中途崩溃,正式表里也不会出现“半份答案”,用户下次进来看到的要么是完全没交卷的状态,要么是完整交卷的状态,不会出现“我明明做了 20 题怎么只剩 12 题”这种让人崩溃的情况。
3.4 编程题怎么判:编译运行还是文本比对
二级Java的上机编程题,通用的判分思路是编译、运行、比对输出。具体做法是把考生写的代码写到临时目录里,用javac编译,再用java运行,把标准输出捕获下来,和预期输出做归一化比对。各家评分细则不一样,这里只说工程上要注意的点。
第一,文件名必须和 public 类名一致。这是 Java 的硬规则,但考生经常忘。判分程序不能假设文件名正确,得先从代码里解析出public class XXX的类名,用这个类名做文件名。解析用正则不够稳,简单做法是先javac试一次,失败就换个类名再试,最多试三次。
第二,必须设超时。考生写出死循环的概率不低,一个while(true)就能把你的判分线程挂死。用Process.waitFor(timeout, TimeUnit.SECONDS),超时直接destroyForcibly(),判零分并给出“运行超时”的提示。
ProcessBuilder pb = new ProcessBuilder("javac", "-encoding", "UTF-8", "-d", outDir, srcFile.toString()); pb.redirectErrorStream(true); Process p = pb.start(); if (!p.waitFor(15, TimeUnit.SECONDS)) { p.destroyForcibly(); return JudgeResult.fail("编译超时"); }第三,输出要做归一化。考生输出的最后多一个换行、中间多几个空格,这些不应该扣分。归一化规则建议是:统一换行符为\n、去掉每行首尾空白、合并连续空白为一个空格、去掉末尾空行。做完这几步再比对,误判率会低很多。
第四,工作目录要隔离。每个考生的每次提交都用一个独立的临时目录,判完就删。用同一个目录会导致上一次的.class文件残留,出现“明明代码有错却跑通了”的诡异情况。临时目录建议放在系统临时目录下用 UUID 命名,程序退出时兜底清理。
4. 错题本和学习记录:数据落库的几个设计取舍
错题本看着是个小功能,实际上它是这类软件里用户黏性最强的模块。学员刷题的目的不是刷完,是把不会的变成会的,而错题本就是“不会的”那个集合。这个模块的设计好坏,直接决定了软件是“一次性工具”还是“能陪着用户走完整个备考周期”。
4.1 表结构:把“答题事实”和“学习状态”分开
我的表设计遵循一条原则:答题记录是流水,学习状态是快照。流水只增不改,快照可以随时重算。这样即使某次统计逻辑写错了,重算一遍快照就能修好,不用去改历史数据。
-- 题目主表(前面讲过,这里只列关键字段) CREATE TABLE question ( code TEXT PRIMARY KEY, type TEXT NOT NULL, chapter_id INTEGER NOT NULL, stem TEXT NOT NULL, score REAL NOT NULL DEFAULT 1.0, difficulty INTEGER NOT NULL DEFAULT 3, status TEXT NOT NULL DEFAULT 'draft' ); -- 答题流水:一次作答一行,永不修改 CREATE TABLE attempt_answer ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, question_code TEXT NOT NULL, chosen TEXT, is_correct INTEGER NOT NULL, elapsed_ms INTEGER NOT NULL DEFAULT 0, answered_at INTEGER NOT NULL ); CREATE INDEX idx_ans_q ON attempt_answer(question_code); CREATE INDEX idx_ans_s ON attempt_answer(session_id); -- 学习状态快照:一道题一行,可重算 CREATE TABLE question_state ( question_code TEXT PRIMARY KEY, wrong_count INTEGER NOT NULL DEFAULT 0, right_count INTEGER NOT NULL DEFAULT 0, last_result INTEGER, last_at INTEGER, in_wrong_book INTEGER NOT NULL DEFAULT 0, favorite INTEGER NOT NULL DEFAULT 0 );question_state这张表是可以从attempt_answer完全重算出来的,所以它的存在纯粹是为了查询快。当用户点开错题本的时候,WHERE in_wrong_book = 1一条语句就出来了,不用去attempt_answer里做GROUP BY加子查询。题量到几万条的时候,这个差别是几百毫秒和几毫秒的差别。
关于“错题什么时候移出错题本”,不同人做法不同。我采用的是连续答对两次自动移出,这个规则的好处是给了用户一个明确的“我掌握了”的信号,而不是让他手动一条条删。同时保留一条“手动移出”的入口,因为总有心烦的时候。这个规则要记在question_state里,用right_count和last_result组合判断就行,不需要额外的字段。
4.2 断点续做:崩溃恢复到底要恢复什么
模拟考做到一半,软件崩了、电脑没电了、用户手滑关了窗口,下次打开要不要恢复?大部分用户会期待恢复,尤其是一场 90 分钟的模拟考,做到第 60 分钟崩了,重来一次的心态是崩的。
恢复的内容有三块,缺一不可:剩余时间、已答题目、当前题目位置。前两块容易想到,第三块最容易被忽略——用户上次做到第 23 题,恢复后回到第 1 题,他还得自己翻回去,体验很差。
实现上,我用一张exam_session表存会话状态,每隔一段时间(比如答完一题、或者每 30 秒)落一次盘:
CREATE TABLE exam_session ( session_id TEXT PRIMARY KEY, template_name TEXT NOT NULL, started_at INTEGER NOT NULL, deadline_at INTEGER NOT NULL, remaining_sec INTEGER NOT NULL, current_index INTEGER NOT NULL DEFAULT 0, state TEXT NOT NULL DEFAULT 'running', answers_json TEXT NOT NULL DEFAULT '{}' );这里的deadline_at存的是绝对时间戳,不是剩余秒数。存剩余秒数的问题是:上次落盘是 30 秒前,恢复的时候你不知道这 30 秒该不该扣。存绝对时间戳就没这个问题,恢复时一算就知道真实剩余多少,如果算出来已经过期了,直接按“超时自动交卷”处理。
还有一点要注意:关窗口时的落盘要靠WindowListener加一个钩子,不能只依赖定时落盘。用户在最后一秒关窗口,定时落盘还没到,这一秒的答案就丢了。windowClosing里同步写一次,虽然会卡个几十毫秒,但在这个场景下值得。
4.3 导入导出与跨设备迁移
题库软件的导入导出需求比想象中频繁:用户换了电脑想带走错题本、你把整理好的题库发给同事、从旧版本升级要迁移数据。这块设计不好,用户的数据就成了孤岛。
我用的格式是一个 zip 包,里面放 JSON 文件加一个清单。清单里写清楚版本号、导出时间、包含哪些模块:
{ "formatVersion": 3, "appVersion": "1.4.2", "exportedAt": "2024-11-05T10:22:31Z", "modules": ["questions", "states", "sessions"], "checksum": "sha256:..." }formatVersion是这套机制的核心。你的表结构一定会变,第一版导出两个字段,第三版可能要导出五个。导入时要按版本号走不同的解析分支,新版本要能读旧版本的包,旧版本遇到新版本的包要明确拒绝并提示升级,而不是解析到一半崩掉。checksum用来校验包完整性,防止传输过程中被截断导致导入一半失败。
导入时的策略也要想清楚:覆盖还是合并。我的默认是合并,按question_code做去重,已存在的不覆盖题干(避免用户手改过的内容被冲掉),只更新学习状态。同时给一个“完全覆盖”的选项藏在高级设置里,因为总有用户想要一个干净的重装。
5. 我在做这类工具时踩过的几个坑,以及绕过去的方法
下面这几条都是实际调试中碰出来的,网上搜不一定搜得到,或者搜到的答案不完整。我把现象、原因和处理方式都写清楚,你遇到类似问题可以直接对照。
5.1 中文乱码:不是一个地方的问题,是三个地方
做 Java 桌面软件,中文乱码会在三个不同的层面各出现一次,很多人解决了一个就以为完事了,结果换台电脑又冒出来。
第一个层面是源码文件的编码。你的.java文件里写了中文字符串,如果文件是 UTF-8 而javac按平台默认编码(Windows 上是 GBK)去读,编译出来的字符串就是乱码。解决办法是编译时显式指定-encoding UTF-8,并且把项目里所有源文件统一成 UTF-8。这一步如果在构建脚本里漏了,IDE 里正常、命令行打包就乱,非常迷惑。
第二个层面是运行时的默认字符集。JDK 18 之前,file.encoding跟随系统区域设置,中文系统下是 GBK。你的代码里如果有new String(bytes)或者FileReader没指定编码,读出来的中文就会乱。JDK 18 之后默认改成 UTF-8 了,但如果你的运行时是jlink裁出来的老版本,还是会有这个问题。稳妥的写法是所有涉及编码的地方都显式指定StandardCharsets.UTF_8,不要依赖默认值。
第三个层面是判分时的子进程输出。你调用javac编译考生代码、调用java运行,捕获到的错误信息如果是中文,编码可能和你的预期不一致。处理办法是在ProcessBuilder里设环境变量JAVA_TOOL_OPTIONS=-Dfile.encoding=UTF-8,同时对捕获到的字节流按 UTF-8 解码,解码失败再退回 GBK 试一次。
5.2 高分屏下的界面模糊,以及那个"看起来能用"的假修复
在 2K、4K 屏上,Swing 应用默认渲染出来的字是糊的,因为系统做了 DPI 缩放,而应用没有声明自己支持缩放,被系统整体拉伸了。这个问题的表象很好认:字发虚、图标边缘有毛刺、1 像素的线变成 2 像素。
最直接的修法是启动参数加-Dsun.java2d.uiScale=1.25(数值按你的缩放比例来,125% 就是 1.25)。这能让 Java 自己按比例渲染,字会清楚很多。但它带来一个新问题:所有硬编码的像素尺寸都要跟着变。你把窗口设成 800×600,缩放 1.25 之后实际占的物理像素是 1000×750,如果布局里写死了控件宽度,就会出现内容被裁掉或者大片空白。
我的处理是把布局里所有尺寸改成相对值:用GridBagLayout的权重分配空间,用BorderFactory.createEmptyBorder按比例算间距,字体大小从一个统一的缩放系数推出来。写起来麻烦一点,但换到任何缩放比例的屏幕上都不会崩。
还有个更隐蔽的坑:不同屏幕之间拖动窗口。笔记本外接显示器,两个屏的缩放比例不一样(比如内置 150%、外接 100%),窗口从一块屏拖到另一块,Java 不会自动重新计算缩放,界面会突然变得特别大或者特别小。这个问题的彻底解决要监听显示器变化事件手动重算,成本不低。我的取舍是在启动时检测一次主屏缩放,全程不处理跨屏切换,因为大部分人不会频繁拖着窗口跨屏用,而处理这个场景的代码复杂度远超收益。
5.3 免安装版的路径陷阱:空格、中文、以及"为什么我这里能跑"
绿色版分发最常收到的反馈是"双击没反应"。十个里面有八个跟路径有关。C:\Program Files\...有空格,D:\我的资料\题库\有中文,C:\Users\张三\Desktop\既有中文又有空格。你的 bat 里如果有一行java -jar question-bank.jar,路径一带空格就断成两截。
处理方式前面提过,用%~dp0加引号。另外还有两个细节:一是程序自己写文件的位置,绝对不能写在安装目录下,因为放在Program Files里是没有写权限的,写日志、写数据库都会失败,而且失败得悄无声息。正确位置是%APPDATA%下的一个子目录,Java 里用System.getenv("APPDATA")拿,拿不到再退回user.home。二是日志文件要能定位,用户报问题的时候,第一句话你应该问"日志在哪",如果日志在安装目录里,他可能根本没权限写,那就永远拿不到日志。我在启动脚本里会打印一行日志路径,用户截图发过来我就能直接定位。
5.4 自动更新:不要在程序运行时覆盖自己
题库软件的内容更新很频繁(新题、修正答案、调整解析),所以自动更新几乎是必备功能。但桌面程序的自动更新有个天然的矛盾:你要替换的文件,正在被正在运行的程序占用。Windows 上对已加载的 jar 文件做写操作会直接失败。
我见过几种处理方式,各有各的毛病。一种是"下载到临时目录,提示用户手动替换",体验差但绝对可靠;一种是"下载后重启程序完成替换",需要一个小巧的引导程序;还有一种是"只更新数据,不更新程序",把题库、解析、配置都做成可以热加载的数据文件,程序本体很少更新。
我最后采用的是第三种为主、第一种兜底。题库数据、知识点配置、卷面模板全部外置成资源包,启动时检查版本号,有新版就下载解压覆盖数据目录,程序本身不动。程序本体的更新频率低,走"提示用户下载新版本"的路子,虽然土,但不会出乱子。这个选择背后的判断是:能用数据解决的更新,不要用二进制替换去解决,因为后者要处理文件锁、权限、杀软误报一大堆事,而这些事跟你的核心功能一点关系都没有。
6. 站在使用者角度:这套东西到底该怎么用才有效
写工具的人和用工具的人,视角差别很大。我把工具做出来之后自己完整刷了一遍题库,才发现有些功能的设计是想当然的。下面这些是我作为使用者的一点体会,如果你手上有类似的二级Java题库软件,可以对照着调整用法。
6.1 三遍刷题法:为什么第一遍不该限时
大多数人拿到题库软件的第一反应是直接开一套模拟卷,看看自己能考多少分。这个做法在备考初期是浪费题库。你连知识点都没过一遍,做卷子只能测出"你有多不会",测不出"你哪里不会",而且错一大片会严重打击信心。
我的建议是分三遍走,每一遍用软件里不同的模式:
| 轮次 | 用的模式 | 目标 | 判据 |
|---|---|---|---|
| 第一遍 | 按知识点顺序练习,关闭计时 | 建立知识框架 | 每章正确率过 70% 再进下一章 |
| 第二遍 | 限时模拟卷 | 训练解题速度和取舍 | 分数稳定在及格线以上 15 分 |
| 第三遍 | 只刷错题本 | 消灭顽固盲点 | 错题本清空或只剩个位数 |
第一遍一定要关掉计时。限时状态下人的注意力会偏向"赶紧做完",遇到需要推演的题(比如多态调用结果、集合遍历顺序)会直接猜,猜对了你也不知道自己是不是真会。关掉计时,允许自己在一道题上花五分钟,把代码在纸上抄一遍推一遍,这一遍的效率是最高的。
第二遍的模拟卷要严格按考试时长来,中途不暂停。软件里的暂停功能在平时练习时很好用,但在模拟考里会破坏节奏感。真实考场上你不可能暂停,所以练习时也不要。
6.2 知识点的时间分配:别在低权重章节上磨
二级Java的知识点分布不是均匀的,有些章节题量大、分值高,有些章节就那么几道。时间有限的情况下,投入产出比差别很大。按我的经验,大致是这么个分布:
| 知识板块 | 大致占比 | 建议投入 | 说明 |
|---|---|---|---|
| 基础语法与流程控制 | 20% 左右 | 高 | 送分题集中区,必须全对 |
| 面向对象(封装继承多态) | 25% 左右 | 最高 | 难点集中区,多态、重载重写、构造顺序 |
| 异常处理 | 10% 左右 | 中 | 题型固定,记住几类常见异常即可 |
| 集合框架 | 15% 左右 | 高 | List/Set/Map 的区别是高频考点 |
| IO 流 | 10% 左右 | 中 | 分类记忆,字节字符、输入输出四个象限 |
| 多线程 | 8% 左右 | 中低 | 概念题为主,线程创建方式必考 |
| GUI 与事件 | 7% 左右 | 低 | 题型固定,背几个常用组件和监听器 |
| 数据库与 JDBC | 5% 左右 | 低 | 连接步骤记牢,SQL 语句基本不超纲 |
这张表是根据常见卷面结构估的,具体到某一年的卷子会有出入,但大方向不会差太多。我特别想说的是基础语法和面向对象这两块占了将近一半,很多人觉得基础语法简单就跳过去,结果考试时在自增运算符、位运算、类型转换这些地方丢分,非常亏。
6.3 编程题的手写训练:编译通过不等于做对
上机编程题是最容易"看着会、写着错"的部分。我强烈建议先在纸上或者纯文本编辑器里手写,写完再放进 IDE 编译。为什么?因为考试环境里没有代码补全、没有实时报错、没有一键格式化,你依赖 IDE 的那些习惯在考场上全部失效。
具体练法我总结成四步。第一步,读题之后先用中文把思路写下来,三五行就行,比如"读入一个字符串,遍历每个字符,统计大写字母个数,输出"。第二步,把思路翻译成代码,注意类名、方法签名、包声明这些格式要求。第三步,自己举两组测试数据手推一遍,尤其注意边界情况(空输入、单个字符、全大写、全小写)。第四步,编译运行,比对输出,不一致就回去看是哪一步的推演错了。
几个高频扣分点列一下,都是血泪教训:public class的类名和文件名不一致(直接编译失败,零分);main方法写成public void main或者参数写成String args少了方括号(运行时报找不到主方法);输出时多一个空格或者少一个换行(文本比对失败);忘记import需要的类(编译失败)。这些错误在 IDE 里会被立刻标红,在考场上就是实打实的失分。
还有一个小技巧:准备几个通用模板。比如"从键盘读入一行"的模板、"遍历数组求最值"的模板、"字符串反转"的模板。考试时遇到同类问题直接套,能省下不少时间,也减少手误。但模板要理解着背,不能死记,因为题目总会变一点点,死记的模板改不动。
最后分享一个个人的小体会:题库软件最大的价值不是"刷了多少题",而是它帮你把不会的题攒起来了。我用的时候有个习惯,每道做错的题,不管解析写得多清楚,我都会自己再写一遍为什么错,写在软件的笔记功能里(如果没有笔记功能,就写在题干旁边的备注里)。这个过程比看十遍解析都管用,因为"看懂"和"能讲出来"之间隔着一道鸿沟,而考试考的恰恰是后者。