灯光模拟HarmonyOS应用实战-74-科一点击“重新开始”为什么可能变空:别把subject2Category传给所有科目

科一第一次从主页进入时,代码明确把分类参数传成空字符串,表示按科目取题;可是理论页底部的“重新开始”不区分科目,统一把 subject2Category 传回选题函数。这个字段默认值是“图标认知”,本来只属于科二。于是科一首轮可以正常取题,重新开始时却可能带着科二分类再次筛选,得到空数组。

真正的问题不是按钮少写了一个 if,而是重开动作没有保存“本次会话如何建立”的上下文。页面从另一个功能域读取一个仍然有值的状态字段,碰巧把它当作所有理论科目的分类。修复应让重开复用不可变的 PracticeQuerySnapshot,同时用类型保证只有科二能携带科二分类。

理论练习重开上下文封面

本文围绕当前控制流、状态串味、强类型查询、原子重开、空题处理和回归矩阵展开。所有实现代码都是建议方案;当前源码没有被本文修改。

一、科一首次进入和重新开始使用了不同分类

当前 openModule() 针对科一调用 startTheoryPractice() 时,第三个参数是空字符串;科二分支才会设置并传入 subject2Category

if (id === PAGE_SUBJECT_ONE) {
  this.startTheoryPractice(
    SUBJECT_ONE,
    '科一灯光题',
    ''
  );
} else if (id === PAGE_SUBJECT_TWO) {
  this.subject2Category = '图标认知';
  this.startTheoryPractice(
    SUBJECT_TWO,
    '科二灯光基础',
    this.subject2Category
  );
}

但共用的理论页脚没有读取首次调用的分类,而是直接读取页面字段:

this.RowActionButton('重新开始', () => {
  this.startTheoryPractice(
    this.activeSubject,
    this.activeMode,
    this.subject2Category
  );
});

subject2Category 的初始值是“图标认知”。所以即使用户从未进入科二,它也不是空值。继续核对当前 SubjectOneLightBank.ets,42 道手工科一题使用的分类包括“使用规则”“近光灯”“远光灯”“会车”“远近交替”“恶劣天气”“危险报警闪光灯”“转向灯”和“超车灯光”,没有“图标认知”。buildBuiltinQuestions() 当前只追加这组手工科一题,没有启用科一自动生成目录。因此,在这份源码快照中,默认状态下的“科一 + 图标认知”会得到空题组。这里仍只是在说明代码路径,不能替代设备上的一次点击证据。

二、选题条件会同时比较科目与分类

selectQuestions() 对每道题先比较科目,然后在分类非空时继续比较 question.category。因此“科一 + 图标认知”不是忽略多余字段,而是一个真实的联合过滤条件。

private selectQuestions(
  subject: string,
  category: string
): QuestionItem[] {
  const source = this.getAllQuestions();
  const result: QuestionItem[] = [];
  for (let index = 0; index < source.length; index++) {
    const question = source[index];
    if (question.subject === subject &&
      (category.length === 0 ||
        question.category === category)) {
      result.push(question);
    }
  }
  return result;
}

首轮科一的查询是 subject1 + '',分类条件被绕过;重开查询可能变成 subject1 + 图标认知,只有同时满足两个字段的题才会保留。两次点击看似执行同一个“开始方法”,输入契约已经变化。

路径科目分类当前过滤语义
主页进入科一subject1''科一全部分类
科一页重新开始activeSubjectsubject1图标认知 或遗留科二值科一中的指定科二分类文本
主页进入科二subject2默认“图标认知”科二单分类
科二切换标签后重开subject2当前标签科二当前分类
主页进入科四后重开subject4页面遗留字段同样存在跨科目串味风险

首次进入与重开参数分叉流程

三、页面有值不代表这个值属于当前会话

subject2Category 是科二标签的页面状态。它决定哪个标签高亮,也在点击标签时建立科二练习。这个字段可以长期保留,是合理的 UI 行为;错误发生在通用重开按钮把它解释为当前会话分类。

建议把状态分成三组:

  1. subject2Category:科二页面下次显示哪个标签,只属于 UI 偏好。
  2. activePracticeQuery:当前理论会话真正使用的科目、模式和分类。
  3. activeQuestions:由查询生成的题目快照。

这三组状态的生命周期不同。重开应该复制第二组并重新抽题,不能临时从第一组重建第二组。否则用户在不同模块之间切换、恢复页面或新增题库入口后,仍会遇到类似串味。

四、用判别联合阻止科一携带科二分类

如果查询仍然是三个 string,任何调用方都能构造“科一 + 图标认知”。类型层可以把合法组合写成判别联合。

type TheoryPracticeQuery =
  | {
      kind: 'subject-one';
      subject: 'subject1';
      mode: '科一灯光题';
      scope: 'all';
    }
  | {
      kind: 'subject-two';
      subject: 'subject2';
      mode: '科二灯光基础';
      scope: SubjectTwoScope;
    }
  | {
      kind: 'subject-four';
      subject: 'subject4';
      mode: '科四灯光题';
      scope: 'all';
    };

type SubjectTwoScope =
  | '图标认知'
  | '上车检查'
  | '夜间场景';

科一和科四分支没有 subject2Category 字段,调用方无法给它们附加科二分类。正式接入时可以继续使用工程现有 SUBJECT_ONE 常量,通过工厂返回对应联合分支;示例中的字面量用于说明类型关系,不代表必须改动现有常量值。

五、首次进入时保存会话快照

页面入口不再把三个参数直接写入多个状态,而是先构造合法查询、准备题组,成功后一次提交。

interface PracticeQuerySnapshot {
  query: TheoryPracticeQuery;
  startedFrom: string;
  generation: number;
}

@State activePracticeQuery:
  TheoryPracticeQuery | undefined = undefined;

private openSubjectOnePractice(): void {
  const query: TheoryPracticeQuery = {
    kind: 'subject-one',
    subject: 'subject1',
    mode: '科一灯光题',
    scope: 'all'
  };
  this.prepareAndCommitPractice(query, 'home-module');
}

startedFrom 可以帮助排查入口差异,但重开时不应假装再次来自主页。generation 表示同一查询第几轮重抽,适合隔离延迟回调;它不代替题目随机种子。若项目需要可复现顺序,还应保存独立会话种子。

页面只在准备成功后更新 activePracticeQueryactiveQuestions、题号和提示。先写科目、后发现题组为空,会让标题和内容处于半更新状态。

六、选择器按联合分支展开分类规则

UI偏好、查询快照与选题器职责

选择器接收查询对象后,先比较科目。只有 subject-two 分支才继续比较分类。

function selectTheoryQuestions(
  source: QuestionItem[],
  query: TheoryPracticeQuery
): QuestionItem[] {
  const result: QuestionItem[] = [];
  for (let index = 0; index < source.length; index++) {
    const question = source[index];
    if (question.subject !== query.subject) {
      continue;
    }
    if (query.kind === 'subject-two' &&
      question.category !== query.scope) {
      continue;
    }
    result.push(question);
  }
  return result;
}

这种写法把“分类仅适用于科二”放在一个可测试的分支。将来若科一也需要章节筛选,应为科一定义自己的 SubjectOneScope,不能复用 SubjectTwoScope,即使两个分类文案偶然相同。

选择器不读取 this.subject2Category,因此 UI 标签在选择完成后发生变化,也不会悄悄改写当前题组。新的标签点击应创建下一份查询快照,而不是修改正在使用的对象。

七、重新开始只复制当前查询,不重新拼参数

重开时首先要求存在活动查询,然后以同一查询准备新的题序。按钮不再知道科目或分类规则。

private restartTheoryPractice(): void {
  const query = this.activePracticeQuery;
  if (query === undefined) {
    this.showPracticeError('MISSING_ACTIVE_QUERY');
    return;
  }
  this.prepareAndCommitPractice(
    query,
    'restart-button'
  );
}

// 页脚只绑定动作
this.RowActionButton('重新开始', () => {
  this.restartTheoryPractice();
});

“同一查询”不等于“同一题序”。如果当前产品语义是重新抽取 20 题,准备函数可以在相同查询范围内重新洗牌;如果语义是回到本轮第一题,就应复用原 activeQuestions 并重置游标。两个动作最好分别命名为“重新抽题”和“回到第一题”,避免一个按钮同时承担两种解释。

题库搜索进入的练习还可能需要保存搜索范围。重开究竟复用搜索命中快照还是回到全科,也应由查询种类明确表达,不能从 questionBankKeyword 或其他页面字段临时推导。

八、空题结果必须阻止幽灵题进入页面

当前 getCurrentQuestion() 在空数组时返回 BUILTIN_QUESTIONS[0]。如果重开因错误分类得到空题组,页面可能展示与本次查询无关的第一题。建议让准备函数先返回结果类型。

type PracticePreparation =
  | { kind: 'ready';
      query: TheoryPracticeQuery;
      questions: QuestionItem[] }
  | { kind: 'empty';
      query: TheoryPracticeQuery }
  | { kind: 'invalid';
      reasonCode: string };

function preparePractice(
  source: QuestionItem[],
  query: TheoryPracticeQuery
): PracticePreparation {
  const questions = selectTheoryQuestions(source, query);
  if (questions.length === 0) {
    return { kind: 'empty', query };
  }
  return { kind: 'ready', query, questions };
}

ready 才提交练习页;empty 保留原会话并显示“当前范围没有可用题目”;invalid 显示配置错误。不能在 empty 时自动删掉分类或切到科一,因为那会把内容缺口变成另一场练习。

若页面渲染必须获得一个题目对象,可以把题目卡放在 activeQuestions.length > 0 分支,而不是让 getter 返回真实题库中的占位题。

九、回归矩阵要覆盖跨模块遗留状态

最有价值的用例不是只打开一次科一,而是先制造可能污染通用按钮的页面状态,再重开当前会话。

describe('理论练习重新开始', () => {
  it('科一重开仍使用 all 范围', () => {
    const query = createSubjectOneQuery();
    const restarted = restartFromSnapshot(query);
    expect(restarted.query.kind)
      .assertEqual('subject-one');
    expect(restarted.query.scope)
      .assertEqual('all');
  });

  it('科二切到夜间场景后重开保留该分类', () => {
    const query = createSubjectTwoQuery('夜间场景');
    const restarted = restartFromSnapshot(query);
    expect(restarted.query.scope)
      .assertEqual('夜间场景');
  });
});

页面层还要覆盖这些路径:科二切换到“夜间场景”后返回主页,再进入科一并重开;恢复进程时 subject2Category 有旧值但活动查询是科四;从题库进入科一后重开;空题返回时原题组是否保持;连续快速点击重开是否只有最后一轮提交。

对于随机抽题,断言每道题都属于查询范围,不要断言固定顺序。若引入会话种子,再单独验证同种子可复放。

十、排障表、验证清单与事实边界

现象优先核对常见原因处理方向
科一首次正常,重开为空重开传入的分类共用 subject2Category从活动查询快照重开
科四重开出现同类问题页面遗留字段修复只特判科一用联合类型覆盖所有理论科目
科二重开丢失当前标签快照创建时机只保存科目,没保存分类标签点击时建立完整查询
空数组仍显示一题当前题 getter借用内置第一题兜底用空态分支阻止题卡渲染
重开后标题先变、题组稍后失败状态提交顺序多字段分步写入准备成功后原子提交
快速连点后旧题组覆盖新题组会话代次异步结果没有代次隔离只接受当前 generation
“重新开始”行为理解不一致按钮语义重抽与回第一题混用拆成明确动作和结果
  • 科一和科四查询类型没有科二分类字段。
  • 科二分类来自受限枚举,不接受任意字符串。
  • 首次进入成功后保存完整活动查询。
  • 重开按钮只调用 restartTheoryPractice()
  • 选题器只消费查询,不读取页面标签状态。
  • 科二 UI 偏好与活动会话快照分开保存。
  • 空题时不借用其他科目的内置第一题。
  • 重抽与回到第一题的产品语义已经明确。
  • 跨模块遗留状态、恢复和快速连点均有回归用例。
  • 页面标题、题组、游标和提示只在准备成功后一起更新。

当前源码能够确认:subject2Category 默认是“图标认知”;科一首次进入传空分类;通用“重新开始”传入 subject2Category;选题器在分类非空时进行精确比较;当前科一手工题没有“图标认知”分类;空题组 getter 会返回内置第一题。因此当前源码默认路径会在科一重开时形成空活动题组,并可能由页面继续展示内置第一题。实际设备是否呈现为“空白”、幽灵题或其他视觉结果,仍取决于页面渲染和运行状态,本文没有把静态路径写成已操作结论。

TheoryPracticeQueryPracticeQuerySnapshot、准备结果和会话代次属于建议方案,尚未写入 The_kemusan。本文没有执行编译,没有生成 HAP,也没有在模拟器或真机验证按钮行为;实现完成后仍需结合真实题库和页面生命周期做运行回归。

Logo

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

更多推荐