灯光模拟HarmonyOS应用实战-60-全量洗牌再取20题会丢分类配额:用考试蓝图做分层抽样
灯光模拟HarmonyOS应用实战-60-全量洗牌再取20题会丢分类配额:用考试蓝图做分层抽样
在灯光理论练习里,“题目能随机出来”只是第一步。更棘手的问题发生在用户连续开始多轮模拟考试之后:同样都是 20 题,有一轮可能抽到大量图标认知题,另一轮却几乎全是夜间场景题。每道题本身都没有错,总题数也正确,但这一轮试卷已经偏离产品希望覆盖的训练结构。
我核对了当前工程的真实实现。Index.ets 中的 selectTestQuestions() 会先取得当前科目与分类对应的全部候选题,复制数组,完成一次全量 Fisher–Yates 洗牌,然后直接截取最多 THEORY_TEST_COUNT 道题。这个实现能改变题目顺序,却没有表达“每类应该抽几道”。与此同时,QuestionBank.ets 的科目二生成种子明确分为“图标认知、上车检查、夜间场景”三类,每类各有 3 个场景。分类信息已经存在,抽题阶段却没有把它当作约束使用。
本文给出一套可落到 ArkTS 的改造建议:用考试蓝图声明分类配额,把候选池按分类分桶,再逐桶抽取并合并成 20 题试卷;当某一分类题量不足时,正式模拟与日常练习采用不同政策。文中的代码用于说明建议方案,尚未写入工程源码。

读完后可以得到四个直接结果:知道全量洗牌为什么无法承诺分类占比;能定义并校验一份 20 题考试蓝图;能实现按分类分桶、按配额抽取、再合并出卷;也能让“题量不足”从隐式意外变成可观察、可选择的业务结果。
一、先把问题说清:随机的20题不等于结构正确的20题
当前抽题链路的关键控制流很简洁。为了只讨论“整池洗牌后截取”这一件事,下面将页面方法收缩成结构等价函数;实际源码使用循环复制与循环收集,效果与这里的工作数组一致:
function shuffleThenTakeWholePool(source: QuestionItem[],
limit: number): QuestionItem[] {
const working: QuestionItem[] = source.slice();
for (let cursor = working.length - 1; cursor > 0; cursor--) {
const swapWith = Math.floor(Math.random() * (cursor + 1));
const saved = working[cursor];
working[cursor] = working[swapWith];
working[swapWith] = saved;
}
const takeCount = Math.min(limit, working.length);
return working.slice(0, takeCount);
}
当前 selectTestQuestions() 传入的上限就是 THEORY_TEST_COUNT。这条控制流拥有两个清晰优点:不会直接修改原候选数组,并且能够让每轮题序发生变化。但它只约束“最多取多少题”,没有约束“这些题分别来自哪些分类”。如果候选池中有 300 道夜间场景题、100 道图标认知题和 100 道上车检查题,那么任意截取 20 道时,夜间场景天然更容易占据更多位置。即使三类题数量接近,单轮结果仍可能出现明显波动。
从业务视角看,需要分开三个概念:
| 概念 | 它回答的问题 | 当前实现是否表达 |
|---|---|---|
| 题序打乱 | 同一批题以什么顺序出现 | 已表达 |
| 总题数 | 一轮考试需要多少题 | 已表达,最多取 20 题 |
| 分类配额 | 每个分类各占多少题 | 未表达 |
因此,问题不在于 Fisher–Yates 写错了,也不需要围绕随机源做额外设计。真正缺少的是抽样前的业务蓝图。先确定结构,再在每个结构单元中选择题目,职责才完整。
二、把产品口径写成ExamBlueprint,而不是散落在页面分支里
考试蓝图是一份明确的出卷合同。它至少要写出科目、目标总题数、每个分类的配额,以及题量不足时采用的政策。本文用“图标认知 6 题、上车检查 6 题、夜间场景 8 题”演示 20 题结构;这只是工程示例,不代表任何考试主管部门的正式比例,实际数值应由产品与业务资料共同确认。
type ShortagePolicy = 'REJECT_EXAM' | 'REDISTRIBUTE';
interface CategoryQuota {
category: string;
count: number;
}
interface ExamBlueprint {
blueprintId: string;
subject: string;
totalCount: number;
quotas: CategoryQuota[];
shortagePolicy: ShortagePolicy;
}
const SUBJECT_TWO_BLUEPRINT: ExamBlueprint = {
blueprintId: 'subject-two-light-v1',
subject: '科目二',
totalCount: 20,
quotas: [
{ category: '图标认知', count: 6 },
{ category: '上车检查', count: 6 },
{ category: '夜间场景', count: 8 }
],
shortagePolicy: 'REJECT_EXAM'
};
blueprintId 让日志、历史成绩和问题反馈能够说明“使用了哪一版出卷结构”;subject 防止把科目二蓝图误用于其他题库;totalCount 是试卷总量合同;quotas 则是本文最关键的分类约束。shortagePolicy 不负责抽题,它只声明当合同无法满足时应采取哪种业务动作。
蓝图适合由题库领域层维护,页面只传入当前科目和考试模式。这样做可以避免一个页面写 6/6/8,另一个页面又写 5/5/10。后续比例发生变化时,也能通过增加蓝图版本而不是在多个点击事件里寻找数字。
三、蓝图必须先通过静态校验,不能带病进入抽样流程
如果配额相加不是 20、同一分类出现两次,或者存在负数,分层抽样写得再严谨也无法给出合理结果。与其让问题在出卷后表现为“少了两题”,更稳的做法是在应用加载蓝图或开始考试前完成一次校验,并返回可读的错误原因。
interface BlueprintValidation {
valid: boolean;
errors: string[];
}
function validateBlueprint(blueprint: ExamBlueprint): BlueprintValidation {
const errors: string[] = [];
const categorySet: Set<string> = new Set<string>();
let quotaTotal: number = 0;
if (blueprint.totalCount <= 0) {
errors.push('总题数必须大于0');
}
for (let index = 0; index < blueprint.quotas.length; index++) {
const quota = blueprint.quotas[index];
if (quota.category.trim().length === 0) {
errors.push(`第${index + 1}项分类不能为空`);
}
if (quota.count < 0 || Math.floor(quota.count) !== quota.count) {
errors.push(`${quota.category}的配额必须是非负整数`);
}
if (categorySet.has(quota.category)) {
errors.push(`${quota.category}重复配置`);
}
categorySet.add(quota.category);
quotaTotal += quota.count;
}
if (quotaTotal !== blueprint.totalCount) {
errors.push(`分类配额合计${quotaTotal},应为${blueprint.totalCount}`);
}
return { valid: errors.length === 0, errors: errors };
}
这段校验守住的是“配置能否描述一张完整试卷”的边界。它不依赖候选题池,因此可以在更早阶段执行。分类题量是否足够属于运行时库存问题,应该在分桶以后判断,不能和蓝图自身的格式问题混为一谈。
建议至少覆盖以下输入:
| 蓝图输入 | 预期结果 | 原因 |
|---|---|---|
| 6 + 6 + 8,目标20 | 接受 | 配额唯一、均为整数、合计正确 |
| 6 + 6 + 7,目标20 | 拒绝 | 合计只有19 |
| “图标认知”出现两次 | 拒绝 | 后续统计会产生歧义 |
| 某类配额为 -1 | 拒绝 | 不能形成合法抽取数量 |
| 某类配额为 0 | 接受 | 可用于暂时关闭某类,但仍要计入配置审阅 |
当校验失败时,页面应阻止开始考试,并记录 blueprintId 与错误列表。不要悄悄把缺失数量补到最后一个分类,因为那会让配置错误变成一张看似正常的试卷。
四、候选题先按category分桶,库存事实才能被看见
分层抽样的输入不是一个扁平数组,而是一组按分类组织的候选桶。QuestionBank.ets 已经给科目二种子写入 category:三条“图标认知”、三条“上车检查”、三条“夜间场景”。生成后的具体题量可能随着扩充逻辑变化,所以抽题时仍应根据最终 QuestionItem 重新统计,不能把种子数量直接当作可抽题数量。
type QuestionBuckets = Map<string, QuestionItem[]>;
function groupByCategory(source: QuestionItem[]): QuestionBuckets {
const buckets: QuestionBuckets = new Map<string, QuestionItem[]>();
for (let index = 0; index < source.length; index++) {
const question = source[index];
const category = question.category.trim();
const bucket = buckets.get(category);
if (bucket) {
bucket.push(question);
} else {
buckets.set(category, [question]);
}
}
return buckets;
}
分桶函数只负责结构转换,不在这里决定配额,也不应修改原题目对象。输入是已经经过科目、启用状态等基础筛选的候选题,输出保留每个分类的完整库存。若题库允许空分类,还应在进入函数前将其视为数据错误;直接把空字符串建立成一个桶,会让缺失字段看起来像合法分类。
分桶完成后可以形成一份库存快照,例如:
interface CategoryInventory {
category: string;
available: number;
required: number;
shortage: number;
}
function buildInventory(
buckets: QuestionBuckets,
quotas: CategoryQuota[]
): CategoryInventory[] {
const result: CategoryInventory[] = [];
for (let index = 0; index < quotas.length; index++) {
const quota = quotas[index];
const bucket = buckets.get(quota.category) ?? [];
result.push({
category: quota.category,
available: bucket.length,
required: quota.count,
shortage: Math.max(0, quota.count - bucket.length)
});
}
return result;
}
这份快照是题量不足政策的事实依据。日志中记录 available 与 required,比只打印“抽题失败”更容易定位是哪一类库存不足,也能帮助题库维护人员优先补齐真正影响出卷的分类。
五、逐桶抽取才是配额约束生效的地方
完成分桶后,抽样步骤应围绕蓝图逐项执行:取出指定分类的候选桶,在桶内打乱,然后拿到该分类要求的数量。这里可以沿用当前工程已经使用的数组复制与交换方式,但它的作用范围从“整份题库”收窄为“一个分类桶”。本文不改变随机机制,只改变抽样的业务层次。
function copyAndShuffle(source: QuestionItem[]): QuestionItem[] {
const result: QuestionItem[] = [];
for (let index = 0; index < source.length; index++) {
result.push(source[index]);
}
for (let index = result.length - 1; index > 0; index--) {
const target = Math.floor(Math.random() * (index + 1));
const current = result[index];
result[index] = result[target];
result[target] = current;
}
return result;
}
function takeFromBucket(
bucket: QuestionItem[],
count: number
): QuestionItem[] {
const shuffled = copyAndShuffle(bucket);
const selected: QuestionItem[] = [];
const maxCount = Math.min(count, shuffled.length);
for (let index = 0; index < maxCount; index++) {
selected.push(shuffled[index]);
}
return selected;
}
copyAndShuffle() 继续避免改动题库原数组;takeFromBucket() 明确只承诺“在库存范围内最多取 count 道”。它不会自行补位,因为补位需要知道其他分类的剩余库存以及当前考试模式,已经超出单桶函数的职责。
逐桶之后,即使三类候选题数量差异很大,只要库存都高于对应配额,最终分类数量始终是 6、6、8。换言之,随机性仍然用于选择“这一类的哪些题”,而考试蓝图决定“这一类有多少题”。两者并不冲突。
六、配额不足不能默默少题,必须选择明确政策
假设“图标认知”只剩 4 道可用题,而蓝图要求 6 道。常见但危险的处理是直接调用 Math.min(),最后得到 18 道题继续进入考试。页面可能仍显示“第 1/20 题”,直到末尾才暴露计数错位;成绩分母、进度条与答题记录也可能不一致。
更清晰的做法是让出卷返回联合结果,而不是始终假设成功:
interface ExamPaperSuccess {
kind: 'SUCCESS';
questions: QuestionItem[];
inventory: CategoryInventory[];
}
interface ExamPaperFailure {
kind: 'SHORTAGE';
inventory: CategoryInventory[];
message: string;
}
type ExamPaperResult = ExamPaperSuccess | ExamPaperFailure;
function hasShortage(inventory: CategoryInventory[]): boolean {
for (let index = 0; index < inventory.length; index++) {
if (inventory[index].shortage > 0) {
return true;
}
}
return false;
}
kind 让页面必须显式处理成功与不足两条路径,避免把空数组、少题数组当成正常结果。对于正式模拟,推荐 REJECT_EXAM:任何分类不足就不创建试卷,向用户说明题库正在补充,并把库存快照写入日志。因为正式模拟强调结构可比性,随意改变比例会破坏不同成绩之间的解释。
日常练习若更看重“马上开始”,可以使用 REDISTRIBUTE,但规则也要确定。例如先满足所有可满足配额,再把缺口按蓝图分类顺序分配给仍有剩余库存的类别,直到达到 20 题或再无候选。不能简单从全局剩余题中随手补,因为那会重新引入不可解释的结构偏移。
| 使用场景 | 建议政策 | 用户侧表现 | 记录内容 |
|---|---|---|---|
| 正式模拟 | REJECT_EXAM | 不进入答题,提示缺少的分类 | 蓝图、各类需求、各类库存 |
| 日常练习 | REDISTRIBUTE | 允许其他分类补足,并提示本轮结构有调整 | 原配额、实际配额、补位来源 |
| 总库存也不足20 | 返回不足 | 不创建残缺试卷 | 总库存与剩余缺口 |
| 分类名不在蓝图中 | 暂不参与补位或按产品规则处理 | 不应悄悄改变正式模拟 | 未纳入分类及题量 |
业务需要的不是永远成功,而是失败时仍能说清发生了什么。配额不足越早暴露,页面状态、成绩口径和题库维护成本越可控。
七、组装完整出卷器,并输出实际分类报告
下面把校验、分桶、库存判断与逐桶抽取串成一个正式模拟版本。它在任何分类不足时直接返回 SHORTAGE,只有完全满足蓝图才产生试卷。选出的各类题目最后可以再合并打乱,使用户不会连续做完同一分类;这一步只影响呈现顺序,不会改变每类数量。

function buildExamPaper(
source: QuestionItem[],
blueprint: ExamBlueprint
): ExamPaperResult {
const validation = validateBlueprint(blueprint);
if (!validation.valid) {
return {
kind: 'SHORTAGE',
inventory: [],
message: validation.errors.join(';')
};
}
const buckets = groupByCategory(source);
const inventory = buildInventory(buckets, blueprint.quotas);
if (hasShortage(inventory) && blueprint.shortagePolicy === 'REJECT_EXAM') {
return {
kind: 'SHORTAGE',
inventory: inventory,
message: '分类题量不足,无法按当前蓝图创建试卷'
};
}
const selected: QuestionItem[] = [];
for (let index = 0; index < blueprint.quotas.length; index++) {
const quota = blueprint.quotas[index];
const bucket = buckets.get(quota.category) ?? [];
const part = takeFromBucket(bucket, quota.count);
for (let itemIndex = 0; itemIndex < part.length; itemIndex++) {
selected.push(part[itemIndex]);
}
}
if (selected.length !== blueprint.totalCount) {
return {
kind: 'SHORTAGE',
inventory: inventory,
message: `实际选出${selected.length}题,目标为${blueprint.totalCount}题`
};
}
return {
kind: 'SUCCESS',
questions: copyAndShuffle(selected),
inventory: inventory
};
}
这段代码刻意把正式模拟路径写完整,而没有把补位策略塞进一个巨大的函数。若需要日常练习补位,可以在逐桶选择后调用单独的 redistributeShortage(),并返回实际分类报告。正式模拟则保持严格:蓝图要求多少,最终就必须有多少。
页面收到结果后,不要再用 questions.length > 0 作为唯一判断。可以按联合类型处理:
const result = buildExamPaper(candidateQuestions, SUBJECT_TWO_BLUEPRINT);
if (result.kind === 'SHORTAGE') {
this.examErrorMessage = result.message;
this.examInventory = result.inventory;
this.showExamError = true;
return;
}
this.testQuestions = result.questions;
this.currentTestIndex = 0;
this.correctCount = 0;
this.showExamError = false;
这里的边界很重要:出卷器负责保证结构,页面负责呈现结果和初始化答题状态。页面不应在失败后自行再抽一次,也不应修改蓝图数字来“救回”流程,否则同一个按钮在不同设备上可能形成不同业务口径。
八、把蓝图、题库库存、出卷结果分成三层责任
当逻辑只放在 Index.ets 的一个私有方法里,分类配额、题库数据和页面状态容易揉在一起。更便于维护的结构是三层:题库提供带分类的候选题;考试蓝图提供配额合同;出卷器只接收两者并返回成功或不足结果。页面并不知道分桶细节,只根据结果进入答题或显示提示。

建议的职责划分如下:
| 责任单元 | 输入 | 输出 | 不负责的事情 |
|---|---|---|---|
QuestionBank | 科目、题库数据 | 带 category 的候选题 | 决定一张试卷比例 |
ExamBlueprintCatalog | 产品确认的规则 | 版本化蓝图 | 读取页面状态 |
StratifiedExamSelector | 候选题、蓝图 | ExamPaperResult | 显示弹窗、计算成绩 |
Index 页面 | 用户操作、出卷结果 | 答题状态或错误提示 | 私自调整分类配额 |
为了让问题可追踪,建议每次创建试卷时记录一份轻量摘要:
interface CategoryCountItem {
category: string;
count: number;
}
interface ExamPaperSummary {
blueprintId: string;
requestedTotal: number;
selectedTotal: number;
categoryCounts: CategoryCountItem[];
adjusted: boolean;
}
function increaseCategoryCount(counts: CategoryCountItem[],
category: string): void {
for (let index = 0; index < counts.length; index++) {
if (counts[index].category === category) {
counts[index].count++;
return;
}
}
counts.push({ category, count: 1 });
}
function summarizePaper(questions: QuestionItem[],
blueprint: ExamBlueprint, adjusted: boolean): ExamPaperSummary {
const counts: CategoryCountItem[] = [];
for (let index = 0; index < questions.length; index++) {
increaseCategoryCount(counts, questions[index].category);
}
return {
blueprintId: blueprint.blueprintId,
requestedTotal: blueprint.totalCount,
selectedTotal: questions.length,
categoryCounts: counts,
adjusted
};
}
摘要不需要保存完整题目文本,避免让日志变得臃肿;它只回答采用哪份蓝图、最终有多少题、各类数量是多少、是否发生补位。出现用户反馈时,这些字段足以判断是题库库存变化、蓝图配置错误,还是页面没有正确处理失败结果。
九、验证要看分类不变量,而不只是数组长度
仅断言 questions.length === 20 无法证明分层抽样正确。验证应围绕不变量展开:库存充足时,每个分类数量严格等于配额;正式模拟库存不足时,不返回残缺试卷;源数组顺序与内容不被修改;最终结果中的所有题都来自输入候选池。
下面给出一组可迁移到工程测试环境的断言思路。示例使用简化构造函数,重点在验证项,不声称当前项目已经存在这些测试文件。
function countCategory(
questions: QuestionItem[],
category: string
): number {
let count: number = 0;
for (let index = 0; index < questions.length; index++) {
if (questions[index].category === category) {
count++;
}
}
return count;
}
const result = buildExamPaper(fullCandidatePool, SUBJECT_TWO_BLUEPRINT);
if (result.kind !== 'SUCCESS') {
throw new Error('库存充足时应成功创建试卷');
}
if (result.questions.length !== 20) {
throw new Error('试卷总题数应为20');
}
if (countCategory(result.questions, '图标认知') !== 6) {
throw new Error('图标认知题数与蓝图不一致');
}
if (countCategory(result.questions, '上车检查') !== 6) {
throw new Error('上车检查题数与蓝图不一致');
}
if (countCategory(result.questions, '夜间场景') !== 8) {
throw new Error('夜间场景题数与蓝图不一致');
}
这组断言验证的是业务结果,不依赖某一道题恰好出现在第几个位置。建议再准备以下矩阵:
| 场景 | 候选库存 | 需要验证的结果 |
|---|---|---|
| 三类都充足 | 20/20/20 | 成功,分类恰为6/6/8,总数20 |
| 图标认知只有5题 | 5/20/20 | 正式模拟返回不足,不进入答题 |
| 三类刚好等于配额 | 6/6/8 | 成功,每道候选恰好使用一次 |
| 存在第四个未配置分类 | 20/20/20 + 其他10 | 正式模拟仍按蓝图三类选择 |
| 蓝图合计为19 | 任意库存 | 在抽样前返回配置错误 |
| 候选数组为空 | 0/0/0 | 返回各类库存不足,不产生试卷 |
人工联调时还应连续开始多轮考试,逐轮统计分类,而不是凭页面前几道题判断。题序可以变化,具体题目可以变化,但三类数量应始终遵守蓝图。若启用了日常练习补位,则要同时展示或记录 adjusted=true 与实际分类数,避免把补位结果误认为正式结构。
十、验证清单、排障表与当前交付边界
落地这项改造时,可以按下面的顺序核对。每一项都对应一个容易被忽略的责任边界:
- 产品已经确认 20 题中各分类的具体配额,示例 6/6/8 没有被当作正式规则直接照搬。
- 蓝图包含唯一版本标识,配额合计等于总题数,分类名称与题库字段逐字符一致。
- 候选题在基础筛选后再按
category分桶,空分类会被明确拒绝或记录。 - 每个分类只从自己的桶中抽取,最终合并后再改变呈现顺序。
- 正式模拟任一分类不足时返回失败,不用少于 20 道的数组启动考试。
- 日常练习若允许补位,已固定补位顺序并记录原配额、实际配额与缺口来源。
- 页面分别处理
SUCCESS与SHORTAGE,失败后不会偷偷重抽或修改蓝图。 - 验证同时断言总数、各类数量、来源合法性和源数组未被修改。
- 运行日志只记录蓝图与数量摘要,不写入不必要的完整题目正文。
- 多入口共用同一个出卷器,避免练习页与考试页各维护一套分类数字。
遇到异常时,先从“蓝图、库存、抽样、页面”四层定位,不要一看到题数不对就反复调整随机逻辑。
| 现象 | 优先排查 | 常见根因 | 修复方向 |
|---|---|---|---|
| 总题数是20,但某类数量不固定 | 是否仍对全量池直接截取 | 页面没有调用分层出卷器 | 改为按蓝图逐桶选择 |
| 明明有该类题,却提示库存为0 | 分类字符串与分桶键 | 空格、名称不同或字段未传递 | 统一分类常量并在入库时校验 |
| 最后一题后进度仍显示未完成 | 是否用少题数组启动考试 | Math.min() 隐式吞掉缺口 | 正式模拟遇到不足直接返回失败 |
| 补位后某一类异常膨胀 | 补位顺序与剩余库存 | 从全局剩余池无规则取题 | 固定补位政策并输出实际分类报告 |
| 修改蓝图后历史成绩难解释 | 是否记录 blueprintId | 只保存了分数和日期 | 成绩记录关联蓝图版本 |
| 某入口结构正确,另一入口仍偏科 | 两个入口是否共用选择器 | 页面内各自复制抽题逻辑 | 抽出统一的领域出卷函数 |
最后明确本文的证据范围。事实部分来自对当前本地源码的只读核实:Index.ets 第 1212—1229 行确实采用全量复制、洗牌并截取最多 20 题;QuestionBank.ets 第 408—489 行确实定义了科目二的三个分类,每类各 3 个生成场景。本文给出的 ExamBlueprint、分桶、配额校验、联合结果与职责拆分均属于工程建议,尚未合入源码,也没有修改现有应用行为。
本次仅形成文章与配图引用,未构建 HarmonyOS 项目,没有生成新的 HAP,没有进行模拟器验证,也没有进行真机设备验证。因此,文中的代码应在实际合入后再结合项目现有类型、构建配置和页面状态完成编译与运行核对。对于这类出卷问题,最可靠的工程方法是先把分类比例写成蓝图合同,再按分类库存逐层选择,并让不足政策成为明确的业务分支。这样每一张 20 题试卷不仅数量正确,训练结构也可以被解释和复核。
更多推荐



所有评论(0)