灯光模拟HarmonyOS应用实战-61-自动题ID跟着循环位置漂移:用sourceKind、seedId与generatorVersion固定身份
灯光模拟HarmonyOS应用实战-61-自动题ID跟着循环位置漂移:用sourceKind、seedId与generatorVersion固定身份
题目 ID 看起来只是一个字符串,真正进入错题记录、搜索结果、考试会话和版本升级后,它却承担着“以后还能否找到同一道题”的责任。对手工题,维护者通常会主动给出稳定 ID;对批量生成题,如果直接拿循环位置编号,数组里插入一颗种子就可能让旧编号指向另一条驾驶规则。
The_kemusan 的当前实现把科二与科四自动题扩展到各 1000 道。静态阅读可以确认:生成函数按全局 index 选择种子并形成 s2_auto_N 或 s4_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,不得伪造生成字段;生成题必须同时提供 seedId、generatorVersion 和非负 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: '科二基础认知需要能区分近光、远光、雾灯和双闪图标。'
}
];
示例沿用源码中已存在的科二内容,只新增 seedId 与 subject。seedId 的命名可以采用“科目.分类.语义”的受限字符形式,但它不是给读者看的标题,也不应包含数组序号。目录加载时需要检查空值、重复值、跨科目冲突以及语义变更是否误用了旧键。
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 与版本字符串的字符集,避免分隔符冲突;若未来需要更紧凑的存储格式,可以变更编码层,不能改变四个坐标的含义。

六、生成函数应接收身份对象,不从题干寻找种子
当身份成为输入,题干只负责展示。生成函数按 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() 必须先按 seedId 和 generatorVersion 查目录,再使用 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_1、s2_auto_9、s2_auto_10 这样的边界值,分别核对种子切换和内部变体递增。
| 场景 | 关键断言 | 失败说明 |
|---|---|---|
| 同坐标生成两次 | ID 与题目快照逐字段一致 | 生成器读取了可变外部状态 |
| 种子目录整体倒序 | 指定种子的 ID 不变 | 身份仍依赖数组位置 |
| 目录中插入新种子 | 旧种子已有 ID 不变 | 仍在使用全局 index |
| V1 与 V2 并存 | 相同种子、变体得到不同版本身份 | 版本未进入业务键 |
| 读取未知版本 | 返回明确不可用,不回落最新版 | 历史可能被错误重放 |
| 旧序号处于轮换边界 | 映射到冻结目录中的预期种子 | 迁移公式或起始序号错误 |
| 重复执行迁移 | 第二次不产生新身份 | 迁移不具备幂等性 |
这些验证关注身份协议,不能替代题目内容审核、规则覆盖统计或真实设备上的页面流程。
十、验证清单、排障顺序与证据边界
落地前可以按下面的清单逐项核对:
- 每颗已发布种子都有非空、唯一、不会随数组排序变化的
seedId。 - 手工题与生成题都明确写出
sourceKind,不再通过_auto猜来源。 - 每个生成题身份同时包含
seedId、generatorVersion与非负variantIndex。 - 同一坐标重复生成时,ID、题干、选项、答案与解析保持一致。
- 种子重排、插入和删除不会改变其他种子的既有身份。
- 未知生成器版本返回可解释状态,不静默选择最新版。
- 旧序号迁移使用对应发布批次的冻结种子顺序。
- 迁移冲突和不可解析记录保留原快照,不自动猜测。
- 搜索、考试、错题与历史链路都传递同一来源协议。
- 新旧格式并行期有计数、对照和可回退路径。
出现异常时,优先按身份坐标向上追踪:
| 现象 | 先看哪里 | 常见原因 | 修复方向 |
|---|---|---|---|
| 同一旧 ID 打开后题干变化 | 旧批次种子快照 | 使用了当前种子数组 | 按发布批次恢复冻结顺序 |
| 改一句场景文案后规范化失败 | findSeedByQuestion 调用点 | 仍靠题干子串找种子 | 改为显式 seedId |
| 插入种子后大量错题指向别题 | 业务键生成输入 | 使用全局 index | 改用种子内 variantIndex |
| 历史 V1 被 V2 内容覆盖 | 版本注册入口 | 未按版本选择生成器 | 保留 V1 或展示已存快照 |
手工 ID 恰含 _auto 被重建 | 来源判断 | 用字符串片段猜来源 | 读取 sourceKind |
| 迁移执行两次产生两个新 ID | 迁移写入条件 | 没有旧到新对照或幂等保护 | 先查已有映射再写入 |
seedId 重复 | 种子目录加载 | 命名规则未集中检查 | 启动时拒绝重复目录 |
当前源码可以确认的事实只有这些:GeneratedQuestionSeed 没有 seedId;自动题按全局循环序号生成 ID;findSeedByQuestion() 用题干场景子串反查种子;normalizeQuestion() 用 _auto 判断生成来源;QuestionItem 没有 sourceKind 与 generatorVersion。本文没有证明用户历史中已经发生错题串位。
文中的来源协议、稳定键、版本注册、信封对象和旧 ID 迁移都是建议方案,尚未合入或写入 The_kemusan。本次未构建项目,没有生成新的 HAP,也没有在模拟器或真机设备上验证生成题、错题恢复、版本升级与迁移回读。真正实施后仍需用目标 SDK 编译、历史数据副本、迁移报告和设备操作补齐证据。
更多推荐



所有评论(0)