灯光模拟HarmonyOS应用实战-25-切后台后倒计时不准:用截止时间重算并统一超时判定
一道灯光题允许操作五秒,切后台几秒回来却可能还剩“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 页面生命周期。普通子组件、业务页签不能一概当成独立页面。相关边界见华为 页面和自定义组件生命周期。
原工程的首页、模拟练习、错题页通过同一个 Index 的 currentPage 分支切换。改变这个字符串不会自动销毁 Index,也不能指望触发另一套页面生命周期。所以接入时要分两条路径:
// 放入原Index的退出本轮、回首页等业务方法中。
private cancelQuestionTiming(): void {
this.stopTimer();
this.questionDeadline.cancel();
}
// 新字段沿用本文核心类,需在Index顶部导入QuestionDeadline。
private questionDeadline: QuestionDeadline = new QuestionDeadline();
片段复用原 stopTimer();退出时仍须保留灯光、动画、考试状态复位。
前后台恢复的顺序是先同步读截止点,再决定是否开显示定时器。如果先重新设置五秒,再启动定时器,就把恢复写成了重开;如果先开定时器等下一次回调,页面会短暂显示旧数字。示例中的 resumeDisplayTimer() 已先调用一次 refreshRemaining()。

七、接到灯光判题入口时,时间校验必须发生在动作副作用之前
原工程有 handleLightAction() 和实操动作入口,正确动作还会启动反馈与下一题调度。时间校验应该紧跟已有“是否处于作答期”的状态门闩,位于灯光动作、得分和记录之前。超时后再回滚动画或减回得分,会把一次简单裁决变成多处补偿。
建议接入顺序如下,每一项都有明确责任:
- 原入口确认当前模式、题目对象、作答锁有效。
- 调用
questionDeadline.submit(token);显式携带旧token的调用会被忽略,到期则转原handleTimeout()。 - 未超时才处理动作与答案比较,正确走原正确反馈,错误走原结束逻辑。
- 下一题真正呈现时调用
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 编译,再进行真机前后台回归。本文没有执行这些接入后的验证,因此保留这两道验收边界。
定时器负责让页面有机会刷新,截止点负责描述时间是否还有效,裁决器负责让一题只结算一次。把这三个职责分开后,页面即使晚刷新,业务也能按明确的期限作出决定。
更多推荐

所有评论(0)