一个二十五分钟专注计时器,如果只写 setInterval(() => second--, 1000),看上去几分钟就能做完。真正让这个小应用暴露问题的场景,是锁屏、接电话、切到别的应用,再隔几分钟回来:页面显示的剩余时间可能仍停在离开前,或明显比实际时间多。只要经历过一次前后台切换,便能理解定时回调不是时钟本身这件事。

离线计时器与时间基准技术封面

这里讨论一个纯本地的专注计时页面,不请求服务器、不需要定位或音频权限。用户可以开始、暂停、继续、重置;页面可见时每隔约一秒刷新一次文案;页面进入后台后不依赖定时器持续唤醒。当用户返回时,使用时间基准计算已经过去的时长,再决定显示什么。它和必须严格计费、秒级审计的计时系统不是一类需求:后者还要解决设备时间可信性、持久存储与跨设备一致性。

为什么每秒减一秒,会越走越慢

setInterval 的参数表达的是回调期望的调度周期,并非强实时承诺。主线程工作繁忙、页面失去焦点、系统节电策略触发,都会影响回调实际运行时机。如果在每次回调中执行 remaining -= 1000,假设中间漏掉四次回调,剩余时间就会比真实时钟晚四秒。即使回到前台之后每秒都正常执行,那四秒的误差也不会自动补回来。

比较稳妥的办法是保存“本次开始的时间戳”和“暂停之前已累计的时间”,每次渲染时重新算差值。公式很简单:运行时已耗时 = 暂停前已累计的时间 + 当前时间 − 本次开始时间;暂停时已耗时就是累计值。剩余时间 = 目标总时长 − 已耗时,并限制在零到目标时长之间。这样回调执行十次还是九次,不再决定业务时间的流逝。它只负责提醒 UI 重新读取一次当前结果。

对于常见的短时离线专注场景,这样的时间基准可以使用标准 Date.now()。不过它是墙上时钟,手动调整系统时间、自动校时等都会影响差值;这与回调调度误差是两类不同问题。本文不会把普通的时间戳计算包装成具备防篡改性质的单调计时。在对时间精准性有强要求的业务中,应选择具备单调特性的时钟与可验证的任务语义,按照目标SDK提供的能力单独评估。

回调次数与实际时间差值的对比时间轴

先把计时逻辑写成可单独检查的模型

将时间计算集中在一个不依赖页面组件的类里,会比在按钮回调里反复修改 @State 更容易验证。模型只管理目标时长、累计时长、运行状态和本次启动时间。页面用它提供的剩余毫秒数生成文案。这样将来要加入圆形进度环、横屏布局或多语言,也不需要重写计时规则。

// model/FocusClock.ets
export class FocusClock {
  private readonly durationMs: number;
  private carriedMs: number = 0;
  private startedAtMs: number = 0;
  private running: boolean = false;

  constructor(durationMs: number) {
    this.durationMs = Math.max(1000, durationMs);
  }

  start(nowMs: number): void {
    if (this.running || this.remaining(nowMs) === 0) { return; }
    this.startedAtMs = nowMs;
    this.running = true;
  }

  pause(nowMs: number): void {
    if (!this.running) { return; }
    this.carriedMs = this.elapsed(nowMs);
    this.running = false;
  }

  reset(): void {
    this.carriedMs = 0;
    this.startedAtMs = 0;
    this.running = false;
  }

  isRunning(): boolean { return this.running; }

  elapsed(nowMs: number): number {
    const part: number = this.running ?
      Math.max(0, nowMs - this.startedAtMs) : 0;
    return Math.max(0, Math.min(this.durationMs, this.carriedMs + part));
  }

  remaining(nowMs: number): number {
    return this.durationMs - this.elapsed(nowMs);
  }
}

注意这里把 nowMs 当作参数传进去,而不是在模型内部每个方法都直接调用 Date.now()。这是个小小的工程取舍:页面正常使用传当前时间,单元测试则可以传固定的数字。例如 clock.start(10000),在 13000 查询应该消耗三秒;暂停于 15000,之后查询 60000 时仍只累计五秒;再次 start(60000) 后计算又接着走。无需真的等待一分钟就能验证业务逻辑。

模型在本例中选择“只有用户点击暂停才停止计时”。应用进入后台不等同于用户暂停,仍然应计入经过的时间;这和锻炼计次、运动记录等产品的定义可能不同。计时需求在写代码之前要把这项规则说明白,否则一段完全正确的生命周期代码,也可能做出不符合产品预期的行为。

页面生命周期只管理刷新,不决定时间流逝

入口页中保留一个 FocusClock 实例,同时维护用于展示的文案与按钮状态。onPageHide 停止 UI 的周期刷新,onPageShow 立刻读取一次剩余时间,并按当前运行状态决定是否重新启动刷新回调。组件最终销毁时再清理一次资源。这三个动作是为了节约无意义的后台执行和避免重复定时器,不会替用户调用 pause()。

// pages/Index.ets
import { FocusClock } from '../model/FocusClock';

@Entry
@Component
struct Index {
  private clock: FocusClock = new FocusClock(25 * 60 * 1000);
  private timerId: number = -1;
  @State remainText: string = '25:00';
  @State running: boolean = false;
  @State finished: boolean = false;

  private updateText(): void {
    const ms: number = this.clock.remaining(Date.now());
    const s: number = Math.ceil(ms / 1000);
    const mm: string = Math.floor(s / 60).toString().padStart(2, '0');
    const ss: string = (s % 60).toString().padStart(2, '0');
    this.remainText = `${mm}:${ss}`;
    if (ms === 0) {
      this.clock.pause(Date.now());
      this.running = false;
      this.finished = true;
      this.stopTicker();
    }
  }

  private stopTicker(): void {
    if (this.timerId !== -1) {
      clearInterval(this.timerId);
      this.timerId = -1;
    }
  }

  private startTicker(): void {
    this.stopTicker();
    if (!this.clock.isRunning()) { return; }
    this.timerId = setInterval(() => { this.updateText(); }, 1000);
  }

  onPageShow(): void {
    this.updateText();
    this.startTicker();
  }
  onPageHide(): void { this.stopTicker(); }
  aboutToDisappear(): void { this.stopTicker(); }

  build() {
    Column({ space: 20 }) {
      Text('离线专注计时').fontSize(26).fontWeight(FontWeight.Bold)
      Text(this.remainText).fontSize(58).fontWeight(FontWeight.Bold)
      Text(this.finished ? '本轮完成' : (this.running ? '进行中' : '已暂停'))
      Row({ space: 12 }) {
        Button(this.running ? '暂停' : '开始 / 继续').onClick(() => {
          if (this.running) {
            this.clock.pause(Date.now());
            this.stopTicker();
          } else {
            this.clock.start(Date.now());
            this.finished = false;
            this.startTicker();
          }
          this.running = this.clock.isRunning();
          this.updateText();
        })
        Button('重置').onClick(() => {
          this.stopTicker();
          this.clock.reset();
          this.running = false;
          this.finished = false;
          this.updateText();
        })
      }
    }.width('100%').height('100%').justifyContent(FlexAlign.Center)
  }
}

这里有几个非常实际的细节。第一,startTicker() 总是先清理旧回调,防止页面重复显示后出现两个定时器竞争更新文案。第二,剩余时间使用 Math.ceil() 转换为秒,刚开始显示 25:00,直到真正过了一秒才变为 24:59,避免一启动就少一秒的感受。第三,达到零时停止计时与刷新,页面显示“本轮完成”,不会让时间继续变成负数。第四,页面进入后台时仅停止回调而不改变 running,返回时的第一次 updateText() 就会按经过时间更新结果。

页面显示、隐藏、恢复与计时状态的关系图

别把组件销毁当成应用永远结束

aboutToDisappear() 适合做同步的计时器清理,不适合拿来承诺某个后台任务一定已完成。页面被系统重建后,这个内存中的 FocusClock 也会重新初始化为二十五分钟;应用进程结束后更不能依靠一个类实例继续保存任务。因此本示例解决的是“页面暂时隐藏但当前实例仍存在”时的准确刷新,不是“跨进程永久恢复”的完整方案。若产品必须在重启之后继续一次专注任务,需要把目标时长、运行状态、起点与累计时间通过适当的持久化机制保存,并在恢复时检查时钟变化与业务有效性。不能把任何秒级计时都简单扔给一个后台循环。

测试可以分三组。第一组固定输入模型时间:开始、运行五秒、暂停、推进到六十秒仍不变化、再次开始,再运行三秒,总耗时应为八秒。第二组检查页面:前台开始后切到后台,稍后回前台,界面要在恢复时直接跳到根据时间差算出的值,不能等定时器累计。第三组检查资源:快速切前后台、反复点暂停/继续,计时器数量应始终不超过一个;重置后不再继续更新旧周期。建议在调试阶段对 startTicker 与 stopTicker 记录日志,确认调用能成对出现,而不是只凭屏幕文字推断定时器已经释放。

还有一个不能忽视的边界:Date.now() 受用户手动更改系统时钟影响。代码中的 Math.max(0, ...) 只处理时间倒退造成的负差值;如果用户把时间向前拨一小时,离线计时器可能立即显示结束。对于专注提示类工具可以把这件事当成明确的产品限制,并在恢复时发现明显不合理的时差后要求用户确认;对于考勤、计费、比赛计时等场景,则要设计更可信的时间来源与审计链路。回调纠偏解决的是调度问题,不自动解决时间可信问题。

从架构上看,一次成功的优化没有增加神秘 API,而是把时间计算与页面刷新拆开:模型按时间戳计算业务时间,页面用定时器驱动可见时的 UI 更新,生命周期负责释放刷新资源。这样即使设备没有按预期频率执行回调,用户回来时看到的也不会是按“调用次数”拼凑出的剩余时间。

相关官方资料:

Logo

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

更多推荐