一道灯光题允许操作五秒,切后台几秒回来却可能还剩“3 秒”;主线程阻塞后也可能只少一秒。真实经过的时间,与回调执行次数并不相同。

用截止时间驱动五秒倒计时

The_kemusan 每秒把 timeLeft 减一。本文改为发题时固定截止点,后台只停显示刷新,提交与超时共享时间规则。

文中给出完整核心、单题页面和边界用例,尚未接入原工程、构建或真机运行。默认时钟 Date.now() 不抵御系统时间调整,后文明确其限制。

一、源码的减一逻辑为什么会把回调次数当作时间

当前 entry/src/main/ets/pages/Index.ets 中的主要计时逻辑如下:

private startTimer(): void {
  this.stopTimer();
  this.timerId = setInterval(() => {
    if (!this.examActive || this.actionLocked) {
      return;
    }
    this.timeLeft = Math.max(0, this.timeLeft - 1);
    if (this.timeLeft <= 0) {
      this.stopTimer();
      this.handleTimeout();
    }
  }, 1000);
}

这段源码有停止旧定时器、状态判断和归零处理,适合解释最初的流程。它的时间基础却是“执行一次就扣一秒”。如果回调实际晚到了,程序没有把迟到部分补进去。把间隔改成 100 毫秒、每次减 0.1 秒,仍然是在累计回调次数,只是误差表现不同。

情况按回调减一按截止点计算
回调按时执行通常接近预期按当前时间显示剩余量
主线程阻塞两秒恢复后可能只扣一次一次刷新补齐已经经过的时间
切后台时停止刷新数字停住,期限隐式延长截止点保留,回来立即重算
最后一次回调尚未触发,用户先点击可能仍看到剩余1秒提交入口重新比较截止点

这些是算法风险,不是某台设备的后台频率实测结论。

发题、停止刷新、返回重算与超时裁决流程

二、先确定产品规则:离开应用后是否继续计时

“切后台修复倒计时”至少有两种合理需求。模拟考试希望期限继续流逝,阅读训练可能希望暂停。它们不能共用一个模糊的 stopTimer() 语义。本文采用前一种:后台不刷新数字,但五秒期限继续有效。

事件本文规则截止点是否变化
发出新指令开始新的五秒作答期设置为当前时刻加五秒
页面隐藏或切后台停止显示刷新不变
页面重新显示立即重算,必要时结算超时不变
用户提交操作用当前时刻裁决是否仍可作答不变
主动退出本轮取消当前题,后续回调不再结算当前题失效
进入下一题开始新的作答期重新设置

边界约定采用半开区间:now < deadline 可以提交,now >= deadline 算超时。恰好等于截止时刻不能依赖“哪个回调先碰巧到达”决定结果。显示数字只是帮助用户理解进度,最后的裁决必须看原始毫秒差值。

若产品允许暂停,应保存剩余毫秒并在返回时重建截止点,明确表达暂停规则。

三、时间计算放进普通类,页面只接收剩余秒数

建议新增 services/QuestionDeadline.ets,用时钟接口支持精确边界验证,默认返回日历时间。

export interface MillisecondClock {
  now(): number;
}

export class WallClock implements MillisecondClock {
  now(): number {
    return Date.now();
  }
}

export interface DeadlineView {
  questionToken: number;
  active: boolean;
  remainingMs: number;
  seconds: number;
}

export enum DeadlineDecision {
  ACCEPTED,
  EXPIRED,
  IGNORED
}

remainingMs 用于裁决和进度,seconds 用于显示。使用 Math.ceil() 是为了让剩余 4900 毫秒显示 5 秒,剩余 100 毫秒仍显示 1 秒;到了零再显示 0。不能先四舍五入成整数,再依据这个整数判断用户有没有超时。

questionToken 是本地题目版本号,开始或取消使旧回调失效;它不是历史记录 ID。

四、让提交和超时共享同一个同步裁决器

下面是同一文件中的完整核心类。它不调用页面方法、不写历史、不直接启停定时器,只负责当前题的截止点和一次性裁决。

export class QuestionDeadline {
  private clock: MillisecondClock;
  private token: number = 0;
  private deadlineMs: number = 0;
  private durationMs: number = 0;
  private active: boolean = false;

  constructor(clock: MillisecondClock = new WallClock()) {
    this.clock = clock;
  }

  begin(durationMs: number): number {
    this.token++;
    this.durationMs = Math.max(0, durationMs);
    this.deadlineMs = this.clock.now() + this.durationMs;
    this.active = true;
    return this.token;
  }

  view(expectedToken: number): DeadlineView {
    const valid: boolean = this.active && expectedToken === this.token;
    const remainingMs: number = valid ?
      Math.max(0, Math.min(this.durationMs, this.deadlineMs - this.clock.now())) : 0;
    const result: DeadlineView = {
      questionToken: this.token,
      active: valid,
      remainingMs,
      seconds: Math.ceil(remainingMs / 1000)
    };
    return result;
  }

  submit(expectedToken: number): DeadlineDecision {
    if (!this.active || expectedToken !== this.token) {
      return DeadlineDecision.IGNORED;
    }
    const expired: boolean = this.clock.now() >= this.deadlineMs;
    this.active = false;
    return expired ? DeadlineDecision.EXPIRED : DeadlineDecision.ACCEPTED;
  }

  expireIfDue(expectedToken: number): boolean {
    if (!this.active || expectedToken !== this.token ||
      this.clock.now() < this.deadlineMs) {
      return false;
    }
    this.active = false;
    return true;
  }

  cancel(): void {
    this.active = false;
    this.token++;
  }
}

submit() 判断时间后立即把 active 置为 false,整个过程没有 await。同一 UI 线程上的下一次点击或下一次超时回调就会被忽略。expireIfDue() 也在返回 true 之前关闭当前题,因此页面只在第一次返回 true 时执行超时业务。

需要看清返回值含义:ACCEPTED 只表示操作到达时没有超过期限,不代表操作答案正确。正确与否仍由灯光动作判题逻辑处理。IGNORED 表示题目已经结算或版本不匹配,不能再追加一条失败记录。

view() 只读,结束必须经过裁决方法,历史写入放在一次性裁决之后。

五、完整单题页面:定时器只负责唤醒显示

以下单题页面展示完整接线,配置路由后可进一步构建验证;不包含原工程音效、动画与历史存储。

import {
  QuestionDeadline, DeadlineDecision, DeadlineView
} from '../services/QuestionDeadline';

@Entry
@Component
struct DeadlinePracticePage {
  @State remainingSeconds: number = 0;
  @State questionRunning: boolean = false;
  @State resultText: string = '点击开始,随后开启近光灯';
  private deadline: QuestionDeadline = new QuestionDeadline();
  private questionToken: number = 0;
  private displayTimerId: number = -1;

  private stopDisplayTimer(): void {
    if (this.displayTimerId >= 0) {
      clearInterval(this.displayTimerId);
      this.displayTimerId = -1;
    }
  }

  private showExpired(): void {
    this.stopDisplayTimer();
    this.remainingSeconds = 0;
    this.questionRunning = false;
    this.resultText = '已超时,本题未完成';
  }

  private refreshRemaining(token: number): void {
    const view: DeadlineView = this.deadline.view(token);
    if (!view.active) {
      return;
    }
    this.remainingSeconds = view.seconds;
    if (this.deadline.expireIfDue(token)) {
      this.showExpired();
    }
  }

  private resumeDisplayTimer(): void {
    this.stopDisplayTimer();
    const token: number = this.questionToken;
    this.refreshRemaining(token);
    if (!this.questionRunning) {
      return;
    }
    this.displayTimerId = setInterval(() => {
      this.refreshRemaining(token);
    }, 200);
  }

  private beginQuestion(): void {
    this.stopDisplayTimer();
    this.questionToken = this.deadline.begin(5000);
    this.questionRunning = true;
    this.resultText = '请在五秒内开启近光灯';
    this.resumeDisplayTimer();
  }

  private submitAction(action: string): void {
    const decision: DeadlineDecision = this.deadline.submit(this.questionToken);
    if (decision === DeadlineDecision.IGNORED) {
      return;
    }
    if (decision === DeadlineDecision.EXPIRED) {
      this.showExpired();
      return;
    }
    this.stopDisplayTimer();
    this.questionRunning = false;
    this.resultText = action === 'lowBeam' ? '操作正确' : '操作错误,应开启近光灯';
  }

  onPageHide(): void {
    this.stopDisplayTimer();
  }

  onPageShow(): void {
    if (this.questionRunning) {
      this.resumeDisplayTimer();
    }
  }

  aboutToDisappear(): void {
    this.stopDisplayTimer();
    this.deadline.cancel();
  }

  build() {
    Column({ space: 16 }) {
      Text(this.resultText)
      Text(`${this.remainingSeconds}s`)
      Button('开始本题').onClick(() => this.beginQuestion())
      Button('近光灯')
        .enabled(this.questionRunning)
        .onClick(() => this.submitAction('lowBeam'))
      Button('远光灯')
        .enabled(this.questionRunning)
        .onClick(() => this.submitAction('highBeam'))
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

200 毫秒只是显示节奏,下一次刷新或提交仍读当前时间。Text 直接消费状态变量,按钮禁用提供交互提示,核心类负责防重。

六、回前台先校准,原工程内的模块切换仍要显式取消

官方页面生命周期说明中,onPageShow 包括页面显示和应用回前台,onPageHide 包括页面隐藏和应用切后台;它们属于 @Entry 页面生命周期。普通子组件、业务页签不能一概当成独立页面。相关边界见华为 页面和自定义组件生命周期

原工程的首页、模拟练习、错题页通过同一个 IndexcurrentPage 分支切换。改变这个字符串不会自动销毁 Index,也不能指望触发另一套页面生命周期。所以接入时要分两条路径:

// 放入原Index的退出本轮、回首页等业务方法中。
private cancelQuestionTiming(): void {
  this.stopTimer();
  this.questionDeadline.cancel();
}

// 新字段沿用本文核心类,需在Index顶部导入QuestionDeadline。
private questionDeadline: QuestionDeadline = new QuestionDeadline();

片段复用原 stopTimer();退出时仍须保留灯光、动画、考试状态复位。

前后台恢复的顺序是先同步读截止点,再决定是否开显示定时器。如果先重新设置五秒,再启动定时器,就把恢复写成了重开;如果先开定时器等下一次回调,页面会短暂显示旧数字。示例中的 resumeDisplayTimer() 已先调用一次 refreshRemaining()

时钟、截止时间状态与页面判题责任

七、接到灯光判题入口时,时间校验必须发生在动作副作用之前

原工程有 handleLightAction() 和实操动作入口,正确动作还会启动反馈与下一题调度。时间校验应该紧跟已有“是否处于作答期”的状态门闩,位于灯光动作、得分和记录之前。超时后再回滚动画或减回得分,会把一次简单裁决变成多处补偿。

建议接入顺序如下,每一项都有明确责任:

  1. 原入口确认当前模式、题目对象、作答锁有效。
  2. 调用 questionDeadline.submit(token);显式携带旧token的调用会被忽略,到期则转原 handleTimeout()
  3. 未超时才处理动作与答案比较,正确走原正确反馈,错误走原结束逻辑。
  4. 下一题真正呈现时调用 begin(5000),不能在答对的一刻提前开始下一题计时。
const decision: DeadlineDecision = this.questionDeadline.submit(this.questionToken);
if (decision === DeadlineDecision.IGNORED) {
  return;
}
if (decision === DeadlineDecision.EXPIRED) {
  this.stopTimer();
  this.handleTimeout();
  return;
}
// 从这里继续现有的动作判题逻辑;不要再次调用submit。

这段入口片段有一个必须匹配的前提:当前业务每题只接受一次正式作答,符合原工程“错误即本轮不合格”的行为。如果未来允许在五秒内连续尝试错误动作,submit() 就不该在每次错误尝试时消耗作答期,需要分开“检查时限”和“最终结算”。不能在改变业务规则后继续原样复制一次性裁决器。

原工程 handleTimeout() 还保留“要求近光且当前已是近光”时判正确的例外。EXPIRED 表示时间到达,接入时继续交由原业务规则处理,不要强行改成失败。上面的独立单题页采用到期未提交即失败的演示规则。

显示回调仅在 expireIfDue() 返回true时调用原超时处理。token只能辨认携带旧版本的调用;示例按钮读取当前token,不能识别新题开始后才处理的旧点击,需另用题目切换期的输入锁管理。

八、使用可控时钟验证边界,无需等待真实五秒

可控时钟能精确验证4999和5000毫秒,避免用真实等待猜测边界。

class ManualClock implements MillisecondClock {
  value: number = 10000;
  now(): number {
    return this.value;
  }
}

const clock: ManualClock = new ManualClock();
const deadline: QuestionDeadline = new QuestionDeadline(clock);
const token: number = deadline.begin(5000);
clock.value = 14999;
const before: DeadlineView = deadline.view(token);
console.info(`remaining=${before.remainingMs}, seconds=${before.seconds}`);
clock.value = 15000;
const result: DeadlineDecision = deadline.submit(token);
console.info(`expired=${result === DeadlineDecision.EXPIRED}`);
console.info(`secondSubmitIgnored=${deadline.submit(token) === DeadlineDecision.IGNORED}`);

预期分别是剩余 1 毫秒、显示 1 秒;到 15000 毫秒提交返回超时;再次提交被忽略。以上是用例及预期说明,没有把代码展示当成已经执行过的测试报告。

用例操作预期
正常刷新开始后推进1000毫秒显示4秒
回调迟到不刷新,直接推进3100毫秒下一次显示2秒
后台短暂停留推进2500毫秒后回来显示3秒,仍可作答
后台超过期限推进7000毫秒后回来立即超时,只结算一次
最后1毫秒截止前1毫秒提交接受,再由业务判断动作
恰好截止等于截止点提交超时
连续两次点击第一次接受后再提交第二次忽略
先超时后点击expireIfDue()为true后提交忽略,不加第二条记录
重开后旧回调新token开始后读取旧token无效,不影响新题
主动退出cancel后恢复显示不继续结算旧题

真机回归覆盖模拟、实操两入口的切后台、锁屏、回首页、重开和末秒点击,并核对结算次数与历史条数。

九、Date.now的边界必须明说:回调漂移与改系统时间是两件事

Date.now() 返回日历时间基准,适合记录历史发生在什么时候,也能构建本文的截止点。但用户手动调整系统时间或系统校时,会影响它计算的时间差。上限夹到原始 durationMs 只能防止页面显示超过五秒,不能阻止时间后退延长实际期限。

因此本文方案的适用边界是:同一进程的一次短时练习,系统时间没有发生跳变。如果产品要覆盖系统时间变化、长时间锁屏或具有严肃考试性质,应评估目标 SDK 的单调运行时钟,并明确它是否包含休眠时间。选择时钟必须与“锁屏是否继续计时”的产品规则一致,不能只把 Date.now() 换成另一个函数名就宣布解决全部问题。

需要保存的信息合适的时间语义注意点
历史记录日期日历时间戳便于格式化年月日
同轮作答用时单调经过时间更合适需要核对休眠是否计入
应用回前台剩余期限与开始时相同的时间基准不能开始用毫秒、恢复用秒
设备重启后的考试恢复明确的持久化协议和重启策略运行时钟值不能直接跨启动沿用

原工程还用 Date.now() 求本轮时长。更换计时基准后,日期与用时分开保存,不能混减不同时间域。

十、排查表:把显示问题、时间规则和结算重复分开

现象常见根因优先修复
切后台回来又有5秒回前台调用了begin或重置timeLeft保留截止点,只重算显示
最后1秒还能无限点击提交只看显示值提交入口重新读时钟并裁决
超时写了两条历史显示回调和点击各自结束考试统一一次性裁决方法
回首页后仍出现考试结束内部模块切换没有取消当前题业务退出路径显式cancel
数字超过5秒时间单位混用或时钟后退核对毫秒单位与时间基准
普通组件不触发onPageShow错把子组件当路由页面从所属页面或容器传递可见状态
页面显示不变但内部数值已变UI未直接消费状态变量检查状态绑定与Builder数据读取

落地顺序可以保持克制:先替换时间计算和提交边界,再接前后台恢复,最后检查退出、新题和记录次数。接入后运行 hvigorw assembleHap --no-daemon 验证 ArkTS 编译,再进行真机前后台回归。本文没有执行这些接入后的验证,因此保留这两道验收边界。

定时器负责让页面有机会刷新,截止点负责描述时间是否还有效,裁决器负责让一题只结算一次。把这三个职责分开后,页面即使晚刷新,业务也能按明确的期限作出决定。

Logo

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

更多推荐