HarmonyOS ArkTS 倒计时器的时间计算与列表渲染
引子:倒计时的核心是"算时间差"
倒计时器看起来简单——显示一个倒数的数字。但做起来有三个问题要处理:时间差的计算、进度环的百分比、列表的动态增删。
今天就从代码出发,拆解时间戳运算、剩余时间格式化、进度环计算、列表渲染四个核心知识点。
完整效果

数据模型:两个 class 的设计
class RemainInfo {
d: number = 0; // 天
h: number = 0; // 时
m: number = 0; // 分
s: number = 0; // 秒
total: number = 0; // 总毫秒差(正=未到,负=已过)
elapsed: number = 0; // 进度百分比(0-1)
}
class CDEvent {
name: string = ''; // 事件名称
target: number = 0; // 目标时间戳(毫秒)
color: string = ''; // 主题色
}

为什么分两个 class
CDEvent 是用户输入的数据(名称、目标时间、颜色),RemainInfo 是计算后的结果(剩余天/时/分/秒、进度)。分开设计让数据和计算解耦——修改计算逻辑不影响输入数据。
target 为什么用时间戳而不是 Date
时间戳是数字(毫秒),可以直接做减法(target - now)。如果用 Date 对象,需要先 .getTime() 转成时间戳,代码更冗长。
核心方法:calcRemain
calcRemain(target: number): RemainInfo {
const total = target - this.now;
const absMs = Math.max(0, total);
const d = Math.floor(absMs / 86400000);
const h = Math.floor((absMs % 86400000) / 3600000);
const m = Math.floor((absMs % 3600000) / 60000);
const s = Math.floor((absMs % 60000) / 1000);
const span = Math.min(30 * 86400000, Math.max(absMs, 1));
const elapsed = Math.max(0, 1 - absMs / span);
const r = new RemainInfo();
r.d = d; r.h = h; r.m = m; r.s = s;
r.total = total; r.elapsed = elapsed;
return r;
}

时间差的计算
const total = target - this.now;
target 是目标时间戳,this.now 是当前时间戳。差值为正表示"还没到",为负表示"已经过了"。
取绝对值的处理
const absMs = Math.max(0, total);
如果 total 是负数(已经过了),取 0 而不是负数。这样显示的剩余时间是 “0天0时0分0秒” 而不是负数。
天/时/分/秒的拆分
总毫秒数 ÷ 86400000 = 天数
剩余毫秒 ÷ 3600000 = 小时
剩余毫秒 ÷ 60000 = 分钟
剩余毫秒 ÷ 1000 = 秒
用取余(%)获取每级的余数,逐级拆分。比如 90000000 毫秒 = 1天1小时。
进度环的计算
const span = Math.min(30 * 86400000, Math.max(absMs, 1));
const elapsed = Math.max(0, 1 - absMs / span);
span:假设事件在 30 天内(最多显示 30 天的进度)elapsed:已过时间的百分比,1 - absMs/span
比如事件还有 15 天,span=30 天,elapsed=0.5(50%)。进度环显示一半。
为什么要 Math.max(absMs, 1)
避免 absMs=0 时除以 0 的错误。如果 absMs=0,span=1,elapsed=1(100%),进度环满。
now 的更新机制
@State now: number = Date.now();
now 只在组件初始化时赋值一次。但倒计时需要每秒更新。这个实现没有用定时器刷新 now,所以倒计时不会实时变化——需要用户手动刷新或重新进入页面。
为什么不用 setInterval
setInterval 每秒更新 now 会让所有倒计时卡片每秒重绘,性能开销大。对于"天级"的倒计时,秒级更新意义不大。
如果需要实时更新,可以用 setInterval:
private timer: number = 0;
aboutToAppear(): void {
this.timer = setInterval(() => { this.now = Date.now(); }, 1000);
}
aboutToDisappear(): void { clearInterval(this.timer); }
但当前实现选择了"性能优先"。
新增事件:输入面板
TextInput
TextInput({placeholder: '事件名称,如:生日、期末考试…', text: this.newName})
.onChange((v: string) => { this.newName = v; })
接收事件名称,onChange 实时更新 newName。
DatePicker
DatePicker({ start: new Date('2025-01-01'), end: new Date('2099-12-31'), selected: this.newDate ? new Date(this.newDate) : new Date() })
.onChange((v: DatePickerResult) => {
this.newDate = `${v.year}-${(v.month!+1).toString().padStart(2,'0')}-${v.day!.toString().padStart(2,'0')}`;
})
DatePicker 的 onChange 回调返回 DatePickerResult,包含 year、month(0-11)、day。需要把 month+1 转成 1-12。
颜色选择
ForEach(CD_COLORS, (c: string) => {
Column()
.width(30).height(30).borderRadius(15).backgroundColor(c)
.border({width: this.selectedColor===c?2:0, color:'#FFFFFF'})
.onClick(() => this.selectedColor = c)
})
6 种预设颜色,点击选中。选中的用白色边框(2px),未选中的无边框。
addEvent 方法
addEvent(): void {
if (this.newName.trim() === '') return;
const ts = this.newDate ? new Date(this.newDate).getTime() : (Date.now() + 86400000);
this.events = [...this.events, { name: this.newName.trim(), target: ts, color: this.selectedColor }];
this.newName = ''; this.newDate = ''; this.showAdd = false;
}
- 名称为空时直接返回
- 没选日期时默认"明天"(
Date.now() + 86400000) - 用展开运算符
[...this.events, newEvent]创建新数组(不可变性) - 清空输入,关闭面板
列表渲染:ForEach + 条件渲染
ForEach(this.events, (evt: CDEvent, idx: number) => {
Column() {
// 标题行
Row() {
Text(evt.name).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }).layoutWeight(1)
Text('✕').fontSize(14).fontColor('#666')
.onClick(() => this.removeEvent(idx))
}
// 内容区
if (this.calcRemain(evt.target).total <= 0) {
Text('🎉 已到达!').fontSize(22).fontColor(evt.color)
} else {
Row() {
// 天:时:分:秒
Column() { Text(d.toString()).fontSize(30).fontColor(evt.color); Text('天').fontSize(10) }
Text(':').fontSize(24).fontColor('#444')
Column() { Text(h.padStart(2,'0')).fontSize(30).fontColor(evt.color); Text('时').fontSize(10) }
// ... 分、秒类似
}
Progress({ value: elapsed * 100, total: 100, type: ProgressType.Ring })
.width(60).height(60).color(evt.color).style({strokeWidth:6})
}
}
.width('100%').padding(18).borderRadius(16).backgroundColor('#1E1E32')
})

条件渲染的两种状态
- 已到达(
total <= 0):显示"🎉 已到达!" - 未到达(
total > 0):显示剩余时间和进度环
padStart(2, ‘0’) 的作用
小时、分钟、秒用 padStart(2, '0') 补零。比如 5 显示为 05,保持两位数对齐。
Progress 组件
Progress({ value: Math.round(elapsed * 100), total: 100, type: ProgressType.Ring })
.width(60).height(60).color(evt.color).style({strokeWidth:6})
ProgressType.Ring 显示环形进度条。value 是当前值(0-100),total 是最大值。strokeWidth:6 控制环的粗细。
删除事件
removeEvent(idx: number): void {
this.events = this.events.filter((_, i) => i !== idx);
}
用 filter 过滤掉指定索引的元素,创建新数组。不直接修改原数组(不可变性)。
为什么用展开运算符而不是 push
// 正确:创建新数组
this.events = [...this.events, newEvent];
// 错误:直接 push
this.events.push(newEvent); // ArkTS 可能不会触发 UI 更新
ArkUI 的 @State 变化检测基于引用比较。push 修改原数组,引用不变,UI 不更新。[...events, newEvent] 创建新数组,引用变了,UI 更新。
踩坑记录
坑 1:DatePicker 的 month 是 0-11
DatePickerResult.month 从 0 开始(0=一月,11=十二月)。显示时要 +1,否则月份会少一个月。
坑 2:时间戳的精度
JavaScript 的 Date.now() 精度是毫秒。如果用秒级时间戳,需要 × 1000。代码中统一用毫秒,避免混淆。
坑 3:进度环的范围
进度环的 value 必须在 0-100 之间。elapsed 是 0-1 的小数,需要 × 100 再传入。
坑 4:removeEvent 的 filter
filter 会遍历整个数组。如果事件很多(几百个),性能会有影响。但对于倒计时应用,事件数量通常很少,不是问题。
坑 5:calcRemain 被多次调用
每个事件在渲染时都会调用 calcRemain,计算剩余时间。如果事件很多,会有性能开销。可以考虑缓存计算结果。
代码改进建议
1. 实时更新
用 setInterval 每秒更新 now,让倒计时实时变化。
2. 本地存储
用 Preferences API 保存事件列表,重启应用后数据不丢失。
3. 提醒功能
在事件到达前(比如提前 1 天)发送通知提醒。
4. 编辑事件
当前只能新增和删除,不能编辑。可以加编辑功能。
5. 分类标签
给事件加标签(比如"工作"、“生活”、“纪念日”),方便筛选。
总结
倒计时器的核心是"时间戳运算"——用 target - now 得到剩余毫秒数,再拆分成天/时/分/秒。进度环基于"剩余时间占总时间的比例"计算。列表用 ForEach 渲染,支持动态增删。
适用边界:这个部分适合用作 ArkUI 时间计算和列表渲染的学习案例,涵盖了时间戳运算、DatePicker 组件、Progress 组件、ForEach 渲染、不可变性更新等核心知识点。但如果要上架应用商店,还需要补充实时更新、本地存储、提醒功能、编辑事件、分类标签等内容。
更多推荐



所有评论(0)