20题理论练习会话封面

“20题测试”首先是一份用户承诺:进入后会得到一组有限题目,完成最后一题后能够看到结果。本项目当前界面确实展示“20题测试”,选题函数也最多截取20题;但页面的“下一题”始终可点,推进函数又使用取模运算。只要题组非空,第20题之后就会回到第1题,流程本身没有“已完成”这一状态。

这不是根据设备现象作出的推断,而是对当前 ArkTS 控制流的只读审查。本文把问题限定在理论练习会话:怎样表示题目快照、作答与跳题、终点、汇总和重开。错题持久化、题目随机算法、整站统计口径仍由既有模块负责,不在这次建议中顺手改造。

一、标签写着20题,代码却只拥有一个循环游标

当前实现中,THEORY_TEST_COUNTentry/src/main/ets/pages/Index.ets:63 定义为20。QuestionBankStartCard()Index.ets:477-513 显示“20题测试”和“开始练习”。selectTestQuestions() 位于 Index.ets:1212-1229,它先复制并打乱候选题,再取 Math.min(20, shuffled.length) 个元素。由此能确认两件事:

  • 一场练习最多20题,题库不足20题时实际题数更少;
  • 这组题是开始练习时生成的内存数组,并非固定永远有20项。

问题出在结束语义。TheoryActionFooter() 的“下一题”直接调用 nextTheoryQuestion(),没有判断是否作答,也没有判断是否已到末题;Index.ets:1282-1290 又把索引更新为“当前索引加一后对题数取模”。下面是与现状等价的最小表达:

function nextIndex(current: number, total: number): number {
  if (total === 0) {
    return 0;
  }
  return (current + 1) % total;
}

这段代码能保证索引不越界,却无法表达“有限测试已经结束”。当 current 为19、total 为20时,返回值是0;对轮播图这是合理行为,对承诺有终点的测试则缺少了一个业务分支。

二、真实问题链不止是20跳回1

只把取模改成 Math.min(current + 1, total - 1) 仍然不够,因为当前字段组合没有形成完整会话。startTheoryPractice()Index.ets:1116-1124 设置科目、模式、题组、当前索引和答题提示;answerTheoryQuestion()Index.ets:1266-1279 只锁定当前题的首次答案并立刻追加一条历史;页面没有记录哪些题被跳过、这一场何时开始、是否结束、总共答了几题。

真实问题链可以按事件展开:

  1. 用户点击“开始练习”,页面创建题目数组和索引0;
  2. 用户可以不选答案直接点“下一题”,当前题没有明确的“跳过”记录;
  3. 每次答题单独写一条历史,但没有一份本场答题清单;
  4. 到末题后仍执行取模,索引回0,页面继续显示可作答流程;
  5. 已作答题的页面锁定只保存在当前 selectedAnswer,回到第1题后该字段被清空;
  6. 页面无法据此生成“答对、答错、跳过、未看”的完整汇总。

因此,索引只是会话的一部分。有限测试需要把状态、题目快照和每题结果放在同一个所有者中。

三、先区分有限测试与循环练习

不是每一种刷题入口都必须终止。若产品确实需要反复练习,可以保留循环模式,但要让模式成为显式配置,而不是由取模运算暗中决定。建议定义两个运行模式:

export enum TheoryRunMode {
  FINITE_TEST = 'FINITE_TEST',
  LOOP_PRACTICE = 'LOOP_PRACTICE'
}

export enum TheorySessionStatus {
  READY = 'READY',
  IN_PROGRESS = 'IN_PROGRESS',
  FINISHED = 'FINISHED',
  CANCELED = 'CANCELED'
}

这两个枚举解决的是两类不同问题。TheoryRunMode 决定到达尾题后的导航策略;TheorySessionStatus 决定页面当前允许哪些事件。“20题测试”入口应创建 FINITE_TEST;若以后增加“循环刷题”,再明确使用 LOOP_PRACTICE。这样,产品文案与代码分支能一一对应。

对当前项目而言,建议先只接入有限测试,避免新增未经要求的入口。循环模式在示例中出现,是为了说明取模何时才有合法位置,并不代表应同时上线两套体验。

四、用TheorySession保存不可变题组与逐题结果

会话对象至少要回答四个问题:本场有哪些题、当前是哪一题、每题处于什么结果、整场是否已经结束。下面是一份保持简单的 ArkTS 模型:

export type AttemptState = 'UNSEEN' | 'SKIPPED' | 'ANSWERED';

export interface TheoryAttempt {
  questionId: string;
  state: AttemptState;
  selectedAnswer: string;
  correct: boolean;
}

export interface TheorySession {
  id: string;
  subject: string;
  mode: TheoryRunMode;
  status: TheorySessionStatus;
  questionIds: string[];
  attempts: TheoryAttempt[];
  currentIndex: number;
  startedAt: number;
  finishedAt: number;
}

questionIds 是本场题目快照的顺序,开始后不再重新洗牌。attempts 与它等长并按索引对齐;这样即使题库对象以后增加说明字段,会话仍然只保存稳定ID与本场答案。finishedAt 在未结束时取0,避免用当前时间冒充完成时间。

创建会话时就建立完整的未访问结果,能消除“数组中没有这一项究竟是未看还是数据丢失”的歧义:

export function createTheorySession(
  id: string,
  subject: string,
  mode: TheoryRunMode,
  questionIds: string[],
  now: number
): TheorySession {
  const attempts: TheoryAttempt[] = [];
  for (let index = 0; index < questionIds.length; index++) {
    attempts.push({
      questionId: questionIds[index],
      state: 'UNSEEN',
      selectedAnswer: '',
      correct: false
    });
  }
  return {
    id,
    subject,
    mode,
    status: questionIds.length === 0 ?
      TheorySessionStatus.FINISHED : TheorySessionStatus.IN_PROGRESS,
    questionIds,
    attempts,
    currentIndex: 0,
    startedAt: now,
    finishedAt: questionIds.length === 0 ? now : 0
  };
}

空题组直接结束还是显示空态,可以由页面决定;关键是不能创建一个“进行中但永远没有当前题”的会话。示例让空题组进入 FINISHED,页面仍应优先显示“暂无题目”,而不是生成0/0的成绩卡。

五、把作答、跳题和推进拆成明确事件

当前“下一题”既承担导航又暗含跳过,但没有留下语义。更稳妥的做法是让页面先决定当前题如何结算,再让纯函数推进。首次作答仍需锁定,避免同一题被重复累计:

export function answerCurrent(
  source: TheorySession,
  answer: string,
  correctAnswer: string
): TheorySession {
  if (source.status !== TheorySessionStatus.IN_PROGRESS) {
    return source;
  }
  const oldAttempt = source.attempts[source.currentIndex];
  if (oldAttempt.state === 'ANSWERED') {
    return source;
  }

  const attempts = source.attempts.slice();
  attempts[source.currentIndex] = {
    questionId: oldAttempt.questionId,
    state: 'ANSWERED',
    selectedAnswer: answer,
    correct: answer === correctAnswer
  };
  return { ...source, attempts };
}

export function skipCurrent(source: TheorySession): TheorySession {
  if (source.status !== TheorySessionStatus.IN_PROGRESS) {
    return source;
  }
  const oldAttempt = source.attempts[source.currentIndex];
  if (oldAttempt.state !== 'UNSEEN') {
    return source;
  }
  const attempts = source.attempts.slice();
  attempts[source.currentIndex] = {
    questionId: oldAttempt.questionId,
    state: 'SKIPPED',
    selectedAnswer: '',
    correct: false
  };
  return { ...source, attempts };
}

这里用新数组替换旧数组,便于 ArkUI 的状态更新观察到引用变化。skipCurrent() 只把 UNSEEN 变成 SKIPPED;已经作答的题不会被下一题按钮改写成跳过。若产品希望允许回看并补答,应另设“答题卡跳转”规则,而不是让同一个按钮兼顾所有行为。

页面按钮文案也应反映决策:未作答时显示“跳过本题”,作答后显示“下一题”,末题则分别显示“跳过并交卷”或“查看结果”。这样用户能预知点击后的结果。

有限测试状态流程

六、推进函数必须在尾题收口

推进函数需要显式处理三种条件:会话不可操作、还有下一题、到达尾题。有限测试到尾题时进入 FINISHED;循环练习才回到0:

export function advanceSession(
  source: TheorySession,
  now: number
): TheorySession {
  if (source.status !== TheorySessionStatus.IN_PROGRESS) {
    return source;
  }

  const isLast = source.currentIndex >= source.questionIds.length - 1;
  if (!isLast) {
    return { ...source, currentIndex: source.currentIndex + 1 };
  }

  if (source.mode === TheoryRunMode.LOOP_PRACTICE) {
    return { ...source, currentIndex: 0 };
  }

  return {
    ...source,
    status: TheorySessionStatus.FINISHED,
    finishedAt: now
  };
}

这段函数没有定时器、存储或 UI 依赖,因此可以用固定输入验证每一条边界。有限测试结束后再次调用会返回原对象,不会重复写完成时间。真正集成时,页面应在状态首次从 IN_PROGRESS 变为 FINISHED 时执行一次汇总持久化,而不是每次重组 UI 都写记录。

如果要求“所有题必须作答才能交卷”,可以在末题分支检查是否存在 UNSEENSKIPPED,然后跳转到第一道未答题。当前界面允许直接点下一题,本文建议忠实记录跳过并允许完成,不额外引入强制作答规则。

七、单题历史与场次汇总必须分开

answerTheoryQuestion() 当前每答一题就调用 addSimpleRecord()。这类记录适合支撑错题与单题回顾,但不能代替场次结果,因为跳过题不会触发它,且20条记录之间没有共同的会话ID。

建议保留单题尝试,同时新增轻量汇总:

export interface TheorySessionSummary {
  sessionId: string;
  subject: string;
  total: number;
  answered: number;
  correct: number;
  wrong: number;
  skipped: number;
  startedAt: number;
  finishedAt: number;
}

export function summarize(session: TheorySession): TheorySessionSummary {
  let answered = 0;
  let correct = 0;
  let skipped = 0;
  for (let index = 0; index < session.attempts.length; index++) {
    const item = session.attempts[index];
    if (item.state === 'ANSWERED') {
      answered++;
      if (item.correct) {
        correct++;
      }
    } else if (item.state === 'SKIPPED') {
      skipped++;
    }
  }
  return {
    sessionId: session.id,
    subject: session.subject,
    total: session.questionIds.length,
    answered,
    correct,
    wrong: answered - correct,
    skipped,
    startedAt: session.startedAt,
    finishedAt: session.finishedAt
  };
}

这里的 total 取实际题组长度,不能硬编码20。若候选题只有12道,结果应显示12题。wrong 只表示已作答且错误,skipped 单独列出,避免把“用户没有选择”伪装成某个错误选项。汇总记录也不应重复塞入现有 PracticeRecord,除非先定义清楚两类记录的区分字段。

理论练习会话结构

八、页面生命周期要决定取消、恢复还是重开

会话引入后,退出页面不应再依赖一组散落字段自然消失。建议明确三条策略:

  • 点击“重新开始”:把当前进行中会话标记为 CANCELED,再用新的题目快照创建新ID;
  • 从理论页返回首页:若本次不支持断点续答,释放内存并记录取消,不生成完成汇总;
  • 应用切入后台:内存会话可以保持;若系统回收进程后仍要求恢复,才增加持久化快照。

不要在第一版同时实现跨进程恢复。当前问题只需让一次页面会话有明确终点。若后续保存快照,恢复时必须校验题目ID仍能从题库解析,并把无法解析的会话标为不可恢复,不能悄悄重新抽题后沿用旧成绩。

ArkUI 页面可以保留一个 @State session 或把会话字段拆成框架允许观察的状态。核心约束是:所有按钮都通过同一个 reducer 更新会话,视图只从会话派生“题号、按钮文案、结果卡”,不再单独修改 activeQuestionIndexselectedAnswer

九、验证矩阵要覆盖题数与行为的笛卡尔积

纯函数验证应先于页面联调。下面的用例能够直接拦住“20回1”和“跳题不入汇总”:

describe('TheorySession finite flow', () => {
  it('finishes after the actual last question', () => {
    let session = createTheorySession(
      'run-1', 'subject1', TheoryRunMode.FINITE_TEST,
      ['q1', 'q2'], 1000
    );
    session = answerCurrent(session, 'A', 'A');
    session = advanceSession(session, 1100);
    session = skipCurrent(session);
    session = advanceSession(session, 1200);

    expect(session.status).assertEqual(TheorySessionStatus.FINISHED);
    expect(session.currentIndex).assertEqual(1);
    expect(summarize(session).skipped).assertEqual(1);
  });
});

这条用例把题组压缩为两题,仍能覆盖开始、作答、推进、跳过和结束。固定时钟值让 finishedAt 可断言,不依赖真实等待。

页面层还需按以下矩阵核对:

维度输入或操作预期结果
题组长度0题显示空态,不出现可作答的“下一题”
题组长度1题首题也是末题,结算后直接显示结果
题组长度12题进度与汇总均为12,不显示20作为实际总数
标准题组20题第20题结算后进入完成页,不回到1/20
未作答推进中间题点击跳过该题记为SKIPPED,后续题可继续
重复点击完成按钮连续点击只产生一份场次汇总
重开第7题点击重新开始旧会话取消,新会话ID与题目快照重新创建
回退完成后系统返回不恢复成进行中的第20题
循环配置LOOP_PRACTICE到尾题只有该显式模式允许回到第1题

这里把“20题”作为上限与“本场实际总数”分开,能覆盖题库不足20题的真实分支。

十、排障与交付边界

集成时最容易出现的问题不是编译语法,而是旧字段和新会话同时写入,形成两个事实源。

现象优先检查修复方向
结果页出现后又回题目页是否仍调用旧的取模推进所有导航统一经过 advanceSession()
跳过数始终为0下一题前是否执行 skipCurrent()未作答时显式结算为SKIPPED
12道题显示20题满分是否继续读取常量UI与汇总读取 questionIds.length
同一场产生两份汇总完成副作用是否在重组期间执行只监听一次状态跃迁并按sessionId幂等
重开后旧答案混入新题组是否复用 attempts 数组创建新会话并生成全新的数组
完成后按钮仍可操作按钮是否读取 session.statusFINISHED状态禁用作答与推进
返回第1题后答案消失仍只依赖 selectedAnswerattempts[currentIndex] 派生选中态

本文能确认的源码事实是:界面存在“20题测试”入口;选题函数最多返回20题;下一题按钮没有作答或末题门禁;推进函数使用取模;当前代码没有场次完成与汇总模型。基于这些事实,可以提出有限会话改造,但不能宣称某款设备已经出现重复记录、卡顿或数据丢失。

文中的 TheorySession、reducer 与汇总代码均为建议示例,尚未集成到 The_kemusan。本次没有修改项目源码,没有执行当前项目构建,没有生成新的 HAP,也没有进行模拟器或真机验证。完成实现后,还应分别补做纯函数用例、页面交互核对、存储幂等验证和真机生命周期验证,再决定是否用于实际练习流程。

Logo

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

更多推荐