灯光模拟HarmonyOS应用实战-27-两千道生成题为什么正确答案全是B:用稳定置换消除答案位置偏置
测试人员连续练习十几道自动生成题后发现一个反常规律:题干、道路场景和干扰项都在变化,只要一直选择 B,正确率仍然很高。此时题库看起来有两千道,实际却暴露了答案位置。用户学到的是按键规律,而不是灯光规则。

这不是显示层偶发的随机问题。当前 QuestionBank.ets 为科二和科四各生成 1000 道自动题,生成函数把 correctIndex 固定为 1,答案随后直接取 OPTION_KEYS[1]。页面在 Index.ets 中按这个答案判题,因此偏置会贯穿搜索、练习和历史记录。
本文只处理 _auto 自动生成题的答案位置分布。手工题和文档题已有明确选项顺序,不在改动范围内。下面的 ArkTS 代码是依据当前数据模型设计的建议实现,尚未放入原工程,也没有执行构建或设备运行。
一、先把问题限定在自动生成题,不要误伤已有题目
源码审计可以确认四件事:GENERATED_SUBJECT_COUNT 为 1000;每个科目都按该数量循环;科二和科四各执行一次自动生成;生成路径将正确选项放在数组下标 1。normalizeQuestion() 对自动题再次组装选项时也沿用相同位置。也就是说,共计 2000 道 _auto 题的答案位置被规则性地固定为 B。
页面的 answerTheoryQuestion() 调用 getQuestionAnswer(question) 后与用户选择比较,它没有制造偏置,只是忠实消费题库数据。因此修复位置应放在题库构建边界,而不是在页面点击事件中临时换答案。
| 数据来源 | 当前选项所有权 | 本篇是否调整 | 原因 |
|---|---|---|---|
_auto 自动生成题 | 生成函数同时创建选项和答案 | 是 | 正确槽位被固定为 B |
| 手工内置题 | 题目数据已写明选项和答案 | 否 | 改动会改变既有题意与顺序 |
| 文档导入题 | 导入结果已包含答案键 | 否 | 应尊重原始资料的确定顺序 |
| 页面当前题 | 只负责展示与判题 | 否 | 页面不应拥有题库置换规则 |
问题属于数据分布,不等同于单题结构错误。单题的 A、B、C、D 都可能合法,答案键也能对应正确文本,但两千道题聚合后仍可能严重偏斜。入口结构校验和分布审计应并存,二者不能互相替代。
二、渲染时随机打乱看似简单,却会制造新的不一致
最直接的念头是在页面每次显示题目时调用 Math.random() 打乱四个选项。这个方案会让同一道题在搜索结果、练习页、返回重进和历史回看时出现不同顺序。如果答案键没有随排列同步更新,会直接判错;即使同步更新,用户截图中的 B 也可能在重现时变成 D,排障证据失去稳定性。
更稳的约束是:同一个题目 ID 永远得到同一个槽位,生成时决定一次,之后只消费快照。 这里需要可复现、近似均匀的置换,不需要密码学安全随机。顺序号连续的自动题可直接按模 4 分配,能够让每四题恰好覆盖 A、B、C、D。
const OPTION_KEYS: string[] = ['A', 'B', 'C', 'D'];
export function getStableCorrectIndex(generatedIndex: number): number {
if (!Number.isInteger(generatedIndex) || generatedIndex < 0) {
throw new Error('generatedIndex must be a non-negative integer');
}
return generatedIndex % OPTION_KEYS.length;
}
这个函数拥有“序号到槽位”的唯一规则。它信任的是自动题构建阶段传入的非负整数,不接受页面索引或当前列表位置。因为列表筛选、删除和排序都会改变页面索引,拿页面位置取模会让同一 ID 漂移。
三、让稳定序号成为题目身份的一部分
仅把循环变量传给函数还不够。后续规范化需要再次确认答案时,必须能从稳定身份恢复同一序号。建议自动题 ID 明确包含不会随列表排序变化的序号,例如 subject2_auto_0007。新增种子时不要重编号旧 ID;删除题目也保留已有 ID 的含义。
export function buildGeneratedQuestionId(subject: string, generatedIndex: number): string {
const serial: string = generatedIndex.toString().padStart(4, '0');
return `${subject}_auto_${serial}`;
}
export function readGeneratedIndex(questionId: string): number {
const marker: string = '_auto_';
const markerIndex: number = questionId.lastIndexOf(marker);
if (markerIndex < 0) {
throw new Error(`Not an auto question: ${questionId}`);
}
const serial: string = questionId.substring(markerIndex + marker.length);
const value: number = Number(serial);
if (!Number.isInteger(value) || value < 0) {
throw new Error(`Invalid auto question id: ${questionId}`);
}
return value;
}
这里的 ID 解析只服务于自动题,不应拿它处理手工题。真实项目若已经发布过另一种 _auto ID,应先盘点旧 ID 格式并写兼容分支,不能直接改名后让历史中的 wrongQuestionId 全部失联。padStart() 只改善可读性,稳定性的根本来自“旧 ID 不重编号”。

四、组装选项时同时返回答案键,避免两套规则分叉
正确文本、三个干扰项、选项数组和答案键应在同一个函数内生成。若一个函数排选项,另一个函数再按自己的算法猜答案键,两段代码迟早会分叉。下面用已确认的槽位放入正确文本,再按原顺序填充干扰项。
import { QuestionOption } from './DrivingLightModels';
export interface GeneratedOptionSnapshot {
options: QuestionOption[];
answer: string;
}
export function buildGeneratedOptions(correct: string, distractors: string[],
correctIndex: number): GeneratedOptionSnapshot {
if (correct.length === 0 || distractors.length !== 3) {
throw new Error('Generated options require one answer and three distractors');
}
const texts: string[] = ['', '', '', ''];
texts[correctIndex] = correct;
let distractorIndex: number = 0;
for (let slot: number = 0; slot < texts.length; slot++) {
if (slot === correctIndex) {
continue;
}
texts[slot] = distractors[distractorIndex];
distractorIndex++;
}
const options: QuestionOption[] = [];
for (let slot: number = 0; slot < OPTION_KEYS.length; slot++) {
options.push({ key: OPTION_KEYS[slot], text: texts[slot] });
}
return { options, answer: OPTION_KEYS[correctIndex] };
}
函数拒绝空正确文本和非三个干扰项,但还没有判断文本是否重复,这一责任留给下一节的构建校验。correctIndex 应只来自 getStableCorrectIndex();如果未来扩展为五选一,选项键、槽位算法和分布规则必须一起升级,不能只改数组长度。
五、自动题在构建阶段生成一次不可变快照
生成题不应在搜索、渲染、答题三个入口反复重建。建议在 buildGeneratedQuestion() 中完成 ID、槽位、选项和答案的绑定,随后将 QuestionItem 作为只读快照加入题库。这里保留当前模型字段,不虚构页面未使用的新协议。
import { QuestionItem, TYPE_CHOICE } from './DrivingLightModels';
export interface GeneratedQuestionSeed {
subject: string;
category: string;
title: string;
correct: string;
distractors: string[];
explanation: string;
}
export function buildGeneratedQuestion(seed: GeneratedQuestionSeed,
generatedIndex: number): QuestionItem {
const id: string = buildGeneratedQuestionId(seed.subject, generatedIndex);
const correctIndex: number = getStableCorrectIndex(generatedIndex);
const snapshot: GeneratedOptionSnapshot = buildGeneratedOptions(
seed.correct, seed.distractors, correctIndex);
return {
id,
subject: seed.subject,
type: TYPE_CHOICE,
title: seed.title,
options: snapshot.options,
answer: snapshot.answer,
explanation: seed.explanation,
category: seed.category
};
}
该函数创建之后不再读取 Math.random()。同一个 ID 在应用重启、搜索、开始考试和历史定位时都对应同一答案键。示例仍需接入现有 QuestionBank.ets 的种子循环,并核对现有 ID 是否已经被历史数据引用。
六、规范化只验证或复制快照,不能偷偷再次洗牌
当前工程需要 normalizeQuestion() 兼容不同来源的数据形状,但规范化不等于重新生成。对已经具备四个选项和明确答案的自动题,应复制并校验;若必须从种子恢复,则只能从 ID 读取同一稳定序号,绝不能用当前时间、数组下标或新的随机数。
export function normalizeGeneratedQuestion(question: QuestionItem,
seed: GeneratedQuestionSeed): QuestionItem {
const generatedIndex: number = readGeneratedIndex(question.id);
const expectedIndex: number = getStableCorrectIndex(generatedIndex);
const expected: GeneratedOptionSnapshot = buildGeneratedOptions(
seed.correct, seed.distractors, expectedIndex);
return {
id: question.id,
subject: question.subject,
type: question.type,
title: question.title,
options: expected.options,
answer: expected.answer,
explanation: question.explanation,
category: question.category
};
}
这个示例要求调用方能按题目 ID 找到原种子。若种子不可追溯,最安全的处理是保留已持久化快照并做一致性检查,而不是凭空编造干扰项。规范化连续执行一百次,输出仍应逐字段相同;这是能否稳定重放的关键断言。

七、分布审计要检查数量,也要检查答案文本对应关系
A、B、C、D 各 250 道并不自动代表题库正确。如果答案键均匀,但键指向了错误文本,数据仍然不可用。构建结束后应同时统计位置分布、验证 ID 唯一、确认四个选项文本非空且互不重复,并核对答案键对应的文本等于种子正确答案。
export interface AnswerDistribution {
A: number;
B: number;
C: number;
D: number;
}
export function buildAnswerDistribution(questions: QuestionItem[]): AnswerDistribution {
const result: AnswerDistribution = { A: 0, B: 0, C: 0, D: 0 };
for (let index: number = 0; index < questions.length; index++) {
const answer: string = questions[index].answer;
if (answer === 'A') result.A++;
else if (answer === 'B') result.B++;
else if (answer === 'C') result.C++;
else if (answer === 'D') result.D++;
else throw new Error(`Unknown answer key: ${answer}`);
}
return result;
}
export function isBalanced(distribution: AnswerDistribution): boolean {
const values: number[] = [distribution.A, distribution.B,
distribution.C, distribution.D];
const max: number = Math.max(...values);
const min: number = Math.min(...values);
return max - min <= 1;
}
这里的“最大差值不超过 1”适用于连续序号按模 4 分配。若业务改成带权槽位或某些题只有两个选项,应为不同题型分别定义期望分布。统计函数应运行在题库构建或单元测试阶段,不要在用户每次进入页面时重复扫描两千道题。
八、验证矩阵覆盖稳定性、隔离范围和升级场景
接入时先保存当前题库 ID、选项和答案快照,再对新实现执行对照。分布变化是预期,题目文本、学科、分类和解释不应无故变化。手工题和文档题必须逐 ID 比较,确认完全未被置换。
| 场景 | 操作 | 预期结果 |
|---|---|---|
| 科二 1000 道自动题 | 统计答案键 | A/B/C/D 各 250 道 |
| 科四 1000 道自动题 | 统计答案键 | A/B/C/D 各 250 道 |
| 同一题重复规范化 100 次 | 比较完整快照 | ID、选项顺序和答案键完全一致 |
| 关闭后重新构建题库 | 比较同一 ID | 槽位不随启动次数变化 |
| 检查每道自动题 | 找到答案键对应文本 | 与种子 correct 完全一致 |
| 手工题与文档题 | 改造前后逐 ID 对比 | 选项和答案不变 |
| 干扰项重复或为空 | 执行构建校验 | 明确拒绝,不进入练习 |
| 新增一个种子 | 比较旧 ID | 旧题槽位不整体漂移 |
| 搜索后开始练习 | 比较题目快照 | 搜索与练习显示顺序一致 |
| 历史定位旧错题 | 用 ID 查找 | 能找到题目;旧 ID 不被重编号 |
自动题答案从全 B 改为均衡后,旧成绩的“用户当时选择 B”仍是历史事实,不应回写篡改。若历史页面展示正确答案文本,需要决定显示答题时快照还是当前题库内容,并在产品层明确版本边界。
九、常见误区和排查顺序
| 现象 | 优先检查 | 可能原因 | 处理方式 |
|---|---|---|---|
| 同一题返回后答案位置变化 | 是否在 Builder 或页面回调洗牌 | 置换发生在渲染期 | 移到题库构建阶段并保存快照 |
| 分布均衡但判题错误 | 答案键对应的选项文本 | 选项和答案由两段逻辑生成 | 一个函数同时返回两者 |
| 新增种子后旧题全变 | ID 是否按数组当前位置生成 | 插入导致后续编号重排 | 使用稳定业务 ID,旧 ID 不重编号 |
| 手工题也被打乱 | 自动题范围条件 | 对全题库统一置换 | 只处理 _auto 来源 |
| 每个科目单独看仍偏斜 | 是否跨科目合并统计 | 总量均衡掩盖子集偏差 | 按 subject 分组检查 |
| 自动题出现重复选项 | 种子干扰项内容 | 仅检查长度,未检查唯一性 | 构建时对四个文本去重 |
| 搜索与考试顺序不同 | 两处是否各自规范化 | 使用了不同恢复规则 | 共用只读题库快照 |
| 旧历史找不到题 | 是否更改旧 ID | 题库升级破坏引用 | 提供 ID 映射或保留旧 ID |
排查时先打印一小组脱敏的 id/answer/optionText,不要把两千道完整题库塞进日志。确认单题映射无误后再看分布,最后才检查页面消费。这样能区分“数据生成错”“答案映射错”和“页面渲染错”三类问题。
十、落地顺序与本文证据边界
建议实施顺序是:冻结旧 ID 清单;为自动题建立稳定序号;让选项与答案在同一构建函数中产生;移除渲染期重排;增加按科目分布和文本一致性断言;最后回归搜索、理论练习与历史定位。若应用已经向用户发布,应先评估题库升级对旧错题引用的影响。
本文通过只读源码审计确认了当前科二、科四各生成 1000 道自动题,并确认自动生成路径把正确位置固定在下标 1。文中的稳定置换、快照结构和审计函数均为建议代码,没有集成到 The_kemusan 原工程,没有执行 hvigorw assembleHap --no-daemon,没有生成新的 HAP,也没有进行真机验证。只有完成接入、构建、自动化断言以及实际练习回归后,才能确认偏置已被消除且没有引入答案错配。
更多推荐



所有评论(0)