引子:倒计时的核心是"算时间差"

倒计时器看起来简单——显示一个倒数的数字。但做起来有三个问题要处理:时间差的计算、进度环的百分比、列表的动态增删。

今天就从代码出发,拆解时间戳运算、剩余时间格式化、进度环计算、列表渲染四个核心知识点。
完整效果
在这里插入图片描述

在这里插入图片描述

数据模型:两个 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=0span=1elapsed=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')}`;
  })

DatePickeronChange 回调返回 DatePickerResult,包含 yearmonth(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 渲染、不可变性更新等核心知识点。但如果要上架应用商店,还需要补充实时更新、本地存储、提醒功能、编辑事件、分类标签等内容。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐