理论题真实耗时封面

历史卡片显示“1s”,读者自然会把它理解为这道题真的只用了1秒。当前源码却没有为理论题记录开始时刻:answerTheoryQuestion() 写历史时不传 durationSecondsaddSimpleRecord() 遇到缺省值便存入1。于是理论题的1秒是构造记录时的兜底常量,而不是从题目呈现到首次有效答案的测量结果。

本文只讨论单题计时口径和记录可解释性。第39篇关注整场20题会话的结束与汇总;这里不新增整场倒计时,也不修改错题统计。目标是让历史中的数字能够回答“从哪一刻算起、到哪一刻停止、旧数据如何区分”,并且让这些规则可以在不等待真实时间的测试里验证。

一、先沿调用链找到1秒的真正来源

PracticeRecordentry/src/main/ets/models/DrivingLightModels.ets:33-46durationSeconds 定义为必填数字。历史卡片在 entry/src/main/ets/pages/Index.ets:1002-1009 直接渲染 record.durationSeconds + 's',页面不会说明它来自测量还是兜底。

写入链更关键:

  • answerTheoryQuestion() 位于 Index.ets:1266-1279,首次选择答案后调用 addSimpleRecord()
  • 这次调用传到 selectedOption 就结束,没有传最后的 durationSeconds
  • addSimpleRecord() 位于 Index.ets:1614-1629,将未提供的耗时写成1;
  • 理论练习开始与下一题函数位于 Index.ets:1116-11241282-1290,两处都没有题目开始时间字段。

与当前行为等价的逻辑可以缩成下面几行:

function resolveDurationSeconds(durationSeconds?: number): number {
  return durationSeconds === undefined ? 1 : durationSeconds;
}

const theoryDuration = resolveDurationSeconds();

这段逻辑保证持久化模型总有数字,但它把“未测量”改写成了“测得1秒”。数据类型完整并不等于语义真实。

二、先写清楚单题耗时的业务口径

计时方案最容易因“开始”含义模糊而分叉。理论题的可操作口径建议定义为:

题目及可选项已经成为当前交互题目时开始;用户第一次提交有效选项时停止。

这里有四个细节:

  1. 题组准备和随机排序不计入单题思考时间;
  2. 页面还未能作答时不能提前开始;
  3. 同一题重复点击不重新计算;
  4. 离开页面、跳过或重开时,不伪造一次已作答耗时。

若产品希望把后台停留排除在外,需要再引入暂停区间;当前源码没有这项规则,本文先采用单调经过时间连续计算。它足以修复固定1秒的语义问题,也更容易验证。

对极快点击,结果可以为0毫秒,但用于秒级展示时通常向上取整并至少显示1秒。这个“最少显示1秒”必须建立在真实差值之上,与完全没有时间戳却直接写1不同。

三、把墙上时间藏进Clock接口

直接在每个方法中调用 Date.now() 会让测试依赖实际等待。建议把读取时间收口成一个小接口:

export interface Clock {
  nowMs(): number;
}

export class SystemClock implements Clock {
  nowMs(): number {
    return Date.now();
  }
}

export class FixedClock implements Clock {
  private currentMs: number = 0;

  setNowMs(value: number): void {
    this.currentMs = value;
  }

  nowMs(): number {
    return this.currentMs;
  }
}

生产页面注入 SystemClock,测试注入 FixedClock。接口只有一个方法,没有加入时区、格式化或定时任务等无关能力。单题耗时只需要计算经过时间,毫秒时间戳足够。

若项目不希望使用构造注入,也可以在独立计时对象中传入 Clock。重点是业务函数不要散落读取系统时间,否则边界用例会变成慢且不稳定的等待用例。

四、QuestionTiming只负责一次开始与一次冻结

每道当前题需要一个很小的计时状态:属于哪道题、何时呈现、是否已经冻结、最终耗时多少。建议模型如下:

export type DurationSource = 'MEASURED' | 'LEGACY_DEFAULT' | 'UNKNOWN';

export interface QuestionTimingSnapshot {
  questionId: string;
  presentedAtMs: number;
  answeredAtMs: number;
  elapsedMs: number;
  source: DurationSource;
}

export class QuestionTiming {
  private snapshot: QuestionTimingSnapshot = {
    questionId: '',
    presentedAtMs: 0,
    answeredAtMs: 0,
    elapsedMs: 0,
    source: 'UNKNOWN'
  };

  present(questionId: string, nowMs: number): void {
    this.snapshot = {
      questionId,
      presentedAtMs: nowMs,
      answeredAtMs: 0,
      elapsedMs: 0,
      source: 'UNKNOWN'
    };
  }

  freeze(questionId: string, nowMs: number): QuestionTimingSnapshot {
    if (this.snapshot.questionId !== questionId ||
      this.snapshot.presentedAtMs <= 0 ||
      this.snapshot.answeredAtMs > 0) {
      return this.snapshot;
    }
    const elapsedMs = Math.max(0, nowMs - this.snapshot.presentedAtMs);
    this.snapshot = {
      questionId,
      presentedAtMs: this.snapshot.presentedAtMs,
      answeredAtMs: nowMs,
      elapsedMs,
      source: 'MEASURED'
    };
    return this.snapshot;
  }

  current(): QuestionTimingSnapshot {
    return this.snapshot;
  }
}

present() 每次切入新的可作答题时重建快照;freeze() 只接受同一题ID,并且只冻结一次。即使用户快速连点选项,第二次调用也不会改变第一次答案时刻。若页面索引变化但异步事件携带旧题ID,题目不匹配分支也不会把旧答案耗时写给新题。

示例用 presentedAtMs <= 0 表示未开始,所以真实实现中的测试时间不要从0开始,或改用显式布尔字段。选择哪种方式并不影响核心规则:无开始证据时不应创建 MEASURED 结果。

五、开始时机必须与题目身份一起更新

当前 startTheoryPractice() 创建题组后把索引设为0;nextTheoryQuestion() 推进后清空答案状态。接入时应在“当前题确定且可操作”这一事务中同时调用 present(),而不是在页面每次重组时调用。

下面展示一个普通方法方向:

private presentTheoryQuestion(index: number): void {
  if (index < 0 || index >= this.activeQuestions.length) {
    return;
  }
  this.activeQuestionIndex = index;
  this.selectedAnswer = '';
  this.answerMessage = '请选择答案';
  this.answerCorrect = true;

  const question = this.activeQuestions[index];
  this.questionTiming.present(question.id, this.clock.nowMs());
}

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

代码解释:presentTheoryQuestion() 让索引、答题状态与开始时刻共同切换,减少“页面显示q2但计时仍属于q1”的窗口。它应由开始、下一题和明确的答题卡跳转调用。ArkUI 的 build()@Builder 或文本 getter 可能执行多次,不适合放置启动计时的副作用。

若图片、长文本等资源会导致选项晚于题干出现,需要产品定义“可操作”到哪一层。当前题卡中的选项来自内存对象,同步构建即可;因此建议以切换当前题的方法作为开始点,并在真机联调时核对页面可见与可点击时机。

单题耗时记录流程

六、首次有效答案冻结耗时并显式传入记录

答案校验通过入口门禁后,先冻结计时,再构造历史记录。毫秒转秒应集中在一个函数中:

export function toDisplaySeconds(elapsedMs: number): number {
  if (elapsedMs <= 0) {
    return 1;
  }
  return Math.max(1, Math.ceil(elapsedMs / 1000));
}

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

  const question = this.getCurrentQuestion();
  const timing = this.questionTiming.freeze(question.id, this.clock.nowMs());
  if (timing.source !== 'MEASURED') {
    this.handleTimingUnavailable(question.id);
    return;
  }

  const correctAnswer = getQuestionAnswer(question);
  const correct = answer === correctAnswer;
  const durationSeconds = toDisplaySeconds(timing.elapsedMs);

  this.addSimpleRecord(
    question.subject, this.activeMode, correct,
    correct ? '答对:' + question.title : '答错:' + question.title,
    correct ? '' : question.id,
    correct ? '' : this.getOptionText(question, correctAnswer),
    correct ? '' : this.getOptionText(question, answer),
    undefined, undefined, durationSeconds
  );
}

代码解释:只有 source === 'MEASURED' 才把秒数写成测量值。Math.ceil 让1001毫秒显示2秒;如果业务更希望四舍五入,可替换换算函数,但应在全站保持一致。示例中的 handleTimingUnavailable() 表示需要显式降级,不能悄悄继续使用1。

更清晰的长期方案是改造记录构造器,让理论题调用命名参数对象,避免连续三个 undefined。为了保持本文主题集中,示例暂时沿用现有函数形状,并在结构设计中给出下一步方向。

七、存储模型要区分测量值、旧默认值与未知值

只修正新记录后,历史中仍会存在旧的1秒数据。由于旧模型没有来源字段,无法从数值本身判断某条1秒到底是真实快速作答,还是旧代码的缺省值。迁移时不能把所有 durationSeconds === 1 自动认定为错误,也不能宣称它们都是准确测量。

建议新记录增加来源和可选毫秒值:

export interface DurationInfo {
  source: DurationSource;
  elapsedMs?: number;
  displaySeconds?: number;
}

export interface TheoryAttemptRecord {
  id: string;
  questionId: string;
  passed: boolean;
  createdAt: number;
  duration: DurationInfo;
}

代码解释:新写入记录使用 MEASURED,并同时保存 elapsedMs 与展示秒数;旧记录迁移成 LEGACY_DEFAULT,保留原有 displaySeconds,历史卡片可以显示“旧记录,耗时口径未知”。如果某次运行没有开始证据,则使用 UNKNOWN 且不填数值,页面显示“未记录”。

若暂时不能修改存储结构,也应至少在新记录的版本字段中标记计时功能上线点。没有任何标记时,历史数据无法可靠追溯来源,这正是用默认数填补语义缺口的长期成本。

计时与历史记录结构

八、跳题、后台与系统时钟变化要有明确策略

计时器并非只有作答一条出口。以下策略应在接入前确定:

  • 下一题未作答:丢弃当前 QuestionTiming 或记录为跳过,不写一个虚构耗时;
  • 重新开始:清除旧题计时,创建新会话后再呈现首题;
  • 页面返回:当前题未作答时终止本次快照;
  • 进入后台:基础方案继续累计经过时间;若产品决定暂停,则记录后台区间并扣除;
  • 系统时间回拨Math.max(0, delta) 只能避免负数,严谨方案应使用平台提供的单调时钟能力。

最后一点属于需要结合目标 HarmonyOS SDK 核实的 API 设计。本文的 ArkTS 示例使用 Date.now() 以贴近现有项目,不能据此宣称已经抵抗用户手动改时钟。实现前应查阅项目目标 API 对单调时间源与生命周期回调的官方说明,再选择具体平台接口。

5秒科三作答期与这里的理论题耗时也应分开:前者是到期即失败的截止控制,后者是答题历史的经过时间度量。它们可以共享 Clock,但不应共享同一个倒计时状态。

九、固定时钟让边界用例无需等待

计时逻辑适合用确定性时钟验证。下面的用例同时覆盖向上取整与重复冻结:

describe('QuestionTiming', () => {
  it('freezes the first valid answer time', () => {
    const timing = new QuestionTiming();
    timing.present('q-1', 10000);

    const first = timing.freeze('q-1', 11201);
    const second = timing.freeze('q-1', 19000);

    expect(first.elapsedMs).assertEqual(1201);
    expect(toDisplaySeconds(first.elapsedMs)).assertEqual(2);
    expect(second.answeredAtMs).assertEqual(11201);
  });

  it('rejects a stale question event', () => {
    const timing = new QuestionTiming();
    timing.present('q-2', 20000);

    const result = timing.freeze('q-1', 23000);

    expect(result.source).assertEqual('UNKNOWN');
    expect(result.elapsedMs).assertEqual(0);
  });
});

代码解释:测试不调用定时器,也不等待1.2秒。第一条保证第二次冻结不能改写首次答案;第二条模拟旧题事件晚到,确认题目身份门禁有效。

完整验证矩阵如下:

场景时间输入预期记录
立即点击1–999msMEASURED,展示1s,保存真实毫秒
整秒边界1000msMEASURED,展示1s
超过整秒1001msMEASURED,展示2s
重复点击1200ms后再点answeredAt与elapsedMs保持首次值
旧题事件当前q2却提交q1不生成q2耗时,不写历史
未调用present直接answerUNKNOWN降级,不使用固定1秒
跳过呈现后点下一题不创建已作答耗时
后台10秒按基础策略继续耗时包含后台10秒,并在需求中说明
旧历史只有durationSeconds=1标记LEGACY_DEFAULT,不反推真实用时
系统回拨now小于presentedAt不产生负数,并记录异常路径

十、排障与证据边界

现象可能原因处理方向
新理论记录仍全是1s调用方仍未传 durationSeconds从答案入口追踪到记录构造器
切到下一题后耗时异常变长没有在题目切换时重新present统一使用 presentTheoryQuestion()
同一题连续点击耗时变化freeze缺少首次锁定answeredAt大于0时返回既有快照
q1耗时写进q2异步事件没有携带questionId同时校验会话ID与题目ID
历史把旧1s显示为真实没有来源字段迁移为LEGACY_DEFAULT并提示口径
后台停留导致数值很大产品未定义暂停规则明确连续累计或暂停扣除策略
设备改时钟后出现0s使用墙上时间计算差值核实并换用适配SDK的单调时间源
页面重组后计时重置在Builder中调用present只在题目切换事务中启动

本文能从源码确认:PracticeRecord.durationSeconds 是必填数字;历史卡片直接展示它;理论题答题路径没有传入耗时;addSimpleRecord() 将缺省值写成1;科三考试结束路径则显式传入从 startAt 计算的整场秒数。由此可以确认理论题1秒来自默认分支,但没有依据判断既有某条1秒记录对应用户真实花费多少时间。

文中的 ClockQuestionTiming、来源字段和接入方法属于建议代码,尚未集成到 The_kemusan。本次没有执行当前项目构建,没有生成新的 HAP,没有在模拟器或真机上验证页面呈现时刻、后台行为或平台时间源,也没有修改现有历史数据。实现后应分别完成纯函数用例、持久化兼容、页面交互和真机生命周期核对,再把新耗时用于对外展示。

Logo

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

更多推荐