HarmonyOS ArkTS 实战:实现一个精准秒表应用

项目效果

本文实现的是一个基于 HarmonyOS 和 ArkTS 的精准秒表应用。项目中使用 ArkUI 组件完成页面布局,通过 @State 管理状态数据,实现开始/暂停/重置计时、计次功能、计次记录列表、毫秒级精度显示和大字体时间显示等功能。

最终运行效果如下:

在这里插入图片描述

页面主要包含以下内容:

  • 顶部应用标题;
  • 大字体时间显示(分:秒.毫秒);
  • 开始/暂停按钮;
  • 计次/重置按钮;
  • 计次记录列表;
  • 计次序号、分段时间和总时间;
  • 空状态提示;
  • 页面整体采用 ArkUI 声明式布局。

本文重点是演示如何在 HarmonyOS 项目中使用 ArkTS 和 ArkUI 实现一个精准计时类单页面应用。项目代码主要写在 entry/src/main/ets/pages/Index.ets 文件中,适合作为 HarmonyOS ArkTS 入门到进阶之间的练习案例。

前言

秒表是生活中常用的计时工具,无论是运动计时、实验记录还是游戏测速,都需要一个精准易用的秒表。手机自带的秒表往往功能简单,而我们可以自己实现一个功能完整、界面美观的秒表应用。

从应用开发角度来看,这个项目不依赖后端接口,也不需要数据库,但能练习 ArkTS 中的状态管理、高精度定时器、时间格式化、列表渲染和按钮交互等内容。

本文基于 HarmonyOS 和 ArkTS 实现一个精准秒表应用。用户可以开始、暂停和重置计时,记录计次分段时间,查看每次计次的详细数据。

这个项目的核心不是简单的数字累加,而是精准的时间控制和计次功能。每一次开始、暂停、计次和重置,本质上都是对计时器状态的管理。状态变化后,页面会自动刷新,这正是 ArkUI 声明式开发的基本思想。

一、项目目标

本次实践主要实现以下目标:

  • 创建 HarmonyOS ArkTS 页面;
  • 使用 @Entry@Component 定义页面组件;
  • 使用 @State 管理页面状态;
  • 使用高精度定时器实现毫秒级计时;
  • 实现开始/暂停功能;
  • 实现重置功能;
  • 实现计次功能;
  • 格式化时间显示(分:秒.毫秒);
  • 记录每次计次的分段时间;
  • 记录每次计次的总时间;
  • 使用 ListForEach 渲染计次记录;
  • 使用空状态提示优化无计次页面;
  • 使用 @Builder 封装按钮和计次项;
  • 完成一个可以运行的秒表页面。

这个项目虽然是单页面应用,但它有完整的计时控制、计次记录和时间显示功能,比普通静态页面更适合练习 ArkTS。

二、技术栈

类型 内容
开发方向 HarmonyOS 应用开发
开发语言 ArkTS
UI 框架 ArkUI
SDK 版本 HarmonyOS API 23 及以上
工程模型 Stage 模型
核心组件 Text / Button / Column / Row / List / ForEach
状态管理 @State
数据处理 时间计算 / 高精度计时 / 数组操作
项目入口 entry/src/main/ets/pages/Index.ets
运行平台 模拟器或真机

本项目是 HarmonyOS 原生 ArkTS 项目。页面主体由 ArkUI 组件构建,核心逻辑写在 Index.ets 文件中,不依赖后端接口,也不需要额外配置数据库。

三、为什么选择秒表项目

精准秒表应用适合作为 ArkTS 练习项目,主要有以下几个原因。

第一,实用性强。秒表是非常经典的工具类应用,在很多场景下都会用到。

第二,业务场景真实。运动、实验、游戏等场景都需要计时功能,是手机必备工具之一。

第三,适合练习状态管理。运行状态、经过时间、开始时间、计次记录都属于页面状态。状态变化后,页面会自动刷新。

第四,适合练习定时器使用。高精度计时需要使用定时器,这是前端开发中重要的基础技能。

第五,适合练习时间格式化。将毫秒数格式化为易读的分秒毫秒格式,是常见的开发需求。

第六,扩展空间比较大。基础功能完成后,可以继续增加倒计时、倒计时+秒表切换、计时分享、本地保存记录和多组计时功能。

在本项目中,计时器状态和计次记录是核心数据。页面中的时间显示和计次列表都由它们计算或渲染得到。

四、功能规则说明

计时精度:10毫秒(0.01秒),更新频率100次/秒。

按钮状态规则:

当前状态 主按钮 副按钮
初始状态 开始 重置(禁用)
运行中 暂停 计次
暂停中 开始 重置

计次规则:

  • 点击计次按钮记录当前时间点
  • 分段时间 = 当前总时间 - 上一次计次总时间
  • 计次按倒序显示,最新的在最上面
  • 最多记录99次计次

时间显示格式:MM:SS.cc,例如01:23.45表示1分23秒45。

五、项目结构

本项目主要修改首页文件:

entry
└── src
    └── main
        └── ets
            └── pages
                └── Index.ets

其中:

文件 作用
Index.ets 编写页面结构、状态数据和秒表计时逻辑

本文不涉及复杂路由,也不需要额外创建多个页面。对于练习项目来说,把主要逻辑集中在一个 Index.ets 文件中更方便理解。

如果后续继续扩展,可以考虑把时间显示组件、控制按钮和计次项拆分成独立组件。

六、核心实现思路

本项目的核心流程如下:

  1. 定义计次记录数据结构;
  2. 使用 @State 保存运行状态、经过时间、定时器ID和计次记录;
  3. 点击开始按钮启动定时器;
  4. 每10毫秒更新一次经过时间;
  5. 点击暂停按钮停止定时器;
  6. 点击计次按钮记录当前时间;
  7. 计算分段时间和总时间;
  8. 点击重置按钮清空所有数据;
  9. 格式化时间显示为易读格式;
  10. 使用 ForEach 渲染计次记录列表;
  11. 根据当前状态动态改变按钮文字和功能;
  12. 没有计次时显示空状态提示。

项目中最重要的状态变量如下:

@State isRunning: boolean = false
@State elapsedTime: number = 0
@State timerId: number = -1
@State lastLapTime: number = 0
@State laps: LapRecord[] = []
@State nextLapNumber: number = 1

其中:

状态变量 作用
isRunning 计时器是否运行中
elapsedTime 经过的总时间(毫秒)
timerId 定时器ID
lastLapTime 上一次计次的时间
laps 计次记录数组
nextLapNumber 下一个计次的序号

elapsedTimelaps 是本项目中最重要的数据。时间显示和计次列表都依赖这两个状态。

七、Index.ets 完整代码

打开文件:

entry/src/main/ets/pages/Index.ets

将其中内容替换为下面代码:

interface LapRecord {
  number: number
  splitTime: number
  totalTime: number
}

@Entry
@Component
struct Index {
  @State isRunning: boolean = false
  @State elapsedTime: number = 0
  @State timerId: number = -1
  @State lastLapTime: number = 0
  @State nextLapNumber: number = 1
  @State laps: LapRecord[] = []
  private startTime: number = 0

  aboutToDisappear() {
    this.stopTimer()
  }

  private startTimer(): void {
    if (this.isRunning) return
    
    this.isRunning = true
    this.startTime = Date.now() - this.elapsedTime
    
    this.timerId = setInterval(() => {
      this.elapsedTime = Date.now() - this.startTime
    }, 10)
  }

  private pauseTimer(): void {
    if (!this.isRunning) return
    
    this.isRunning = false
    this.stopTimer()
  }

  private stopTimer(): void {
    if (this.timerId !== -1) {
      clearInterval(this.timerId)
      this.timerId = -1
    }
  }

  private resetTimer(): void {
    this.pauseTimer()
    this.elapsedTime = 0
    this.lastLapTime = 0
    this.nextLapNumber = 1
    this.laps = []
  }

  private recordLap(): void {
    if (!this.isRunning) return
    if (this.laps.length >= 99) return
    
    let splitTime = this.elapsedTime - this.lastLapTime
    let lap: LapRecord = {
      number: this.nextLapNumber,
      splitTime: splitTime,
      totalTime: this.elapsedTime
    }
    
    this.laps = [lap, ...this.laps]
    this.lastLapTime = this.elapsedTime
    this.nextLapNumber++
  }

  private formatTime(ms: number): string {
    let minutes = Math.floor(ms / 60000)
    let seconds = Math.floor((ms % 60000) / 1000)
    let centiseconds = Math.floor((ms % 1000) / 10)
    
    return `${this.formatTwo(minutes)}:${this.formatTwo(seconds)}.${this.formatTwo(centiseconds)}`
  }

  private formatTwo(value: number): string {
    return value < 10 ? '0' + value.toString() : value.toString()
  }

  private getFastestLap(): number {
    if (this.laps.length < 2) return -1
    let minTime = Math.min(...this.laps.map(l => l.splitTime))
    return this.laps.find(l => l.splitTime === minTime)?.number || -1
  }

  private getSlowestLap(): number {
    if (this.laps.length < 2) return -1
    let maxTime = Math.max(...this.laps.map(l => l.splitTime))
    return this.laps.find(l => l.splitTime === maxTime)?.number || -1
  }

  @Builder
  ControlButton(text: string, color: string, bgColor: string, onClick: () => void) {
    Button(text)
      .width(80)
      .height(80)
      .fontSize(18)
      .fontColor(color)
      .backgroundColor(bgColor)
      .borderRadius(40)
      .onClick(onClick)
  }

  @Builder
  LapItem(lap: LapRecord) {
    let fastest = this.getFastestLap()
    let slowest = this.getSlowestLap()
    let textColor = '#182431'
    
    if (lap.number === fastest) {
      textColor = '#10B981'
    } else if (lap.number === slowest) {
      textColor = '#EF4444'
    }

    Row() {
      Text(`计次 ${lap.number}`)
        .fontSize(15)
        .fontColor('#6B7280')
        .width(80)
      
      Text(this.formatTime(lap.splitTime))
        .fontSize(16)
        .fontWeight(FontWeight.Medium)
        .fontColor(textColor)
        .layoutWeight(1)
        .textAlign(TextAlign.Center)
      
      Text(this.formatTime(lap.totalTime))
        .fontSize(16)
        .fontColor('#182431')
        .width(100)
        .textAlign(TextAlign.End)
    }
    .width('100%')
    .padding(16)
    .margin({ bottom: 1 })
    .backgroundColor(Color.White)
  }

  build() {
    Column() {
      Text('秒表')
        .fontSize(28)
        .fontWeight(FontWeight.Bold)
        .fontColor('#182431')
        .margin({ top: 22 })
      Text('基于 HarmonyOS ArkTS 的精准计时工具')
        .fontSize(14)
        .fontColor('#6B7280')
        .margin({ top: 8, bottom: 40 })

      Text(this.formatTime(this.elapsedTime))
        .fontSize(72)
        .fontWeight(FontWeight.Bold)
        .fontColor('#182431')
        .fontFamily('monospace')
        .textAlign(TextAlign.Center)
        .margin({ bottom: 60 })

      Row() {
        if (this.isRunning) {
          this.ControlButton('计次', '#0A59F7', '#EFF6FF', () => {
            this.recordLap()
          })
        } else {
          this.ControlButton('重置', this.elapsedTime > 0 ? '#EF4444' : '#9CA3AF', 
            this.elapsedTime > 0 ? '#FEF2F2' : '#F3F4F6', () => {
            this.resetTimer()
          })
        }

        Blank()

        if (this.isRunning) {
          this.ControlButton('暂停', '#F59E0B', '#FEF3C7', () => {
            this.pauseTimer()
          })
        } else {
          this.ControlButton('开始', '#10B981', '#D1FAE5', () => {
            this.startTimer()
          })
        }
      }
      .width('100%')
      .padding({ left: 30, right: 30 })
      .margin({ bottom: 30 })

      if (this.laps.length > 0) {
        Row() {
          Text('计次')
            .fontSize(13)
            .fontColor('#9CA3AF')
            .width(80)
          Text('分段时间')
            .fontSize(13)
            .fontColor('#9CA3AF')
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
          Text('总时间')
            .fontSize(13)
            .fontColor('#9CA3AF')
            .width(100)
            .textAlign(TextAlign.End)
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 12, bottom: 12 })
        .backgroundColor('#F9FAFB')
      }

      if (this.laps.length === 0) {
        Column() {
          Text('暂无计次记录')
            .fontSize(16)
            .fontColor('#6B7280')
          Text('开始计时后点击"计次"按钮记录分段时间')
            .fontSize(13)
            .fontColor('#9CA3AF')
            .margin({ top: 8 })
        }
        .width('100%')
        .padding(30)
        .backgroundColor(Color.White)
        .borderRadius(16)
      } else {
        List() {
          ForEach(this.laps, (lap: LapRecord) => {
            ListItem() {
              this.LapItem(lap)
            }
          }, (lap: LapRecord) => lap.number.toString())
        }
        .width('100%')
        .layoutWeight(1)
        .scrollBar(BarState.Off)
        .borderRadius(16)
        .clip(true)
      }
    }
    .width('100%')
    .height('100%')
    .padding({ left: 18, right: 18 })
    .backgroundColor('#F5F7FA')
  }
}

八、代码实现说明

1. 高精度计时

使用Date.now()获取时间戳,计算经过的时间,而不是简单累加,这样可以避免定时器误差累积:

this.startTime = Date.now() - this.elapsedTime
this.timerId = setInterval(() => {
  this.elapsedTime = Date.now() - this.startTime
}, 10)

每10毫秒更新一次,实现10毫秒精度。

2. 时间格式化

将毫秒数格式化为MM:SS.cc格式:

private formatTime(ms: number): string {
  let minutes = Math.floor(ms / 60000)
  let seconds = Math.floor((ms % 60000) / 1000)
  let centiseconds = Math.floor((ms % 1000) / 10)
  return `${this.formatTwo(minutes)}:${this.formatTwo(seconds)}.${this.formatTwo(centiseconds)}`
}
3. 按钮状态管理

根据当前运行状态动态改变按钮的文字、颜色和功能:

  • 未运行且时间为0:开始/重置(禁用)
  • 运行中:暂停/计次
  • 暂停中:开始/重置
4. 计次功能

记录每次计次的分段时间和总时间,最快计次显示绿色,最慢计次显示红色:

let splitTime = this.elapsedTime - this.lastLapTime
let lap: LapRecord = {
  number: this.nextLapNumber,
  splitTime: splitTime,
  totalTime: this.elapsedTime
}
5. 定时器清理

页面销毁时清除定时器,避免内存泄漏:

aboutToDisappear() {
  this.stopTimer()
}

九、运行项目

代码编写完成后,在DevEco Studio中运行项目。测试开始/暂停/重置、计次功能,检查时间显示是否准确,计次记录是否正确。

十、开发中遇到的问题

  • 定时器误差问题:使用时间戳计算而不是累加,避免误差累积
  • 按钮状态切换:根据不同状态显示不同按钮
  • 最快最慢计次标记:自动识别并高亮显示
  • 等宽字体:使用monospace字体让数字对齐更美观

十一、总结

本文基于HarmonyOS和ArkTS实现了一个精准秒表应用。项目通过@State管理计时状态,使用高精度定时器实现毫秒级计时,实现了开始/暂停/重置、计次记录和时间格式化等功能。这个项目展示了ArkTS中定时器使用、时间计算和状态管理的基本方法,适合作为入门练习项目。

Logo

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

更多推荐