灯光模拟HarmonyOS应用实战-92-科二做到一半切分类为何会立刻换一套题:用PracticeTransitionReceipt处理放弃与重建

科二灯光基础页顶部有“图标认知”“上车检查”“夜间场景”三个分类。用户做到了第十二题,想看一眼另一个分类,点下标签后会发生什么?The_kemusan 当前不会先询问,也不会保存本场题序:点击回调立即改写分类,重新随机选题,把索引归零,并清空当前选择。即使用户再次点击已经选中的分类,也会执行同一条重建链。

科二分类切换与练习会话转场封面

这里要区分“历史记录”和“进行中会话”。已经作答的单题会通过 addSimpleRecord() 发起保存,切分类并不会主动清空历史;但本场随机题组、当前位置、当前题是否正在复盘以及尚未作答的页面现场都只在内存字段中。本文处理的是分类意图怎样安全地关闭旧会话并创建新快照,不把它描述成所有已答数据必然丢失。

本次仅审阅当前源码,没有在设备上进行“答题中切分类”的手工操作。文中的 PracticeTransitionReceipt、转场协调器、确认界面和测试代码都是建议方案,尚未接入 The_kemusan

系列前文已经说明过三类题如何筛选、不同入口怎样统一查询条件,以及随机题序如何重放;本文不再设计分类题池或随机算法,而是聚焦一个更窄的工程问题:旧会话已有进度时,分类点击能否在用户确认前保持无副作用,并在确认后只提交一次新会话

一、三个分类标签直接绑定了重建练习的副作用

Index.ets:370-397 构建科二标签。每个 CategoryChip 的点击回调没有检查标签是否已经选中,也没有判断当前练习是否已有进度:

@Builder
SubjectTwoTabs() {
  Row({ space: 8 }) {
    this.CategoryChip('图标认知')
    this.CategoryChip('上车检查')
    this.CategoryChip('夜间场景')
  }
}

@Builder
CategoryChip(label: string) {
  Text(label)
    .onClick(() => {
      this.subject2Category = label;
      this.startTheoryPractice(
        SUBJECT_TWO, '科二灯光基础', label);
    })
}

标签既是显示选择器,又直接承担“关闭当前练习并创建另一场”的命令。当前没有 if (this.subject2Category === label) 的幂等守卫,因此重复点击当前高亮标签同样调用 startTheoryPractice()

二、startTheoryPractice会一次性替换题组和当前进度

Index.ets:1116-1124 的函数名像“开始练习”,而分类标签也直接调用它。该函数同步覆盖六项页面状态:

private startTheoryPractice(subject: string,
  mode: string, category: string): void {
  this.activeSubject = subject;
  this.activeMode = mode;
  this.activeQuestions =
    this.selectTestQuestions(subject, category);
  this.activeQuestionIndex = 0;
  this.selectedAnswer = '';
  this.answerMessage = '请选择答案';
  this.answerCorrect = true;
}

这段控制流说明,分类点击后不是在原题组上做筛选,也不是暂存旧位置后预览新列表,而是立刻生成新 activeQuestions,把当前题号恢复为第一题,并清空答题反馈。赋值完成后,旧数组没有另一个会话对象持有,页面无法再恢复到原来的第十二题与原顺序。

如果当前题已经作答,它对应的单题记录可能已发起保存;如果当前题尚未作答,代码没有为“被分类切换跳过”生成记录。两种情况都不能简单称为“答案丢了”,更准确的说法是“进行中练习上下文被替换,且没有转场回执”。

三、重新调用选题函数意味着新样本和新顺序

selectTestQuestions()Index.ets:1212-1229 先复制候选题,再直接用 Math.random() 执行 Fisher–Yates 形态的打乱,最后最多取 20 题:

private selectTestQuestions(subject: string,
  category: string): QuestionItem[] {
  const source = this.selectQuestions(subject, category);
  const shuffled: QuestionItem[] = [];
  for (let index = 0; index < source.length; index++) {
    shuffled.push(source[index]);
  }
  for (let index = shuffled.length - 1; index > 0; index--) {
    const target = Math.floor(Math.random() * (index + 1));
    const current = shuffled[index];
    shuffled[index] = shuffled[target];
    shuffled[target] = current;
  }
  const result: QuestionItem[] = [];
  const maxCount = Math.min(THEORY_TEST_COUNT, shuffled.length);
  for (let index = 0; index < maxCount; index++) {
    result.push(shuffled[index]);
  }
  return result;
}

当前题库生成常量为每个自动生成科目 1000 题,科二三个分类的候选量都大于 20。因此重新调用不只是把索引设为零,还可能取得另一组 20 题或另一种顺序。由于随机源没有会话种子,旧顺序也不能从分类名反推回来。

本文不重复改造随机算法本身。这里需要的最小约束是:只有转场决定允许重建时才调用一次选题,并把结果冻结成新会话快照;重复标签点击不应偷偷消费一轮新的随机数。

四、分类点击至少要区分四种意图

把所有点击都解释为“重开”会隐藏用户意图。更清楚的规则如下:

点击情境建议结果是否重抽题是否需要确认
点击当前分类保持当前会话
当前会话尚未开始,点击新分类直接创建新会话
已有进度,点击新分类展示转场决策尚不执行
用户取消放弃回到原会话与原位置
用户确认放弃关闭旧会话,再创建新快照已完成
快速连续点击多个分类只处理最新有效意图最多一次取决于旧会话

科二分类切换决策流程

“已有进度”不能只看 selectedAnswer。用户可能已经答过前十一题,但第十二题尚未选择,此时当前字段为空,整场仍然有进度。建议由会话保存 visitedCountansweredCount 或逐题尝试状态,再用明确规则判断是否需要确认。

五、PracticeSessionSnapshot冻结题序与当前游标

进行中上下文应成为一个对象,而不是由页面上的分类、数组、索引和选择字段拼出来。最小模型可以这样定义:

enum PracticeStatus {
  READY = 'READY',
  ACTIVE = 'ACTIVE',
  COMPLETED = 'COMPLETED',
  ABANDONED = 'ABANDONED'
}

interface PracticeAttempt {
  questionId: string;
  selectedAnswer: string;
  answered: boolean;
}

interface PracticeSessionSnapshot {
  sessionId: string;
  subject: string;
  category: string;
  status: PracticeStatus;
  questionIds: string[];
  attempts: PracticeAttempt[];
  currentIndex: number;
  revision: number;
}

questionIds 保存开始时已经确定的顺序;attempts 与题目 ID 对齐;currentIndex 只在数组范围内移动;revision 每次接受有效事件后递增。页面重新构建时从这个快照派生高亮分类、题号、选中项和提示,不再分别修改六个松散字段。

分类并不属于全局设置,而属于会话创建参数。切换分类意味着请求另一份会话,旧对象必须先得到明确处置。

六、TransitionDecision先计算,确认前不修改页面

转场协调器接收目标分类和当前会话,只返回决定,不调用随机数、不清空数组、不改高亮标签:

enum TransitionAction {
  KEEP_CURRENT = 'KEEP_CURRENT',
  START_NEW = 'START_NEW',
  REQUEST_ABANDON = 'REQUEST_ABANDON'
}

interface TransitionDecision {
  action: TransitionAction;
  targetCategory: string;
  expectedRevision: number;
  reason: string;
}

function decideCategoryTransition(current: PracticeSessionSnapshot,
  targetCategory: string): TransitionDecision {
  if (targetCategory === current.category) {
    return {
      action: TransitionAction.KEEP_CURRENT,
      targetCategory,
      expectedRevision: current.revision,
      reason: 'same_category'
    };
  }
  if (current.status === PracticeStatus.READY) {
    return {
      action: TransitionAction.START_NEW,
      targetCategory,
      expectedRevision: current.revision,
      reason: 'session_not_started'
    };
  }
  return {
    action: TransitionAction.REQUEST_ABANDON,
    targetCategory,
    expectedRevision: current.revision,
    reason: 'active_progress_exists'
  };
}

如果当前分类相同,函数返回 KEEP_CURRENT,页面不变,随机源也不会被调用。已有进度则返回请求确认,直到用户给出决定之前,subject2Category、题组、游标和答案都保持原值。

七、确认放弃后,用一份Receipt原子提交新会话

用户确认只是授权转场,不代表新题组一定创建成功。选题可能返回空数组,未来也可能由异步仓储或远端题库提供。因此应先构造候选新会话,再一次性提交页面状态,并返回回执:

interface PracticeTransitionReceipt {
  fromSessionId: string;
  toSessionId: string;
  targetCategory: string;
  committed: boolean;
  questionCount: number;
  issueCode: string;
}

function buildTransitionReceipt(from: PracticeSessionSnapshot,
  targetCategory: string, next: PracticeSessionSnapshot | null):
  PracticeTransitionReceipt {
  if (!next || next.questionIds.length === 0) {
    return {
      fromSessionId: from.sessionId,
      toSessionId: '',
      targetCategory,
      committed: false,
      questionCount: 0,
      issueCode: 'empty_target_pool'
    };
  }
  return {
    fromSessionId: from.sessionId,
    toSessionId: next.sessionId,
    targetCategory,
    committed: true,
    questionCount: next.questionIds.length,
    issueCode: ''
  };
}

committed=false 时继续保留旧会话,不能先把标签改成目标分类再显示空题。成功回执记录旧、新会话 ID 和实际题数,但不复制题目正文或用户答案。历史层是否保存一条“主动放弃”摘要由产品规则决定,不能把取消确认自动算作答错。

分类意图、转场协调器、题目采样与页面状态的职责

八、页面点击只提交Intent,协调器拥有副作用顺序

Builder 不应直接调用 startTheoryPractice()。它只提交 CategoryIntent,由协调器完成“判断 → 确认 → 建候选 → 提交”的顺序:

private onCategoryIntent(targetCategory: string): void {
  const decision = decideCategoryTransition(
    this.practiceSession, targetCategory);

  if (decision.action === TransitionAction.KEEP_CURRENT) {
    return;
  }
  if (decision.action === TransitionAction.REQUEST_ABANDON) {
    this.pendingTransition = decision;
    this.showAbandonDialog = true;
    return;
  }
  this.commitNewCategory(decision);
}

private commitNewCategory(decision: TransitionDecision): void {
  if (this.practiceSession.revision !==
    decision.expectedRevision) {
    return;
  }
  const next = this.createPracticeSession(
    decision.targetCategory);
  const receipt = buildTransitionReceipt(
    this.practiceSession, decision.targetCategory, next);
  if (receipt.committed && next) {
    this.practiceSession = next;
  }
  this.lastTransitionReceipt = receipt;
}

确认框打开期间用户仍可能答题或通过其他入口重开,expectedRevision 用来拒绝已经过期的决定。若修订号变化,旧确认不能关闭新会话。真正接入时还应给对话框事件加唯一 intent ID,避免双击“确认”创建两份新题组。

九、重复点击、快速切换和保存时序要一起验证

建议先把随机源替换为可计数的测试桩,才能断言同分类点击没有触发重抽:

class CountingSampler {
  calls: number = 0;

  sample(category: string): string[] {
    this.calls++;
    return [`${category}_q1`, `${category}_q2`];
  }
}

describe('PracticeTransition', () => {
  it('keeps_same_category_without_sampling', 0, () => {
    const session = makeActiveSession('图标认知', 12);
    const sampler = new CountingSampler();
    const decision = decideCategoryTransition(
      session, '图标认知');

    expect(decision.action)
      .assertEqual(TransitionAction.KEEP_CURRENT);
    expect(sampler.calls).assertEqual(0);
    expect(session.currentIndex).assertEqual(12);
  });
});

实现后的验证清单包括:

  • 重复点击当前分类,题目 ID 顺序、游标、答案和修订号都不变。
  • 未开始会话切到新分类,只创建一次新题快照。
  • 已有进度切分类,确认前高亮标签与当前题不变。
  • 取消放弃后回到完全相同的题目和位置。
  • 确认放弃后旧会话进入 ABANDONED,新会话从索引零开始。
  • 目标分类为空题时不提交,旧会话仍可继续。
  • 确认层打开后又答一题,旧修订号决定被拒绝。
  • 快速点击三个分类只提交最后一项有效意图。
  • 双击确认只生成一个 toSessionId
  • 已完成单题记录不会因分类切换被清空或重复保存。

十、排障表、落地顺序与事实边界

现象优先查看常见原因处理方向
再点当前标签就回第1题点击幂等性标签直接调用开始函数同分类返回 KEEP_CURRENT
取消切换后题序变了候选创建时机确认前已经调用随机选题用户确认后才采样
标签变了但页面为空提交顺序先写 category,后发现无题候选完整后原子替换
旧确认关闭了新会话修订号对话框回调没有上下文比较 sessionId 与 revision
双击确认产生两组题意图幂等同一决定重复消费为 intent 建唯一 ID 和完成态
已答记录重复出现历史副作用切分类时重放答案历史只响应明确的作答事件
新分类仍显示旧答案色页面字段双写新会话与旧 selectedAnswer 并存UI 全部从会话快照派生
切换后无法回看旧场次放弃策略不清直接删除旧对象明确保存摘要或仅关闭,不冒充完成

落地时可以保持改动很小:先给同分类点击增加无副作用决策;再引入最小会话快照;随后把确认后的选题与提交收进一个协调器;最后才决定是否持久化主动放弃摘要。不要同时改造整个题库、随机算法和历史协议,否则很难判断哪一项改变了用户体验。

当前源码可以确认:三个科二标签的点击回调立即写入 subject2Category 并调用 startTheoryPractice();该函数会重建随机题组、把索引归零并清空当前选择;选题函数每次直接使用 Math.random();现有代码没有同分类守卫、在途会话判断、放弃确认或转场回执。由此能确定当前控制流会替换内存会话,但不能宣称本轮已在设备上观察到特定题号、历史丢失或重复记录。

PracticeTransitionReceipt 让一次分类切换变得可解释:旧会话是谁、用户是否允许放弃、新题组是否创建成功、最终有没有提交。本文没有修改 The_kemusan 业务源码,没有执行 hvigorw assembleHap --no-daemon,没有生成新的 HAP,也没有进行模拟器或真机交互;真正接入后,还需用固定随机源、页面确认流程和设备快速点击场景完成验证。

Logo

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

更多推荐