🍅 零基础学 ArkUI05:手把手教你开发一个番茄钟应用


📱 应用场景

番茄工作法(Pomodoro Technique)是时间管理界的经典工具:专注 25 分钟 → 休息 5 分钟 → 每 4 个循环后长休息 15 分钟。我们要开发的番茄钟 App 会实现:

  • 倒计时核心功能(25 分钟工作 / 5 分钟休息)
  • 开始 / 暂停 / 重置控制
  • 工作与休息状态的自动切换
  • 美观的圆形进度指示器
  • 状态提示和番茄计数

⚙️ 运行环境要求

项目 版本要求
操作系统 Windows 10/11、macOS 13+ 或 Ubuntu 22.04+
DevEco Studio 5.0.3.800 及以上
HarmonyOS SDK API 12(HarmonyOS 5.0.0)及以上
应用模型 Stage 模型
开发语言 ArkTS(TypeScript 的超集)

环境配置截图示意

在这里插入图片描述
在这里插入图片描述

🛠️ 实战:从零搭建番茄钟

Step 1:理解 ArkUI 的「声明式思维」

在传统开发中,我们这样写 UI:

// 伪代码 — 命令式
Button btn = new Button("开始");
btn.setOnClickListener(() -> { /* 处理点击 */ });
container.add(btn);

在 ArkUI 中完全不同 — 你只需要 描述 UI 应该长什么样,框架自己去更新:

// ArkTS — 声明式
Button('开始')
  .onClick(() => { /* 处理点击 */ })

核心思想:UI 是状态(State)的函数 — 状态变了,UI 自动重新渲染。

Step 2:项目结构与入口文件

我们的项目结构如下:

com.example.pomodoro/
├── entry/src/main/ets/
│   ├── entryability/
│   │   └── EntryAbility.ts      // Ability 生命周期
│   └── pages/
│       └── Index.ets            // 主页面 — 番茄钟就在这里!

打开 Index.ets,删掉默认代码,我们来写番茄钟。

Step 3:定义番茄钟的状态

在 ArkUI 中,用 @State 装饰器标记「可观察状态」:

// Index.ets — 番茄钟核心代码
@Entry
@Component
struct PomodoroTimer {
  // ========== 核心状态 ==========
  @State private remainingSeconds: number = 25 * 60;  // 剩余秒数(初始25分钟)
  @State private isRunning: boolean = false;            // 是否正在运行
  @State private isWorkPhase: boolean = true;           // true=工作 / false=休息
  @State private tomatoCount: number = 0;               // 完成的番茄数
  @State private phaseText: string = '🍅 专注工作中';

  // ========== 常量配置 ==========
  private readonly WORK_TIME: number = 25 * 60;         // 25分钟
  private readonly BREAK_TIME: number = 5 * 60;         // 5分钟
  private readonly LONG_BREAK_TIME: number = 15 * 60;   // 15分钟
  private timerId: number = -1;                         // 定时器ID

  // ========== 计算属性 ==========
  get progress(): number {
    // 当前进度百分比(用于圆形进度条)
    const total = this.isWorkPhase ? this.WORK_TIME : this.BREAK_TIME;
    return 1 - this.remainingSeconds / total;
  }
📌 @State 详解 — 新手最容易懵的地方
概念 一句话解释
@State 告诉 ArkUI:“这个变量变了,UI 要重新画”
private 状态仅在当前组件内部使用
remainingSeconds: number = 25*60 TypeScript 的类型声明 + 初始值

避坑指南①: @State 必须是 简单类型或可以直接比较的对象。不要放复杂嵌套对象 — 想更新嵌套字段时 UI 不会刷新,你得用 @ObjectLink 或深拷贝。


Step 4:构建 UI 层 — 圆形进度 + 控制按钮

ArkUI 的 UI 是 嵌套的组件树,就像搭积木:

  // ========== UI 构建 ==========
  build() {
    Column({ space: 20 }) {
      // 🏷️ 标题区域
      Text('🍅 番茄钟')
        .fontSize(28)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 30 })

      // 📊 圆形进度 + 倒计时
      Stack() {
        // 背景圆环
        Circle()
          .width(220)
          .height(220)
          .stroke('#E8E8E8')
          .strokeWidth(12)
          .fill(Color.Transparent)

        // 前景进度弧
        Circle()
          .width(220)
          .height(220)
          .stroke(this.isWorkPhase ? '#FF6B6B' : '#4ECDC4')
          .strokeWidth(12)
          .fill(Color.Transparent)
          .strokeLineCap(LineCapStyle.Round)  // 圆角端点
          .progress({ value: this.progress, total: 1 })

        // 中间倒计时数字 + 状态文字
        Column({ space: 8 }) {
          Text(this.formatTime(this.remainingSeconds))
            .fontSize(56)
            .fontWeight(FontWeight.Bold)
            .fontColor(this.isWorkPhase ? '#FF6B6B' : '#4ECDC4')

          Text(this.phaseText)
            .fontSize(16)
            .fontColor('#666666')
        }
      }
      .width(240)
      .height(240)

      // 🎮 控制按钮
      Row({ space: 20 }) {
        // 重置按钮
        Button() {
          Image($r('app.media.ic_reset'))  // 需要用图片资源
            .width(24).height(24)
        }
        .width(56).height(56)
        .backgroundColor('#F0F0F0')
        .borderRadius(28)
        .onClick(() => this.resetTimer())

        // 开始/暂停按钮(核心交互)
        Button() {
          Text(this.isRunning ? '⏸️ 暂停' : '▶️ 开始')
            .fontSize(20)
            .fontColor(Color.White)
        }
        .width(130).height(56)
        .backgroundColor(this.isRunning ? '#FFA726' : '#FF6B6B')
        .borderRadius(28)
        .onClick(() => this.toggleTimer())

        // 跳过按钮
        Button() {
          Image($r('app.media.ic_skip'))
            .width(24).height(24)
        }
        .width(56).height(56)
        .backgroundColor('#F0F0F0')
        .borderRadius(28)
        .onClick(() => this.skipPhase())
      }

      // 📋 番茄计数
      Text(`已完成 ${this.tomatoCount} 个番茄 🎉`)
        .fontSize(18)
        .fontColor('#999999')
        .margin({ top: 20 })
    }
    .width('100%')
    .height('100%')
    .padding(20)
  }
📌 布局原理解析
组件 作用
Column 垂直排列 — 像 HTML 的 flex-direction: column
Row 水平排列 — 像 flex-direction: row
Stack 层叠排列 — 子组件重叠,像 position: absolute
.space(20) 子组件之间的间距
.width('100%') 撑满父容器宽度

避坑指南②: Stack 中的子组件默认 水平居中 + 垂直居中。如果你想让某个子组件靠边,用 .align()


Step 5:核心逻辑 — 倒计时 + 状态切换

  // ========== 核心方法 ==========

  // 格式化时间:秒 → "25:00"
  formatTime(totalSeconds: number): string {
    const min = Math.floor(totalSeconds / 60);
    const sec = totalSeconds % 60;
    return `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
  }

  // 切换 开始/暂停
  toggleTimer(): void {
    if (this.isRunning) {
      // ⏸️ 暂停:清除定时器
      clearInterval(this.timerId);
      this.isRunning = false;
    } else {
      // ▶️ 开始:启动定时器,每秒减1
      this.isRunning = true;
      this.timerId = setInterval(() => {
        if (this.remainingSeconds <= 1) {
          // ⏰ 时间到!
          this.onTimerComplete();
        } else {
          // 正常倒计时
          this.remainingSeconds--;
        }
      }, 1000);
    }
  }

  // ⏰ 当前阶段结束
  onTimerComplete(): void {
    // 停止定时器
    clearInterval(this.timerId);
    this.isRunning = false;

    if (this.isWorkPhase) {
      // 工作结束 → 进入休息
      this.tomatoCount++;
      this.isWorkPhase = false;
      this.remainingSeconds = (this.tomatoCount % 4 === 0)
        ? this.LONG_BREAK_TIME    // 每4个番茄,长休息15分钟
        : this.BREAK_TIME;        // 否则短休息5分钟
      this.phaseText = '☕ 休息一下';
    } else {
      // 休息结束 → 进入工作
      this.isWorkPhase = true;
      this.remainingSeconds = this.WORK_TIME;
      this.phaseText = '🍅 专注工作中';
    }
  }

  // 🔄 重置计时器
  resetTimer(): void {
    clearInterval(this.timerId);
    this.isRunning = false;
    this.isWorkPhase = true;
    this.remainingSeconds = this.WORK_TIME;
    this.phaseText = '🍅 专注工作中';
  }

  // ⏭️ 跳过当前阶段
  skipPhase(): void {
    clearInterval(this.timerId);
    this.isRunning = false;
    this.onTimerComplete();  // 复用「时间到」的处理逻辑
  }

Step 6:完整代码汇总

在这里插入图片描述

完整的 Index.ets 代码:

@Entry
@Component
struct PomodoroTimer {
  @State private remainingSeconds: number = 25 * 60;
  @State private isRunning: boolean = false;
  @State private isWorkPhase: boolean = true;
  @State private tomatoCount: number = 0;
  @State private phaseText: string = '🍅 专注工作中';

  private readonly WORK_TIME: number = 25 * 60;
  private readonly BREAK_TIME: number = 5 * 60;
  private readonly LONG_BREAK_TIME: number = 15 * 60;
  private timerId: number = -1;

  get progress(): number {
    const total = this.isWorkPhase ? this.WORK_TIME : this.BREAK_TIME;
    return 1 - this.remainingSeconds / total;
  }

  aboutToDisappear(): void {
    clearInterval(this.timerId);
  }

  build() {
    Column({ space: 20 }) {
      Text('🍅 番茄钟')
        .fontSize(28)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 30 })

      Stack() {
        Circle()
          .width(220).height(220)
          .stroke('#E8E8E8').strokeWidth(12)
          .fill(Color.Transparent)

        Circle()
          .width(220).height(220)
          .stroke(this.isWorkPhase ? '#FF6B6B' : '#4ECDC4')
          .strokeWidth(12).fill(Color.Transparent)
          .strokeLineCap(LineCapStyle.Round)
          .progress({ value: this.progress, total: 1 })

        Column({ space: 8 }) {
          Text(this.formatTime(this.remainingSeconds))
            .fontSize(56).fontWeight(FontWeight.Bold)
            .fontColor(this.isWorkPhase ? '#FF6B6B' : '#4ECDC4')

          Text(this.phaseText)
            .fontSize(16).fontColor('#666666')
        }
      }
      .width(240).height(240)

      Row({ space: 20 }) {
        Button() { Text('🔄').fontSize(24) }
          .width(56).height(56)
          .backgroundColor('#F0F0F0')
          .borderRadius(28)
          .onClick(() => this.resetTimer())

        Button() {
          Text(this.isRunning ? '⏸️ 暂停' : '▶️ 开始')
            .fontSize(20).fontColor(Color.White)
        }
        .width(130).height(56)
        .backgroundColor(this.isRunning ? '#FFA726' : '#FF6B6B')
        .borderRadius(28)
        .onClick(() => this.toggleTimer())

        Button() { Text('⏭️').fontSize(24) }
          .width(56).height(56)
          .backgroundColor('#F0F0F0')
          .borderRadius(28)
          .onClick(() => this.skipPhase())
      }

      Text(`已完成 ${this.tomatoCount} 个番茄 🎉`)
        .fontSize(18).fontColor('#999999')
        .margin({ top: 20 })
    }
    .width('100%').height('100%')
    .padding(20)
  }

  formatTime(totalSeconds: number): string {
    const min = Math.floor(totalSeconds / 60);
    const sec = totalSeconds % 60;
    return `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
  }

  toggleTimer(): void {
    if (this.isRunning) {
      clearInterval(this.timerId);
      this.isRunning = false;
    } else {
      this.isRunning = true;
      this.timerId = setInterval(() => {
        if (this.remainingSeconds <= 1) {
          this.onTimerComplete();
        } else {
          this.remainingSeconds--;
        }
      }, 1000);
    }
  }

  onTimerComplete(): void {
    clearInterval(this.timerId);
    this.isRunning = false;
    if (this.isWorkPhase) {
      this.tomatoCount++;
      this.isWorkPhase = false;
      this.remainingSeconds = (this.tomatoCount % 4 === 0)
        ? this.LONG_BREAK_TIME
        : this.BREAK_TIME;
      this.phaseText = '☕ 休息一下';
    } else {
      this.isWorkPhase = true;
      this.remainingSeconds = this.WORK_TIME;
      this.phaseText = '🍅 专注工作中';
    }
  }

  resetTimer(): void {
    clearInterval(this.timerId);
    this.isRunning = false;
    this.isWorkPhase = true;
    this.remainingSeconds = this.WORK_TIME;
    this.phaseText = '🍅 专注工作中';
  }

  skipPhase(): void {
    clearInterval(this.timerId);
    this.isRunning = false;
    this.onTimerComplete();
  }
}

🚨 避坑指南(新手必看)

❌ 坑1:定时器不释放 → 应用崩溃

// ❌ 错误:页面销毁时定时器还在跑
private timerId = setInterval(() => { /* ... */ }, 1000);

// ✅ 正确:在 aboutToDisappear 生命周期中清理
aboutToDisappear(): void {
  clearInterval(this.timerId);
}

aboutToDisappear 是 ArkUI 组件销毁前的钩子 — 相当于 Android 的 onDestroy

❌ 坑2:setInterval 精度问题

setInterval 的 1000ms 并不精确!如果用户在后台挂起应用,恢复后可能看到跳跃。生产环境建议用 @ohos.util.Timer 或系统时间差来计算。

// 更精确的方案:记录开始时间,计算差值
private startTimestamp: number = 0;

toggleTimer(): void {
  if (!this.isRunning) {
    this.startTimestamp = Date.now();
    this.timerId = setInterval(() => {
      const elapsed = Math.floor((Date.now() - this.startTimestamp) / 1000);
      this.remainingSeconds = this.WORK_TIME - elapsed;
    }, 200); // 用 200ms 间隔更平滑
  }
}

❌ 坑3:@State 与 get 的循环依赖

不要在 @State 变量的 getter 中修改其他 @State 变量!这会触发 无限渲染循环


💡 最佳实践

  1. 常量提取:工作/休息时间定义为 readonly 常量,不要硬编码在业务逻辑中。
  2. 单一职责toggleTimer() 只做「切换」这件事,onTimerComplete() 只做「阶段完成」的处理 — 不要在按钮点击回调里写一大坨逻辑。
  3. 命名规范:布尔变量用 is 前缀(isRunning, isWorkPhase),方法用动宾结构(resetTimer, skipPhase)。
  4. 使用 getterprogressget 定义而不是每次都调用函数 — ArkUI 会自动追踪依赖,更高效。
  5. 资源引用:如果用图标资源,存放在 resources/base/media/ 目录中,用 $r('app.media.xxx') 引用。

📚 本章小结

通过番茄钟项目,你学会了:

知识点 掌握程度
✅ ArkUI 声明式语法(build 函数) ⭐⭐⭐
✅ @State 状态管理 ⭐⭐⭐⭐⭐
✅ Column / Row / Stack 布局 ⭐⭐⭐⭐
✅ Circle 组件 + progress 属性 ⭐⭐⭐
✅ setInterval 定时器 + 生命周期 ⭐⭐⭐⭐
✅ 条件渲染(三元表达式) ⭐⭐⭐⭐⭐

🔗 参考资源


Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐