ArkTS + HarmonyOS 离线计时器:前后台切换与时间漂移修正【共创稿事节】
一个二十五分钟专注计时器,如果只写 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 更新,生命周期负责释放刷新资源。这样即使设备没有按预期频率执行回调,用户回来时看到的也不会是按“调用次数”拼凑出的剩余时间。
相关官方资料:
更多推荐



所有评论(0)