鸿蒙 ArkTS 实战:倒计时 Countdown

倒计时是与秒表互为镜像的另一类时间型应用:秒表从零向前数,倒计时从目标值向后数。两者的代码骨架非常相似——都依赖定时器驱动、状态管理刷新界面、生命周期清理资源——但倒计时多出了两个值得单独学习的点:一是要从用户的输入中解析目标秒数并做合法性校验,二是要用进度环(Progress 的 Ring 形态)直观展示"剩余比例"。 均与真实源码一致,对照 index6.ets 阅读体验更佳。
1. 应用概述与功能
index6.ets 定义了一个名为 Index6 的 @Entry 组件,实现一个完整的倒计时页面。页面自上而下分为几块:顶部返回栏,包含"返回"按钮(router.back())与标题"倒计时";紧接着是页面的视觉重心——一个深色圆形卡片,卡片内部居中叠放着一个蓝色进度环,环中心显示"剩余时间"字样和剩余时间的 mm:ss 大字;再往下是秒数输入区,包含一个数字输入框(TextInput),上方有提示文案"输入总秒数",输入框占位符为"如 90";输入区下方是三个快捷按钮"1 分"“5 分”“10 分”,分别对应 60、300、600 秒;页面底部是"开始/暂停"与"重置"两个控制按钮。
从功能上看,倒计时支持这样几条操作路径:直接在输入框填入秒数后点击"开始",倒计时以每秒一次的节奏递减,进度环同步收缩,用百分比反映剩余比例;计时过程中点击"暂停"可以停在当前值,再点"开始"继续;点击"重置"把剩余时间恢复到设定的总秒数、进度环回到满格;点击快捷按钮会立即把总时间设定为对应分钟数,同时进度环复位;当剩余时间递减到零时,自动停止计时,并通过 promptAction.showToast 弹出"时间到!"的提示。
值得一提的细节是输入与启动的配合逻辑:首次点击"开始"时,如果还没有设定过总秒数,代码会从输入框解析数值并作为总时间;而只要在未计时状态下修改过输入框内容,之前设定的总时间就会被清除(this.total = 0),下次点击"开始"会重新读取输入框的值。这种"输入永远是最新的"设计,避免用户改完数字后还得先重置才能生效的困惑。
从整体结构看,Index6 同样是一个标准的 @Entry + @Component 页面:状态声明集中在组件顶部,逻辑函数紧随其后,最后是 build 布局函数。与秒表相比,这个页面多了一层"用户输入"的入口,因此代码里出现了 TextInput 的 onChange 回调和 parseFloat 解析,其余骨架如返回栏、定时器句柄、生命周期清理都与秒表一脉相承。两个页面放在同一个 pages 目录下互为对照,非常适合用来体会"同一种骨架,不同的业务逻辑"应该怎么组织。
2. 核心知识点
@State 驱动状态刷新。 源码中 input、total、remain、running、progress 五个成员全部用 @State 装饰。与秒表示例不同,这里的 running 也直接作为 @State,因为"开始/暂停"按钮的文案 Button(this.running ? '暂停' : '开始') 依赖它刷新,而倒计时的递减不总是伴随其他状态变化,不装饰的话按钮可能不更新。五个状态各有分工:input 是输入框文本,total 是总秒数,remain 是剩余秒数,running 是运行标志,progress 是进度环的 0~100 数值。
Progress 进度环组件。 这是与秒表相比新增的 UI 能力。源码用 Progress({ value: this.progress, total: 100, type: ProgressType.Ring }) 创建了一个环形进度组件,value 是当前值、total 是总量、type 指定环状形态,并通过 .color('#1a6cff') 设置颜色、.style({ strokeWidth: 14 }) 设置环的粗细。剩余时间越少,progress 越小,环越收缩,视觉反馈直观。
setInterval 秒级定时器。 倒计时的刷新节拍是 1000 毫秒,每次回调把 remain 减一,再判断是否到零。与秒表不同,这里直接用"每秒减一"就够用,因为倒计时不需要厘秒级精度,且它的误差只在显示层面——即使回调延迟,最终停止的时机也会落在数值归零的判断里。
TextInput 输入框与 InputType.Number。 倒计时的总时间来自用户输入。TextInput({ text: this.input }) 创建输入框,.type(InputType.Number) 限定只能输入数字,onChange 回调实时同步 input 状态。由于限定了数字键盘,输入内容天然是数字字符串,但仍需在启动时用 parseFloat 解析并校验。
promptAction.showToast 轻提示。 页面顶部 import 了 promptAction,在两种情况下弹出 Toast:输入无效秒数时提示"请输入有效秒数",倒计时结束时提示"时间到!"。Toast 是鸿蒙中最轻量的用户反馈手段,不打断操作。
Stack 叠放布局。 进度环与环中心文字是叠放关系,源码用 Stack 容器:先放 210x210 的 Progress,再放一个 Column(含"剩余时间"与时间大字),两者居中对齐,形成"环包文字"的视觉效果;外层再包一层 230x230 的深色圆形卡片,borderRadius(115) 使卡片呈正圆。
生命周期清理与定时器句柄管理。 stopTimer() 统一负责 clearInterval,aboutToDisappear 在页面销毁时兜底清理,与秒表示例一脉相承,凡是 setInterval 的创建与销毁都必须成对出现。
这个示例在状态设计上比秒表更进一步:五个状态全部加 @State,尤其是 running 与 progress。因为倒计时的数据流是"递减余数 + 收缩进度环 + 切换按钮状态"三者并行的,任何一个 @State 缺失都会导致对应 UI 不刷新。与之相对,输入框的 onChange 回调把每一次键入都同步到 input,这种"受控输入"的模式让输入框的值始终与状态保持一致,无论焦点如何变化,读取 this.input 得到的都是最新文本。
另外值得体会的是 total 与 remain 这对状态的分工:total 只在"设定总时间"的两个入口发生变化,remain 则在运行期间被定时器逐秒改写。把"目标值"和"当前值"分开维护,比只维护一个"剩余秒数"要清晰得多——重置需要回填目标值、进度环需要做除法,两者都依赖这对分工明确的状态。这个设计思路同样适用于秒表里的 elapsed 与 startTime:一个负责累积,一个负责基准,各有各的职责。
最后回到 Progress 组件本身。它是 ArkUI 内置的基础组件,除了 ProgressType.Ring 环状形态,还有 Linear(线性条)、Scale(刻度环)、Capsule(胶囊)等形态,适用于加载、评分、存储占用、健康指标等不同场景。理解本示例里"数值状态 + 进度组件"的组合方式后,迁移到其他形态只是换一个 type 参数的事。这一层抽象的价值在于:开发者不需要自己画环、算弧长,只要维护一个 0~100 的数值,剩下的绘制交给组件完成。
3. 源码逐段解析
3.1 状态与辅助函数
@State input: string = '';
@State total: number = 0;
@State remain: number = 0;
@State running: boolean = false;
@State progress: number = 0;
private timer: number = -1;
五个 @State 加一个私有定时器句柄。input 记录输入框文本,total 是被确认的总秒数,remain 是当前剩余秒数,running 控制开始/暂停切换,progress 是进度环数值。timer 保持普通私有成员,句柄 -1 表示当前没有活跃的定时器。
private formatTime(sec: number): string {
const s: number = Math.max(0, Math.floor(sec));
const m: number = Math.floor(s / 60);
const r: number = s % 60;
const mm: string = m < 10 ? '0' + m : String(m);
const ss: string = r < 10 ? '0' + r : String(r);
return mm + ':' + ss;
}
formatTime 把秒数格式化为 mm:ss。注意第一行 Math.max(0, Math.floor(sec)):先向下取整,再钳制到不小于 0,这样即使传入负数或带小数的值,显示也不会出现 -1 或 00:00.9 这种异常。分和秒分别通过对 60 取整、取余得到,再各自补零,与秒表的 format 思路一致。
3.2 设定总时间 applyTotal
private applyTotal(sec: number): void {
this.stopTimer();
this.total = sec;
this.remain = sec;
this.running = false;
this.input = String(sec);
this.progress = 100;
}
applyTotal 由三个快捷按钮调用(applyTotal(60)、applyTotal(300)、applyTotal(600))。它先把可能存在的定时器停掉,然后一次性设定总秒数、剩余秒数、输入框文本,并把运行状态置为 false、进度环拉满到 100。把输入框文本同步为 String(sec) 是本函数的一个贴心设计:点击"1 分"后,输入框会显示 60,用户能直观看到当前设定的总秒数,形成输入框与快捷按钮的互相确认。
3.3 开始与暂停 toggle
private toggle(): void {
if (this.running) {
this.stopTimer();
this.running = false;
return;
}
if (this.total <= 0) {
const val: number = parseFloat(this.input);
if (Number.isNaN(val) || val <= 0) {
promptAction.showToast({ message: '请输入有效秒数' });
return;
}
this.total = Math.floor(val);
this.remain = this.total;
}
this.running = true;
this.timer = setInterval(() => {
this.remain -= 1;
if (this.remain <= 0) {
this.remain = 0;
this.running = false;
this.stopTimer();
promptAction.showToast({ message: '时间到!' });
}
this.progress = this.total > 0 ? Math.round(this.remain / this.total * 100) : 0;
}, 1000);
}
toggle 是开始与暂停的统一入口。若正在运行则直接停止;否则进入启动逻辑:当 total 尚未设定(初始为 0,或在未运行时改过输入框被清为 0)时,用 parseFloat 解析输入框,Number.isNaN(val) || val <= 0 判断非法并弹 Toast,合法则 Math.floor 取整后作为总时间。之后启动 1000 毫秒定时器,回调里每次把 remain 减一;减到小于等于 0 时归零、停表、弹 Toast。progress 的更新放在回调末尾,用 Math.round(remain / total * 100) 计算剩余百分比。
这里有一个值得注意的次序:先处理归零(remain <= 0 分支),再更新 progress。归零分支里已经弹出"时间到!"的 Toast 并停表,若把进度更新写在前面,最后一次回调同样会把 progress 算成 0,两者语义一致;但把"终态处理"放在最前更符合阅读直觉。另外 Math.round 取整避免了浮点显示,例如 90 秒总时长跑到第 30 秒时,remain / total * 100 恰好是 66.66,四舍五入后显示 67,进度环以一个"看得见的整数百分比"跳变。
3.4 重置与定时器清理
private reset(): void {
this.stopTimer();
this.running = false;
this.remain = this.total;
this.progress = this.total > 0 ? 100 : 0;
}
reset 让剩余时间回到总时间,进度环回到满格。注意 total > 0 ? 100 : 0 的细节:如果还没设定过总时间(total 为 0),进度环直接显示 0 而不是 100,避免"什么都没设定却显示满环"的误导。
private stopTimer(): void {
if (this.timer !== -1) {
clearInterval(this.timer);
this.timer = -1;
}
}
aboutToDisappear(): void {
this.stopTimer();
}
stopTimer 与秒表完全一致:句柄为 -1 表示无定时器,清除后复位,重复调用安全。aboutToDisappear 保证页面销毁时定时器被清理,是资源管理的兜底防线。
3.5 页面布局 build
build 方法由外层 Column 纵向排布,背景浅灰 #f2f3f5。最显眼的是 Stack 叠放结构:先放 210x210 的环形 Progress,再放一个居中的 Column,里面是 14 号灰色的"剩余时间"字样和 42 号加粗白色的 formatTime(this.remain) 大字;整个 Stack 宽高 230、背景 #1f2733、圆角 borderRadius(115),恰好切成正圆,形成深色卡片衬底。输入区由一个带文案的 Text 和 TextInput 组成,输入框 type(InputType.Number) 调起数字键盘、placeholder('如 90') 给出示例。三个快捷按钮 layoutWeight(1) 平分一行,点击分别调用 applyTotal。底部"开始/暂停"与"重置"两个按钮同样 layoutWeight(1) 平分,分别绑定 toggle 与 reset。
这段布局里还有几个可圈可点的地方。返回栏与秒表一样是"返回按钮 + 标题 + Blank"的固定组合,保证了所有示例页面视觉一致;输入框前的提示文案用 14 号灰色 #8a8a8a,与页面内其他说明文字层级分明;三个快捷按钮用 Row({ space: 12 }) 拉开间距,白底蓝字、没有边框,点击时按钮自带的按压态会给出反馈;底部两个按钮之间间距加大到 16,比快捷按钮行更宽松,用来区分"设定类"与"控制类"两组操作。Blank() 放在页面最底部撑起剩余空间,使整个布局在中等屏幕高度下也不会显得拥挤。
4. 关键实现细节分析
4.1 输入与启动的解耦:total 的两种来源
total 有两个赋值入口:快捷按钮的 applyTotal,以及首次启动时从输入框解析。为了让"输入框是唯一权威来源",onChange 在非运行状态下会把 total 清零,从而强制下次 toggle 走"重新解析输入框"分支。这是一处非常体现产品思维的小设计——用户先点"1 分"再手动改成 120,如果不清 total,启动会继续用旧的 60 秒,显然不符合直觉。
顺着这个设计再想一层:total = 0 的清零只在 !this.running 时发生,是为了防止用户在计时途中误改输入框把正在进行的倒计时"没收"。如果去掉这个条件,会出现更隐蔽的问题——倒计时还在跑,但 total 已被改成新值,进度环的除法分母瞬间变化,比例显示会突然跳变,这是任何倒计时都不希望出现的。所以这个看似不起眼的条件判断,实际上同时保护了"输入优先"和"计时不受干扰"两个目标。
4.2 秒级倒计时的误差容忍度
倒计时用的是"每秒减一"而非"按时间戳差计算",这与秒表不同。原因在于需求不同:倒计时只要保证"大约每秒减少一秒、到点停止"即可,1000 毫秒定时器即使因主线程忙碌延迟几百毫秒触发,也只是让"时间到"晚一点点出现,用户几乎感知不到。不过要注意,setInterval 的漂移在长计时场景(如 25 分钟的番茄钟)会累积,严谨的做法是在回调里用 Date.now() 差值校准,示例为了简洁选择了直接减一,读者在扩展时可根据需求取舍。
4.3 进度环的比例计算
progress = Math.round(this.remain / this.total * 100):剩余比例 = 剩余秒数 / 总秒数,再乘 100 得到百分比并四舍五入。倒计时开始时为 100,结束时为 0。因为 Progress 的 total 固定为 100,value 直接传百分比即可。注意 this.total > 0 ? ... : 0 的守卫,避免总时间为 0 时出现除零或 NaN。Math.round 让百分比永远是整数,进度环的跳变也显得干净利落。
4.4 Toast 的两种使用场景
promptAction.showToast({ message: '请输入有效秒数' }) 与 promptAction.showToast({ message: '时间到!' }) 分别用于"输入校验失败"和"倒计时自然结束"。Toast 不阻塞操作、自动消失,非常适合这种轻量反馈。如果想要更醒目的提示,可以替换成 @ohos.promptAction 的对话框接口或自定义弹窗,但普通场景下 Toast 已经足够。
4.5 Stack 叠放与圆角卡片
进度环、中心文字、外层圆角卡片构成了"同心圆"布局。Stack 的子组件默认居中(配合 alignItems(HorizontalAlign.Center) 让 Column 水平居中),borderRadius(115) 是 230 的一半,正好切出正圆。视觉上,深色 #1f2733 卡片压住页面浅灰背景 #f2f3f5,蓝色环与白色大字形成高对比,是典型的"卡片式主视觉"布局套路。
4.6 定时器生命周期的一致性
stopTimer 被 toggle(暂停分支)、applyTotal、reset、aboutToDisappear 四处调用,所有退出运行态的路径都经过同一清理入口,杜绝"漏清理"。这个模式与秒表完全一致,是时间型应用的标准写法:创建定时器的入口只有一个(toggle),清理定时器的入口也收敛到一个函数,状态机上每一处"离开运行态"的转移都必须经过它。
4.7 数字输入与解析的边界情况
TextInput 配合 InputType.Number 已经把输入限制在数字范围内,但用户的输入仍然可能是 0、负号、空串或者超长的数字。toggle 里 Number.isNaN(val) || val <= 0 这两个条件正好覆盖了这些情况:空串和非法字符经 parseFloat 变成 NaN;0 与负数被大小判断拦下;超长数字虽然能解析,但 Math.floor 会取整并作为总秒数,显示时由 formatTime 自然折叠成 mm:ss,不会崩坏。这套"限定输入类型 + 解析时二次校验"的双保险,是处理用户输入的通用姿势。
4.8 快捷按钮与输入框的交互一致性
三个快捷按钮看似只是"设定固定值",实际上还承担了"把输入框回填成可读文本"的任务。applyTotal 里 this.input = String(sec) 保证了无论用户之前手输了什么,点击快捷按钮后输入框都会变成 60、300 或 600,两者指向同一个语义。反过来,onChange 里对 total 的清零又保证手输内容优先。这样快捷按钮和输入框互为镜像、互不矛盾,避免了"按钮选的是 5 分、框里却显示别的数"的混乱体验。对这类小型页面来说,保持所有入口指向同一份状态,就是最简单可靠的一致性方案。
5. 运行效果与操作指南
编译运行后进入"倒计时"页面。首次打开时,进度环停在 0,中心显示 00:00,输入框为空、占位符为"如 90",快捷按钮与底部按钮均可用。
典型操作流程:
- 在输入框输入秒数,例如
90,然后点击橙色"开始"。进度环立即变为满环,中心时间显示01:30,随后每秒减一秒,环同步收缩。 - 计时中点击"暂停",剩余时间停住,按钮文案变回"开始";再点击"开始"从原处继续。
- 随时点击"重置",剩余时间回到总秒数,环满格,计时停止。
- 点击"1 分"“5 分”"10 分"快捷按钮,直接设定 60/300/600 秒并复位;输入框会同步显示对应秒数。
- 倒计时归零时,中心显示
00:00,环缩到最小,弹出"时间到!"的 Toast 提示。
可以尝试的边界操作:直接不输入数字就点"开始",会弹出"请输入有效秒数";输入 0 也会被校验拦截;计时中修改输入框内容不影响当前计时(因为 total 只在非运行态被清零);计时结束后再点"开始",会按输入框(或快捷设定)重新开始一轮新的倒计时。把快捷按钮、输入框、开始、暂停、重置交错使用,能比较完整地覆盖源码里每一条分支路径。
运行中值得留意的视觉细节:进度环的收缩是离散的百分比跳变而非连续动画——剩余 90 秒时环显示约 90%,每秒跳一次;环的起点在圆的正上方,随 value 减小沿顺时针方向收缩。中心时间文字的字体比秒表大号显示略小(42 号),与环形卡片的比例更协调。深色圆形卡片是整页唯一的彩色块,起到了视觉锚点的作用,让用户的目光第一时间落在剩余时间上。
如果设备支持,还可以按 home 键把应用退到后台再回来:倒计时会继续走(setInterval 在应用存活期间仍会触发),回来时看到的是正确缩减后的剩余时间。这一行为与秒表一致,说明两个示例对"生命周期"的把握只到页面级;若想让计时在应用彻底被杀后仍能延续,就需要借助 @ohos.data.preferences 持久化时间戳,在页面重建时恢复剩余值,这是从"页面应用"走向"常驻能力"的一步,也可以作为进阶练习。
6. 可扩展方向
- 结束后的二次操作:归零后自动弹出是否"再来一轮"或"延长 5 分钟"的按钮,把
promptAction升级为CustomDialog,让倒计时结束后的流程更完整。 - 声音与震动提醒:结束瞬间播放提示音、触发震动,可调用系统媒体与传感器接口,让用户不必一直盯着屏幕。
- 精度升级:把 1000 毫秒定时器改成 250 毫秒并配合
Date.now()差值校准,让长计时更精确,同时支持毫秒级显示。 - 预设方案库:把"1 分"“5 分”"10 分"扩展成可自定义的预设列表,配合
ForEach渲染更多快捷卡片,并支持长按编辑。 - 多任务倒计时:支持同时运行多个倒计时,各自持有独立的
total/remain/timer,用列表展示,能练习更复杂的状态组织。 - 主题与进度条美化:给进度环加渐变、圆头端帽、背景环,或改用横向进度条,丰富
Progress样式的使用。 - 数据持久化:把常用的番茄钟配置存到 Preferences,重启后保留,减少重复设定的成本。
这些扩展里,前两类改动的是"结束时发生了什么",后几类改动的是"如何设定与呈现"。如果按优先级排序,建议先做声音与震动提醒——它成本最低、对体验提升最明显;再做预设方案库,让应用从"一个输入框加三个按钮"进化成真正的番茄钟工具。无论做哪一项,都要记住本示例最核心的两条纪律:total/remain/progress 的数值逻辑保持单一来源,定时器只在 toggle 创建、只经 stopTimer 清理。
7. 常见问题与调试技巧
- 点击"开始"没有反应:检查
total <= 0分支的解析逻辑。若输入框为空或非数字,parseFloat返回 NaN,Number.isNaN判定成立,只会弹 Toast;确认输入框确实填了合法数字。 - 倒计时走完不停 / 时间到后仍在跳:确认回调里
this.remain <= 0分支同时调用了this.stopTimer()且running置为 false,否则定时器会继续空转。 - 进度环不变化:确认
progress已用@State装饰,且在setInterval回调里被赋值;直接改普通成员变量不会触发 UI 刷新。 - 修改输入框后重新开始用的是旧时间:这是
onChange里total = 0设计的目的;检查该分支是否只在非运行态生效,避免在计时中误清导致正在进行的倒计时被悄悄重置。 - 进度环出现 NaN 或 100:多为
total为 0 时除零。progress计算里this.total > 0 ? ... : 0的守卫就是为此而设,不要擅自删掉。 - 页面返回后日志仍在打印:确认
aboutToDisappear调用了stopTimer,并用日志验证清理时机;否则倒计时可能在你离开页面后继续"悄悄走完"。 - 快捷按钮点击后没有复位进度环:检查
applyTotal是否同时设置了progress = 100与running = false。两者缺一都会出现"环还是半圈、按钮还写着暂停"的半途状态。 - 重新开始一轮时出现负秒数:确认回调里
remain <= 0分支先把remain归零再停表,若顺序颠倒或漏掉归零,界面可能在"时间到"之后短暂显示负数。
调试时,可以在 setInterval 回调开头打印 this.remain 与 this.progress,确认递减节奏与百分比;在 stopTimer 里打印日志,确认每个"退出运行态"的路径都触发了清理。真机上定时器行为与模拟器略有差异,涉及精度问题优先真机验证。如果输入框的值包含空格或小数,parseFloat 也能正确处理(会忽略开头空格、取整数部分),所以 Math.floor 之前的校验只需关注 NaN 与小于等于 0 两种情形即可。遇到异常表现时不要急着改代码,先把状态转移链理顺:从"运行"到"暂停"“重置”"归零"各经过哪些函数、改动了哪些 @State,链条一清楚,问题通常就一目了然了。
8. 总结
倒计时示例与秒表形成完美互补:同样的定时器骨架,不同的数据流方向,再加上输入框解析与进度环反馈两个新知识点。它的实现亮点可以归结为四点:输入框与启动逻辑的解耦设计(total 清零策略);Progress 环状组件与 @State 数值联动;promptAction 对非法输入与自然结束的统一反馈;以及四处入口共用 stopTimer 的生命周期管理。理解这些,再面对"番茄钟"“休息提醒”"抢购倒计时"等真实需求时,就能快速搭建出可靠的时间驱动页面。
建议读者把本篇与秒表那篇对照着读:两个页面共享 formatTime/format 的格式化思路与 stopTimer 的清理模式,差异集中在数据来源(时间戳差值 vs 输入解析)和视觉反馈(大号时间 vs 进度环)上。把二者的共同点抽出来,再各自补上差异点,就是一套可以反复使用的时间型页面模板。动手改一改——比如把倒计时改成 250 毫秒刷新、加上声音提醒,或者给秒表加一个分段统计——会更快地把这些知识变成自己的肌肉记忆。
最后可以做一个自测:不看源码,试着用五句话讲清这个倒计时的工作流程。参考答案是:设定来源有二(快捷按钮或输入框解析);运行状态由 running 守卫;每秒回调让 remain 减一并同步 progress;归零即停并弹 Toast;任何退出路径都经 stopTimer 收尾。能完整复述出这五步,说明核心逻辑已经内化,接下来就可以自信地把它改造成属于自己的番茄钟、抢购倒计时或休息提醒了。
更多推荐




所有评论(0)