☰
【口算王|03】HarmonyOS ArkTS 限时训练实战:处理倒计时、暂停与页面离开
2026/10/27 1:39:39 网站建设 项目流程

【口算王|03】HarmonyOS ArkTS 限时训练实战:处理倒计时、暂停与页面离开

限时训练最直观的写法是每秒把计数器加一,再用总时长减去已用时。这个方案在页面一直位于前台时通常正常,一旦用户切到后台、系统暂停任务、窗口发生变化、页面被路由替换,或者倒计时归零与手动交卷同时发生,“每秒加一”就不再等于真实经过时间。

本文基于口算王项目本地口算王工程(脱敏路径)的真实源码,复核PracticePage.ets、SettingsPage.ets、EntryAbility.ets、QuestionUtils.ets与UserDataManager.ets。当前实现支持 20、30、45、60 分钟挑战时长,每秒更新倒计时,归零自动提交,页面离开时清理 interval;但没有暂停按钮、前后台时间校准或挑战快照恢复。文章会把现有能力和改进设计分开说明。

证据边界:本文“当前实现”来自本轮对 PracticePage、SettingsPage、EntryAbility、ExamResultPage、QuestionUtils 与 UserDataManager 的静态复核;DeadlineClock、暂停策略、会话快照、幂等结算和故障注入均为建议设计。本轮没有执行构建、模拟器或真机前后台测试,也没有把建议代码合入工程。

一、当前计时模型是什么

PracticePage保存:

@State timerSec: number = 0 @StorageLink('examDurationSec') examDurationSec: number = 1800 private timerId: number = -1

页面出现时启动:

this.timerId = setInterval(() => { this.timerSec++ if (this.mode === 'exam' && this.examRemainSec() <= 0) { this.autoSubmitExam() } }, 1000)

这是“累计 tick”模型:每收到一次 interval 回调,认为经过 1 秒。实现简单,能驱动普通前台场景,但它依赖定时器回调与真实时间保持同步。

二、倒计时显示怎样计算

剩余秒数不是单独状态,而是实时派生:

private examRemainSec(): number { return Math.max( 0, this.examDurationSec - this.timerSec ) }

格式化使用QuestionUtils.formatTime():

const m = Math.floor(sec / 60) const s = sec % 60 return `${String(m).padStart(2, '0')}:` + `${String(s).padStart(2, '0')}`

Math.max(0, ...)保证 UI 不出现负倒计时。剩余时间不大于 60 秒时,文字颜色切换为错误色,否则使用警告色。这些都是当前源码可以确认的行为。

三、挑战时长从哪里来

设置页通过@StorageLink('examDurationSec')共享时长,提供:

20 分钟 = 1200 秒 30 分钟 = 1800 秒 45 分钟 = 2700 秒 60 分钟 = 3600 秒

用户选择后:

this.examDurationSec = value UserDataManager.saveExamDurationSec(value)

UserDataManager把值持久化,应用初始化时再恢复到 AppStorage。这样新进入的练习页能读取最新设置。

要注意,PracticePage直接链接这个全局值。如果设置能在挑战进行期间被其他页面修改,当前场次的总时长也可能随之变化。考试模式下头部没有设置按钮,降低了常规操作中的概率,但模型层仍没有“开场时冻结时长”的明确字段。

四、为什么所有模式都在累计 timerSec

interval 无论章节、随机、单题、错题还是考试模式都会启动,只有自动提交条件检查mode === 'exam'。

因此:

  • 普通练习也会累计timerSec。
  • 考试模式用它计算剩余时间。
  • 完成时会把它作为durationSec传给结果页。

这让同一计时器兼顾练习耗时和考试倒计时,但也意味着所有模式都要正确清理 interval,不能只测试考试页。

五、归零后怎样自动交卷

autoSubmitExam()会先停止 interval:

if (this.timerId !== -1) { clearInterval(this.timerId) } this.timerId = -1

然后计算:

const correctCount = QuestionUtils.correctCount(this.records) const score = QuestionUtils.calcScore( this.records, this.questions.length )

接着更新学习进度,并通过router.replaceUrl()打开结果页。未作答题不会出现在records中,但分母仍是questions.length,因此超时未答题按未得分处理。

六、页面离开时做了哪些清理

真实代码在aboutToDisappear()中:

if (this.timerId !== -1) { clearInterval(this.timerId) }

还会停止并关闭 TTS 引擎。清理 interval 可以防止页面离开后继续每秒修改状态,也避免同一个组件生命周期中累积多个计时器。

但清理动作没有把timerId重置为-1。这通常不妨碍已经完成的clearInterval(),却让字段状态仍像“持有计时器”。统一的stopTimer()方法应同时清理并归位。

七、当前没有真正的暂停能力

源码没有:

isPaused pausedAt totalPausedMs pauseTimer() resumeTimer()

界面也没有暂停按钮。标题中的“暂停”因此应理解为限时训练必须处理的工程问题,而不是口算王已经提供的功能。

如果直接用clearInterval()当暂停,再用setInterval()当恢复,只能阻止 tick 增长;还必须决定后台停留时间是否计入考试,以及恢复时如何避免重复启动 interval。

八、切到后台会发生什么

EntryAbility已实现:

onForeground(): void { hilog.info(...) } onBackground(): void { hilog.info(...) }

两个生命周期目前只记录日志,没有通知PracticePage暂停、校准或提交。页面的 interval 在后台如何调度取决于系统运行策略,不能假设仍会精确每秒回调。

若系统暂停 JavaScript 执行 30 秒,累计 tick 可能比真实时间少;恢复前台后,页面显示的剩余时间就会偏大。对考试类计时,这属于规则漏洞;对普通练习耗时,则属于统计误差。

九、应该使用时间戳还是 tick

更稳健的倒计时以绝对时间为准:

startedAtMs: number deadlineAtMs: number pausedAtMs: number totalPausedMs: number

每次刷新 UI 时计算:

remainingSec = Math.max( 0, Math.ceil( (deadlineAtMs + totalPausedMs - Date.now()) / 1000 ) )

interval 只负责触发界面刷新,不负责定义时间。即使某几次回调被系统延迟,下一次刷新仍会回到真实剩余时间。

这是改进方案,当前源码仍使用timerSec++。

十、暂停语义必须先选规则

“切后台是否暂停”没有唯一答案,产品要明确:

严格挑战

切后台仍计时。恢复时根据绝对 deadline 重新计算,若已经超时立即交卷。这能防止通过切后台冻结时间。

练习模式

切后台自动暂停。恢复时把后台时长加入totalPausedMs,用户可继续训练。

主动暂停

仅用户点击暂停按钮才停止计时;后台仍按产品规则处理。暂停时应遮挡题目和选项,防止用户在不计时状态继续思考。

口算王当前没有区分这三种语义。

十一、页面离开和应用后台不是同一件事

aboutToDisappear()属于页面生命周期,可能由路由跳转、页面返回或页面被替换触发。UIAbility.onBackground()属于应用生命周期,用户按 Home 键或系统把应用转入后台时触发。

两者需要不同处理:

页面离开: 取消刷新任务 保存或结束当前 session 释放页面资源 应用后台: 记录 backgroundAt 按挑战规则决定是否继续计时 应用前台: 用 Date.now() 校准 超时则一次性提交

只在aboutToDisappear()清理 interval,不能完整覆盖前后台切换语义。

十二、返回页面是否应该恢复

当前页面没有保存:

当前题序号 已选答案 答题 records 挑战开始时间 截止时间 暂停累计时间 sessionId

如果页面因路由或进程回收被重建,只能重新执行aboutToAppear()加载题目和启动 interval,无法保证恢复为离开前状态。

要支持恢复,必须先定义快照:

interface ExamSessionSnapshot { sessionId: string bankId: string questionIds: string[] currentIndex: number records: AnswerRecord[] startedAtMs: number deadlineAtMs: number totalPausedMs: number phase: string }

这同样是设计建议,不代表当前项目已持久化挑战快照。

十三、同小时题组有利于恢复

考试模式的 20 道题由pickHourlyExamQuestions()生成。同一bankId和同一 UTC 小时会得到稳定题序。

这对短时间重新进入有一定帮助,但不足以作为恢复机制:

  • 跨小时后题组会改变。
  • 没有保存题目 ID 列表。
  • 没有保存用户已答记录。
  • 没有保存 deadline。

真正快照应保存本次题目 ID,而不是重新依赖当前小时推导。

小时种子适合解决“同一时段题组可复现”,却不具备会话身份。恢复逻辑还要核对题库版本和题目存在性:如果只缺一题,不能简单删除该题后继续沿用原索引,因为 records、当前题号和总题数会随之错位;如果题库整体升级,应按明确策略终止旧场次或加载兼容快照,而不是悄悄换题。

十四、倒计时和手动交卷需要同一个锁

归零时调用autoSubmitExam(),最后一题手动完成则进入goNext()的考试分支。两个方法都会:

停止 interval 计算成绩 更新进度 replaceUrl 到结果页

仅把timerId设置为-1不能阻止另一个结算入口,因为两条路径都没有先检查“本场是否已经完成”。

建议共享:

enum ExamPhase { Active, Paused, Finalizing, Finalized }

任何提交入口都先执行原子式守卫:

if (this.examPhase !== ExamPhase.Active) return this.examPhase = ExamPhase.Finalizing

然后再更新进度和路由。

十五、开始计时也需要防重复

aboutToAppear()直接创建 interval,没有先调用统一的stopTimer()。如果生命周期在同一组件实例上重复进入,而上一次 interval 没有按预期清理,就可能出现两个 tick 同时递增。

稳妥写法:

private startRefreshTimer(): void { this.stopRefreshTimer() this.timerId = setInterval(() => { this.refreshRemainingTime() }, 250) } private stopRefreshTimer(): void { if (this.timerId === -1) return clearInterval(this.timerId) this.timerId = -1 }

250ms 刷新并不意味着时间精度依赖 250ms;真实剩余时间仍由 deadline 计算。

十六、为什么用 ceil 而不是 floor

假设 deadline 还剩 1500ms:

floor(1500 / 1000) = 1 ceil(1500 / 1000) = 2

倒计时 UI 通常希望在最后不足 2 秒时显示 2,直到剩余不超过 1 秒再显示 1,归零后显示 0。使用ceil更符合用户对整秒倒计时的直觉。

但最终超时判断必须基于:

Date.now() >= deadlineAtMs

不能只依赖显示数字,因为显示和业务截止属于两个层次。

十七、设置值需要冻结到 session

设置页允许随时更改全局examDurationSec。挑战开始时应复制:

sessionDurationSec = this.examDurationSec deadlineAtMs = Date.now() + sessionDurationSec * 1000

此后本场挑战使用sessionDurationSec,全局设置只影响下一场。如果直接在每次刷新时读取@StorageLink,外部变化可能让正在进行的考试突然延长或缩短。

当前源码没有单独的sessionDurationSec。

十八、普通练习耗时与考试倒计时应分离

目前所有模式共享timerSec。更清晰的模型是:

elapsedSec:普通练习统计 remainingSec:考试界面派生值 deadlineAtMs:考试业务截止

普通练习可按活动时间累计,也可只记录从进入到完成的墙钟时间;考试必须基于明确 deadline。两个需求不同,不必强行由一个自增字段承担。

十九、限时训练的五层结构

可以拆为:

  1. Config:20/30/45/60 分钟和产品规则。
  2. Session:题组、开始时间、deadline、暂停累计。
  3. Clock:读取Date.now(),计算真实剩余时间。
  4. Lifecycle:前台、后台、页面离开和恢复。
  5. Finalize:超时或手动交卷的一次性结算。

interval 只属于 Clock 的 UI 刷新机制,不应同时承担 Session 的真实时间来源和 Finalize 的唯一入口。

二十、必须覆盖的测试矩阵

20/30/45/60 分钟设置正确恢复 前台持续 10 秒后剩余时间减少约 10 秒 interval 延迟后按墙钟时间校准 最后 60 秒切换警示颜色 归零只提交一次 归零同时点击交卷仍只结算一次 切后台 30 秒后按产品规则处理 前台恢复时已超时立即提交 页面返回后旧 interval 不再运行 重复 aboutToAppear 不产生双计时器 页面销毁后 interval 和 TTS 均释放 进程重建时快照可校验或明确放弃恢复

当前项目没有证据表明这些场景已全部自动化或真机验证,因此文章只给出测试设计。

二十一、如何验证没有定时器泄漏

可在调试版本中为 start/stop 记录 sessionId 和 timerId,但日志不能包含账号、隐私数据或敏感凭据。

关键断言:

任一 PracticePage 实例最多一个刷新 interval 离页后 timerSec 不再增长 再次进入只启动一个新 interval Finalized 后所有刷新任务均停止 后台策略执行后不会残留重复回调

还要观察长时间前后台切换后的 CPU 占用和页面响应,避免仅凭字段值判断没有泄漏。

二十二、当前实现可以确认什么

可以确认:

  • 挑战时长支持 20、30、45、60 分钟。
  • 默认时长为 1800 秒。
  • timerSec每个 interval tick 增加 1。
  • 剩余时间不会显示负数。
  • 最后 60 秒切换警示颜色。
  • 考试归零调用自动提交。
  • 自动提交和手动完成都会停止 interval。
  • 页面离开会清理 interval 和 TTS。
  • 前后台 Ability 生命周期已经存在,但只记录日志。

不能确认:

  • 后台时间会被精确计入或排除。
  • 当前实现支持主动暂停和恢复。
  • 定时器被系统延迟后能按真实时间校准。
  • 挑战快照能在路由或进程重建后恢复。
  • 自动交卷与手动交卷已有共享一次性锁。
  • 计时生命周期已通过真机前后台测试。

二十三、最小改造顺序

第一步:提取 startTimer/stopTimer,保证单实例 第二步:挑战开始时冻结 sessionDurationSec 第三步:改为 startedAt/deadlineAt 的墙钟模型 第四步:定义后台是否计时的明确产品规则 第五步:接入前后台事件并在恢复时校准 第六步:增加 ExamPhase,统一自动与手动结算 第七步:保存题目 ID、records 与 deadline 快照 第八步:补齐前后台、离页、超时竞态测试

限时训练真正要管理的不是一个每秒变化的数字,而是一场有开始、有规则、有生命周期、有唯一结束点的 session。口算王当前已经实现设置、倒计时显示、超时提交和离页清理;要进一步可靠处理暂停与后台,就应让 interval 退回到“刷新 UI”的角色,把真实时间交给 deadline,把重复提交交给一次性状态机,把恢复交给可校验快照。

二十四、先把计时问题改写成状态与事件

定时器问题难排查,往往不是因为少写了一次clearInterval(),而是代码没有回答“此刻是哪一场挑战、处于什么阶段、哪个事件有权改变截止时间”。当前页面只有timerSec和timerId,两者能描述显示进度,却不能表达暂停原因、后台规则或结算是否已经开始。

事件允许的前置状态时间处理业务动作
startidle冻结场次时长并生成 deadline创建 session
pauseactive记录 pausedAt停止刷新并遮挡题目
resumepaused按规则移动 deadline 或保持不变重新计算剩余时间
backgroundactive / paused记录离开时刻保存最小快照
timeoutactive剩余时间归零进入唯一结算入口
submitactive冻结耗时进入唯一结算入口

把这张表先定下来,才能判断后台 30 秒究竟应该扣除、补回还是直接禁止恢复。否则每个生命周期回调都会自行修改计数器,最后得到一个能变化却无法解释的数字。

二十五、让 Clock 负责读时间,让页面只负责展示

页面直接调用Date.now()虽然比累计 tick 准确,但会让测试依赖真实等待。更可迁移的方式是给时间源一个很窄的接口:生产环境读系统墙钟,测试环境由用例主动推进。

interface Clock { nowMs(): number } class SystemClock implements Clock { nowMs(): number { return Date.now() } } interface TimerProjection { remainingSec: number expired: boolean } function projectTimer(deadlineAtMs: number, clock: Clock): TimerProjection { const deltaMs = deadlineAtMs - clock.nowMs() return { remainingSec: Math.max(0, Math.ceil(deltaMs / 1000)), expired: deltaMs <= 0 } }

setInterval()仍然可以每 250ms 或 1000ms 请求一次投影,但回调晚到不会改变截止规则。页面把remainingSec存为可观察状态并渲染,业务层根据expired触发结算;显示频率与计时准确性由此分离。

二十六、严格挑战与练习暂停要使用不同策略

“后台是否暂停”不是技术细节,而是产品规则。严格挑战通常让 deadline 保持不变,用户返回时如果墙钟已经越过截止点,就立即提交;练习暂停则把本次暂停时长累加到totalPausedMs,并把 deadline 向后移动。两种规则不能同时藏在一个布尔值里。

enum BackgroundPolicy { CountElapsed, PauseAndExtend } interface PauseResult { deadlineAtMs: number totalPausedMs: number } function resumeSession(deadlineAtMs: number, pausedAtMs: number, totalPausedMs: number, nowMs: number, policy: BackgroundPolicy): PauseResult { const pausedMs = Math.max(0, nowMs - pausedAtMs) if (policy === BackgroundPolicy.CountElapsed) { return { deadlineAtMs, totalPausedMs } } return { deadlineAtMs: deadlineAtMs + pausedMs, totalPausedMs: totalPausedMs + pausedMs } }

当前EntryAbility.onForeground()与onBackground()只写日志。建议由能力层发布一个窄的前后台事件,Session 服务决定策略,页面只消费新的投影结果;不要把页面实例塞进应用级 Context,也不要让能力层直接操作题目索引。

二十七、恢复快照必须保存题目 ID,而不是重新抽题

当前考试题组由bankId与 UTC 小时共同生成种子,同一小时内能够得到稳定顺序,但跨小时就会整体轮换。若离开页面时只记住 bankId,恢复时重新抽题,用户可能看到另一组题,既有答案也无法可靠对齐。

interface ExamSessionSnapshot { schemaVersion: number sessionId: string bankId: string questionIds: string[] currentIndex: number records: AnswerRecord[] sessionDurationSec: number startedAtMs: number deadlineAtMs: number totalPausedMs: number phase: ExamPhase savedAtMs: number }

恢复前至少校验版本号、题库是否存在、题目 ID 是否能完整解析、索引是否越界、答案是否重复、deadline 是否为有限数值。严格挑战若已经过期,应走 timeout 结算而不是重置时间;练习模式若快照损坏,则明确提示“本次记录无法恢复”,不要静默拼出一场新挑战。

二十八、自动与手动交卷必须收敛到一次结算

静态源码里,autoSubmitExam()和goNext()的考试分支都在计算成绩、更新进度并替换到结果页。倒计时归零与最后一次点击可能落在相邻事件循环中,仅靠把timerId设为-1不能证明业务结算只执行一次。

enum ExamPhase { Active, Paused, Finalizing, Finalized } enum FinalizeSource { Manual, Timeout } private finalizeExam(source: FinalizeSource): void { if (this.examPhase !== ExamPhase.Active) return this.examPhase = ExamPhase.Finalizing this.stopRefreshTimer() const snapshot = this.records.slice() const result = this.buildExamResult(snapshot, source) this.saveAndOpenResult(result) this.examPhase = ExamPhase.Finalized }

这里的关键不是枚举名称,而是先抢占状态,再做有副作用的更新。所有入口都调用同一方法,成绩计算、进度写入、历史记录和路由参数才能保持一致。还应给结算结果携带 sessionId,使持久化层可以拒绝同一场挑战的重复写入。

二十九、结果页写历史也需要幂等边界

ExamResultPage.aboutToAppear()当前会调用addExamHistory()。现有历史项包含 bankId、score、total、correct、durationSec 和 finishedAt,却没有 sessionId。若结果页生命周期再次触发,静态结构本身无法区分“再次展示同一结果”和“完成了另一场内容完全相同的挑战”。

更稳的边界有两种:在finalizeExam()中先以 sessionId 写入历史,再把只读结果传给页面;或者让结果页调用saveIfAbsent(sessionId)。无论选哪一种,写入成功与路由成功都要分别记录。路由失败可以重试打开页面,但不能再次累计学习进度。

三十、设置恢复与持久化失败不能静默

UserDataManager.init()当前在一个try中解析多组 JSON;任一字段解析失败都会进入统一兜底,并把多类 AppStorage 值恢复为默认。persist()又会捕获写入异常而不向调用方反馈。对于挑战时长,这意味着界面可能刚显示“已设置”,下次启动却回到 30 分钟。

function normalizeDuration(value: number): number { const allowed: number[] = [1200, 1800, 2700, 3600] return allowed.includes(value) ? value : 1800 } interface PersistResult { success: boolean message: string }

读取时应按 key 独立解析和校验,坏掉的提醒时间不应顺带清空考试历史;写入时把成功或失败返回给设置页,失败就回滚显示值或给出可重试提示。挑战开始仍要把设置值复制为sessionDurationSec,避免运行中的全局值变化影响本场规则。

三十一、用可控时间源覆盖竞态,不要让测试真的等待

真实等待 20 分钟既慢又不稳定。FakeClock 可以把时间直接推进到 deadline 前后,分别触发刷新、后台、恢复与手动交卷,再断言结算次数和路由参数。这样测试的是业务规则,而不是测试机是否恰好按时调度回调。

用例操作必须断言
回调延迟一次推进 31 秒后刷新剩余时间减少 31 秒,不是 1 秒
严格后台越过 deadline 后回前台只触发一次 timeout 结算
练习暂停暂停 30 秒再恢复deadline 后移 30 秒
边界点击同一时刻发送 timeout 与 submit历史、进度和路由各执行一次
重复出现连续触发两次 aboutToAppear最多存在一个刷新任务
损坏快照缺题、越界或字段非法明确降级,不伪造恢复结果

真机验证仍然不可省略:需要真实按 Home 键、锁屏、切换窗口、改变系统时间、长时间停留后台,再查看恢复时刻、耗时、历史条目和 CPU 占用。本轮没有执行这些步骤,因此这里只给出可复现的验收方法。

三十二、日志只记录状态转换与单调字段

定位重复结算时,最有价值的不是每秒打印 remainingSec,而是记录匿名 session 标识、旧 phase、新 phase、事件来源、deadline 与结算结果。题目文本和用户答案不进入日志。对一次挑战,Active → Finalizing → Finalized应各出现一次;看到第二次 Finalizing,就能直接定位守卫失效。

session=local-42 event=timeout phase=Active->Finalizing session=local-42 event=persist result=success session=local-42 event=navigate phase=Finalizing->Finalized

生产日志还要设置级别和保留范围,避免把调试输出永久留在发布包。开发期可以统计 active timer 数量,离页后应回到零;该计数只是诊断证据,不能替代真实资源与功耗观察。

三十三、总结:计时器只是刷新器,Session 才是业务主体

当前口算王已经具备时长设置、前台倒计时、超时提交、结果参数传递和离页清理,这些都能从源码中确认。它尚未实现墙钟校准、主动暂停、前后台策略、快照恢复和统一结算锁,这些能力也不应通过几行 interval 补丁假装完成。

可靠的改造顺序是:先冻结场次配置,再引入可替换 Clock 与 deadline;随后定义后台策略和 ExamPhase,把所有提交入口收敛到一次结算;最后补上带 sessionId 的快照、幂等历史和可控时间测试。做到这一步,页面显示的每一秒才有明确来源,每一次离开和恢复才有可验证规则。


CSDN-SERIES:ALL-163186659

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询