灯光模拟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;
}

这份快照是题量不足政策的事实依据。日志中记录 availablerequired,比只打印“抽题失败”更容易定位是哪一类库存不足,也能帮助题库维护人员优先补齐真正影响出卷的分类。

五、逐桶抽取才是配额约束生效的地方

完成分桶后,抽样步骤应围绕蓝图逐项执行:取出指定分类的候选桶,在桶内打乱,然后拿到该分类要求的数量。这里可以沿用当前工程已经使用的数组复制与交换方式,但它的作用范围从“整份题库”收窄为“一个分类桶”。本文不改变随机机制,只改变抽样的业务层次。

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 道的数组启动考试。
  • 日常练习若允许补位,已固定补位顺序并记录原配额、实际配额与缺口来源。
  • 页面分别处理 SUCCESSSHORTAGE,失败后不会偷偷重抽或修改蓝图。
  • 验证同时断言总数、各类数量、来源合法性和源数组未被修改。
  • 运行日志只记录蓝图与数量摘要,不写入不必要的完整题目正文。
  • 多入口共用同一个出卷器,避免练习页与考试页各维护一套分类数字。

遇到异常时,先从“蓝图、库存、抽样、页面”四层定位,不要一看到题数不对就反复调整随机逻辑。

现象优先排查常见根因修复方向
总题数是20,但某类数量不固定是否仍对全量池直接截取页面没有调用分层出卷器改为按蓝图逐桶选择
明明有该类题,却提示库存为0分类字符串与分桶键空格、名称不同或字段未传递统一分类常量并在入库时校验
最后一题后进度仍显示未完成是否用少题数组启动考试Math.min() 隐式吞掉缺口正式模拟遇到不足直接返回失败
补位后某一类异常膨胀补位顺序与剩余库存从全局剩余池无规则取题固定补位政策并输出实际分类报告
修改蓝图后历史成绩难解释是否记录 blueprintId只保存了分数和日期成绩记录关联蓝图版本
某入口结构正确,另一入口仍偏科两个入口是否共用选择器页面内各自复制抽题逻辑抽出统一的领域出卷函数

最后明确本文的证据范围。事实部分来自对当前本地源码的只读核实:Index.ets 第 1212—1229 行确实采用全量复制、洗牌并截取最多 20 题;QuestionBank.ets 第 408—489 行确实定义了科目二的三个分类,每类各 3 个生成场景。本文给出的 ExamBlueprint、分桶、配额校验、联合结果与职责拆分均属于工程建议,尚未合入源码,也没有修改现有应用行为。

本次仅形成文章与配图引用,未构建 HarmonyOS 项目,没有生成新的 HAP,没有进行模拟器验证,也没有进行真机设备验证。因此,文中的代码应在实际合入后再结合项目现有类型、构建配置和页面状态完成编译与运行核对。对于这类出卷问题,最可靠的工程方法是先把分类比例写成蓝图合同,再按分类库存逐层选择,并让不足政策成为明确的业务分支。这样每一张 20 题试卷不仅数量正确,训练结构也可以被解释和复核。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐