HarmonyOS ArkTS 倒计时组件实战:从需求分析到基础实现

38 阅读7分钟

HarmonyOS ArkTS 倒计时组件实战:从需求分析到基础实现

本文是《HarmonyOS ArkTS 企业级倒计时组件设计与实现》系列的第一篇,将从零开始,带你实现一个功能完整的倒计时组件。适合 HarmonyOS 开发初学者,通过实际案例学习组件开发的核心技能。

📋 前言

倒计时组件是移动应用中的常见需求,无论是电商秒杀、活动倒计时,还是限时优惠,都需要一个稳定可靠的倒计时组件。本文将带你从需求分析开始,逐步实现一个企业级的倒计时组件。

🎯 需求分析

核心需求

一个倒计时组件需要满足以下基本需求:

  1. 时间显示:能够显示剩余时间(天、时、分、秒)
  2. 实时更新:时间需要实时递减
  3. 多种格式:支持不同的时间显示格式
  4. 生命周期管理:页面切换时正确处理
  5. 倒计时结束:时间到0时的处理

时间格式需求

根据业务场景,我们需要支持以下8种时间格式:

格式类型示例使用场景
hh:mm:ss12:30:45标准格式,最常用
D天hh小时2天12小时超过48小时时使用
mm:ss30:45低于1小时时隐藏小时
hh:mm:ss.ms12:30:45.5需要毫秒精度
mm:ss.ms30:45.5毫秒+隐藏小时
ss.ms45.5仅显示秒和毫秒
ss45仅显示秒
自定义文本即将开始完全自定义文本

功能需求

  1. 定时器管理:每100ms更新一次,保证流畅度
  2. 状态管理:支持运行、暂停、停止等状态
  3. 页面生命周期:页面隐藏时暂停,显示时恢复
  4. 倒计时结束处理:支持关闭弹窗、跳转页面等操作

🏗️ 基础架构设计

组件结构

使用 HarmonyOS 的 @ComponentV2 装饰器创建组件:

@ComponentV2
export struct CountdownView {
  // 组件属性
  @Require @Param viewBuilderParam: ComponentParams
  
  // 状态变量
  @Local remainingTime: number = 0
  @Local status: TimerStatus = TimerStatus.IDLE
  
  // 时间分段
  @Local D: number = 0    // 天
  @Local hh: number = 0   // 小时
  @Local mm: number = 0   // 分钟
  @Local ss: number = 0   // 秒
  @Local ms: number = 0  // 毫秒
  
  // 定时器
  @Local timerId: number | null = null
  private readonly interval: number = 100 // 100ms刷新间隔
}

状态管理

使用枚举管理倒计时状态:

enum TimerStatus {
  IDLE = 0,      // 空闲
  RUNNING = 1,   // 运行中
  PAUSED = 2     // 暂停
}

状态转换流程:

IDLE → RUNNING → PAUSED → RUNNING → IDLE

数据流设计

配置参数 → init() → 初始化状态
                ↓
         aboutToAppear() → 启动定时器
                ↓
         定时器回调 → 更新剩余时间 → 刷新UI
                ↓
         时间到0 → 停止定时器 → 执行结束操作

💻 核心功能实现

1. 初始化方法

组件初始化时,需要从配置中读取参数:

init() {
  // 获取配置
  const element = this.viewBuilderParam.element
  const config = element?.config as ElementConfig
  const dataPath = element?.dataPath as DataPath
  
  // 设置总时间
  this.totalTime = DataUtil.handleArg(
    dataPath.countDown, 
    this.viewBuilderParam?.args?.state
  ) as number
  this.remainingTime = this.totalTime
  
  // 读取配置
  this.fontSize = sizeAdapt(config?.fontInfo?.size)
  this.shouldShowMS = (config?.showMS === 1) ?? false
  this.shouldHideHour = (
    (this.totalTime < 60 * 60) && (config?.disableHour === 1)
  ) ?? false
  
  // 颜色配置
  this.numberFontColor = parseColor(config?.color)
  this.numberBgColor = parseColor(config?.numberBgColor)
}

2. 时间计算

将总秒数转换为天、时、分、秒、毫秒:

refreshTimeNumber(seconds: number) {
  // 计算天
  this.D = Math.floor(seconds / (24 * 3600))
  
  // 计算小时(需要考虑是否在hh:mm:ss模式)
  const hhAsTop = this.keyList?.startsWith('hh')
  this.hh = Math.floor(
    (hhAsTop ? seconds : (seconds % (24 * 3600))) / 3600
  )
  
  // 计算分钟
  this.mm = Math.floor((seconds % 3600) / 60)
  
  // 计算秒(需要考虑是否在ss模式)
  const ssAsTop = this.keyList?.startsWith('ss')
  this.ss = Math.floor(ssAsTop ? seconds : (seconds % 60))
  
  // 计算毫秒(取小数部分的第一位)
  this.ms = Math.floor((seconds % 1) * 10)
}

关键点:

  • hh:mm:ss 模式中,需要把天数折算成小时叠加
  • ss 模式中,直接使用总秒数作为秒数
  • 毫秒只显示1位,通过 (seconds % 1) * 10 计算

3. 定时器管理

使用 setInterval 实现定时更新:

startCountdown(): void {
  // 如果正在运行,直接返回
  if (this.status === TimerStatus.RUNNING) {
    return
  }
  
  this.status = TimerStatus.RUNNING
  
  // 清除之前的定时器
  if (this.timerId !== 0) {
    clearInterval(this.timerId)
  }
  
  // 设置新的定时器,每100ms更新一次
  this.timerId = setInterval(() => {
    this.refreshTimeNumber(this.remainingTime)
    
    if (this.remainingTime > 0) {
      // 递减0.1秒,保留1位小数
      this.remainingTime = Math.max(0, this.remainingTime - 0.1)
      this.remainingTime = Math.round(this.remainingTime * 10) / 10
      
      // 时间到0
      if (this.remainingTime <= 0) {
        this.stopCountdown()
        this.timeOutAction()
      }
    }
  }, this.interval)
}

关键点:

  • 使用100ms间隔,平衡流畅度和性能
  • 每次递减0.1秒,保留1位小数精度
  • 使用 Math.round 避免浮点数精度问题

4. 暂停和恢复

pauseCountdown(): void {
  if (this.status === TimerStatus.RUNNING) {
    this.status = TimerStatus.PAUSED
    if (this.timerId !== 0) {
      clearInterval(this.timerId)
      this.timerId = 0
    }
  }
  this.refreshTimeNumber(this.remainingTime)
}

stopCountdown(): void {
  this.status = TimerStatus.IDLE
  if (this.timerId !== 0) {
    clearInterval(this.timerId)
    this.timerId = 0
  }
  this.remainingTime = 0
  this.refreshTimeNumber(this.remainingTime)
}

5. 生命周期管理

aboutToAppear 中启动定时器,在 aboutToDisappear 中清理资源:

aboutToAppear(): void {
  this.init()
  this.startCountdown()
  this.refreshTimeNumber(this.remainingTime)
  
  // 监听页面显示/隐藏事件
  if (!this.ignorePause) {
    emitter.on(PAGE_SHOW_EVENT, () => this.startCountdown())
    emitter.on(PAGE_HIDE_EVENT, () => this.pauseCountdown())
  }
}

aboutToDisappear(): void {
  this.stopCountdown()
  emitter.off(PAGE_HIDE_EVENT)
  emitter.off(PAGE_SHOW_EVENT)
}

关键点:

  • 页面隐藏时自动暂停,节省资源
  • 页面显示时自动恢复,保证用户体验
  • 组件销毁时清理定时器和事件监听,防止内存泄漏

6. 基础UI渲染

使用标志位控制显示内容:

@Local timeFormatFlags: Record<string, boolean> = {
  'D': false,
  '天': false,
  'hh': !this.shouldHideHour ?? false,
  ':1': !this.shouldHideHour ?? false,
  '小时': false,
  'mm': false,
  ':2': false,
  'ss': false,
  '.': this.shouldShowMS ?? false,
  'ms': this.shouldShowMS ?? false,
}

build() {
  Row() {
    // 天
    if (this.timeFormatFlags['D']) {
      Text(this.D.toString())
        .fontSize(this.fontSize)
        .fontColor(this.numberFontColor)
    }
    if (this.timeFormatFlags['天']) {
      Text('天').fontSize(this.fontSize)
    }
    
    // 小时
    if (this.timeFormatFlags['hh']) {
      Text(this.padZero(this.hh))
        .fontSize(this.fontSize)
        .fontColor(this.numberFontColor)
    }
    if (this.timeFormatFlags[':1']) {
      Text(':').fontSize(this.fontSize)
    }
    
    // 分钟
    if (this.timeFormatFlags['mm']) {
      Text(this.padZero(this.mm))
        .fontSize(this.fontSize)
        .fontColor(this.numberFontColor)
    }
    if (this.timeFormatFlags[':2']) {
      Text(':').fontSize(this.fontSize)
    }
    
    // 秒
    if (this.timeFormatFlags['ss']) {
      Text(this.padZero(this.ss))
        .fontSize(this.fontSize)
        .fontColor(this.numberFontColor)
    }
    
    // 毫秒
    if (this.timeFormatFlags['.']) {
      Text('.').fontSize(this.fontSize)
    }
    if (this.timeFormatFlags['ms']) {
      Text(this.ms.toString())
        .fontSize(this.fontSize)
        .fontColor(this.numberFontColor)
    }
  }
  .position({ top: this.y, left: this.x })
}

关键点:

  • 使用 Record<string, boolean> 标志位控制显示
  • 通过 if 条件渲染,灵活控制显示内容
  • 使用 padZero 方法补零,保证两位数显示

7. 辅助方法

// 数字补零
padZero(num: number): string {
  return num >= 10 || !this.keyList?.includes(':2ss') 
    ? `${num}` 
    : `0${num}`
}

📦 完整代码示例

以下是简化版的基础实现(完整版包含更多配置和功能):

@ComponentV2
export struct CountdownView {
  @Require @Param viewBuilderParam: ComponentParams
  
  @Local remainingTime: number = 0
  @Local status: TimerStatus = TimerStatus.IDLE
  @Local timerId: number | null = null
  
  @Local D: number = 0
  @Local hh: number = 0
  @Local mm: number = 0
  @Local ss: number = 0
  @Local ms: number = 0
  
  private totalTime: number = 0
  private readonly interval: number = 100
  
  init() {
    // 初始化配置
    this.totalTime = 3600 // 示例:1小时
    this.remainingTime = this.totalTime
  }
  
  aboutToAppear(): void {
    this.startCountdown()
    this.refreshTimeNumber(this.remainingTime)
  }
  
  refreshTimeNumber(seconds: number) {
    this.D = Math.floor(seconds / (24 * 3600))
    this.hh = Math.floor((seconds % (24 * 3600)) / 3600)
    this.mm = Math.floor((seconds % 3600) / 60)
    this.ss = Math.floor(seconds % 60)
    this.ms = Math.floor((seconds % 1) * 10)
  }
  
  startCountdown(): void {
    if (this.status === TimerStatus.RUNNING) {
      return
    }
    
    this.status = TimerStatus.RUNNING
    
    if (this.timerId !== 0) {
      clearInterval(this.timerId)
    }
    
    this.timerId = setInterval(() => {
      this.refreshTimeNumber(this.remainingTime)
      
      if (this.remainingTime > 0) {
        this.remainingTime = Math.max(0, this.remainingTime - 0.1)
        this.remainingTime = Math.round(this.remainingTime * 10) / 10
        
        if (this.remainingTime <= 0) {
          this.stopCountdown()
        }
      }
    }, this.interval)
  }
  
  stopCountdown(): void {
    this.status = TimerStatus.IDLE
    if (this.timerId !== 0) {
      clearInterval(this.timerId)
      this.timerId = 0
    }
  }
  
  aboutToDisappear(): void {
    this.stopCountdown()
  }
  
  build() {
    Row() {
      Text(`${this.padZero(this.hh)}:${this.padZero(this.mm)}:${this.padZero(this.ss)}`)
        .fontSize(30)
    }
  }
  
  padZero(num: number): string {
    return num < 10 ? `0${num}` : `${num}`
  }
}

🎓 关键知识点总结

1. 组件生命周期

  • init(): 组件初始化,读取配置
  • aboutToAppear(): 组件即将显示,启动定时器
  • aboutToDisappear(): 组件即将销毁,清理资源

2. 状态管理

  • 使用 @Local 装饰器声明响应式状态
  • 使用枚举管理复杂状态
  • 状态变化触发UI自动更新

3. 定时器管理

  • 使用 setInterval 实现定时更新
  • 注意清理定时器,防止内存泄漏
  • 合理设置刷新间隔(100ms平衡流畅度和性能)

4. 时间计算

  • 注意不同格式下的计算逻辑
  • 处理浮点数精度问题
  • 毫秒显示1位即可

5. 生命周期管理

  • 页面隐藏时暂停,节省资源
  • 页面显示时恢复,保证体验
  • 组件销毁时清理,防止泄漏

🚀 下一步

现在你已经掌握了倒计时组件的基础实现。在下一篇文章中,我们将深入探讨:

  • 设计模式实践:如何识别过度设计,选择合适的设计模式
  • 标志位驱动渲染:更优雅的条件渲染方式
  • 状态机模式:如何管理复杂的状态转换

敬请期待!


系列文章导航:

  • [第1篇] 基础篇:从需求分析到基础实现(本文)
  • [第2篇] 设计模式反思篇:当AI建议用策略模式时,我选择了质疑
  • [第3篇] 设计模式实践篇:标志位驱动渲染与状态机模式
  • [第4篇] 性能优化篇:从100ms刷新到流畅体验
  • [第5篇] 高级特性篇:时间区间样式切换
  • [第6篇] 工程实践篇:测试与质量保证
  • [第7篇] 总结篇:最佳实践与思考

相关资源:

讨论: 如果你在实现过程中遇到问题,欢迎在评论区留言讨论!