在这里插入图片描述

引言

如果说示例 41「世界时钟」关注的是「每个时刻的当前时间」,那么示例 44「节日倒计时」关注的就是「从当前时刻到目标日期的剩余时间」。用户可以在列表中看到 6 个节日的剩余天数,点击某个节日后,顶部卡片会以大号字体展示到该节日的精确倒计时(天/小时/分/秒)。

这个应用的实现核心是每秒定时器驱动倒计时刷新——nowTick 状态每秒 +1 触发 UI 重绘,重绘时动态计算到各节日的剩余时间。设计上的亮点包括:节日列表与主显示区的联动(点击列表项切换主显示)、日期跨年处理(当年已过则顺延明年)、以及秒级精度的倒计时展示。

1. 应用概述与功能

「节日倒计时」页面自上而下分为:顶部返回栏、主显示倒计时卡片、全部节日剩余天数列表。

1.1 核心功能清单

  • 6 个预置节日:元旦、春节、劳动节、国庆节、圣诞节、七夕。
  • 秒级倒计时:实时展示到目标节日的精确剩余时间(天/小时/分/秒)。
  • 主显示切换:点击列表中的节日条目,顶部卡片切换显示该节日的倒计时。
  • 自动跨年:如果当年节日已过,自动顺延到明年。
  • 春节特殊处理:春节固定为 2027-02-06(示例),到期后顺延到 2028。
  • 列表剩余天数:列表中每个节日显示剩余整天数。

1.2 技术要点一览

  1. Festival 接口:定义节日数据结构(名称、月份、日期)。
  2. 定时器驱动刷新:setInterval 每秒更新 nowTick 状态,触发所有倒计时重新计算。
  3. 动态日期计算:getTarget() 方法处理节日已过/未过的逻辑,返回正确的目标日期。
  4. 剩余时间格式化:remainText() 将毫秒差格式化为「天 小时 分 秒」的文本。
  5. ForEach 列表渲染:节日列表与主显示区联动,点击切换当前节日。

2. 核心知识点

2.1 Festival 接口

interface Festival {
  name: string;
  month: number;
  day: number;
}
  • name:节日中文名。
  • month:月份(1-12)。
  • day:日期(1-31)。

6 个节日的预置数据覆盖了全年主要节日。

2.2 @State 状态变量

@State festivals: Festival[] = [...]
@State currentName: string = '元旦';
@State nowTick: number = 0;
  • festivals:节日数据数组,标记为 @State 是因为需要 ForEach 遍历渲染(尽管数据不变,但 ForEach 要求数据源是响应式的)。
  • currentName:当前主显示的节日名称,初始为「元旦」。
  • nowTick:每秒 +1 的计数器,用于触发 UI 刷新(因为倒计时数据是在渲染时动态计算的,不需要存储为状态)。

2.3 定时器驱动

aboutToAppear(): void {
  this.timer = setInterval(() => {
    this.nowTick = this.nowTick + 1;
  }, 1000);
}

与示例 41 不同,这里定时器只做一件事:将 nowTick 状态 +1。UI 上显示的倒计时不是存储在 @State 中,而是在渲染时通过 remainText() 和 remainDays() 动态计算。这种设计的好处是:不需要在定时器回调中计算和存储大量状态,只需触发一次重绘即可。

2.4 getTarget:动态目标日期

getTarget(f: Festival): Date {
  const now: Date = new Date();
  if (f.name === '春节') {
    const d: Date = new Date(2027, 1, 6);
    if (d.getTime() > now.getTime()) {
      return d;
    }
    return new Date(2028, 1, 6);
  }
  const year: number = now.getFullYear();
  const d: Date = new Date(year, f.month - 1, f.day);
  if (d.getTime() > now.getTime()) {
    return d;
  }
  return new Date(year + 1, f.month - 1, f.day);
}

日期处理逻辑:

  • 春节特殊处理:因为春节是农历节日,公历日期每年不同,所以用固定示例日期(2027-02-06),过期后顺延到 2028-02-06。
  • 其他节日:默认使用当年的日期。如果当年节日已过(d.getTime() > now.getTime() 为 false),则顺延到明年。

month - 1 是因为 Date 构造函数中月份从 0 开始(0=一月,11=十二月)。

2.5 remainText:剩余时间格式化

remainText(f: Festival): string {
  const diff: number = this.getTarget(f).getTime() - new Date().getTime();
  const sec: number = Math.max(0, Math.floor(diff / 1000));
  const days: number = Math.floor(sec / 86400);
  const hours: number = Math.floor((sec % 86400) / 3600);
  const minutes: number = Math.floor((sec % 3600) / 60);
  const seconds: number = sec % 60;
  return days + ' 天 ' + hours + ' 小时 ' + minutes + ' 分 ' + seconds + ' 秒';
}

计算步骤:

  1. 毫秒差:目标日期时间戳 - 当前时间戳 = 剩余毫秒数。
  2. 转秒:Math.floor(diff / 1000),用 Math.max(0, ...) 防止负数。
  3. 分解为天/小时/分/秒:
    • 天:Math.floor(sec / 86400)(86400 = 24 × 3600)
    • 小时:Math.floor((sec % 86400) / 3600)
    • 分钟:Math.floor((sec % 3600) / 60)
    • 秒:sec % 60
  4. 拼接字符串:'X 天 X 小时 X 分 X 秒'

2.6 remainDays:剩余整天数

remainDays(f: Festival): number {
  const diff: number = this.getTarget(f).getTime() - new Date().getTime();
  return Math.max(0, Math.ceil(diff / (1000 * 3600 * 24)));
}

Math.ceil 向上取整。例如剩余 2 天 1 小时,显示 3 天。这比 Math.floor 更符合「还剩几天」的直觉——用户更愿意看到「还有 3 天」而不是「还有 2 天」。

2.7 current:获取当前节日

current(): Festival {
  let cur: Festival = this.festivals[0];
  for (let i: number = 0; i < this.festivals.length; i++) {
    if (this.festivals[i].name === this.currentName) {
      cur = this.festivals[i];
    }
  }
  return cur;
}

遍历 festivals 找到 name 匹配 currentName 的节日。使用变量而非索引是为了展示遍历查找的模式,实际可以用索引直接访问。

2.8 列表点击切换

.onClick(() => {
  this.currentName = item.name;
})

点击列表项时,将 currentName 更新为点击的节日名称,触发主显示卡片刷新。这是「主从联动」的标准实现。

3. 源码逐段解析

3.1 数据初始化

6 个节日覆盖了公历和农历节日:元旦(1/1)、春节(农历,固定示例)、劳动节(5/1)、国庆节(10/1)、圣诞节(12/25)、七夕(农历,按公历 8/19 示例)。

3.2 主显示卡片

主显示卡片使用白色圆角卡片,居中对齐,顶部显示「距 XXX 还有」,下方是 20fp 蓝色加粗的精确倒计时文本。

3.3 节日列表

列表使用 List + ForEach,每个条目显示节日名和剩余天数(红色强调)。layoutWeight(1) 占据剩余空间。

3.4 整体布局

与系列其他示例一致:顶部返回栏 → 主内容区 → 浅灰背景。

4. 关键实现细节分析

4.1 动态计算 vs 存储状态

倒计时文本是在渲染时通过方法动态计算的,而不是存储在 @State 中。这种设计的优势是:

  • 代码简洁:不需要在定时器回调中更新多个状态变量。
  • 数据一致性:所有倒计时都基于同一个 nowTick 驱动的重绘,不会出现不同步。
  • 缺点:每次重绘都要重新计算所有节日的倒计时,但 6 个节日的计算量很小,性能完全没问题。

4.2 nowTick 的作用

nowTick 本身没有业务含义,它的唯一作用是「触发 UI 重绘」。每当 nowTick +1,@State 系统通知 UI 重新渲染,渲染过程中 remainText() 和 remainDays() 被调用,计算出最新的倒计时数值。

4.3 跨年处理

getTarget() 中的跨年逻辑非常重要:

  • 如果今年的节日还没过 → 用今年的日期。
  • 如果今年的节日已过 → 用明年的日期。

这确保了倒计时永远指向「下一次」该节日,而不是已经过去的那一次。

4.4 春节的农历日期问题

春节是农历节日,公历日期不固定。当前实现使用了固定日期(2027-02-06)作为示例。更精确的实现需要一个农历到公历的转换表或算法。

4.5 性能考虑

每秒调用 remainText() 和 remainDays() 各 7 次(6 个节日 + 1 个当前节日),对于手机来说毫无压力。即使扩展到 100 个节日也不会有性能问题。

5. 运行效果与操作指南

5.1 运行步骤

  1. 从入口页导航到「节日倒计时」页面。
  2. 页面打开后立即显示当前默认节日(元旦)的精确倒计时。
  3. 点击列表中的其他节日,切换主显示。
  4. 倒计时每秒自动刷新。

5.2 使用场景

  • 节日等待:期待某个节日的到来。
  • 活动倒计时:自定义节日用于活动倒计时。
  • 学期/假期倒计时:学生可以用它来期待假期。

6. 可扩展方向

6.1 添加自定义节日

允许用户添加自定义节日(如生日、纪念日、考试日期等)。

6.2 节日持久化

使用 @StorageLink 保存自定义节日列表。

6.3 节日提醒

在节日前 N 天发送通知。

6.4 动画效果

倒计时数字变化时添加过渡动画。

6.5 分类展示

按类别分组节日(传统节日、法定假日、西方节日等)。

7. 常见问题与调试技巧

7.1 倒计时不刷新

检查定时器是否启动。在 aboutToAppear 中添加日志确认。

7.2 倒计时显示负数

检查 remainText() 和 remainDays() 中的 Math.max(0, ...) 是否正确使用。

7.3 日期跨年不正确

检查 getTarget() 的逻辑。确认当年节日已过时正确顺延到明年。

7.4 春节日期不准确

当前使用固定示例日期。对于真实春节日期,需要实现农历转换。

8. 总结

示例 44「节日倒计时」用约 152 行代码,展示了 ArkTS 在「定时刷新 + 动态列表」场景下的实现。重点回顾:

  • 定时器驱动重绘:nowTick 每秒 +1 触发 UI 重绘,渲染时动态计算倒计时。
  • 动态日期计算:getTarget() 处理节日已过/未过的逻辑。
  • 时间格式化:remainText() 将毫秒差格式化为「天/小时/分/秒」。
  • 主从联动:点击列表切换主显示,是 ArkUI 交互的标准模式。
  • 跨年处理:自动将已过节日顺延到明年。

这些技术在后续的番茄钟、计时器等示例中会被复用。

Logo

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

更多推荐