Demo:DeadlineLedger | 页面:CountdownPage | 任务:DL-2503
一、那个停在53秒的倒计时,不一定代表任务还有53秒
给离线设备巡检做一张倒计时页面时,我最先关注的是“秒数有没有在变”。屏幕亮着、组件持续收到回调,看起来一切正常。但当人把应用切到后台,隔一会儿再回来,表面上同样的数字就变得不可靠了。计时器是为了安排下一次刷新,业务期限却不应该依赖它跳过了多少次回调。否则一次后台冻结,就可能让超时任务重新获得额外时间。
这次把演示工程固定为 DeadlineLedger,页面叫 CountdownPage,任务编号 DL-2503。任务初始总时长90秒,演示在运行37秒后返回前台,期望看到的是53秒,而不是“定时器累计执行53次”之类的替代指标。图中16:30、90秒、53秒及状态RUNNING都是人为安排的示例,不是真机计时结果,也不是系统对后台执行作出的保证。
二、先定义时钟合同:截止时刻是事实,跳动秒数只是显示
项目最初的写法是每次定时回调执行remaining--。这个写法让逻辑隐含一个前提:每秒都能准时收到回调。华为的定时器资料明确提醒,UI界面退到后台后,定时器会受到冻结机制影响。也就是说,把回调次数作为业务剩余时间的依据,在系统调度层面就站不住脚。改动的核心不是调小定时周期,而是让状态保存一个绝对截止时刻。
本例演示采用墙上时钟毫秒数Date.now():deadlineMs = startedAtMs + 90_000。恢复显示时只做一次差值计算,毫秒先向上取整为秒,再把负数压到零。向上取整有意避免剩余几百毫秒时提前显示零;但如果产品需要按服务端时间计费、核销、授权,则必须由可信服务端裁决,不能把可调整的本地系统时钟当成安全来源。
下面这段是纯业务计算,它不依赖 ArkUI 布局,也不会自行申请系统计时器。让时间作为参数传入,能把“37秒后显示53秒”变成可复算的测试向量,不必通过真实等待来证明公式。这里的completed是业务完成门闩,和界面是否可见不是同一个量。
interface DeadlineState { taskId: string; startedAtMs: number; deadlineMs: number; completed: boolean; } const DEMO_TOTAL_MS: number = 90000; const TASK_ID: string = 'DL-2503'; function remainingSeconds(state: DeadlineState, nowMs: number): number { if (!Number.isFinite(nowMs) || !Number.isFinite(state.deadlineMs)) { return 0; } return Math.max(0, Math.ceil((state.deadlineMs - nowMs) / 1000)); } function markCompleteOnce(state: DeadlineState, nowMs: number): boolean { if (!Number.isFinite(nowMs) || !Number.isFinite(state.deadlineMs)) { return false; } if (state.completed || remainingSeconds(state, nowMs) > 0) { return false; } state.completed = true; return true; }这段代码特意把“已经到零”和“已经处理完成”分开:倒计时归零可能在前台刷新、页面再次显示和外部事件通知时同时被发现,但完成副作用只准触发一次。对于非法的非有限数字,示例保守显示零,并要求上层报告配置错误,不允许将它当作正常结束提交。资源释放不在这个纯函数里发生,它只负责给外层提供明确结论;当任务重新创建时,应使用新的状态对象,绝不把上一任务的门闩沿用下去。
三、定时器只是唤醒器,不是业务时钟
下一步才接入setInterval。它的责任很窄:页面处于可见状态时,每秒请求重新计算一次。在页面隐藏时清掉句柄,重新显示时再建一个。这里没有“后台继续跑一秒钟就加一秒”的逻辑,因此冻结不会产生额外的业务时长。每次启动前都停止旧实例,也是为了避免路由返回和反复展示造成多个定时器并存。
这段封装明确保留计时器 ID,并把回调注入进来。它不会持有页面上的图片、网络请求或长任务;离开页面调用stop()后,也不会继续通过自己的句柄发起更新。需要强调,取消定时器不等于取消业务任务,更不等于取消一条已经发出的服务器请求,两者资源边界必须分别处理。
class DeadlineTicker { private handle: number | null = null; private notify: (nowMs: number) => void; constructor(notify: (nowMs: number) => void) { this.notify = notify; } start(): void { this.stop(); this.notify(Date.now()); this.handle = setInterval((): void => { this.notify(Date.now()); }, 1000); } stop(): void { if (this.handle !== null) { clearInterval(this.handle); this.handle = null; } } }start中先立即校准再安装周期回调,避免刚回到前台时还要多等一秒才能刷新。这个顺序产生的只是显示上的及时性,不是系统承诺每秒精确触发。若页面退出时没有显式停止,回调会无意义地持有页面引用,并可能在离开后修改已不再展示的数据;因此这个句柄必须跟随页面可见性收口,而不是只靠垃圾回收兜底。
四、页面显示和业务完成,要走两条不同的路径
在 CountdownPage 中保留一份任务状态,页面生命周期只管理“是否需要刷新”。onPageShow对应页面再次显示的时机,在这里先重算并恢复 ticker;onPageHide和aboutToDisappear做幂等停止。示例初始化90秒任务,文中的53秒画面表示启动37秒后的一个观察点,而不是页面初次打开时固定写死53。
下面是演示页的最小关键结构。真实工程还应把截止时刻写入适合的任务存储,并在重建页面时恢复同一个任务ID;这里为了突出UI生命周期,示例只演示当前页面实例内的时钟模型。只在markCompleteOnce返回真时输出一次完成日志,避免显示层重复结算。
@Entry @Component struct CountdownPage { @State remaining: number = 90; @State phase: string = 'RUNNING'; private ticker: DeadlineTicker | null = null; private deadline: DeadlineState = { taskId: TASK_ID, startedAtMs: 0, deadlineMs: 0, completed: false }; aboutToAppear(): void { const start: number = Date.now(); this.deadline.startedAtMs = start; this.deadline.deadlineMs = start + DEMO_TOTAL_MS; } onPageShow(): void { this.ticker = this.ticker ?? new DeadlineTicker((now: number): void => { this.remaining = remainingSeconds(this.deadline, now); if (markCompleteOnce(this.deadline, now)) { this.phase = 'FINISHED'; console.info('[DEMO] DL-2503 complete-once'); } }); this.ticker.start(); } onPageHide(): void { this.ticker?.stop(); } aboutToDisappear(): void { this.ticker?.stop(); this.ticker = null; } build() { Column({ space: 20 }) { Text('DeadlineLedger · DL-2503').fontSize(21) Text(`${this.remaining}s`).fontSize(64).fontWeight(FontWeight.Bold) Text(this.phase).fontSize(20) Text('总时长 90 秒 · 返回前台按截止时刻重算').fontSize(15) }.width('100%').height('100%').justifyContent(FlexAlign.Center) } }这段示例还存在一个刻意保留的产品边界:Date.now()会受用户手动调整系统时间影响;如果有多窗口同时维护同一个倒计时,也需要把完成门闩提升到共享任务层。本例只有一张单进程演示页,所以completed可以放在页面状态中。UIAbility 的前后台事件若还要更新共享计时数据,可以调用同一套remainingSeconds计算函数,不能另造一套靠回调次数扣减的算法。
五、验收不用盯秒表,先写清楚几个时间点
我给这次演示准备的时间线是:在16:30:00建立 DL-2503,截止时间16:31:30;16:30:12把页面切到后台;16:30:37重新进入前台。此时距离截止还剩53秒,界面立刻显示RUNNING。注意“后台25秒”的假设只属于演示脚本,并未声称在某台设备上复现过定时器被冻结25秒。模拟画面中所示的数据只是按公式排出的可视化证据。
真正需要核对的是四种情况。其一,回到前台后不允许先闪回78秒再缓慢追到53秒;其二,在16:31:30前不触发完成事件;其三,到期后页面反复显示或定时器多次回调,完成日志仍只能有一条;其四,换成新任务时需要新 taskId 和新的completed=false,防止旧任务状态误伤新任务。这些检查应当由可控时间源驱动,而不是靠肉眼观察随机调度。
为了区分可验证内容,演示日志写成[DEMO] DL-2503 restore now=16:30:37 remain=53s state=RUNNING。它是人工构造的验收样例,不来自 HiLog 真机抓取。公式层能独立执行纯函数测试,生命周期的实际触发顺序以及后台行为则仍需要 DevEco 编译、模拟器和设备上的事件记录补充;本文不会把前者冒充后者。
六、从“定时准确”到“业务可靠”,还隔着一层产品决定
最后需要处理异常时钟。系统时钟被拨快,墙上时钟的差值可能立即归零;时钟被拨慢,剩余秒数可能突然增加。因此,如果这个倒计时只是引导用户等待,当前实现可以配合明显的时间校准提示继续使用;如果它决定是否有权提交设备验收、领取权益或自动结算,就必须引入服务器签发的截止事实与再次核验,不能信任页面本地倒计时。
此外,后台冻结不是“业务自动取消”。页面隐藏时停止 ticker 只是降低无意义刷新成本,仍要考虑任务元数据是否可恢复、应用进程被回收后如何重新识别任务、跨实例完成事件如何幂等。这里的门闩只保证当前演示状态对象的一次触发;跨进程或跨端的严格幂等应由持久化标识或服务端去重承担。
这次改动给我留下的工程判断是:计时器负责通知“该看一眼时间了”,截止时刻负责回答“还有多久”。这两者分开以后,页面可见性、延迟执行和退出释放才有清晰职责。示例代码仍需按目标SDK进行编译和生命周期验证,不能把演示截图中的53秒理解为一次设备实测结论。
参考资料:华为官方 Timer API(UI退后台后定时器冻结提示)、ArkUI 页面与自定义组件生命周期文档。见文末链接:
- https://developer.huawei.com/consumer/cn/doc/harmonyos-references-V14/js-apis-timer-V14
- https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-v5/arkts-page-custom-components-lifecycle-V5