HarmonyOS NEXT 实战:从零开发一个高颜值番茄钟应用

时间管理神器,专注从此开始!本文将带你从零开始,在 HarmonyOS NEXT 平台上开发一个功能完整、界面精美的番茄钟应用。通过 Canvas 绘制动态进度环、状态机管理计时逻辑、响应式 UI 切换等技术,打造属于你的专注力管理工具。


一、项目背景与需求分析

1.1 为什么选择番茄钟?

番茄工作法(Pomodoro Technique)是一种经典的时间管理方法:工作 25 分钟,休息 5 分钟,每完成 4 个番茄后进行 15 分钟长休息。这种方法简单高效,被广泛应用于个人 productivity 提升领域。

作为一个程序员,我深知「专注」的重要性。而 HarmonyOS NEXT 作为华为最新的操作系统,提供了强大的 ArkTS 框架和流畅的 UI 组件,非常适合开发这类轻量级工具应用。

1.2 功能需求

  • 核心功能

    • 25 分钟工作时间计时
    • 5 分钟短休息计时
    • 15 分钟长休息计时(每 4 个番茄后)
    • 开始/暂停/重置/跳过控制
    • 已完成番茄数统计
  • UI 需求

    • 动态进度圆环显示
    • 不同会话类型的颜色切换(工作/短休息/长休息)
    • 番茄进度指示器
    • 流畅的动画效果

1.3 项目结构

MyApplication/
├── AppScope/
│   └── app.json5              # 应用全局配置
├── entry/
│   ├── src/main/
│   │   ├── ets/
│   │   │   ├── entryability/
│   │   │   │   └── EntryAbility.ets    # 应用入口
│   │   │   └── pages/
│   │   │       ├── Index.ets           # 主界面
│   │   │       └── TimerViewModel.ets  # 计时器业务逻辑
│   │   └── resources/
│   │       └── base/
│   │           ├── element/            # 字符串、颜色资源
│   │           ├── media/               # 图片资源
│   │           └── profile/            # 页面路由配置
│   └── build-profile.json5
└── hvigor/                              # 构建配置

二、核心代码实现

2.1 计时器状态机设计

首先,我们需要设计一个清晰的计时器状态机。在 TimerViewModel.ets 中,我定义了两个枚举类型:

// 计时器状态
export enum TimerState {
  IDLE = 'IDLE',       // 空闲状态
  RUNNING = 'RUNNING', // 运行中
  PAUSED = 'PAUSED'   // 已暂停
}

// 会话类型
export enum SessionType {
  WORK = 'WORK',               // 工作时间
  SHORT_BREAK = 'SHORT_BREAK', // 短休息
  LONG_BREAK = 'LONG_BREAK'    // 长休息
}

这种状态机设计的好处是:

  1. 状态清晰:每个状态都有明确的含义,不会出现模糊的中间状态
  2. 易于扩展:如果以后需要添加「准备中」、「结束」等状态,只需在枚举中添加
  3. 类型安全:TypeScript/ArkTS 的枚举提供了编译时检查

2.2 TimerViewModel 核心类

接下来是计时器的核心业务逻辑类:

export class TimerViewModel {
  // 默认配置
  static readonly DEFAULT_WORK_DURATION = 25 * 60      // 25分钟工作
  static readonly DEFAULT_SHORT_BREAK = 5 * 60        // 5分钟短休息
  static readonly DEFAULT_LONG_BREAK = 15 * 60        // 15分钟长休息
  static readonly POMODOROS_BEFORE_LONG_BREAK = 4

  // 状态属性
  state: TimerState = TimerState.IDLE
  sessionType: SessionType = SessionType.WORK
  remainingSeconds: number = TimerViewModel.DEFAULT_WORK_DURATION
  completedPomodoros: number = 0
  totalPomodoros: number = 0

  // 回调函数
  onTick?: (remaining: number) => void
  onStateChange?: (state: TimerState) => void
  onSessionEnd?: (type: SessionType, pomodoros: number) => void

  private intervalId: number = -1
}

设计亮点

  1. 静态常量配置:将时长配置作为静态只读属性,方便统一管理和修改
  2. 回调机制:通过 onTickonStateChangeonSessionEnd 回调,实现 UI 与业务逻辑的解耦
  3. 单一职责:ViewModel 只负责计时逻辑,不涉及任何 UI 渲染

2.3 计时器核心方法

开始计时
start(): void {
  if (this.state === TimerState.RUNNING) {
    return  // 防止重复启动
  }
  this.state = TimerState.RUNNING
  this.onStateChange?.(this.state)

  this.intervalId = setInterval(() => {
    if (this.remainingSeconds > 0) {
      this.remainingSeconds--
      this.onTick?.(this.remainingSeconds)
    } else {
      this.completeSession()
    }
  }, 1000)
}

这里使用了 setInterval 每秒触发一次计时,同时通过 onTick 回调通知 UI 更新显示时间。

暂停与重置
pause(): void {
  if (this.state !== TimerState.RUNNING) {
    return
  }
  clearInterval(this.intervalId)
  this.state = TimerState.PAUSED
  this.onStateChange?.(this.state)
}

reset(): void {
  clearInterval(this.intervalId)
  this.state = TimerState.IDLE
  this.remainingSeconds = this.getDuration(this.sessionType)
  this.onTick?.(this.remainingSeconds)
  this.onStateChange?.(this.state)
}

暂停时清除定时器,重置时恢复到当前会话类型的初始时长。

会话结束与切换
private completeSession(): void {
  clearInterval(this.intervalId)
  this.state = TimerState.IDLE

  // 如果是工作时间结束,增加番茄计数
  if (this.sessionType === SessionType.WORK) {
    this.completedPomodoros++
    this.totalPomodoros++
  }

  this.onSessionEnd?.(this.sessionType, this.completedPomodoros)
  this.transitionToNext()
}

private transitionToNext(): void {
  // 决定下一个会话类型
  if (this.sessionType === SessionType.WORK) {
    if (this.completedPomodoros > 0 && 
        this.completedPomodoros % TimerViewModel.POMODOROS_BEFORE_LONG_BREAK === 0) {
      this.sessionType = SessionType.LONG_BREAK
    } else {
      this.sessionType = SessionType.SHORT_BREAK
    }
  } else {
    this.sessionType = SessionType.WORK
  }

  this.remainingSeconds = this.getDuration(this.sessionType)
  this.onStateChange?.(this.state)
  this.onTick?.(this.remainingSeconds)
}

这里的逻辑是:

  • 工作结束后,判断是否达到 4 个番茄,达到则进入长休息,否则进入短休息
  • 休息结束后,切换回工作时间

2.4 进度计算与时间格式化

getProgress(): number {
  const total = this.getTotalDuration(this.sessionType)
  if (total === 0) return 0
  return 1 - (this.remainingSeconds / total)
}

formatTime(seconds: number): string {
  const m = Math.floor(seconds / 60)
  const s = seconds % 60
  return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
}

getSessionLabel(type: SessionType): string {
  switch (type) {
    case SessionType.WORK:
      return '🍅 工作时间'
    case SessionType.SHORT_BREAK:
      return '☕ 短休息'
    case SessionType.LONG_BREAK:
      return '🌿 长休息'
  }
}

进度值是一个 0-1 的浮点数,用于后续的进度环绘制;时间格式化为 MM:SS 的形式,符合日常习惯。


三、主界面 UI 实现

3.1 整体布局结构

主界面采用 Stack 布局,背景色占满全屏,内容居中显示:

@Entry
@Component
struct Index {
  @State displayTime: string = '25:00'
  @State timerState: TimerState = TimerState.IDLE
  @State sessionLabel: string = '🍅 工作时间'
  @State sessionColor: string = '#E74C3C'
  @State completedCount: number = 0
  @State totalPomodoros: number = 0
  @State progress: number = 0
  @State ringProgress: number = 0

  private timerVM: TimerViewModel = new TimerViewModel()

  build() {
    Stack({ alignContent: Alignment.Center }) {
      // 背景色
      Column()
        .width('100%')
        .height('100%')
        .backgroundColor(this.sessionColor)

      Column({ space: 20 }) {
        // 标题
        Text('🍅 番茄钟')
          .fontSize(26)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')

        // 会话标签
        Text(this.sessionLabel)
          .fontSize(15)
          .fontColor('#FFFFFF')
          .opacity(0.88)

        // 计时器圆环(核心)
        // ...

        // 操作按钮
        // ...

        // 统计卡片
        // ...
      }
    }
  }
}

3.2 Canvas 绘制进度圆环

这是本应用最核心的 UI 组件。我使用 Canvas API 绘制一个动态的进度环:

@Component
struct ProgressRingCanvas {
  @Prop progress: number = 0
  ringColor: string = '#E74C3C'
  ringSize: number = 230
  strokeWidth: number = 14

  private settings: RenderingContextSettings = new RenderingContextSettings(true)
  private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)

  private drawRing(): void {
    const ctx = this.ctx
    const w = this.ringSize
    const h = this.ringSize
    const center = w / 2
    const radius = center - this.strokeWidth / 2 - 2
    const startAngle = -Math.PI / 2 // 从12点钟方向开始

    // 清空画布
    ctx.clearRect(0, 0, w, h)

    // 绘制背景弧(半透明)
    ctx.beginPath()
    ctx.arc(center, center, radius, 0, Math.PI * 2)
    ctx.strokeStyle = 'rgba(255,255,255,0.25)'
    ctx.lineWidth = this.strokeWidth
    ctx.lineCap = 'round'
    ctx.stroke()

    // 绘制进度弧
    const endAngle = startAngle + (Math.PI * 2 * Math.min(this.progress, 1))
    ctx.beginPath()
    ctx.arc(center, center, radius, startAngle, endAngle)
    ctx.strokeStyle = '#FFFFFF'  // 进度弧用白色,与背景形成对比
    ctx.lineWidth = this.strokeWidth
    ctx.lineCap = 'round'
    ctx.stroke()
  }

  build() {
    Canvas(this.ctx)
      .width(this.ringSize)
      .height(this.ringSize)
      .backgroundColor('rgba(0,0,0,0)')
  }
}

绘制逻辑

  1. 先画一个完整的半透明圆环作为背景
  2. 根据进度值绘制白色弧线
  3. 使用 lineCap = 'round' 让弧线末端圆润
  4. 起始角度 -Math.PI / 2 确保从 12 点钟方向开始

3.3 控制按钮组

按钮组包含三个按钮:重置、开始/暂停、跳过:

Row({ space: 24 }) {
  // 重置按钮
  this.ctrlButton('🔄', () => {
    this.timerVM.reset()
    this.displayTime = this.timerVM.formatTime(this.timerVM.remainingSeconds)
    this.progress = 0
    this.ringProgress = 0
  })

  // 主按钮:开始/暂停
  Button({ type: ButtonType.Normal }) {
    Text(this.getMainButtonLabel())
      .fontSize(22)
      .fontWeight(FontWeight.Bold)
      .fontColor(this.sessionColor)
  }
  .width(130)
  .height(54)
  .backgroundColor('#FFFFFF')
  .borderRadius(27)
  .shadow({
    radius: 12,
    color: 'rgba(0,0,0,0.18)',
    offsetX: 0,
    offsetY: 4
  })
  .onClick(() => {
    if (this.timerState === TimerState.RUNNING) {
      this.timerVM.pause()
    } else {
      this.timerVM.start()
    }
  })

  // 跳过按钮
  this.ctrlButton('⏭', () => {
    this.timerVM.skip()
    this.displayTime = this.timerVM.formatTime(this.timerVM.remainingSeconds)
    this.progress = 0
    this.ringProgress = 0
    this.applySessionStyle(this.timerVM.sessionType)
  })
}

按钮使用 @Builder 装饰器封装:

@Builder
ctrlButton(emoji: string, handler: () => void): void {
  Button({ type: ButtonType.Normal }) {
    Text(emoji)
      .fontSize(22)
  }
  .width(52)
  .height(52)
  .backgroundColor('#FFFFFF')
  .borderRadius(26)
  .shadow({
    radius: 8,
    color: 'rgba(0,0,0,0.14)',
    offsetX: 0,
    offsetY: 2
  })
  .onClick(handler)
}

3.4 统计卡片与进度指示器

统计卡片显示已完成番茄数和总番茄数:

Row() {
  Column() {
    Text(`${this.completedCount}`)
      .fontSize(30)
      .fontWeight(FontWeight.Bold)
      .fontColor('#FFFFFF')
    Text('已完成')
      .fontSize(12)
      .fontColor('#FFFFFF')
      .opacity(0.8)
  }
  .alignItems(HorizontalAlign.Center)
  .layoutWeight(1)

  Divider()
    .vertical(true)
    .height(44)
    .backgroundColor('rgba(255,255,255,0.25)')

  Column() {
    Text(`${this.totalPomodoros}`)
      .fontSize(30)
      .fontWeight(FontWeight.Bold)
      .fontColor('#FFFFFF')
    Text('总番茄数')
      .fontSize(12)
      .fontColor('#FFFFFF')
      .opacity(0.8)
  }
  .alignItems(HorizontalAlign.Center)
  .layoutWeight(1)
}
.width('100%')
.padding({ top: 18, bottom: 18 })
.backgroundColor('rgba(255,255,255,0.18)')
.borderRadius(20)

番茄进度指示器显示当前周期完成情况:

Row({ space: 10 }) {
  Text('今日进度')
    .fontSize(12)
    .fontColor('#FFFFFF')
    .opacity(0.7)
  ForEach([0, 1, 2, 3], (i: number) => {
    Circle({ width: 14, height: 14 })
      .fill(i < (this.completedCount % 4) ? '#FFFFFF' : 'rgba(255,255,255,0.28)')
  })
  Text(`满4个自动长休息`)
    .fontSize(12)
    .fontColor('#FFFFFF')
    .opacity(0.7)
}

3.5 颜色主题切换

不同会话类型使用不同颜色,增强视觉区分:

// 颜色常量
const COLOR_WORK = '#E74C3C'        // 红色 - 工作
const COLOR_SHORT_BREAK = '#27AE60'  // 绿色 - 短休息
const COLOR_LONG_BREAK = '#2980B9'  // 蓝色 - 长休息

applySessionStyle(type: SessionType): void {
  this.sessionLabel = this.timerVM.getSessionLabel(type)
  if (type === SessionType.WORK) {
    this.sessionColor = COLOR_WORK
  } else if (type === SessionType.SHORT_BREAK) {
    this.sessionColor = COLOR_SHORT_BREAK
  } else {
    this.sessionColor = COLOR_LONG_BREAK
  }
}

四、生命周期与回调绑定

4.1 aboutToAppear 初始化

在组件即将出现时,绑定 ViewModel 的回调函数:

aboutToAppear(): void {
  // 绑定计时器回调
  this.timerVM.onTick = (remaining: number) => {
    this.displayTime = this.timerVM.formatTime(remaining)
    this.progress = this.timerVM.getProgress()
    this.ringProgress = this.timerVM.getProgress()
  }

  // 绑定状态变化回调
  this.timerVM.onStateChange = (state: TimerState) => {
    this.timerState = state
  }

  // 绑定会话结束回调
  this.timerVM.onSessionEnd = (type: SessionType, count: number) => {
    this.completedCount = count
    this.totalPomodoros = this.timerVM.totalPomodoros
    this.applySessionStyle(type)
    this.displayTime = this.timerVM.formatTime(this.timerVM.remainingSeconds)
    this.progress = 0
    this.ringProgress = 0
  }

  // 初始化显示
  this.displayTime = this.timerVM.formatTime(this.timerVM.remainingSeconds)
  this.sessionLabel = this.timerVM.getSessionLabel(this.timerVM.sessionType)
}

这种回调机制让 ViewModel 可以在不直接引用 UI 组件的情况下,通知 UI 更新,实现了良好的解耦。


五、踩坑记录与解决方案

5.1 Canvas 绘制不更新

问题:在 ProgressRingCanvas 组件中,当 progress 属性变化时,Canvas 没有自动重绘。

解决方案:使用 @Prop 接收进度值,并在 aboutToUpdate 生命周期中触发重绘:

@Component
struct ProgressRingCanvas {
  @Prop progress: number = 0  // 使用 @Prop 接收父组件传递的值

  aboutToUpdate(): void {
    this.drawRing()  // 属性变化时重绘
  }
}

5.2 定时器无法清除

问题:调用 pause()reset() 后,定时器仍在运行。

原因intervalId 需要在所有状态变化前清除。

解决方案:确保在每个可能改变状态的方法中先清除定时器:

reset(): void {
  clearInterval(this.intervalId)  // 先清除
  this.state = TimerState.IDLE    // 再改变状态
  // ...
}

5.3 进度环绘制方向错误

问题:进度环从 3 点钟方向开始绘制,而不是 12 点钟。

原因:Canvas 的 arc 方法默认从水平右侧(0 弧度)开始。

解决方案:设置起始角度为 -Math.PI / 2

const startAngle = -Math.PI / 2  // -90度,即12点钟方向
ctx.arc(center, center, radius, startAngle, endAngle)

5.4 状态切换时颜色不更新

问题:会话结束时,背景颜色没有及时变化。

原因sessionColor 状态更新后,Canvas 组件需要重新绑定颜色。

解决方案:在 onSessionEnd 回调中调用 applySessionStyle,并确保传递正确的会话类型:

this.timerVM.onSessionEnd = (type: SessionType, count: number) => {
  this.applySessionStyle(type)  // 先应用样式
  // 再更新其他状态
}

5.5 阴影效果渲染问题

问题:按钮阴影在某些设备上不显示。

原因shadow 属性的 radius 参数在某些 API 版本上行为不一致。

解决方案:使用相对较小的 radius 值,并添加 offsetY 增强立体感:

.shadow({
  radius: 12,
  color: 'rgba(0,0,0,0.18)',
  offsetX: 0,
  offsetY: 4
})

六、功能扩展思路

6.1 数据持久化

使用 @ohos.data.preferences 保存番茄记录:

import preferences from '@ohos.data.preferences'

// 保存今日完成数
await preferences.put('todayPomodoros', this.totalPomodoros)
await preferences.flush()

6.2 通知提醒

计时结束时发送系统通知:

import notification from '@ohos.notification'

function sendNotification(title: string, text: string) {
  notification.publish({
    content: {
      notificationContentType: notification.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
      normal: { title, text }
    }
  })
}

6.3 自定义时长

添加设置页面,允许用户修改工作/休息时长:

// 在 TimerViewModel 中添加配置属性
workDuration: number = 25 * 60
shortBreakDuration: number = 5 * 60
longBreakDuration: number = 15 * 60

6.4 统计图表

使用第三方图表库绘制每周番茄完成趋势图。


七、运行效果展示

在这里插入图片描述


八、总结

通过这个番茄钟应用的开发,我学到了很多 HarmonyOS NEXT 的核心技术:

  1. 状态管理:使用 @State@Prop 实现响应式 UI
  2. Canvas 绘制:自定义进度环,实现流畅的动画效果
  3. 状态机模式:清晰管理计时器的多种状态
  4. MVVM 架构:分离业务逻辑与 UI,提高代码可维护性
  5. 回调机制:实现 ViewModel 与 View 的解耦通信

这个应用虽然功能简单,但涵盖了鸿蒙开发的多个核心知识点。希望这篇实战记录能帮助你快速入门 HarmonyOS NEXT 应用开发!


项目信息

  • 包名com.example.myapplication
  • API 版本:API 23
  • 开发工具:DevEco Studio

相关链接


如果这篇文章对你有帮助,欢迎点赞收藏!有问题欢迎在评论区讨论~

Logo

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

更多推荐