灯光模拟HarmonyOS应用实战-61-自动题ID跟着循环位置漂移:用sourceKind、seedId与generatorVersion固定身份

题目 ID 看起来只是一个字符串,真正进入错题记录、搜索结果、考试会话和版本升级后,它却承担着“以后还能否找到同一道题”的责任。对手工题,维护者通常会主动给出稳定 ID;对批量生成题,如果直接拿循环位置编号,数组里插入一颗种子就可能让旧编号指向另一条驾驶规则。

The_kemusan 的当前实现把科二与科四自动题扩展到各 1000 道。静态阅读可以确认:生成函数按全局 index 选择种子并形成 s2_auto_Ns4_auto_N,规范化函数看到 _auto 后,又从题干里搜索种子的 scenario。这条链能在当前数组上工作,却没有把来源、种子身份与生成语义写进数据协议。

本文不改答案位置,也不重新设计历史记录 ID。要解决的问题更窄:让每道生成题都能回答“来自哪里、由哪颗种子产生、按哪一版规则产生、是该种子的第几个变体”,并让旧数据迁移有可核对、可拒绝的路径。

自动题显式身份协议封面

一、当前链路把位置ID、题干反查和_auto标记串在一起

先把指定源码压缩成结构等价的片段。GeneratedQuestionSeed 只有分类、场景、正确项、干扰项与解析;生成循环用 index % seeds.length 选种子,用 index + 1 写 ID。读取时没有种子键,只能判断 ID 是否含 _auto,再用题干是否包含场景文字寻找种子。

interface GeneratedQuestionSeed {
  category: string;
  scenario: string;
  correct: string;
  wrongOne: string;
  wrongTwo: string;
  wrongThree: string;
  explanation: string;
}

function appendGeneratedSubjectQuestions(target: QuestionItem[], subject: string,
  prefix: string, seeds: GeneratedQuestionSeed[], count: number): void {
  for (let index: number = 0; index < count; index++) {
    const seed: GeneratedQuestionSeed = seeds[index % seeds.length];
    target.push({
      id: prefix + '_' + (index + 1).toString(),
      subject,
      type: TYPE_CHOICE,
      title: buildGeneratedTitle(subject, seed, index),
      options: buildGeneratedOptions(seed).options,
      answer: buildGeneratedOptions(seed).answer,
      explanation: seed.explanation,
      category: seed.category
    });
  }
}

这段是便于讨论的等价摘录,不是对源码逐字符复制。现状中 buildGeneratedOptions() 只调用一次,但身份关系与片段一致:全局位置同时影响 ID、种子选择和变体文案。假设九颗科二种子前面插入一颗新种子,s2_auto_10 的字符串仍然存在,它对应的种子和场景却可能改变。

规范化链路又增加了第二个隐式约定:

function findSeedByQuestion(question: QuestionItem,
  seeds: GeneratedQuestionSeed[]): GeneratedQuestionSeed | null {
  for (let index: number = 0; index < seeds.length; index++) {
    if (question.title.indexOf(seeds[index].scenario) >= 0) {
      return seeds[index];
    }
  }
  return null;
}

export function normalizeQuestion(question: QuestionItem): QuestionItem {
  if (question.id.indexOf('_auto') < 0) {
    return question;
  }
  const seed: GeneratedQuestionSeed | null = getGeneratedSeed(question);
  return seed ? rebuildWithSeed(question, seed) : question;
}

题干本来是给读者看的内容,可以改标点、调整措辞、增加场景说明;它不适合兼任外键。_auto 也只是命名片段,不能说明是哪类生成器、使用哪版算法。把这几个信号放进一张表,更容易看出边界:

当前信号当前用途缺少的约束
s2_auto_10标记科二自动题并提供顺序号没有种子键和生成器版本
全局 index选种子、造 ID、造变体种子数组变化会改变含义
题干中的 scenario规范化时反查种子文案变化或场景前缀相似时不稳
question.subject选择科目种子数组不能区分手工来源与多版生成来源
QuestionItem 快照提供渲染和判题字段没有可审计的来源元数据

这不是说当前运行中已经发生了错绑,而是说明协议没有把“不变项”表达出来。后续方案要保留题目快照,同时把身份从位置与文案中抽离。

二、题目身份需要四个正交坐标

对生成题,单个 id 很难同时表达来源、业务种子、算法版本和变体序号。更清晰的做法是定义来源协议:sourceKind 区分手工与生成,seedId 指向业务种子,generatorVersion 锁定生成语义,variantIndex 表示同一颗种子内部的变体位置。

export enum QuestionSourceKind {
  MANUAL = 'manual',
  GENERATED = 'generated'
}

export interface QuestionIdentity {
  sourceKind: QuestionSourceKind;
  subject: string;
  questionId: string;
  seedId?: string;
  generatorVersion?: string;
  variantIndex?: number;
}

export interface QuestionEnvelope {
  item: QuestionItem;
  identity: QuestionIdentity;
}

可选字段并不意味着调用方可以随意遗漏。协议约束应写成:手工题必须有 questionId,不得伪造生成字段;生成题必须同时提供 seedIdgeneratorVersion 和非负 variantIndex。使用单一接口是为了贴合现有 QuestionItem 的低侵入接入方式,真正落地也可以采用手工、生成两个接口的联合类型,但必须先用目标 ArkTS 工具链确认项目写法。

四个坐标各自回答不同问题:

字段回答的问题不变量示例
sourceKind题目来自手工目录还是生成器写入后不因页面入口变化generated
seedId这道题基于哪条驾驶知识种子语义不变时永久稳定s2.icon.low_beam_green
generatorVersion使用哪一版场景与选项生成规则能重新选择对应旧实现s2-gen-v1
variantIndex是该种子的第几个变体不受其他种子增删影响37
questionId外部列表使用哪个紧凑键由上述坐标确定形成规范化业务键

这里要和 RuleId 区分:RuleId 用于回答题库覆盖了哪条规则,QuestionIdentity 用于回答这是哪一个具体题目实例。一颗种子可以关联一条 RuleId,也可以生成很多 variantIndex;覆盖统计不能替代实例定位,实例键也不能证明规则覆盖充分。

三、seedId属于种子目录,不能从scenario推导

seedId 应由维护者在种子进入目录时明确命名。以科二真实种子“看到仪表盘绿色近光灯图标”为例,场景文案以后可以改成更自然的句子,只要知识语义仍是识别绿色近光灯图标,seedId 就不变。若知识语义改成远光灯识别,则应新增种子键,而不是沿用旧键覆盖含义。

export interface IdentifiedQuestionSeed {
  seedId: string;
  subject: string;
  category: string;
  scenario: string;
  correct: string;
  wrongOne: string;
  wrongTwo: string;
  wrongThree: string;
  explanation: string;
}

const SUBJECT_TWO_SEED_CATALOG: IdentifiedQuestionSeed[] = [
  {
    seedId: 's2.icon.low_beam_green',
    subject: SUBJECT_TWO,
    category: '图标认知',
    scenario: '看到仪表盘绿色近光灯图标',
    correct: '确认近光灯处于开启状态',
    wrongOne: '判断远光灯处于开启状态',
    wrongTwo: '判断双闪处于开启状态',
    wrongThree: '认为灯光系统故障',
    explanation: '科二基础认知需要能区分近光、远光、雾灯和双闪图标。'
  }
];

示例沿用源码中已存在的科二内容,只新增 seedIdsubjectseedId 的命名可以采用“科目.分类.语义”的受限字符形式,但它不是给读者看的标题,也不应包含数组序号。目录加载时需要检查空值、重复值、跨科目冲突以及语义变更是否误用了旧键。

function assertSeedCatalog(seeds: IdentifiedQuestionSeed[]): void {
  const seen: Set<string> = new Set<string>();
  for (let index: number = 0; index < seeds.length; index++) {
    const seed: IdentifiedQuestionSeed = seeds[index];
    if (seed.seedId.length === 0 || seed.subject.length === 0) {
      throw new Error('Seed identity is incomplete');
    }
    if (seen.has(seed.seedId)) {
      throw new Error('Duplicate seedId: ' + seed.seedId);
    }
    seen.add(seed.seedId);
  }
}

这一步只保证目录内部唯一,还需要团队约定“何时保留旧键,何时新建键”。改一个标点、补充解释通常保留;改变正确操作、适用科目或规则语义时新建。把判断记录在代码评审中,后续迁移才能解释为什么两版内容属于同一题或不同题。

四、generatorVersion锁定生成语义,不追随应用版本

generatorVersion 不应直接等于应用版本、提交哈希或当前日期。应用可能只改颜色而没有碰题库;反过来,生成器可能在一个应用版本内调整场景组合。只有会改变相同 seedId + variantIndex 输出含义的规则变化,才需要登记新的生成器版本。

变化是否新建生成器版本处理理由
页面间距或按钮颜色变化不影响题目生成
解析文字修正但知识语义不变通常否更新快照内容修订即可
地点、条件的索引映射变化同一变体序号会得到不同场景
选项排列算法变化重放结果与答案键可能改变
新增一颗带新 seedId 的种子旧种子内部变体不应受影响
旧种子正确操作语义改变新种子键或迁移决策不能悄悄复用旧身份

实现上不要只有一个永远被覆盖的常量,应保留能读取历史版本的注册入口。最小写法可以先用显式分支:

export const GENERATOR_V1: string = 's2-gen-v1';
export const GENERATOR_V2: string = 's2-gen-v2';

export interface VariantDescriptor {
  place: string;
  condition: string;
}

function buildVariant(version: string, variantIndex: number): VariantDescriptor {
  if (version === GENERATOR_V1) {
    return buildVariantV1(variantIndex);
  }
  if (version === GENERATOR_V2) {
    return buildVariantV2(variantIndex);
  }
  throw new Error('Unsupported generatorVersion: ' + version);
}

显式抛错比自动回落到最新版更安全。若旧记录声明 s2-gen-v1,运行时却只剩 V2,实现应返回“版本不可用”,保留原题快照并记录待迁移状态,不能用新算法假装还原成功。历史版本保留多久要结合存储周期与包体约束决定,本文只给出协议边界。

五、variantIndex必须是种子内序号

当前全局循环中,index = 9 在九颗科二种子的情况下会回到第一颗种子。可以推导出旧关系:seedIndex = index % seedCount,而某颗种子的内部变体序号是 floor(index / seedCount)。问题在于 seedCount 一变,这组关系也变。

新生成路径应先确定种子,再枚举该种子的 variantIndex。即使目录前面插入新种子,旧种子的第 37 个变体仍然是第 37 个,不再借用全局数组位置。

function buildGeneratedQuestionId(identity: QuestionIdentity): string {
  if (identity.sourceKind !== QuestionSourceKind.GENERATED ||
    !identity.seedId || !identity.generatorVersion ||
    identity.variantIndex === undefined || identity.variantIndex < 0) {
    throw new Error('Generated identity is incomplete');
  }
  return 'generated:' + identity.subject + ':' +
    identity.seedId + ':' + identity.generatorVersion + ':' +
    identity.variantIndex.toString();
}

function createGeneratedIdentity(subject: string, seedId: string,
  generatorVersion: string, variantIndex: number): QuestionIdentity {
  const draft: QuestionIdentity = {
    sourceKind: QuestionSourceKind.GENERATED,
    subject,
    questionId: '',
    seedId,
    generatorVersion,
    variantIndex
  };
  draft.questionId = buildGeneratedQuestionId(draft);
  return draft;
}

这里的 questionId 是坐标的物化键,便于沿用现有字符串索引,但业务逻辑仍应读取身份字段,不应再把字符串拆开猜数据。实际项目还要限制 seedId 与版本字符串的字符集,避免分隔符冲突;若未来需要更紧凑的存储格式,可以变更编码层,不能改变四个坐标的含义。

sourceKind到稳定题目身份的生成流程

六、生成函数应接收身份对象,不从题干寻找种子

当身份成为输入,题干只负责展示。生成函数按 seedId 取得种子,按 generatorVersion 选择算法,按 variantIndex 构造地点与条件,最后把题目快照和身份一起返回。它不读取当前种子在数组中的位置。

function buildGeneratedEnvelope(seed: IdentifiedQuestionSeed,
  generatorVersion: string, variantIndex: number): QuestionEnvelope {
  const identity: QuestionIdentity = createGeneratedIdentity(
    seed.subject, seed.seedId, generatorVersion, variantIndex);
  const variant: VariantDescriptor = buildVariant(
    generatorVersion, variantIndex);
  const optionResult: GeneratedOptionResult = buildGeneratedOptions(seed);
  const item: QuestionItem = {
    id: identity.questionId,
    subject: seed.subject,
    type: TYPE_CHOICE,
    title: seed.scenario + ',场景为' + variant.place + '、' +
      variant.condition + ',应如何处理?',
    options: optionResult.options,
    answer: optionResult.answer,
    explanation: seed.explanation,
    category: seed.category
  };
  return { item, identity };
}

函数仍然产出完整 QuestionItem,因此页面不必立即理解所有生成细节。不同之处是任何需要重放或迁移的调用方都能从 identity 找到原始输入。若只保留 item 丢弃信封,协议就会在第一道存储边界上再次退化,所以题库缓存、考试会话和历史引用需要逐层盘点。

批量生成也应该以种子为外层循环:

function appendSeedVariants(target: QuestionEnvelope[],
  seeds: IdentifiedQuestionSeed[], generatorVersion: string,
  variantsPerSeed: number): void {
  for (let seedIndex: number = 0; seedIndex < seeds.length; seedIndex++) {
    const seed: IdentifiedQuestionSeed = seeds[seedIndex];
    for (let variantIndex: number = 0;
      variantIndex < variantsPerSeed; variantIndex++) {
      target.push(buildGeneratedEnvelope(
        seed, generatorVersion, variantIndex));
    }
  }
}

这段改变了题目排列顺序,因此接入时不能直接替换旧题库后宣称无迁移影响。正确步骤是先让新旧生成结果并存于对照工具中,确认业务键唯一、旧引用映射清楚,再决定页面展示顺序。排序可以独立调整,身份不随排序变化。

七、来源协议要与题目快照一起经过每个边界

题库、搜索、考试抽样、错题写入和恢复不是五套身份规则。它们应传递同一个 QuestionEnvelope,或传递能无损恢复信封的 QuestionRef。页面仍可渲染 item,存储层则必须保存 identity 的全部坐标。

题目快照、来源协议与版本目录的结构关系

对手工题也要显式包装,避免继续用“ID 不含 _auto”作为来源判断:

function wrapManualQuestion(question: QuestionItem): QuestionEnvelope {
  const identity: QuestionIdentity = {
    sourceKind: QuestionSourceKind.MANUAL,
    subject: question.subject,
    questionId: question.id
  };
  return { item: question, identity };
}

function readQuestionAnswer(envelope: QuestionEnvelope): string {
  if (envelope.identity.sourceKind === QuestionSourceKind.MANUAL) {
    return envelope.item.answer;
  }
  return rebuildGeneratedEnvelope(envelope.identity).item.answer;
}

rebuildGeneratedEnvelope() 必须先按 seedIdgeneratorVersion 查目录,再使用 variantIndex,任何一项缺失都返回明确失败。若历史中已经保存完整题目快照,读取失败时可以展示只读快照,但不能把快照冒充为已成功按当前目录重建。

各消费方只承担自己的职责:

消费方使用身份的方式不应再做的事
搜索索引保存 questionId 与来源字段,命中后返回信封从题干猜来源
考试会话冻结本场题目的完整身份用当前列表下标代表题目
错题记录保存稳定 questionId,需要时附带来源坐标只保留可能漂移的旧序号
规范化入口按版本目录重放或校验快照搜索 scenario 子串
数据迁移输出成功、冲突、不可解析状态失败后回落到第一道题

这份职责表刻意不重做复练导航与历史会话序列。它只规定题目实体怎样在那些链路中保持同一身份。

八、旧_auto记录迁移必须冻结旧种子顺序

已有 s2_auto_N 仍然有价值:如果能够确认 V1 发布时的种子顺序与数量,就能把全局序号还原为旧 seedId 和种子内 variantIndex。关键是使用冻结快照,不能读取已经增删过的当前数组。

const LEGACY_V1_S2_SEED_IDS: string[] = [
  's2.icon.low_beam_green',
  's2.icon.hazard_arrows',
  's2.icon.fog_lamp',
  's2.precheck.initial_state',
  's2.precheck.high_beam_on',
  's2.precheck.wait_instruction',
  's2.night.start_visibility',
  's2.night.crosswalk',
  's2.night.rain_snow'
];

export interface LegacyIdentityMigration {
  status: string;
  identity?: QuestionIdentity;
  reason: string;
}

function migrateLegacyS2Id(oldId: string): LegacyIdentityMigration {
  const prefix: string = 's2_auto_';
  if (!oldId.startsWith(prefix)) {
    return { status: 'not-applicable', reason: 'legacy prefix mismatch' };
  }
  const serial: number = Number(oldId.substring(prefix.length));
  if (!Number.isInteger(serial) || serial <= 0) {
    return { status: 'unresolved', reason: 'legacy serial is invalid' };
  }
  const globalIndex: number = serial - 1;
  const seedCount: number = LEGACY_V1_S2_SEED_IDS.length;
  const seedId: string = LEGACY_V1_S2_SEED_IDS[globalIndex % seedCount];
  const variantIndex: number = Math.floor(globalIndex / seedCount);
  return {
    status: 'mapped',
    identity: createGeneratedIdentity(
      SUBJECT_TWO, seedId, GENERATOR_V1, variantIndex),
    reason: 'mapped by frozen V1 seed order'
  };
}

这段迁移依赖一个必须核实的前提:旧 ID 确实由这九颗种子按当前所示顺序产生。如果历史包曾使用其他顺序,必须为那一批数据建立单独快照。旧题干可作为迁移后的交叉核对证据,例如确认它仍含预期场景,但不能替代冻结顺序成为主映射规则。

迁移结果至少区分三类:

状态含义后续动作
mapped旧发布批次与冻结顺序都能确认写入新身份并保留旧 ID 对照
conflict序号映射与已存题干、科目或答案明显冲突隔离记录,交给人工复核
unresolved缺少发布批次、旧顺序或合法序号保留旧快照,不猜新身份

迁移需要幂等:已经写入新身份的记录再次处理时,应返回“无需变化”;同一个旧 ID 每次都得到同一映射。等所有引用读取路径都支持新协议后,才可以停止新增旧格式数据,不能一上来删除旧字段。

九、身份验证要覆盖重排、插入与版本并存

普通样例只证明函数能生成字符串,真正重要的是不变量。把种子数组重排或插入新种子后,已有 seedId + generatorVersion + variantIndex 的 ID 必须完全不变;同一坐标反复生成,题干、选项与答案应一致;版本改变后,身份应明确不同,并且 V1 仍能按 V1 规则读取。

function verifyIdentityInvariants(seed: IdentifiedQuestionSeed): void {
  const first: QuestionEnvelope =
    buildGeneratedEnvelope(seed, GENERATOR_V1, 37);
  const second: QuestionEnvelope =
    buildGeneratedEnvelope(seed, GENERATOR_V1, 37);
  if (first.identity.questionId !== second.identity.questionId) {
    throw new Error('same coordinates produced different ids');
  }
  if (first.item.title !== second.item.title ||
    first.item.answer !== second.item.answer) {
    throw new Error('same coordinates produced different snapshots');
  }

  const nextVersion: QuestionEnvelope =
    buildGeneratedEnvelope(seed, GENERATOR_V2, 37);
  if (first.identity.questionId === nextVersion.identity.questionId) {
    throw new Error('generator versions share one identity');
  }
}

还需要在测试准备阶段复制并重排种子目录,确认生成指定种子的身份不受目录位置影响。迁移用例则要固定 s2_auto_1s2_auto_9s2_auto_10 这样的边界值,分别核对种子切换和内部变体递增。

场景关键断言失败说明
同坐标生成两次ID 与题目快照逐字段一致生成器读取了可变外部状态
种子目录整体倒序指定种子的 ID 不变身份仍依赖数组位置
目录中插入新种子旧种子已有 ID 不变仍在使用全局 index
V1 与 V2 并存相同种子、变体得到不同版本身份版本未进入业务键
读取未知版本返回明确不可用,不回落最新版历史可能被错误重放
旧序号处于轮换边界映射到冻结目录中的预期种子迁移公式或起始序号错误
重复执行迁移第二次不产生新身份迁移不具备幂等性

这些验证关注身份协议,不能替代题目内容审核、规则覆盖统计或真实设备上的页面流程。

十、验证清单、排障顺序与证据边界

落地前可以按下面的清单逐项核对:

  • 每颗已发布种子都有非空、唯一、不会随数组排序变化的 seedId
  • 手工题与生成题都明确写出 sourceKind,不再通过 _auto 猜来源。
  • 每个生成题身份同时包含 seedIdgeneratorVersion 与非负 variantIndex
  • 同一坐标重复生成时,ID、题干、选项、答案与解析保持一致。
  • 种子重排、插入和删除不会改变其他种子的既有身份。
  • 未知生成器版本返回可解释状态,不静默选择最新版。
  • 旧序号迁移使用对应发布批次的冻结种子顺序。
  • 迁移冲突和不可解析记录保留原快照,不自动猜测。
  • 搜索、考试、错题与历史链路都传递同一来源协议。
  • 新旧格式并行期有计数、对照和可回退路径。

出现异常时,优先按身份坐标向上追踪:

现象先看哪里常见原因修复方向
同一旧 ID 打开后题干变化旧批次种子快照使用了当前种子数组按发布批次恢复冻结顺序
改一句场景文案后规范化失败findSeedByQuestion 调用点仍靠题干子串找种子改为显式 seedId
插入种子后大量错题指向别题业务键生成输入使用全局 index改用种子内 variantIndex
历史 V1 被 V2 内容覆盖版本注册入口未按版本选择生成器保留 V1 或展示已存快照
手工 ID 恰含 _auto 被重建来源判断用字符串片段猜来源读取 sourceKind
迁移执行两次产生两个新 ID迁移写入条件没有旧到新对照或幂等保护先查已有映射再写入
seedId 重复种子目录加载命名规则未集中检查启动时拒绝重复目录

当前源码可以确认的事实只有这些:GeneratedQuestionSeed 没有 seedId;自动题按全局循环序号生成 ID;findSeedByQuestion() 用题干场景子串反查种子;normalizeQuestion()_auto 判断生成来源;QuestionItem 没有 sourceKindgeneratorVersion。本文没有证明用户历史中已经发生错题串位。

文中的来源协议、稳定键、版本注册、信封对象和旧 ID 迁移都是建议方案,尚未合入或写入 The_kemusan。本次未构建项目,没有生成新的 HAP,也没有在模拟器或真机设备上验证生成题、错题恢复、版本升级与迁移回读。真正实施后仍需用目标 SDK 编译、历史数据副本、迁移报告和设备操作补齐证据。

Logo

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

更多推荐