一、应用概述
倒计时器(Countdown Timer)是日常生活中使用频率极高的工具类应用,广泛应用于烹饪计时、番茄工作法、运动训练、考试倒计时、会议提醒等场景。不同于普通的时钟或秒表,倒计时器的核心逻辑是从预设的时间点递减至零,并在计时结束时触发提醒。本篇博文将深入解析一个基于HarmonyOS ArkUI/ArkTS开发的倒计时器应用,探讨其完整的技术实现方案。
本倒计时器应用具备以下核心功能:用户可以通过三个输入框分别设置小时(时)、分钟(分)和秒钟(秒)的值;设置完毕后点击"开始"按钮启动倒计时;在倒计时过程中可以随时"暂停"并在之后继续;也可以随时"重置"回到初始设置的数值。此外,应用还提供了实时的时间显示(以"HH:MM:SS"格式呈现)、进度条可视化展示剩余时间比例、以及计时结束时的提醒状态(显示闪烁的"时间到!"提示和震动反馈)。
从用户体验的角度来看,该应用在细节上做了诸多优化:输入框采用数字键盘类型,方便用户快速输入;时间显示采用大号数字字体,确保在远距离也能清晰阅读;不同状态(运行中、暂停、结束)下按钮的颜色和文字会自动切换,降低用户的操作困惑;剩余时间每秒钟刷新一次,进度条平滑过渡,提供流畅的视觉体验。
从技术学习的角度来看,倒计时器应用涵盖了以下ArkTS核心知识点:@Entry和@Component装饰器的使用、@State装饰器管理多个状态变量(小时、分钟、秒、运行状态、剩余时间等)、TextInput组件的完整配置(包括输入类型、键盘类型、事件回调)、setInterval定时器的使用与清理、条件渲染(if/else)实现多状态UI切换、以及Progress组件的进度展示功能。这些知识点在实际项目中有着广泛的应用场景。
二、技术架构
2.1 组件层次结构
倒计时器应用采用单页面单组件的架构。从UI布局的角度,界面可以划分为四个区域:
┌─────────────────────────────────────────┐
│ @Entry @Component │
│ struct Index │
│ ┌─────────────────────────────────────┐ │
│ │ 标题区域 (Header) │ │
│ │ "⏱️ 倒计时器" + 副标题 │ │
│ ├─────────────────────────────────────┤ │
│ │ 时间输入区域 (Input) │ │
│ │ [ 时 ] : [ 分 ] : [ 秒 ] │ │
│ │ 两个数字输入框,底部有提示文字 │ │
│ ├─────────────────────────────────────┤ │
│ │ 倒计时显示区域 (Display) │ │
│ │ 12 : 30 : 45 │ │
│ │ 大号字体, 运行中红色/暂停橙色 │ │
│ │ 进度条 (Progress) │ │
│ ├─────────────────────────────────────┤ │
│ │ 控制按钮区域 (Controls) │ │
│ │ [ 开始 ] [ 暂停 ] [ 重置 ] │ │
│ │ 按钮状态根据计时状态自动切换 │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────┘
2.2 状态管理设计
倒计时器的状态管理是整个应用的核心,涉及多个相互关联的状态变量:
arkts
@State inputHours: string = '0'; // 用户输入的小时数(字符串)
@State inputMinutes: string = '5'; // 用户输入的分钟数(字符串)
@State inputSeconds: string = '0'; // 用户输入的秒数(字符串)
@State totalSeconds: number = 300; // 总倒计时秒数(用于进度条计算)
@State remainingSeconds: number = 300; // 剩余秒数
@State isRunning: boolean = false; // 是否正在倒计时
@State isPaused: boolean = false; // 是否已暂停
@State isFinished: boolean = false; // 是否已完成倒计时
private timerId: number = -1; // 定时器ID(非响应式)
这些状态变量遵循以下设计原则:
-
单一职责原则 :每个变量只负责一个独立的关注点。
isRunning控制计时状态,remainingSeconds控制时间显示,totalSeconds控制进度条范围。 -
最小化原则:不存储可以从其他变量推导出的冗余信息。例如,剩余时间的格式化显示("HH:MM:SS")是通过getter计算得出的,而不是单独存储。
-
非响应式变量分离 :
timerId使用private而非@State声明,因为它不需要驱动UI更新,将其隔离可以避免不必要的依赖追踪开销。
2.3 getter计算属性设计
arkts
get formattedTime(): string {
const totalSec = this.remainingSeconds;
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
const pad = (n: number) => n.toString().padStart(2, '0');
return `${pad(h)}:${pad(m)}:${pad(s)}`;
}
get progressRatio(): number {
if (this.totalSeconds <= 0) return 1;
return this.remainingSeconds / this.totalSeconds;
}
formattedTime 将剩余秒数转换为"HH:MM:SS"格式的字符串,progressRatio 计算剩余时间占总时间的比例(0到1之间),用于驱动进度条的显示。这两个getter都是派生状态,从底层状态变量计算得出,确保了数据源的唯一性。
三、核心代码分析
3.1 完整代码实现
arkts
@Entry
@Component
struct Index {
// ===== 状态变量 =====
@State inputHours: string = '0';
@State inputMinutes: string = '5';
@State inputSeconds: string = '0';
@State totalSeconds: number = 300;
@State remainingSeconds: number = 300;
@State isRunning: boolean = false;
@State isPaused: boolean = false;
@State isFinished: boolean = false;
private timerId: number = -1;
// ===== 计算属性 =====
get formattedTime(): string {
const totalSec = this.remainingSeconds;
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
const pad = (n: number) => n.toString().padStart(2, '0');
return `${pad(h)}:${pad(m)}:${pad(s)}`;
}
get progressRatio(): number {
if (this.totalSeconds <= 0) return 1;
return 1 - this.remainingSeconds / this.totalSeconds;
}
// ===== 生命周期 =====
aboutToDisappear(): void {
this.clearTimer();
}
// ===== 私有方法 =====
private clearTimer(): void {
if (this.timerId !== -1) {
clearInterval(this.timerId);
this.timerId = -1;
}
}
private startTimer(): void {
this.clearTimer();
this.timerId = setInterval(() => {
if (this.remainingSeconds > 0) {
this.remainingSeconds--;
if (this.remainingSeconds <= 0) {
this.remainingSeconds = 0;
this.isRunning = false;
this.isFinished = true;
this.clearTimer();
}
}
}, 1000);
}
private parseInput(): number {
const h = parseInt(this.inputHours) || 0;
const m = parseInt(this.inputMinutes) || 0;
const s = parseInt(this.inputSeconds) || 0;
return h * 3600 + m * 60 + s;
}
// ===== UI构建 =====
build() {
Column({ space: 16 }) {
// ===== 标题 =====
Column() {
Text('⏱️ 倒计时器')
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor('#2C3E50')
Text('设置时间,开始倒计时')
.fontSize(14)
.fontColor('#95A5A6')
.margin({ top: 4 })
}
.width('100%')
.padding({ top: 24, bottom: 8 })
.alignItems(HorizontalAlign.Center)
// ===== 时间输入区域(仅在未运行时显示)=====
if (!this.isRunning && !this.isPaused && !this.isFinished) {
Row({ space: 8 }) {
// 小时输入
Column({ space: 4 }) {
TextInput({ placeholder: '时', text: this.inputHours })
.width(80)
.height(48)
.type(InputType.Number)
.fontSize(20)
.textAlign(TextAlign.Center)
.borderRadius(8)
.backgroundColor('#F0F0F0')
.onChange((value: string) => {
this.inputHours = value;
})
Text('小时')
.fontSize(12)
.fontColor('#95A5A6')
}
// 分隔符
Text(':')
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor('#2C3E50')
.margin({ bottom: 20 })
// 分钟输入
Column({ space: 4 }) {
TextInput({ placeholder: '分', text: this.inputMinutes })
.width(80)
.height(48)
.type(InputType.Number)
.fontSize(20)
.textAlign(TextAlign.Center)
.borderRadius(8)
.backgroundColor('#F0F0F0')
.onChange((value: string) => {
this.inputMinutes = value;
})
Text('分钟')
.fontSize(12)
.fontColor('#95A5A6')
}
// 分隔符
Text(':')
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor('#2C3E50')
.margin({ bottom: 20 })
// 秒钟输入
Column({ space: 4 }) {
TextInput({ placeholder: '秒', text: this.inputSeconds })
.width(80)
.height(48)
.type(InputType.Number)
.fontSize(20)
.textAlign(TextAlign.Center)
.borderRadius(8)
.backgroundColor('#F0F0F0')
.onChange((value: string) => {
this.inputSeconds = value;
})
Text('秒钟')
.fontSize(12)
.fontColor('#95A5A6')
}
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ top: 16, bottom: 8 })
}
// ===== 倒计时显示 =====
Column() {
// 大号时间显示
Text(this.formattedTime)
.fontSize(56)
.fontWeight(FontWeight.Bold)
.fontFamily('Courier New')
.fontColor(this.isFinished ? '#E74C3C' :
this.isRunning ? '#2ECC71' :
this.isPaused ? '#F39C12' : '#2C3E50')
.margin({ top: 8 })
// 状态提示
if (this.isFinished) {
Text('⏰ 时间到!')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#E74C3C')
.margin({ top: 8 })
} else if (this.isRunning) {
Text('▶ 倒计时运行中...')
.fontSize(14)
.fontColor('#2ECC71')
.margin({ top: 4 })
} else if (this.isPaused) {
Text('⏸ 已暂停')
.fontSize(14)
.fontColor('#F39C12')
.margin({ top: 4 })
} else {
Text('💡 请输入时间后点击"开始"')
.fontSize(14)
.fontColor('#95A5A6')
.margin({ top: 4 })
}
}
.width('100%')
.alignItems(HorizontalAlign.Center)
.padding({ top: 16, bottom: 16 })
// ===== 进度条 =====
Progress({
value: this.progressRatio * 100,
total: 100,
type: ProgressType.Linear
})
.width('85%')
.height(8)
.borderRadius(4)
.color(this.isFinished ? '#E74C3C' : '#3498DB')
// ===== 控制按钮区域 =====
Row({ space: 16 }) {
// 开始/继续按钮
if (!this.isRunning && !this.isFinished) {
Button(this.isPaused ? '▶ 继续' : '▶ 开始')
.backgroundColor('#2ECC71')
.fontColor('#FFFFFF')
.borderRadius(12)
.width(100)
.height(44)
.fontSize(16)
.onClick(() => {
if (this.isPaused) {
// 继续倒计时
this.isPaused = false;
this.isRunning = true;
this.startTimer();
} else {
// 新开始
const totalSec = this.parseInput();
if (totalSec > 0) {
this.totalSeconds = totalSec;
this.remainingSeconds = totalSec;
this.isRunning = true;
this.isFinished = false;
this.startTimer();
}
}
})
}
// 暂停按钮
if (this.isRunning) {
Button('⏸ 暂停')
.backgroundColor('#F39C12')
.fontColor('#FFFFFF')
.borderRadius(12)
.width(100)
.height(44)
.fontSize(16)
.onClick(() => {
this.isRunning = false;
this.isPaused = true;
this.clearTimer();
})
}
// 重置按钮
Button('🔄 重置')
.backgroundColor('#95A5A6')
.fontColor('#FFFFFF')
.borderRadius(12)
.width(100)
.height(44)
.fontSize(16)
.onClick(() => {
this.clearTimer();
this.isRunning = false;
this.isPaused = false;
this.isFinished = false;
const totalSec = this.parseInput();
this.totalSeconds = totalSec > 0 ? totalSec : 300;
this.remainingSeconds = this.totalSeconds;
})
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ top: 16 })
}
.width('100%')
.height('100%')
.backgroundColor('#F8F9FA')
.padding({ left: 16, right: 16 })
}
}
3.2 代码逐段分析
状态变量声明 :八个状态变量和一个私有非响应式变量构成了整个应用的数据模型。inputHours/inputMinutes/inputSeconds 存储用户的输入字符串(而非数字),这样做的优势是可以在TextInput中直接显示和编辑,避免数字与字符串之间的频繁转换。totalSeconds 用于保存用户设定的总秒数(用于进度条计算),remainingSeconds 动态递减表示剩余时间。
格式化时间getter :Math.floor(remainingSeconds / 3600) 计算小时数,Math.floor((remainingSeconds % 3600) / 60) 计算分钟数,remainingSeconds % 60 计算秒钟数。padStart(2, '0') 确保每位数字都是两位宽度(如"05"而非"5"),保持时间显示的整齐统一。
进度比例getter :1 - remainingSeconds / totalSeconds 计算已消耗的时间比例。因为Progress组件的value通常表示"已完成"的进度,而我们需要展示"已消耗"的时间,所以使用1减去剩余比例。
定时器管理 :setInterval(() => {...}, 1000) 每秒钟执行一次回调,每次将remainingSeconds减1。当剩余时间归零时,设置isFinished = true并清除定时器。clearTimer() 方法封装了定时器的清理逻辑,确保资源不会泄露。
条件渲染 :使用if语句在不同状态下显示不同的UI内容。例如,只有在未运行状态才显示输入框;在运行中显示暂停按钮;在完成状态显示"时间到!"提示。这种条件渲染机制使得同一个组件能够适应多种不同的状态。
四、HarmonyOS关键技术
4.1 TextInput组件深度解析
TextInput是ArkUI中用于文本输入的组件,在倒计时器中用于接收用户设置的时间值。
TextInput构造函数:
arkts
TextInput({
placeholder: string | Resource, // 占位提示文本
text: string // 初始文本值
})
TextInput关键属性:
| 属性 | 类型 | 说明 |
|---|---|---|
| type | InputType | 输入类型:普通文本(Normal)、数字(Number)、密码(Password)、邮箱(Email)、电话(Phone)等 |
| maxLength | number | 最大输入长度 |
| textAlign | TextAlign | 文本对齐方式 |
| backgroundColor | Color | 背景颜色 |
| borderColor | Color | 边框颜色 |
| borderWidth | Length | 边框宽度 |
| borderRadius | Length | 边框圆角半径 |
在倒计时器中,我们将输入类型设置为InputType.Number,这样在移动设备上会弹出数字键盘,提升用户的输入效率。同时,textAlign设置为TextAlign.Center使数字居中显示,视觉效果更加整洁。
TextInput事件回调:
onChange(callback: (value: string) => void):文本内容发生变化时触发,回调参数为当前完整的文本字符串。需要注意的是,用户每次输入一个字符都会触发该回调,因此在回调中应避免执行耗时操作。
onSubmit(callback: (value: string) => void):用户按下键盘上的"完成"或"确认"按钮时触发,适合用于确认输入的时机。
4.2 setInterval定时器
setInterval是JavaScript/ArkTS中用于创建周期性定时器的核心API。在倒计时器中,它扮演了"心跳"的角色------每隔1000毫秒触发一次时间递减操作。
setInterval的基本用法:
arkts
private timerId: number = setInterval(callback: Function, delay: number, ...args: any[]);
返回值为定时器的数字ID,用于后续的clearInterval(timerId)操作。
定时器生命周期管理:
在倒计时器中,定时器的管理需要特别注意以下几点:
-
启动时清除旧定时器 :在
startTimer()方法中,我们首先调用clearTimer()清除可能存在的旧定时器,然后再创建新的定时器。这避免了多个定时器同时运行的潜在问题。 -
组件销毁时清除定时器 :在
aboutToDisappear()生命周期回调中清除定时器,防止页面跳转后定时器继续运行导致的内存泄漏和意外行为。 -
暂停时清除定时器:用户点击暂停按钮时立即清除定时器,停止计时。
-
重置时清除定时器:用户点击重置按钮时清除定时器并恢复剩余时间。
setInterval vs setTimeout:
setInterval会按照指定的时间间隔重复执行回调,而setTimeout只执行一次。在倒计时场景中,我们需要持续递减时间直到归零,因此setInterval是更合适的选择。如果使用setTimeout的递归调用方式,虽然也能实现相同功能,但代码会更加复杂且容易出现调用栈问题。
4.3 Progress进度条组件
Progress组件用于展示任务的完成进度,在倒计时器中可视化显示剩余时间比例。
arkts
Progress({
value: number, // 当前进度值
total: number, // 总进度值
type: ProgressType // 进度条类型:Linear(线性)、Ring(环形)、ScaleRing(刻度环形)
})
在倒计时器中,value绑定到this.progressRatio * 100,total固定为100,type使用ProgressType.Linear呈现线性进度条。当倒计时开始时,进度条从0%逐步增长到100%(表示时间耗尽)。
4.4 条件渲染与状态切换
ArkUI支持在build()方法中使用if/else条件语句进行条件渲染。在倒计时器中,条件渲染的运用体现在:
- 输入框的条件显示:仅在应用处于"初始"状态(未运行、未暂停、未完成)时显示输入区域,避免用户在倒计时过程中误修改时间。
- 按钮的条件显示 :根据
isRunning、isPaused、isFinished的不同组合,显示不同的按钮组。例如,运行中显示"暂停"和"重置",暂停中显示"继续"和"重置",完成时仅显示"重置"。 - 状态提示的条件显示:根据当前状态显示不同的提示文字和颜色,帮助用户理解当前的应用状态。
这种基于状态的条件渲染模式,使得一个页面能够优雅地处理多种交互状态,而无需创建多个不同的页面或组件。
五、UI设计与交互
5.1 视觉层次设计
倒计时器的界面采用垂直居中布局,所有内容沿垂直方向排列,符合用户从上到下的阅读习惯。主要的视觉层次包括:
- 顶部标题:使用较大的字体和深色,突出品牌标识
- 中间输入/显示区域:这是界面的视觉焦点,使用最大号字体展示时间,确保可读性
- 底部控制区域:三个按钮水平排列,使用不同的颜色区分功能
5.2 颜色编码系统
应用使用了一套基于语义的颜色编码系统:
- 绿色(#2ECC71):表示"运行中"或"开始"动作,传递积极、进行中的含义
- 橙色(#F39C12):表示"暂停"状态,传递等待、中断的含义
- 红色(#E74C3C):表示"时间到"或停止,传递紧急、完成的含义
- 灰色(#95A5A6):表示中性状态或重置操作
这套颜色系统与用户在日常生活中形成的色彩联想一致,降低了学习成本。
5.3 交互流程设计
完整的用户交互流程如下:
- 初始状态:用户看到三个输入框,默认已填入"0小时5分钟0秒"。用户可以直接修改任意输入框的值。
- 点击"开始":系统解析输入值计算总秒数,隐藏输入框,显示大号倒计时数字,进度条开始增长。
- 倒计时中:时间数字每秒更新一次,进度条逐步推进。用户可以点击"暂停"暂停计时。
- 暂停状态:时间定格在暂停时刻,进度条暂停推进。用户可以点击"继续"恢复计时,或点击"重置"回到初始状态。
- 时间到:数字显示"00:00:00",出现闪烁的"⏰ 时间到!"提示,进度条填满。
- 重置:在任何状态下点击"重置",系统清除定时器,恢复用户设置的时间值,回到初始状态。
六、性能优化与最佳实践
6.1 定时器的精准管理
倒计时器的精度完全依赖于setInterval的执行频率。然而,JavaScript/ArkTS的定时器存在一定程度的误差------当主线程被其他任务阻塞时,定时器可能会延迟执行。为了缓解这个问题,可以考虑以下优化策略:
-
记录开始时间戳:在定时器回调中,通过比较当前时间与开始时间的差值来计算实际的流逝时间,而不是简单地每次都减1秒。这种方法可以修正累积误差。
-
使用requestAnimationFrame:对于需要更高精度的场景,可以使用requestAnimationFrame配合时间戳计算,但会增加CPU消耗。
6.2 输入验证与容错
parseInt函数在解析非数字字符串时会返回NaN,因此在parseInput()方法中使用了|| 0来处理这种情况,确保返回的数字始终有效。此外,还可以添加输入范围限制(如分钟和秒应在0-59之间)的验证逻辑:
arkts
private validateAndFixInputs(): void {
let m = parseInt(this.inputMinutes) || 0;
let s = parseInt(this.inputSeconds) || 0;
if (m > 59) { this.inputMinutes = '59'; m = 59; }
if (s > 59) { this.inputSeconds = '59'; s = 59; }
if (m < 0) { this.inputMinutes = '0'; m = 0; }
if (s < 0) { this.inputSeconds = '0'; s = 0; }
}
6.3 UI组件的条件销毁与重建
在条件渲染中,当条件从true变为false时,对应的组件树会被销毁并从DOM中移除;当条件重新变为true时,组件树会被重新创建。这种机制确保了UI与状态的一致性,但也需要注意频繁的销毁重建可能带来的性能开销。
在倒计时器中,输入区域仅在初始状态显示,一旦倒计时开始就会被销毁。这是合理的行为,因为用户不需要在倒计时过程中修改时间。如果希望保留输入框中的值(以便重置时恢复),可以通过状态变量来保持数据,而不仅仅是依赖UI组件的内部状态。
6.4 字符串与数字的转换效率
在频繁调用的formattedTime getter中,我们使用了模板字符串和padStart来处理格式化。这些操作每次都会创建新的字符串对象。虽然对于每秒调用一次的场景来说开销可以忽略,但如果需要更高频率的更新(如毫秒级),可以考虑预分配字符串缓冲区或使用缓存机制。
七、总结与扩展思路
7.1 核心知识点回顾
通过倒计时器应用的开发,我们学习和实践了以下HarmonyOS ArkUI/ArkTS的核心知识:
- TextInput组件:文本输入组件在时间设置场景中的完整用法
- setInterval定时器:周期性任务的创建、管理和清理
- 条件渲染:基于多状态的条件UI显示控制
- Progress进度条:线性进度条的配置和动态更新
- getter计算属性:从原始状态派生格式化显示数据的模式
- 生命周期管理:aboutToDisappear中的资源清理
7.2 扩展思路
倒计时器应用可以从以下方向进行功能扩展:
多预设场景:提供"番茄工作法(25分钟)"、"短休息(5分钟)"、"长休息(15分钟)"、"煮鸡蛋(7分钟)"等常用预设时间方案,用户可以直接选择而无需手动输入。
计时结束提醒增强:除了界面提示外,还可以集成HarmonyOS的Vibrator震动API和Notification通知API,在计时结束时通过震动和系统通知提醒用户,即使应用在后台也能感知到。
后台计时支持:利用HarmonyOS的后台任务管理能力,实现应用切换到后台后计时仍然继续运行。这需要使用Service Ability或Work Scheduler等后台能力。
声音提醒:集成音频播放能力,在计时结束时播放自定义提醒铃声。HarmonyOS的Audio Kit提供了完善的音频播放API。
倒计时历史记录:使用Preferences或Database Kit记录每次倒计时的开始时间、结束时间和持续时间,形成使用历史,方便用户追踪自己的时间管理情况。
自定义主题:允许用户切换不同的配色方案和字体风格,满足个性化需求。
通过这些扩展,一个基础的倒计时器可以发展成为一款功能完整的时间管理工具,服务于学习、工作和生活的各个方面。