灯光模拟HarmonyOS应用实战-57-科二分类从两个入口传成两套:用PracticeQuery统一练习上下文

科二灯光练习同时存在两个入口:主页模块进入后,页面会默认选择“图标认知”,顶部还能切换“上车检查”和“夜间场景”;题库页则先选择科目,再点击“开始练习”。两个入口最后都进入同一个 startTheoryPractice(),但它们携带分类的方式并不相同。前者把当前分类继续向下传递,后者传入空字符串。只看最终页面,两条路径都能出现题目,很容易忽略它们建立的其实不是同一种练习上下文。

这类问题的难点不在参数数量,而在参数之间存在约束:科目决定允许哪些分类,模式决定页面标题和历史口径,入口又决定“没有指定分类”究竟表示全部分类,还是调用方忘记传值。如果继续让多个字符串在页面方法之间裸传,新增入口时就只能靠开发者记住隐含规则。更稳的做法是把一次练习所需的信息收进 PracticeQuery,让构造、校验、选题和会话保存都围绕同一个不可拆散的快照工作。

PracticeQuery统一练习上下文封面

一、两条入口已经呈现出不同的分类语义

Index.ets 第338—397行只在科二页面展示分类标签。用户点击标签后,代码先修改 subject2Category,再把科目、模式和标签文本传给开始方法。第477—513行的题库开始卡片则根据当前科目取得标题和模式,最后一个参数固定传空字符串。第1116—1129行负责把这些值写入活动状态并选题。

// 科二页内入口
this.subject2Category = label;
this.startTheoryPractice(SUBJECT_TWO, '科二灯光基础', label);

// 题库页入口
this.openQuestionBankTest(
  this.questionBankSubject,
  this.getQuestionBankSubjectTitle(),
  this.getQuestionBankSubjectMode(),
  ''
);

private startTheoryPractice(subject: string, mode: string, category: string): void {
  this.activeSubject = subject;
  this.activeMode = mode;
  this.activeQuestions = this.selectTestQuestions(subject, category);
}

这段控制流说明,分类既是页面选中态,也是选题条件,却没有成为一个有名称的业务概念。空字符串在 selectQuestions() 中会绕过分类比较,因此当前效果接近“该科目全部分类”。这只是从源码条件表达式得到的静态结论;它不能说明产品一定希望题库入口混合全部科二分类,更不能说明用户已经遇到错误题目。

入口当前分类来源向下传递值可读出的语义
科二主页默认进入subject2Category = '图标认知'图标认知单一分类练习
科二顶部标签用户点击的标签标签文本单一分类练习
题库开始卡片没有分类控件''当前实现中相当于全部分类
将来错题复练可能来自历史记录尚无统一入口契约需要明确继承或覆盖规则

二、三个字符串无法表达组合约束

单看函数签名,subjectmodecategory 都是合法字符串,编译器无法阻止下面这些组合:科一科目配“夜间场景”、科二模式配科四分类、空科目配非空分类。调用方还可能把页面标题误传到模式字段,因为二者类型完全相同。

真正需要维护的是组合不变量,而不是单字段非空:

  1. subject 必须来自受支持科目集合。
  2. mode 必须与科目匹配,不能由任意页面文案代替。
  3. 科二允许三个已登记分类;科一和科四是否支持分类筛选要单独定义。
  4. “全部分类”应该是明确枚举,不能借用空字符串。
  5. 一旦会话启动,查询快照不能再随页面标签变化。

如果这些规则散落在 CategoryChip()、题库卡片、openQuestionBankTest() 和选题函数中,任何一处新增分支都可能产生另一种解释。PracticeQuery 的价值就在于给组合一个单独的校验入口。

三、用判别联合写出分类范围

先不要急着把三个字符串换成一个对象。对象仍然可以装入任意字符串,只有类型把可选项和适用范围写清,才能真正收紧入口。

type PracticeSubject = 'subject-one' | 'subject-two' | 'subject-four';
type SubjectTwoCategory = '图标认知' | '上车检查' | '夜间场景';
type CategoryScope =
  | { kind: 'all' }
  | { kind: 'subject-two'; value: SubjectTwoCategory };

type PracticeEntry =
  | 'home-module'
  | 'category-chip'
  | 'question-bank'
  | 'wrong-review';

interface PracticeQuery {
  subject: PracticeSubject;
  mode: string;
  category: CategoryScope;
  entry: PracticeEntry;
}

CategoryScope 把“全部”与“科二具体分类”分成两个分支。后续代码必须先判断 kind 才能读取 value,不再靠 category.length === 0 猜调用意图。entry 不参与选题,却能帮助历史与排障记录说明这次会话从哪里建立;它应记录有限枚举,不能保存页面对象、路由实例或用户输入全文。

这里保留 mode: string 是迁移阶段的折中。如果工程已经有稳定模式常量,可以进一步收窄为联合类型;若模式文本还承担展示职责,应先分出稳定的 modeId 和本地化标题,避免一次改造同时改变过多层。

四、查询工厂集中处理科目、模式与分类

页面不应直接拼装任意 PracticeQuery。建立一个小型工厂,让各入口只能提交自己真实拥有的信息,再由工厂补齐模式并验证分类归属。

interface PracticeQueryResult {
  ok: boolean;
  query?: PracticeQuery;
  reason?: 'UNSUPPORTED_SUBJECT' | 'CATEGORY_NOT_ALLOWED';
}

const SUBJECT_TWO_CATEGORIES: SubjectTwoCategory[] = [
  '图标认知',
  '上车检查',
  '夜间场景'
];

function isSubjectTwoCategory(value: string): value is SubjectTwoCategory {
  return SUBJECT_TWO_CATEGORIES.indexOf(value as SubjectTwoCategory) >= 0;
}

function createSubjectTwoQuery(category: string, entry: PracticeEntry): PracticeQueryResult {
  if (!isSubjectTwoCategory(category)) {
    return { ok: false, reason: 'CATEGORY_NOT_ALLOWED' };
  }
  return {
    ok: true,
    query: {
      subject: 'subject-two',
      mode: '科二灯光基础',
      category: { kind: 'subject-two', value: category },
      entry
    }
  };
}

工厂拥有科二分类白名单,因此页面标签与题库种子使用的分类名称发生漂移时,会在会话开始前返回明确原因。它不擅自把未知分类改成“全部”,因为静默降级会掩盖配置错误。实际接入时还应让标签数据也从同一分类目录生成,避免类型数组和 UI 文案再复制一份。

PracticeQueryResult 使用可选字段是为了展示业务结果形态。正式实现可改成更严格的判别联合,让 ok: true 分支必有 queryok: false 分支必有 reason;选择哪种写法要以工程当前 ArkTS 规则和目标 SDK 编译结果为准。

五、把每个页面入口改造成薄适配器

工厂建立后,页面只负责收集交互值和处理失败,不再决定选题规则。科二分类标签可以传精确分类,题库入口则要由产品语义决定是默认某一类,还是明确选择全部。

private startFromSubjectTwoChip(label: string): void {
  const result = createSubjectTwoQuery(label, 'category-chip');
  if (!result.ok || result.query === undefined) {
    this.answerMessage = '该分类暂不可用';
    return;
  }
  this.beginPractice(result.query);
}

private startSubjectTwoFromBank(): void {
  const query: PracticeQuery = {
    subject: 'subject-two',
    mode: '科二灯光基础',
    category: { kind: 'all' },
    entry: 'question-bank'
  };
  this.beginPractice(query);
}

第二个适配器显式写出 kind: 'all',这不代表产品必须保留当前混合行为,而是让选择可见、可审阅。如果产品希望题库入口沿用上次科二分类,就应该从稳定的偏好设置读取合法枚举,读取失败时给出明确默认值;不能继续把空字符串同时当作“全部”“没有选择”和“读取失败”。

科二分类上下文传递流程

六、题目选择器只消费查询,不读取页面字段

目前 startTheoryPractice() 先写多个响应式字段,再调用 selectTestQuestions(subject, category)。改造后,选择器应只接收已经通过入口校验的查询快照。这样即使用户快速切换标签,正在建立的会话也不会在一半流程里读到新分类。

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

选择器不再知道 subject2CategoryquestionBankSubject 或当前页面。它只比较题目字段与查询字段,因此可以在不创建 ArkUI 页面的情况下验证。还要注意,示例中的 PracticeSubject 值需要与工程真实 SUBJECT_TWO 常量做适配,不能把文章里的字符串直接替换进现有数据后假定兼容。

若返回空数组,调用方必须区分“这个合法分类目前没有题”和“查询不合法”。前者属于内容缺口,后者属于入口错误;两种情况都不应自动回退到另一分类,更不能补入内置第一题。

七、会话一次性提交状态,避免半更新

查询完成并取得题目后,再生成会话启动结果。页面只提交一次完整快照,而不是先改 activeSubject、再改 activeMode、最后等待题目数组出现。

interface PracticeStartSnapshot {
  query: PracticeQuery;
  questions: QuestionItem[];
  firstIndex: number;
  initialMessage: string;
}

type PracticeStartResult =
  | { kind: 'ready'; snapshot: PracticeStartSnapshot }
  | { kind: 'empty'; query: PracticeQuery }
  | { kind: 'rejected'; reason: string };

function preparePractice(query: PracticeQuery, catalog: QuestionItem[]): PracticeStartResult {
  const questions = selectByPracticeQuery(catalog, query);
  if (questions.length === 0) {
    return { kind: 'empty', query };
  }
  return {
    kind: 'ready',
    snapshot: {
      query,
      questions,
      firstIndex: 0,
      initialMessage: '请选择答案'
    }
  };
}

这个结果把“准备”与“提交”分开。preparePractice() 是纯计算,可以核对输入输出;页面拿到 ready 后再一次性更新活动查询、题目、索引、已选答案和提示文案。empty 分支保留原查询,UI 才能告诉用户究竟哪个分类没有内容,而不是只显示一个没有上下文的空页。

页面入口、PracticeQuery、题目选择器与练习会话责任边界

八、历史与重进保存稳定查询快照

会话开始后,应把 PracticeQuery 视为只读事实。历史记录可以保存科目、分类范围和入口,但不应在展示时重新读取当前标签。重进练习时还要重新校验:旧分类可能在新版本中被移除,旧模式名称也可能已经迁移。

interface StoredPracticeContextV1 {
  schemaVersion: 1;
  subject: PracticeSubject;
  categoryKind: 'all' | 'subject-two';
  categoryValue?: SubjectTwoCategory;
  entry: PracticeEntry;
}

function toStoredContext(query: PracticeQuery): StoredPracticeContextV1 {
  return {
    schemaVersion: 1,
    subject: query.subject,
    categoryKind: query.category.kind,
    categoryValue: query.category.kind === 'subject-two'
      ? query.category.value
      : undefined,
    entry: query.entry
  };
}

存储对象只包含稳定枚举,不保存页面标题,也不保存 QuestionItem[] 的整份副本。恢复时根据 schemaVersion 迁移,再交给查询工厂核对。若旧数据缺少分类,迁移规则必须明确选择 all 或某个产品默认值,并记录这是兼容决定,不能假装旧记录原本就携带了分类。

九、用入口矩阵验证上下文不会漂移

这次改造不能只核对“能进入页面”。需要把每个入口、分类切换、空题组和快速操作都放进矩阵,观察最终快照和题目集合是否一致。

编号操作应形成的查询应观察到的结果
P57-01从科二模块首次进入科二、图标认知、主页入口只出现图标认知题
P57-02点击“上车检查”科二、上车检查、标签入口新会话快照完整替换旧快照
P57-03从题库选择科二并开始科二、明确的 all 或产品默认分类行为与产品规则一致,不依赖空串
P57-04传入未知分类工厂拒绝不启动、不回退、不写历史
P57-05合法分类没有题empty页面展示科目与分类,并允许返回
P57-06启动过程中快速切换标签原查询被冻结已开始会话不被新标签污染
P57-07从历史重进旧分类迁移后查询合法时恢复,失效时给出恢复入口
P57-08科一入口误传科二分类组合校验拒绝不出现跨科目题目

除结果数组外,还应核对两个不变量:所有题目的 subject 必须等于查询科目;当分类分支是 subject-two 时,所有题目的 category 必须等于查询分类。若选择器后面还会洗牌或截取,这两个不变量应在最终会话数组上再核对一次。

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

  • 所有练习入口都通过查询工厂或明确适配器建立 PracticeQuery
  • “全部分类”使用显式分支,不再借用空字符串。
  • 科二分类名称来自单一目录,UI 与题库不各存一份。
  • 非法科目与分类组合在选题前被拒绝。
  • 选择器只消费查询和题目目录,不读取页面响应式字段。
  • 会话状态由一个完整快照提交,不产生半更新画面。
  • 空题组保留原查询并显示准确恢复入口。
  • 历史只保存稳定枚举和版本,不保存页面对象或完整题库。
  • 快速切换、返回重进和旧数据迁移都有独立用例。
  • 目标 SDK 下的联合类型、集合写法和 ArkUI 状态提交方式经过文档与编译复核。
症状优先检查常见原因处理方向
同为科二入口却出现不同题组category.kind一个入口传具体分类,另一个仍传空串统一由适配器构造查询
点击未知分类后出现全部题工厂失败分支非法值被降级为 all明确拒绝并保留当前会话
切换标签后旧会话题目变化查询是否冻结选择器读取了页面当前字段会话保存不可变查询快照
历史卡片分类与实际题目不符历史字段来源展示时读取当前标签从已提交查询映射历史
空分类恢复成内置第一题空结果处理仍存在隐式兜底返回 empty 并停止提交
新增第四个科二分类后入口不一致分类目录UI、工厂和题库分别维护用同一目录生成选项并做差异审阅
旧记录无法重进schemaVersion旧数据没有分类范围写清迁移规则并提供安全退出

当前源码可以确认:科二页内分类标签会把具体标签传入 startTheoryPractice();题库开始卡片会把分类传为空字符串;开始方法再用科目与分类选择题目。这些是指定源码行的静态事实,不等于已经在设备上观察到跨分类故障。本文提出的 PracticeQuery、查询工厂、入口适配器、准备结果和存储快照均为建议方案,尚未合入或写入 The_kemusan。本次未构建项目,没有生成新的 HAP,也没有在模拟器或真机上验证科二入口、快速切换、历史重进和空题组行为。真正接入后仍需依据目标 SDK 文档、编译结果和设备操作补齐证据。

Logo

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

更多推荐