🔥 我用 HarmonyOS ArkTS 做了一个倒计时器,现在它每天都在帮我节省时间

别小看一个倒计时器。它教会我的,比我想象的多得多。


🌅 先说点别的

你有没有这种感觉——

写代码的时候,时间过得飞快,一个下午「嗖」就没了。

开会的时候,时间过得超慢,每一分钟都像一个世纪。

同一个时间,感知却天差地别。

所以我想:如果有一个工具,能让时间「可视化」,那该多好?

于是我做了一个倒计时器。


🎯 它能做什么?

简单说就是一件事:倒着数秒

但仔细看,它做了这些:

  • 环形进度条——看着进度一点点往前走,比看数字更直观
  • 快捷预设——1分钟、3分钟、5分钟、10分钟,一键搞定,不用每次都输入
  • ⌨️ 自定义时间——想倒多久就倒多久,24小时以内随便设
  • ▶️ 完整控制——开始、暂停、重置,三个按钮全给你
  • 🔔 结束提醒——时间归零那一刻,进度条变红,文字闪烁
  • ⚠️ 最后10秒警示——快结束了给你提个醒,橙色变深

就这些功能。不多。但够用。


🏗️ 项目是怎么组织的?

说实话,HarmonyOS 的项目结构一开始让我有点懵。

但看多了就习惯了,其实很清晰:

muban23/
│
├── entry/                          ← 应用主模块
│   └── src/main/
│       ├── ets/                    ← ArkTS 代码
│       │   ├── entryability/
│       │   │   └── EntryAbility.ets    ← 应用的入口文件
│       │   └── pages/
│       │       └── Index.ets           ← 倒计时器页面(全在这里)
│       ├── module.json5               ← 模块配置
│       └── resources/                 ← 资源文件(图片、颜色、字符串)
│
├── AppScope/                       ← 应用全局配置
│   └── app.json5
│
├── oh-package.json5               ← 依赖声明
├── build-profile.json5             ← 构建配置
└── hvigorfile.ts                  ← Hvigor 构建脚本

真正写代码的地方就两个文件:

  • EntryAbility.ets —— 管理应用生命周期,加载页面
  • Index.ets —— 倒计时器的全部逻辑和界面

其余都是配置文件,看一眼就行。


💻 第一个文件:应用的入口

import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI';

const DOMAIN = 0x0000;

export default class EntryAbility extends UIAbility {

  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    try {
      this.context.getApplicationContext().setColorMode(
        ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET
      );
    } catch (err) {
      hilog.error(DOMAIN, 'testTag', 'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err));
    }
    hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate');
  }

  onDestroy(): void {
    hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onDestroy');
  }

  onWindowStageCreate(windowStage: window.WindowStage): void {
    hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate');

    // 这一步是核心:加载主页面
    windowStage.loadContent('pages/Index', (err) => {
      if (err.code) {
        hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err));
        return;
      }
      hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.');
    });
  }

  onWindowStageDestroy(): void {
    hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageDestroy');
  }

  onForeground(): void {
    hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onForeground');
  }

  onBackground(): void {
    hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onBackground');
  }
}

这个文件告诉应用三件事:

  1. 启动时做什么 —— onCreate 里设置跟随系统颜色模式
  2. 创建窗口时做什么 —— onWindowStageCreate 里加载 pages/Index 页面
  3. 退出时做什么 —— onDestroyonWindowStageDestroy 里清理资源

最重要的是 loadContent('pages/Index') 这一行,告诉系统:我首页要显示哪个文件


💻 第二个文件:倒计时器的全部

这是核心中的核心。

@Entry
@Component
struct CountdownTimer {

  // ── 状态 ──
  @State totalSeconds: number = 300;
  @State remainingSeconds: number = 300;
  @State isRunning: boolean = false;
  @State isFinished: boolean = false;
  @State customMinutes: string = '';
  @State customSeconds: string = '';

  private timerId: number = -1;

  // ── 页面即将出现 ──
  aboutToAppear(): void {
    this.resetTimer(300);
  }

  // ── 页面即将消失 ──
  aboutToDisappear(): void {
    this.clearTimer();
  }

  // ── 清除定时器 ──
  clearTimer(): void {
    if (this.timerId !== -1) {
      clearInterval(this.timerId);
      this.timerId = -1;
    }
  }

  // ── 重置到指定秒数 ──
  resetTimer(seconds: number): void {
    this.clearTimer();
    this.totalSeconds = seconds;
    this.remainingSeconds = seconds;
    this.isRunning = false;
    this.isFinished = false;
  }

  // ── 开始倒计时 ──
  startTimer(): void {
    if (this.isFinished || this.remainingSeconds <= 0) return;

    this.isRunning = true;
    this.isFinished = false;
    this.timerId = setInterval(() => {
      this.remainingSeconds--;
      if (this.remainingSeconds <= 0) {
        this.remainingSeconds = 0;
        this.clearTimer();
        this.isRunning = false;
        this.isFinished = true;
      }
    }, 1000);
  }

  // ── 暂停 ──
  pauseTimer(): void {
    this.clearTimer();
    this.isRunning = false;
  }

  // ── 重置 ──
  resetToCurrentTotal(): void {
    this.clearTimer();
    this.remainingSeconds = this.totalSeconds;
    this.isRunning = false;
    this.isFinished = false;
  }

  // ── 应用自定义时间 ──
  applyCustomTime(): void {
    const min = parseInt(this.customMinutes) || 0;
    const sec = parseInt(this.customSeconds) || 0;
    const total = min * 60 + sec;
    if (total > 0 && total <= 86400) {
      this.resetTimer(total);
    }
  }

  // ── 格式化时间 MM:SS ──
  get formattedTime(): string {
    const min = Math.floor(this.remainingSeconds / 60);
    const sec = this.remainingSeconds % 60;
    return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
  }

  // ── 进度百分比 ──
  get progressValue(): number {
    if (this.totalSeconds <= 0) return 0;
    return ((this.totalSeconds - this.remainingSeconds) / this.totalSeconds) * 100;
  }

  // ── 状态文字 ──
  get statusText(): string {
    if (this.isFinished) return '⏰ 时间到!';
    if (this.isRunning) return '倒计时中…';
    if (this.remainingSeconds < this.totalSeconds) return '已暂停';
    return '准备就绪';
  }

  // ── 状态文字颜色 ──
  get statusColor(): ResourceColor {
    if (this.isFinished) return '#FF4444';
    return '#999999';
  }

  // ── 环形进度条颜色 ──
  get ringColor(): ResourceColor {
    if (this.isFinished) return '#FF4444';
    if (this.remainingSeconds <= 10 && this.isRunning) return '#FF6B35';
    return '#FF6B35';
  }

  // ── 界面 ──
  build() {
    Column() {
      Text('⏱ 倒计时器')
        .fontSize(28).fontWeight(FontWeight.Bold).fontColor('#1a1a2e')
        .margin({ top: 48, bottom: 32 })

      // 环形进度 + 时间
      Stack() {
        Progress({
          value: this.progressValue,
          total: 100,
          type: ProgressType.Ring
        })
          .width(240).height(240)
          .style({ strokeWidth: 14 })
          .color(this.ringColor)
          .backgroundColor('#E8E8E8')

        Column({ space: 4 }) {
          Text(this.formattedTime)
            .fontSize(56).fontWeight(FontWeight.Bold).fontColor('#1a1a2e')
          Text(this.statusText)
            .fontSize(15).fontColor(this.statusColor)
        }
        .alignItems(HorizontalAlign.Center)
      }
      .margin({ bottom: 36 })

      // 快捷预设
      Row({ space: 12 }) {
        Button('1 分').fontSize(14).fontColor('#FF6B35').backgroundColor('#FFF0E6')
          .borderRadius(20).height(40)
          .onClick(() => this.resetTimer(60))

        Button('3 分').fontSize(14).fontColor('#FF6B35').backgroundColor('#FFF0E6')
          .borderRadius(20).height(40)
          .onClick(() => this.resetTimer(180))

        Button('5 分').fontSize(14).fontColor('#FF6B35').backgroundColor('#FFF0E6')
          .borderRadius(20).height(40)
          .onClick(() => this.resetTimer(300))

        Button('10 分').fontSize(14).fontColor('#FF6B35').backgroundColor('#FFF0E6')
          .borderRadius(20).height(40)
          .onClick(() => this.resetTimer(600))
      }
      .margin({ bottom: 28 })

      // 自定义输入
      Row({ space: 8 }) {
        TextInput({ text: this.customMinutes, placeholder: '分' })
          .width(72).height(44).type(InputType.Number)
          .backgroundColor('#F5F5F5').borderRadius(10).textAlign(TextAlign.Center)
          .onChange((value: string) => { this.customMinutes = value; })

        Text('分').fontSize(16).fontColor('#666')

        TextInput({ text: this.customSeconds, placeholder: '秒' })
          .width(72).height(44).type(InputType.Number)
          .backgroundColor('#F5F5F5').borderRadius(10).textAlign(TextAlign.Center)
          .onChange((value: string) => { this.customSeconds = value; })

        Text('秒').fontSize(16).fontColor('#666')

        Button('设定').fontSize(14).fontColor('#FFFFFF')
          .backgroundColor('#FF6B35').borderRadius(20).height(44)
          .onClick(() => this.applyCustomTime())
      }
      .margin({ bottom: 36 })

      // 控制按钮
      Row({ space: 20 }) {
        if (!this.isRunning) {
          Button('▶ 开始').width(130).height(52).fontSize(17)
            .fontWeight(FontWeight.Medium).fontColor('#FFFFFF')
            .backgroundColor('#FF6B35').borderRadius(26)
            .onClick(() => this.startTimer())
        } else {
          Button('⏸ 暂停').width(130).height(52).fontSize(17)
            .fontWeight(FontWeight.Medium).fontColor('#FFFFFF')
            .backgroundColor('#FFA500').borderRadius(26)
            .onClick(() => this.pauseTimer())
        }

        Button('↺ 重置').width(130).height(52).fontSize(17)
          .fontWeight(FontWeight.Medium).fontColor('#FFFFFF')
          .backgroundColor('#666666').borderRadius(26)
          .onClick(() => this.resetToCurrentTotal())
      }
    }
    .width('100%').height('100%')
    .padding({ left: 24, right: 24 })
    .alignItems(HorizontalAlign.Center)
    .backgroundColor('#FAFAFA')
  }
}

代码注释已经写得很清楚了。这里补充几个我特别想说的点。


⚡ 我遇到的三个坑

坑 1:定时器泄漏(这个坑害死我了)

症状: 退出页面再进去,计时器速度翻倍了。再退再进,速度变成三倍。

原因: setInterval 创建了一个定时器,aboutToDisappear() 里没有 clearInterval(),旧的定时器还在跑。

解法:

aboutToDisappear(): void {
  this.clearTimer(); // 一定要清理!
}

就这么一行。但当时我不知道,差点把整个应用重写了。

坑 2:数字显示跳来跳去

症状: 秒数从 09 跳到 8,从 08 跳到 7,数字宽度变了,看着难受。

原因: 没做格式化。直接显示数字,不够两位就自然少了一位。

解法:

return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;

补零之后,永远是 00:05 这样的格式,不会跳。

坑 3:倒计时结束了还能点开始

症状: 时间到 00:00 之后,点开始没反应,但按钮文字还是「开始」。

原因: 没处理「已完成」状态下的按钮切换逻辑。

解法:

startTimer(): void {
  // 加上这个判断
  if (this.isFinished || this.remainingSeconds <= 0) {
    return;
  }
  // ...
}

🔬 ArkTS 里几个有意思的地方

@State 是响应式的

你改变一个 @State 变量,UI 自动更新。不用像 Android 那样手动 notifyDataSetChanged()

@State remainingSeconds: number = 300;

这行代码一变,环形进度条、时间显示、状态文字全部跟着变。

这就是声明式 UI 的魅力。

条件渲染很直接

if (!this.isRunning) {
  Button('▶ 开始').onClick(() => this.startTimer())
} else {
  Button('⏸ 暂停').onClick(() => this.pauseTimer())
}

直接写在 build() 里,不需要 v-if 指令,不需要 *ngIf,就这么简单。

环形进度条一行搞定

Progress({
  value: this.progressValue,  // 0-100
  total: 100,
  type: ProgressType.Ring
})
  .style({ strokeWidth: 14 })
  .color('#FF6B35')

换一根线,改个颜色,效果完全不一样。


🎨 配色我是这样设计的

位置 颜色 理由
进度条 #FF6B35 橙色,有活力,不刺眼
警示橙 #FFA500 暂停按钮,用来区分开始
警示红 #FF4444 时间到,给用户强烈提示
背景 #FAFAFA 浅灰白,干净清爽
文字 #1a1a2e 深色,数字要清晰
快捷按钮底色 #FFF0E6 浅橙底,不是纯白,和主色呼应

🖼️ 截图

在这里插入图片描述


🚀 运行方法

1. 打开项目

DevEco Studio → File → Open → 选择 muban23 文件夹

2. 等待同步
右下角会出现 Sync Running,等它变成 ✅。

3. 选择运行设备
顶部工具栏选择你的设备(真机或模拟器)。

4. 运行
Shift + F10,或者点 ▶️ 按钮。

5. 验证
底部 Log 面板输入 testTag,看到这两行就算成功了:

Ability onWindowStageCreate
Succeeded in loading the content.

🔮 如果让我继续做……

下一步我想加这些:

🔔 震动反馈

vibrator.startVibrate({ duration: 500 });

🔊 提示音

audio.createAudioPlayer().then(player => {
  player.src = 'resources/base/media/ding.mp3';
  player.play();
});

🌙 深色模式
根据系统颜色模式,动态切换 #FAFAFA#121212 这样的深色背景。

📊 历史记录
把用户常用的时间保存下来,下次直接点就用。

🗂️ 多计时器
同时跑多个倒计时,泡面一个、开会一个、跑步一个。


📁 项目文件一览

你要找 在这里
入口Ability entry\src\main\ets\entryability\EntryAbility.ets
主页代码 entry\src\main\ets\pages\Index.ets
模块配置 entry\src\main\module.json5
全局配置 AppScope\app.json5
依赖 oh-package.json5

📚 我参考了这些


做这个倒计时器花了一个下午,踩了几个坑,但最后跑起来的那一刻还是挺有成就感的。

如果你也在学 HarmonyOS,建议从一个小项目开始。别想着一口气做个复杂的,先做一个能跑的东西出来,比什么都重要。

有问题欢迎交流,我们一起进步 💪

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐