尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

鸿蒙原生计时器开发复盘:避开后台精度、提醒与状态恢复的坑

鸿蒙原生计时器开发复盘:避开后台精度、提醒与状态恢复的坑 传统计时器看似不难但真正要做成“多组可自定义、锁屏后仍能准点提醒、App 被杀也不丢状态”的原生计时器应用技术细节远比想象中多。一位习惯了 Web 开发的开发者想给鸿蒙写一个原生计时器 APP结果连续失败两次第三次才把完整链路跑通。这篇文章不会只讲故事而是把两次失败拆成两条可复用教训再给出一套基于 Stage 模型 ArkTS 的最小计时器工程方案用来避开同样的问题。鸿蒙原生开发最容易被低估的地方不是 ArkUI 组件怎么画而是应用生命周期、后台任务、系统提醒和页面状态如何配合。计时器这一类“长时间运行、到点需要提醒”的应用恰好会把这些问题全部暴露出来。看完这套复盘你不仅能理解为什么有人会连续失败也能知道自己的鸿蒙计时器项目应该从哪些地方入手设计。1. 一个“再也不想被计时器绑定”的需求为什么值得用鸿蒙原生重写计时器应用的市面产品并不少但大部分只能完成最基本的功能。真正频繁使用计时器的人往往同时面对几个更具体的需求一段倒计时要起名字、一天里要排多个连续任务、倒计时切后台后最好不掉精度、到点后要能明确提醒而不是只有弱提示。这类需求单靠传统计时器很难满足于是自己开发几乎成了必经之路。1.1 传统计时器在真实使用中的三个缺口第一次让这位开发者产生自研冲动的场景是一天里需要按项目切割时间。他打开手机里的计时器发现只能设置一个倒数时间不能给任务命名也不能把多个倒计时并列管理。第 2 个缺口是应用切到后台后锁屏一段时间回来倒计时往往会“慢”或者“卡住”。第 3 个缺口是倒计时结束后的提醒不够明确用户希望它能像闹钟一样从系统层面被调度而不是只在前台页面弹一个状态。这些缺口最终都变成了技术问题多任务语义要求设计合理的数据模型。后台精度要求应用不能依赖页面上的普通定时器。准确到点提醒要求接入系统提醒能力。进程被杀后状态恢复要求做本地持久化。一个面向普通用户的简单场景落到原生开发层面横向覆盖了 UI 状态管理、生命周期、后台调度、通知提醒和本地存储。这类项目很适合用来理解鸿蒙应用工程。因为规模不大每一块都能单独看明白但因为场景真实又必须完整把链路打通。1.2 为什么不做跨端或者 Web 页面而是选择原生对这个项目来说原生不是最优选而是唯一能满足核心诉求的选择。因为计时器应用最关键的“到点提醒”和“后台行为”最终都要和操作系统的调度机制绑定。跨端框架可以封装页面也可以封装部分原生模块但遇到系统级提醒、通知点击跳转、Ability 生命周期这些能力时最终还是要去写桥接层和原生代码。在鸿蒙生态里原生开发通常指使用 DevEco Studio、ArkTS 和 ArkUI 构建的 HarmonyOS 应用。与普通 Web 页面或者小程序相比它的核心优势在下面几点领域Web 或小程序开发鸿蒙原生开发UI 刷新通常由框架 diff DOM手动更新控件容易失控声明式 UI状态变化驱动组件刷新层级清晰后台能力依赖浏览器或宿主 App 分派限制明显原生 Ability 生命周期可申请系统后台能力到点提醒Web 页面关闭后基本不可用可调用提醒代理应用不存活也能触发提醒本地数据localStorage 或 host 能力Preferences、数据库等原生能力直接可用跨端方案并不是不能开发计时器而是要把“切后台不偏差”和“到点系统提醒”做成稳定体验成本会明显增加。对一个想要快速找到最小可行方案的开发者来说鸿蒙原生反而更容易实现闭环。1.3 复盘这条路线时要掌握哪些前置知识进入代码前可以先把这条技术路线拆成几个阶段。阶段目标涉及内容环境搭建能创建并运行鸿蒙工程DevEco Studio、项目模板、签名或模拟器基础页面掌握声明式 UI 的状态驱动方式ArkTS、State、Entry、组件封装核心计时保证页面在前后台切换时不丢精度时间戳计算、setInterval 清理、生命周期系统提醒到点后即使 App 被杀也能提醒reminderAgentManager、权限、reminderId 管理本地持久化重启应用后能恢复未完成的计时Preferences、对象序列化运行验证覆盖锁屏、杀掉进程、重复启动等场景真机调试、日志、边界测试2. 第一次失败按 Web 时代的手感写 ArkUI状态越改越乱很多有 Web 开发经验的人转向鸿蒙时遇到的第一个障碍不是语法而是思维停留在命令式更新 UI 上。这位开发者的第一版页面能正常画出来但交互越写越怪最终不得不推翻重来。2.1 失败现场页面能渲染交互却开始“各自为政”第一版的主要现象有三个按下开始按钮后倒计时文本有时不更新。连续按开始键页面出现多个计时器同时在跳。暂停和重新开始后显示剩余时间和实际经过时间对不上。这类问题的典型原因是开发者沿用了 Web 开发里的做法把“剩余秒数”放在一个普通变量里然后每隔一秒去定位到页面上的文本节点手动把新内容写进去。在 ArkUI 里这样的更新路径绕过了框架的状态管理机制组件不会因为外部变量变化而重新渲染于是页面性能、刷新时机和用户操作之间就产生了裂缝。下面这段伪代码代表了当时的错误思维真正在 ArkTS 中并不能这样操作// Web 思维伪代码先改变量再手动找控件改文本 let remainSecond 25 * 60; let timer setInterval(() { remainSecond--; document.querySelector(#time).textContent format(remainSecond); }, 1000);在 ArkUI 中使用State声明状态后页面会随状态自动更新不需要手动去寻找 Text 节点并写入内容。基本写法如下Entry Component struct CountDownView { State remainMs: number 25 * 60 * 1000; private pad(n: number): string { return n 10 ? 0${n} : ${n}; } private format(ms: number): string { const totalSec Math.floor(ms / 1000); const min Math.floor(totalSec / 60); const sec totalSec % 60; return ${this.pad(min)}:${this.pad(sec)}; } build() { Column({ space: 12 }) { Text(this.format(this.remainMs)) .fontSize(48) .fontWeight(FontWeight.Bold) Button(减少一秒) .onClick(() { this.remainMs Math.max(0, this.remainMs - 1000); }) } .width(100%) .height(100%) .justifyContent(FlexAlign.Center) } }这段代码没有修改任何控件实例只是改remainMs。当按钮点击后remainMs发生变化ArkUI 会重新执行build()文本自然更新成新值。这就是声明式 UI 和命令式 UI 的最大区别。2.2 为什么“状态单一”是 ArkUI 组件设计的底线第一次失败的本质是数据模型没有设计好。倒计时页面里同时存在“任务名称、总时长、剩余时长、是否运行中、结束时间点”等多条状态。一个人如果把这些状态散落在不同全局变量、组件属性和临时控制器里任何一次用户操作都会变成一次状态同步灾难。合理的做法是在进入页面之前先定义组件需要什么状态再决定组件怎么分层。页面上只保留State声明的最小状态普通常量不要混入 UI 状态。计算属性如剩余文本尽量由组件即时计算而不是多个地方各自维护“当前秒数”。第一次失败的项目后来被重构为下面这样的状态模型这也为第三次成功打下了基础Observed class TimerTask { id: number Date.now(); name: string ; totalMs: number 0; remainMs: number 0; endTime: number 0; running: boolean false; reminderId: number -1; }这个类中最关键的是endTime。它不是用户操作产生的“剩余时间副本”而是后续所有时间计算都依赖的唯一时间基准。3. 第二次失败页面在前台一切正常锁屏回来却“偷走”了几十秒第一版重构后页面交互已经稳定下来。开发者随后把应用安装到真机上测试结果发现一个更致命的问题倒计时在前台正常一旦锁屏或者切到后台再回到页面时时间经常慢了几十秒有时甚至完全卡住不动。3.1 失败现象回来时倒计时不等于真实经过的时间现场表现是从 25 分钟开始倒数锁屏 5 分钟解锁回来看见剩余 21 分 30 秒左右而不是 20 分钟。继续观察时界面又会在下一秒突然跳到正确值或者恢复后跳得特别快。这说明页面里的定时器在后台被系统冻结恢复后没有补上缺失的时间。还有更极端的情况应用在后台被系统回收等再次打开时倒计时竟然恢复到了刚启动时的 25 分钟。这说明应用没有在进程被杀前持久化当前状态也没有把到点提醒交给系统。3.2 根因一用每秒递减来维护剩余时间进程一挂起就不准很多开发者会写这样的定时器State remainSec: number 25 * 60; start() { setInterval(() { // 这种方式看起来直接但后台时回调可能不再执行 this.remainSec--; }, 1000); }这样写存在的问题是1 秒只是浏览器或 UI 主线程调度器的近似值。当应用进入后台系统为了省电会降低冻结应用的时间片或者暂停其主线程调度。此时setInterval即使没有被销毁回调也可能不会严格按照每秒一次触发。于是出现了“锁屏 5 分钟后回来剩余时间只减少了 4 分 30 秒”的偏差。真正可靠的做法是启动计时时用“结束时间点 当前时间 剩余时间”记录目标UI 每次刷新时用当前时间减去结束时间点来得到剩余时间。这样即使回调延迟也不会把延迟误判成时间流逝。private timerId: number -1; private endTime: number 0; State remainMs: number 25 * 60 * 1000; start() { if (this.timerId ! -1) { return; } this.endTime Date.now() this.remainMs; this.timerId setInterval(() { const rest this.endTime - Date.now(); if (rest 0) { clearInterval(this.timerId); this.timerId -1; this.remainMs 0; return; } this.remainMs rest; }, 200); } pause() { if (this.timerId -1) { return; } this.remainMs Math.max(0, this.endTime - Date.now()); clearInterval(this.timerId); this.timerId -1; }这段代码中定时器只负责“刷新 UI 显示”不负责累加时间。应用即使在后台被冻结 5 分钟回到前台后 interval 恢复一次计算就能把剩余时间跳到正确值。setInterval的间隔也不需要设定成 1000 毫秒这里使用 200 毫秒是为了让进度条和文本更新更平滑。实际项目可以根据精度需求调整。3.3 根因二到点提醒不应该由“页面里的定时器”撑全流程改造完时间戳算法后后台回来时间偏差问题消失了但还剩下一个隐性问题如果用户在倒计时过程中把应用进程杀掉页面定时器会被一起销毁。此时还需要有人负责“到点后提醒用户”。这已经不是前台计时问题而是后台任务和系统提醒问题。最终方案是把到点提醒交给系统提醒代理让应用在后台或被杀后也能准点提醒。三类方案在计时器场景中的定位差别方案基本原理适用场景风险或限制普通 setIntervalApp 活着且主线程调度不冻结时触发前台秒表、页面内的短计时后台或锁屏时不可靠长时任务向系统申请后台继续运行下载、导航、录音等系统允许场景倒计时应用是否能申请取决于系统定义类型耗电和保活会成为问题提醒代理把到点条件发布给系统App 存活状态不影响触发闹钟、日历提醒、倒计时提醒要保存 reminderId取消任务时同步移除这个选择让项目从“页面应用”升级成了“系统能力调用者”。应用仍然可以在前台显示流畅的倒计时动画但到点的“最终裁判权”交给了系统提醒代理彻底规避了进程被杀后的失效问题。4. 第三次跑通一个最小可运行的鸿蒙原生计时器工程怎么拆第二次失败之后技术路线已经清楚ArkUI 状态驱动页面时间戳保证精度提醒代理保证后台触发Preferences 保存任务状态。第三次开发没有急着写页面而是先把工程结构和功能动作整理好再开始实现。4.1 环境准备与工程创建顺序开发鸿蒙原生应用前需要按以下顺序确认环境安装 DevEco Studio建议使用支持 Stage 模型和 ArkTS 的较新版本。首次启动后配置 HarmonyOS SDK。工程创建时选择 Application Empty Ability。编译模型保持默认的 Stage 模型。真机调试前开启开发者模式或直接使用模拟器。不能忽略的一点是版本差异。不同 HarmonyOS SDK 版本对提醒代理、Preferences、后台任务等接口的封装可能不同。项目开发前先把依赖版本和 SDK 版本固定下来否则后面查找报错非常痛苦。4.2 工程目录结构与核心模块按 Stage 模型创建的工程目录基本如下entry/src/main/ module.json5 ets/ entryability/ EntryAbility.ets pages/ TimerHome.ets components/ TimerCard.ets model/ TimerTask.ets utils/ StorageUtil.ets ReminderUtil.ets resources/ base/ element/ media/这个目录把模型、工具、组件和页面分开避免所有代码都堆在一个超大.ets文件里。4.3 倒计时任务模型要先于页面确定多组倒计时需要维护一个数组。在 ArkUI 中如果要让子组件观察对象内部属性的变化可以在组件间使用ObjectLink绑定一个被Observed标记的类实例。Observed export class TimerTask { id: number Date.now(); name: string ; totalMs: number 0; remainMs: number 0; endTime: number 0; running: boolean false; reminderId: number -1; }页面父组件持有任务列表Entry Component struct TimerHome { State tasks: TimerTask[] []; State refreshTick: number 0; private tickTimer: number -1; aboutToAppear(): void { this.restoreTasks(); this.startTick(); } aboutToDisappear(): void { this.stopTick(); this.saveTasks(); } private startTick(): void { this.tickTimer setInterval(() { // 用 refreshTick 触发页面按固定节奏重新计算剩余时间 this.refreshTick; this.checkFinished(); }, 250); } private stopTick(): void { if (this.tickTimer ! -1) { clearInterval(this.tickTimer); this.tickTimer -1; } } }这里间隔刷新的重点是“显示更新”不是时间累计。页面每 250 毫秒重新评估一次所有任务计算它们是否已经到达结束时间。任务具体剩余多少毫秒由结束时间点和当前时间现场算出。为了减少不必要刷新更精细的工程可以让每个子组件自己本周期但示例中统一刷新更容易理解。4.4 多任务列表与单个计时卡片的组织方式页面主体使用List和ForEach渲染每个任务单个任务用独立TimerCard组件封装。Component export struct TimerCard { ObjectLink task: TimerTask; Prop refreshTick: number 0; private remainText(): string { if (this.task.running) { const rest this.task.endTime - Date.now(); return this.formatTime(Math.max(0, rest)); } return this.formatTime(this.task.remainMs); } private formatTime(ms: number): string { const totalSec Math.ceil(ms / 1000); const min Math.floor(totalSec / 60); const sec totalSec % 60; return
返回列表