灯光模拟HarmonyOS应用实战-24-题库进入练习前先校验:把重复ID、选项键和答案错误挡在入口
两条题共用 ID,列表可能串卡片;选项写了两次 B,内容与判题键不再对应;答案填成 D,题目却只有前三个选项,用户怎么选都不会正确。界面异常的根因,可能早在题库入口就存在。

灯光模拟工程 The_kemusan 已有多题源合并与内容去重。本文补充一份只读校验报告,在练习开始前判断题库能否使用,并指出需要修改的字段。
本文实现 ID、选项、答案校验和练习入口报告。示例针对工程内置 QuestionItem[],不代替外部 JSON 解析。新增代码尚未写入原工程,没有构建或真机结论。
一、内容去重与结构校验解决不同问题
当前 entry/src/main/ets/models/QuestionBank.ets 的 hasDuplicateQuestion(),比较的是科目、分类和题干:
function hasDuplicateQuestion(target: QuestionItem[], question: QuestionItem): boolean {
for (let index = 0; index < target.length; index++) {
const current = target[index];
if (current.subject === question.subject &&
current.category === question.category &&
current.title === question.title) {
return true;
}
}
return false;
}
这段源码可以避免同一科目、同一分类中完全相同的题干重复加入,但它没有承诺 ID 唯一,也没有检查选项。假设两条题的标题不同、ID 相同,内容去重会保留它们;反过来,标题相同却答案不同的两条题,后加入的题会被跳过,潜在冲突可能因此被隐藏。
| 约束 | 当前合并逻辑的作用 | 本文入口校验的作用 |
|---|---|---|
| 同科目、分类、题干重复 | 跳过后加入的数据 | 不重复承担内容去重 |
| 全部题目 ID 唯一 | 不是比较条件 | 必须验证,重复即阻止启动 |
| 每道题选项键唯一 | 未在这里检查 | 必须验证,报告具体选项位置 |
| 答案属于当前选项 | 未在这里检查 | 必须验证,禁止无法答对的题 |
| 知识结论正确 | 无法证明 | 仍需内容来源和人工复核 |
生产题库时应在去重前观察来源冲突,准备练习时校验最终数据。本文实现后者;最终数组有效,不能证明被去重的上游数据没有冲突。

二、先固定报告协议,避免页面拼接错误字符串
校验应汇总全部问题及其位置,而非遇到首错就返回false。以下类型放在建议新增的 models/QuestionValidation.ets。
export interface QuestionIssue {
code: string;
index: number;
questionId: string;
field: string;
message: string;
}
export interface QuestionValidationReport {
total: number;
invalidCount: number;
issueCount: number;
issues: QuestionIssue[];
canStart: boolean;
}
index 保留输入数组中的零基位置,即使 ID 为空也能定位。invalidCount 表示至少有一个问题的题目数,issueCount 表示问题总数,两者不能混为一谈。一道题同时有重复选项和答案不存在,会产生两项问题,但只增加一次无效题计数。
程序按 code 分类,页面展示消息;canStart 要求题库非空且问题数为零。可以限制报告展示条数,但必须完成全部校验。
三、用一次遍历建立 ID 索引,重复项必须指向首次出现位置
建议新增 services/QuestionBankValidator.ets。下面是完整校验器的主体;第四、五节给出它依赖的函数,放在同一个文件即可。模型继续复用工程已有的 QuestionItem 和科目、题型常量。
import {
QuestionItem, SUBJECT_ONE, SUBJECT_TWO, SUBJECT_FOUR,
TYPE_CHOICE, TYPE_JUDGE, TYPE_CHECK
} from '../models/DrivingLightModels';
import {
QuestionIssue, QuestionValidationReport
} from '../models/QuestionValidation';
function addIssue(issues: QuestionIssue[], code: string, index: number,
question: QuestionItem, field: string, message: string): void {
const issue: QuestionIssue = {
code, index, questionId: question.id, field, message
};
issues.push(issue);
}
export function validateQuestionBank(questions: QuestionItem[]): QuestionValidationReport {
const issues: QuestionIssue[] = [];
const idFirstIndex: Map<string, number> = new Map<string, number>();
let invalidCount: number = 0;
for (let index = 0; index < questions.length; index++) {
const question: QuestionItem = questions[index];
const countBefore: number = issues.length;
if (question.id.trim().length === 0) {
addIssue(issues, 'ID_EMPTY', index, question, 'id', '题目ID不能为空');
} else if (question.id !== question.id.trim()) {
addIssue(issues, 'ID_SPACE', index, question, 'id', '题目ID不能带首尾空格');
}
if (question.id.trim().length > 0) {
const first: number | undefined = idFirstIndex.get(question.id);
if (first !== undefined) {
addIssue(issues, 'ID_DUPLICATE', index, question, 'id',
`题目ID重复,首次出现在第${first + 1}题`);
} else {
idFirstIndex.set(question.id, index);
}
}
validateQuestionFields(question, index, issues);
validateQuestionOptions(question, index, issues);
if (issues.length > countBefore) {
invalidCount++;
}
}
const report: QuestionValidationReport = {
total: questions.length,
invalidCount,
issueCount: issues.length,
issues,
canStart: questions.length > 0 && issues.length === 0
};
return report;
}
Map 保留首次出现位置。必须判断 first !== undefined;第一题索引为0,不能用真值判断。
这里不会自动给 ID 添加后缀,也不把带空格的 ID 悄悄裁剪后写回。工程的历史记录使用 wrongQuestionId 关联题目;校验器擅自改 ID,可能让旧错题失去关联。报告只负责指出问题,真正修复应发生在题源,必要时同时制定历史映射策略。
空数组的报告是 total=0、issueCount=0、canStart=false,这没有矛盾:没有逐题错误,不等于具备可以练习的题目。入口展示应把“空题库”和“存在坏题”分开。
四、字段规则必须匹配当前业务,不把任意字符串都当作有效值
源码的科目和题型都是string,拼错仍能通过类型检查,因此还需业务白名单。
function validateQuestionFields(question: QuestionItem, index: number,
issues: QuestionIssue[]): void {
const subjects: string[] = [SUBJECT_ONE, SUBJECT_TWO, SUBJECT_FOUR];
const types: string[] = [TYPE_CHOICE, TYPE_JUDGE, TYPE_CHECK];
if (subjects.indexOf(question.subject) < 0) {
addIssue(issues, 'SUBJECT_UNKNOWN', index, question, 'subject',
'理论题库只接受已登记的科一、科二、科四科目');
}
if (types.indexOf(question.type) < 0) {
addIssue(issues, 'TYPE_UNKNOWN', index, question, 'type', '题型未登记');
}
if (question.title.trim().length === 0) {
addIssue(issues, 'TITLE_EMPTY', index, question, 'title', '题干不能为空');
}
if (question.explanation.trim().length === 0) {
addIssue(issues, 'EXPLANATION_EMPTY', index, question, 'explanation',
'练习题需要提供答案解析');
}
if (question.category.trim().length === 0) {
addIssue(issues, 'CATEGORY_EMPTY', index, question, 'category',
'题目分类不能为空');
}
}
这些约束属于当前理论题业务。科三使用其他指令模型,不进入本校验器;新增科目时同步更新白名单、入口、筛选和用例。
TYPE_CHECK 在当前工程中也使用单个 answer: string 进行选项比较。不能看到名字带 check 就假设支持多选。如果业务未来加入多选,必须更新模型为答案集合,说明顺序、重复项与部分得分规则,再另写对应校验分支。直接接受 A,C 会把现有单答案判题链路变成隐蔽故障。
五、选项键、文本和答案必须形成一对一关系
选项的关键约束不是“看起来有四行”。题内每个键只能出现一次;每个选项应有可阅读文本;答案必须精确对应当前键。下面函数继续放在同一个校验器文件中。
function validateQuestionOptions(question: QuestionItem, index: number,
issues: QuestionIssue[]): void {
const keys: Set<string> = new Set<string>();
const allowedKeys: string[] = ['A', 'B', 'C', 'D'];
if (question.options.length < 2 || question.options.length > 4) {
addIssue(issues, 'OPTION_COUNT', index, question, 'options',
'当前练习页面要求每题有2至4个选项');
}
if (question.type === TYPE_JUDGE && question.options.length !== 2) {
addIssue(issues, 'JUDGE_OPTION_COUNT', index, question, 'options',
'判断题必须有两个选项');
}
for (let optionIndex = 0; optionIndex < question.options.length; optionIndex++) {
const option = question.options[optionIndex];
const field: string = `options[${optionIndex}]`;
if (allowedKeys.indexOf(option.key) < 0) {
addIssue(issues, 'OPTION_KEY_UNKNOWN', index, question, `${field}.key`,
'选项键必须是A、B、C、D之一');
}
if (keys.has(option.key)) {
addIssue(issues, 'OPTION_KEY_DUPLICATE', index, question, `${field}.key`,
`选项键${option.key}重复`);
}
keys.add(option.key);
const text: string = option.text.trim();
if (text.length === 0) {
addIssue(issues, 'OPTION_TEXT_EMPTY', index, question, `${field}.text`,
'选项内容不能为空');
}
}
if (!keys.has(question.answer)) {
addIssue(issues, 'ANSWER_NOT_IN_OPTIONS', index, question, 'answer',
'答案必须精确对应本题的一个选项键');
}
}
trim() 只识别空文本,不修改题目。答案 b、B 不等于 B。本文只要求键在白名单内且唯一,因此 A/C 可以成立;若产品还要求连续编号,应另加规则和用例。
六、校验的是实际发给页面的快照,避免前后两套答案
当前源码对 ID 含 _auto 的生成题提供 normalizeQuestion(),并由 getQuestionAnswer() 读取规范化答案。入口若校验原数组,而页面又重新生成选项,校验对象与渲染对象就可能不一致。更稳妥的接入方式是先按既有规则生成最终快照,再校验和使用这同一份快照。
import { QuestionItem } from '../models/DrivingLightModels';
import { BUILTIN_QUESTIONS, normalizeQuestion } from '../models/QuestionBank';
import { QuestionValidationReport } from '../models/QuestionValidation';
import { validateQuestionBank } from './QuestionBankValidator';
export interface PreparedQuestionBank {
questions: QuestionItem[];
report: QuestionValidationReport;
}
export function prepareQuestionBank(): PreparedQuestionBank {
const questions: QuestionItem[] = [];
for (let index = 0; index < BUILTIN_QUESTIONS.length; index++) {
questions.push(normalizeQuestion(BUILTIN_QUESTIONS[index]));
}
const report: QuestionValidationReport = validateQuestionBank(questions);
const prepared: PreparedQuestionBank = { questions, report };
return prepared;
}
准备层规范化,校验器只读,页面使用同一数组。题库在进程内不变时,初始化一次即可,不必随搜索输入重复校验。
同时要守住一个限制:规范化可能修正旧格式,却也可能掩盖原始数据错误。开发阶段可以分别校验题源和最终快照,前者用于定位生产问题,后者决定用户能否练习;它们应标出不同阶段,不能把两份报告合并后误算无效题数量。

七、失败时关掉启动入口,报告保留给维护者
页面应使用 canStart 判断能否进入练习,不能只看 questions.length。下面的报告摘要函数没有框架依赖,可放在校验器旁边;页面把结果绑定到状态文本,把 canStart 绑定到开始按钮即可。
export function describeQuestionReport(report: QuestionValidationReport): string {
if (report.total === 0) {
return '当前没有可练习的题目';
}
if (report.canStart) {
return `已准备${report.total}道题`;
}
const lines: string[] = [
`题库暂不可用:${report.invalidCount}道题存在${report.issueCount}处问题`
];
const limit: number = Math.min(report.issues.length, 6);
for (let index = 0; index < limit; index++) {
const issue: QuestionIssue = report.issues[index];
lines.push(`第${issue.index + 1}题 ${issue.field}:${issue.message}`);
}
if (report.issues.length > limit) {
lines.push(`另有${report.issues.length - limit}处问题,请查看完整报告`);
}
return lines.join('\n');
}
详细字段留给维护者,普通用户看到“题库准备失败”的提示即可。本例整批阻断;若要过滤坏题继续练习,还需明确最小题数、科目分布和固定题规则。
八、用小样本验证规则,再对真实题库跑完整报告
先用小样本验证规则。下面验证重复 ID,不涉及交通知识正确性。
const first: QuestionItem = {
id: 'validation_001', subject: SUBJECT_TWO, type: TYPE_CHOICE,
title: '校验用题目一',
options: [{ key: 'A', text: '候选一' }, { key: 'B', text: '候选二' }],
answer: 'A', explanation: '仅用于验证数据结构', category: '校验样本'
};
const second: QuestionItem = {
id: 'validation_001', subject: SUBJECT_TWO, type: TYPE_CHOICE,
title: '校验用题目二',
options: [{ key: 'A', text: '候选三' }, { key: 'B', text: '候选四' }],
answer: 'B', explanation: '仅用于验证重复ID', category: '校验样本'
};
const report: QuestionValidationReport = validateQuestionBank([first, second]);
console.info(`canStart=${report.canStart}, issues=${report.issueCount}`);
该样本的预期是 canStart=false、invalidCount=1、issueCount=1,错误项指向第二题,消息指出首次位于第一题。这里无效题计数按“被报告问题的题目”计算;第一次出现的题作为参照,不再追加第二份重复错误。
| 输入变更 | 应有结果 | 需要额外确认 |
|---|---|---|
| 空数组 | 不允许开始,题目总数为0 | 页面显示空态而非数据损坏 |
| 单条完整题 | 允许开始 | 校验不改变原对象 |
| 第二题复用第一题ID | ID_DUPLICATE | 首次位置为0时仍能命中 |
| 同题两个选项键都为B | OPTION_KEY_DUPLICATE | 不因答案也是B而忽略 |
| 答案D但没有D选项 | ANSWER_NOT_IN_OPTIONS | 不能在判题时回退到第一项 |
| 选项内容仅空格 | OPTION_TEXT_EMPTY | 输入原文仍然保留 |
| 判断题四个选项 | JUDGE_OPTION_COUNT | 不影响普通选择题四选项 |
| 同题同时两个问题 | 问题数2,无效题数1 | 摘要与完整报告一致 |
| 最终快照完整但原始题源冲突 | 练习报告可通过 | 生产阶段报告仍需人工处理 |
小样本通过后再验证整库,并随题库版本记录结果。
九、入口通过以后,仍然保留内容和界面验收
结构校验能证明“键和答案连得上”,不能证明答案在现实交通规则中正确。生成题也不能因为换了地点、天气词语就自动获得知识有效性。题源应保留出处,结论与正式考试要求需要另外核对;不要在应用和文章中把训练材料包装成官方题库。
验收至少分开三层:
- 数据层:完整报告无问题,题目总数、各科分布符合发布计划,校验前后输入未被修改。
- 页面层:长题干、两选项、四选项、空搜索结果都能正常展示;ID 用作列表键时,滚动和筛选没有串项。
- 判题层:显示的选项键、点击提交键、正确答案三者一致;答错后保存的
wrongQuestionId能重新定位原题。
落地时在分支接入并运行项目测试,再执行 hvigorw assembleHap --no-daemon,最后完成页面和判题回归。本文尚未执行这些接入验收。
十、常见故障与落地取舍
| 现象 | 优先检查 | 修复方向 |
|---|---|---|
| 校验通过,点击仍一直判错 | 页面是否用了另一份规范化结果 | 校验和发题共享同一快照 |
| 重复ID恰好漏掉第一题 | 是否把索引0当成false | 使用 first !== undefined |
| 加题后旧错题打不开 | 是否自动重命名了已有ID | 保持稳定ID,必要时提供迁移映射 |
| 几千条题只报告第一条错误 | 是否遇错就throw或return | 累积问题后统一返回 |
| 清理空白以后历史关联出错 | 是否在校验时修改输入 | 校验只读,题源修复单独评审 |
| 允许选择新科目却没有题 | 科目白名单与入口是否同步 | 同时更新筛选规则和用例 |
导入JSON后在 .trim() 崩溃 | 是否直接把外部对象断言为模型 | 先做外部格式解析,再进入本文校验器 |
先在准备入口校验,修复题源,再接入题库维护流程。核心契约是:每道题身份稳定,选项键唯一,答案属于实际选项,失败能够定位题源。
更多推荐

所有评论(0)