灯光模拟HarmonyOS应用实战-73-题库标题回退成科一,查询条件却仍是非法科目:用SubjectResolution统一显示与选题

题库页为了避免标题为空,提供了一个看似友好的回退:在科目标签中找不到当前值时,返回第一项“科一灯光题”。但原始 questionBankSubject 并没有随之修正。于是同一个界面可能同时持有两套事实:标题和模式来自科一,搜索过滤与开始练习仍使用非法科目字符串。

这不是单纯的文案问题。展示回退掩盖了状态损坏,后续选题会得到空数组,页面还可能借用内置第一题作为展示兜底,使错误继续向下传播。更稳的做法是建立 SubjectResolution:一次解析同时产出规范科目、标签配置、解析依据和错误状态,显示、搜索、路由与选题只能消费这一份结果。

SubjectResolution统一科目封面

本文会先还原当前分叉,再给出不静默伪装的解析结果、接入顺序、空题保护与回归矩阵。示例方案均与当前源码事实分开说明。

一、当前回退只返回第一张标签,没有修正原始科目

Index.ets 中,题库科目配置只有科一、科二和科四。getQuestionBankSubjectInfo() 遍历配置;如果找不到 questionBankSubject,最终直接返回 QUESTION_BANK_TABS[0]

private getQuestionBankSubjectInfo(): QuestionBankSubjectTab {
  for (let index = 0;
    index < QUESTION_BANK_TABS.length; index++) {
    if (QUESTION_BANK_TABS[index].subject ===
      this.questionBankSubject) {
      return QUESTION_BANK_TABS[index];
    }
  }
  return QUESTION_BANK_TABS[0];
}

因此 getQuestionBankSubjectTitle()getQuestionBankSubjectSubtitle()getQuestionBankSubjectMode() 都会显示科一配置。可是函数没有给 this.questionBankSubject 赋值,也没有返回“发生过回退”的标志。调用方只能看到一张合法配置,无法知道输入本来非法。

输入状态配置查询结果原始字段当前可见风险
subject1科一配置subject1语义一致
subject2科二配置subject2语义一致
subject4科四配置subject4语义一致
subject3科一配置仍为 subject3标题合法、过滤条件不一致
空串或未知值科一配置仍为原值状态损坏被展示回退隐藏

从当前 UI 的三个标签看,正常点击只会产生三个合法值。非法值可能来自未来恢复、深链、重构或其他入口;本文不能断言现网用户已经遇到它,但函数契约确实允许这种不一致继续存在。

二、搜索和开始练习都继续使用未解析值

标题卡读取的是回退后的配置,搜索循环比较的却是 questionBankSubject。当字段为未知值时,没有任何题目能满足比较。

private refreshQuestionBankResults(): void {
  const result: QuestionItem[] = [];
  for (let index = 0;
    index < BUILTIN_QUESTIONS.length; index++) {
    const rawQuestion = BUILTIN_QUESTIONS[index];
    if (rawQuestion.subject !== this.questionBankSubject) {
      continue;
    }
    // 关键词匹配后追加结果
  }
  this.questionBankDataSource.setQuestions(result);
}

点击“开始练习”时,代码同样把未解析的字段传给 openQuestionBankTest(),只是标题和模式来自回退配置。

this.openQuestionBankTest(
  this.questionBankSubject,
  this.getQuestionBankSubjectTitle(),
  this.getQuestionBankSubjectMode(),
  ''
);

openQuestionBankTest() 会把 currentPage 设置为该字符串,随后按这个字符串选题。build() 对未知页面走最终 else,所以会渲染理论练习页,但 selectQuestions() 又按非法科目过滤,结果仍为空。这条静态控制流说明错误能够跨过页面分支;它不是一次真机操作记录。

非法科目从显示回退到空题会话的风险链路

三、内置第一题兜底会进一步模糊空会话

当前 getCurrentQuestion()activeQuestions 为空时返回 BUILTIN_QUESTIONS[0]。页面因非法科目得到空选题后,仍可能绘制第一道内置题的文本。与此同时,answerTheoryQuestion() 会因为活动数组为空而直接返回。

private getCurrentQuestion(): QuestionItem {
  if (this.activeQuestions.length === 0) {
    return BUILTIN_QUESTIONS[0];
  }
  return this.activeQuestions[this.activeQuestionIndex];
}

private answerTheoryQuestion(answer: string): void {
  if (this.selectedAnswer.length > 0 ||
    this.activeQuestions.length === 0) {
    return;
  }
  // 正常判题
}

这会形成一个“看得到却答不了”的幽灵题风险。第一题具体属于哪个科目取决于当前内置题库构建顺序,不能把它当作可靠的科一回退协议。空会话应该显示明确空态或错误态,而不是借一条真实题目装饰页面。

需要分别处理两个问题:科目解析失败时不要开始会话;合法科目暂时没有题时,也不要从其他科目借题。两者都返回空,但修复入口和用户提示不同。

四、SubjectResolution同时保留原始值与规范结果

解析器不应只返回一个标签对象。它要说明输入是否受支持、最终规范值是什么、依据来自哪里,供日志和页面选择正确分支。

export enum SubjectResolutionKind {
  EXACT = 'exact',
  RECOVERED = 'recovered',
  REJECTED = 'rejected'
}

export interface SubjectResolution {
  kind: SubjectResolutionKind;
  rawSubject: string;
  canonicalSubject: string;
  tab: QuestionBankSubjectTab | undefined;
  reasonCode: string;
}

正常标签点击返回 EXACT。如果产品确实允许旧值迁移,可以在受审阅的映射表中返回 RECOVERED,例如旧协议 subject-one 映射到 subject1。其他未知值返回 REJECTED,不伪装成科一。

rawSubject 只用于诊断或迁移判断,不应继续参与题目过滤。若它可能来自外部输入,日志只记录有限原因码和经过长度限制的值,不记录完整路由载荷。

五、解析器以标签目录作为唯一支持列表

支持科目不要再维护一套平行 if。直接在题库标签目录中查找,并把旧值迁移放在显式表里。

interface LegacySubjectAlias {
  legacy: string;
  canonical: string;
}

const SUBJECT_ALIASES: LegacySubjectAlias[] = [
  { legacy: 'subject-one', canonical: SUBJECT_ONE }
];

function resolveQuestionBankSubject(
  rawSubject: string,
  tabs: QuestionBankSubjectTab[]
): SubjectResolution {
  const exact = findTab(rawSubject, tabs);
  if (exact !== undefined) {
    return {
      kind: SubjectResolutionKind.EXACT,
      rawSubject,
      canonicalSubject: exact.subject,
      tab: exact,
      reasonCode: 'EXACT_TAB'
    };
  }

  const alias = findAlias(rawSubject, SUBJECT_ALIASES);
  const recovered = alias === undefined ? undefined :
    findTab(alias.canonical, tabs);
  if (recovered !== undefined) {
    return {
      kind: SubjectResolutionKind.RECOVERED,
      rawSubject,
      canonicalSubject: recovered.subject,
      tab: recovered,
      reasonCode: 'REVIEWED_ALIAS'
    };
  }

  return {
    kind: SubjectResolutionKind.REJECTED,
    rawSubject,
    canonicalSubject: '',
    tab: undefined,
    reasonCode: 'UNSUPPORTED_SUBJECT'
  };
}

findTab()findAlias() 是很薄的查找函数,接入时应按项目 ArkTS 约束实现。关键是未知值不再返回第一项。若业务希望应用首次启动默认科一,应在“初始化题库状态”这一明确场景设置 SUBJECT_ONE,不能让任意解析失败都共享这个默认。

六、一次解析要被显示、搜索和启动共同消费

SubjectResolution统一驱动显示搜索与选题结构

在一次用户事件里重复调用四个 getter,仍可能因中途状态变化读到不同结果。可以先取得解析快照,再交给卡片、搜索和启动函数。

private prepareQuestionBankContext():
  SubjectResolution {
  return resolveQuestionBankSubject(
    this.questionBankSubject,
    QUESTION_BANK_TABS
  );
}

private startResolvedQuestionBank(): void {
  const resolution = this.prepareQuestionBankContext();
  if (resolution.kind === SubjectResolutionKind.REJECTED ||
    resolution.tab === undefined) {
    this.showQuestionBankSubjectError(
      resolution.reasonCode
    );
    return;
  }

  this.openQuestionBankTest(
    resolution.canonicalSubject,
    resolution.tab.title,
    resolution.tab.mode,
    ''
  );
}

搜索函数也要接收 resolution.canonicalSubject,不能继续读取页面原始字段。若解析结果是 RECOVERED,可在状态提交点把页面字段更新为规范值;提交应发生一次,避免 getter 在渲染期间偷偷改 @State

卡片渲染遇到 REJECTED 时显示“科目状态不可用,请重新选择”,并提供三个合法标签。此时不显示科一标题,也不启用开始按钮。错误变得可见,恢复路径也明确。

七、规范科目要与页面路由分开建模

当前理论页面 ID 恰好与 SUBJECT_ONESUBJECT_TWOSUBJECT_FOUR 字符串相同,这使 currentPage = subject 看起来方便,却把“内容分类”和“页面身份”绑在一起。若以后科目编码升级,路由也会被迫变化。

type TheoryPageId =
  | 'subject1'
  | 'subject2'
  | 'subject4';

interface TheoryDestination {
  pageId: TheoryPageId;
  subject: string;
  title: string;
  mode: string;
}

function toTheoryDestination(
  resolution: SubjectResolution
): TheoryDestination | undefined {
  if (resolution.tab === undefined) return undefined;
  return {
    pageId: resolution.tab.subject as TheoryPageId,
    subject: resolution.canonicalSubject,
    title: resolution.tab.title,
    mode: resolution.tab.mode
  };
}

示例暂时保持当前页面 ID,目的是显式标出转换边界。正式实现可进一步让 pageId 使用独立常量。只有 toTheoryDestination() 成功后,页面才提交 currentPage、标题和会话查询,避免先跳页后发现选题失败。

八、启动结果要在提交页面状态前确认非空

合法科目也可能因内容配置、分类条件或数据加载问题得到空题组。可以先准备会话,再一次性提交。

type TheoryPreparation =
  | { kind: 'ready'; subject: string;
      questions: QuestionItem[] }
  | { kind: 'empty'; subject: string }
  | { kind: 'rejected'; reasonCode: string };

function prepareTheory(
  resolution: SubjectResolution,
  allQuestions: QuestionItem[]
): TheoryPreparation {
  if (resolution.tab === undefined) {
    return {
      kind: 'rejected',
      reasonCode: resolution.reasonCode
    };
  }
  const questions = selectBySubject(
    allQuestions,
    resolution.canonicalSubject
  );
  if (questions.length === 0) {
    return {
      kind: 'empty',
      subject: resolution.canonicalSubject
    };
  }
  return {
    kind: 'ready',
    subject: resolution.canonicalSubject,
    questions
  };
}

ready 才允许进入理论页;empty 显示内容空态;rejected 要求重新选择科目。这样 getCurrentQuestion() 不再需要借用 BUILTIN_QUESTIONS[0] 作为业务兜底,页面也不会把“非法科目”和“合法但没有题”混成同一种空数组。

九、用解析矩阵锁定所有输入分支

解析器适合做纯函数测试。至少覆盖精确值、经过审阅的旧别名、空串、科三值、大小写变化和前后空格。是否允许 trim() 或大小写归一要写入协议,不能临时宽松处理。

const cases = [
  { raw: 'subject1', kind: 'exact', canonical: 'subject1' },
  { raw: 'subject2', kind: 'exact', canonical: 'subject2' },
  { raw: 'subject4', kind: 'exact', canonical: 'subject4' },
  { raw: 'subject-one', kind: 'recovered', canonical: 'subject1' },
  { raw: 'subject3', kind: 'rejected', canonical: '' },
  { raw: '', kind: 'rejected', canonical: '' }
];

cases.forEach((item) => {
  const result = resolveQuestionBankSubject(
    item.raw,
    QUESTION_BANK_TABS
  );
  expect(result.kind).assertEqual(item.kind);
  expect(result.canonicalSubject)
    .assertEqual(item.canonical);
});

集成层再断言同一次解析结果同时驱动卡片标题、搜索科目和会话科目。只测解析器会漏掉接入处继续使用 questionBankSubject 的回归。

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

现象优先核对常见原因修复方向
标题是科一,搜索始终为空原始科目与解析结果getter 回退但字段未修正统一消费 canonicalSubject
页面显示第一题却不能作答activeQuestions 长度空数组仍借内置第一题渲染空会话显示专用空态
旧版本科目全部被拒绝别名迁移表忘记登记经过确认的旧值加版本化别名,不做任意猜测
点击开始后标题与历史模式不一致是否重复读取 getter中途状态变化或多套映射事件开始时冻结一次解析快照
科三误进理论页页面 ID 来源把任意 subject 直接赋给 currentPage通过目的地转换后再跳页
错误自动变成科一且没有提示初始化与恢复混用任意失败都返回第一项只在明确初始化场景默认科一
  • 题库支持科目只来自一个标签目录。
  • 未知科目返回 REJECTED,不伪装为第一项。
  • 旧值只有进入受审阅别名表才能恢复。
  • 标题、摘要、模式、搜索和选题消费同一解析快照。
  • 原始科目不再直接参与过滤和路由。
  • 空题组显示明确空态,不渲染其他科目的第一题。
  • 页面状态只在解析和会话准备成功后一次提交。
  • 错误态提供重新选择科目的恢复路径。
  • 解析原因码进入最小诊断记录,不保存完整外部载荷。
  • 合法三科、非法输入和旧别名都有集成回归。

当前源码能够确认:题库配置包含科一、科二、科四;查找失败时 getQuestionBankSubjectInfo() 返回第一项;搜索和开始按钮仍使用原始 questionBankSubject;理论会话为空时 getCurrentQuestion() 返回内置第一题。SubjectResolution、别名表、目的地转换和准备结果都是建议设计,尚未写入项目。

本文没有执行构建,没有生成 HAP,也没有在模拟器或真机制造非法科目状态。完成改造后还需用项目实际恢复入口、题库页面和历史写入做运行回归,不能把控制流分析扩大成已经修复的产品结论。

Logo

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

更多推荐