在这里插入图片描述

一、引言

计时器是所有持续运行型应用的基础模式——它不是由用户主动触发(如点击按钮),而是靠定时器自动驱动状态变化。这在 ArkTS 中引入了一个新问题:生命周期管理

在 index1(计数器)中,用户点击一次按钮,count 变化一次。而在计时器中,setInterval 每秒钟自动递增 seconds。如果页面退出时没有清除定时器,会导致:

  1. 内存泄漏:定时器回调仍然持有组件引用,无法 GC
  2. 状态丢失:页面重新进入时,定时器 ID 丢失,无法停止
  3. 异常行为:定时器在后台继续更新已销毁组件的状态

本文将深入分析 index3.ets 中如何使用 ArkScript 的 setInterval / clearInterval,以及组件生命周期中定时器的正确管理方案。


二、完整源码

// index3.ets
@Entry
@Component
struct Index3 {
  @State seconds: number = 0;
  @State isRunning: boolean = false;
  private intervalId: number = -1;

  formatTime(totalSeconds: number): string {
    const m = Math.floor(totalSeconds / 60);
    const s = totalSeconds % 60;
    return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
  }

  startTimer(): void {
    if (!this.isRunning) {
      this.isRunning = true;
      this.intervalId = setInterval(() => {
        this.seconds++;
      }, 1000);
    }
  }

  stopTimer(): void {
    if (this.isRunning) {
      this.isRunning = false;
      clearInterval(this.intervalId);
    }
  }

  resetTimer(): void {
    this.stopTimer();
    this.seconds = 0;
  }

  build() {
    Column() {
      Text('计时器')
        .fontSize(28)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 40, bottom: 10 })

      Text(this.formatTime(this.seconds))
        .fontSize(64)
        .fontWeight(FontWeight.Bold)
        .fontColor('#007AFF')
        .fontFamily('Courier New')

      Row() {
        Button('开始')
          .width(80).height(44).fontSize(16)
          .backgroundColor('#34C759').borderRadius(22)
          .enabled(!this.isRunning)
          .onClick(() => { this.startTimer(); })

        Button('暂停')
          .width(80).height(44).fontSize(16)
          .backgroundColor('#FF9500').borderRadius(22)
          .enabled(this.isRunning)
          .margin({ left: 15, right: 15 })
          .onClick(() => { this.stopTimer(); })

        Button('重置')
          .width(80).height(44).fontSize(16)
          .backgroundColor('#FF3B30').borderRadius(22)
          .onClick(() => { this.resetTimer(); })
      }
      .margin({ top: 40 })

      Text(this.isRunning ? '⏱ 计时中...' : '⏹ 已停止')
        .fontSize(16)
        .fontColor(this.isRunning ? '#34C759' : '#8E8E93')
        .margin({ top: 30 })
    }
    .width('100%').height('100%')
    .justifyContent(FlexAlign.Start)
    .alignItems(HorizontalAlign.Center)
  }
}

三、状态变量设计

3.1 两个 @State + 一个 private

@State seconds: number = 0;       // 🎯 驱动 UI 更新 —— 秒数
@State isRunning: boolean = false;  // 🎯 驱动 UI 更新 —— 运行状态
private intervalId: number = -1;    // 🔒 不驱动 UI —— 定时器 ID

设计原则

  • seconds:页面显示的核心数据,必须 @State,每秒变化时 UI 自动更新
  • isRunning:控制按钮启用/禁用状态和底部提示文字,必须 @State
  • intervalId:纯内部实现细节,界面不需要知道定时器 ID,用 private 即可

3.2 为什么 intervalId 不需要 @State?

private intervalId: number = -1;

如果 intervalId 被声明为 @State

  1. 不必要的性能开销:每次 setInterval 返回新 ID 时,框架检查依赖并标记脏节点
  2. 语义不清晰intervalId 不是页面状态,是内部实现
  3. 无 UI 依赖:没有任何 UI 组件绑定 intervalId

准则:只有视图需要反应的值才用 @State


四、setInterval / clearInterval 详解

4.1 启动定时器

startTimer(): void {
  if (!this.isRunning) {        // 保护:防止重复启动
    this.isRunning = true;       // 标记运行中
    this.intervalId = setInterval(() => {
      this.seconds++;
    }, 1000);                    // 每 1000ms (1秒) 执行一次
  }
}

setInterval(callback, delay) 参数:

参数 类型 说明
callback Function 周期性执行的函数
delay number 间隔时间,单位毫秒(1000 = 1 秒)
返回值 number 定时器 ID,用于 clearInterval

4.2 停止定时器

stopTimer(): void {
  if (this.isRunning) {          // 保护:只在运行时停止
    this.isRunning = false;      // 标记已停止
    clearInterval(this.intervalId);  // 根据 ID 清除定时器
  }
}

4.3 重置逻辑

resetTimer(): void {
  this.stopTimer();   // ① 先停止定时器
  this.seconds = 0;   // ② 再重置秒数
}

顺序很重要

  • stopTimer() 停止定时器,防止重置后定时器自动递增
  • this.seconds = 0 重置显示

如果顺序颠倒:

// ❌ 错误顺序
resetTimer(): void {
  this.seconds = 0;          // ① 先重置
  // 此时定时器还在运行,可能立即触发 ++
  this.stopTimer();          // ② 再停止
  // 结果:seconds 可能变成 1 而非 0
}

五、条件禁用:enabled 属性

5.1 基础用法

Button('开始')
  .enabled(!this.isRunning)   // 运行时禁用
Button('暂停')
  .enabled(this.isRunning)    // 停止时禁用

enabled 属性控制按钮是否可交互:

enabled 效果
true 正常状态,可点击
false 灰色/半透明,不可点击,事件不触发

5.2 状态驱动的按钮启用矩阵

状态 开始按钮 暂停按钮 重置按钮
已停止 (isRunning = false) ✅ 可用 ❌ 禁用 ✅ 可用
运行中 (isRunning = true) ❌ 禁用 ✅ 可用 ✅ 可用

这种互斥启用的设计让用户在任意时刻都只能做合理的操作:

  • 运行时不能"开始"
  • 停止时不能"暂停"
  • 重置始终可用

5.3 enabled 的视觉表现

在 ArkTS 中,enabled(false) 的按钮会:

  1. 降低透明度(通常 0.4)
  2. 阻止点击事件
  3. 不响应触摸

如果需要自定义禁用样式:

Button('开始')
  .enabled(!this.isRunning)
  .opacity(this.isRunning ? 0.4 : 1.0)   // 手动控制透明度

六、时间格式化函数

6.1 实现

formatTime(totalSeconds: number): string {
  const m = Math.floor(totalSeconds / 60);    // 取分钟(向下取整)
  const s = totalSeconds % 60;                 // 取秒(取模)
  return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
}

6.2 逐行解析

代码 输入 输出 说明
totalSeconds / 60 125 2.0833 浮点数除法
Math.floor(...) 2.0833 2 向下取整得分钟
totalSeconds % 60 125 5 取模得秒数
(m < 10 ? '0' : '') 2 '0' 补零:02
(s < 10 ? '0' : '') 5 '0' 补零:05
最终拼接 - '02:05' 标准计时格式

6.3 测试用例

formatTime(0)"00:00"
formatTime(59)"00:59"
formatTime(60)"01:00"
formatTime(125)"02:05"
formatTime(3600)"60:00"   // 未做小时格式化
formatTime(3661)"61:01"   // 超过 1 小时仍显示 61 分

6.4 带小时的格式化

formatTime(totalSeconds: number): string {
  const h = Math.floor(totalSeconds / 3600);
  const m = Math.floor((totalSeconds % 3600) / 60);
  const s = totalSeconds % 60;

  const pad = (n: number) => (n < 10 ? '0' : '') + n.toString();

  if (h > 0) {
    return pad(h) + ':' + pad(m) + ':' + pad(s);
  }
  return pad(m) + ':' + pad(s);
}

七、等宽字体与数字显示

Text(this.formatTime(this.seconds))
  .fontFamily('Courier New')

为什么计时器要用等宽字体

等宽字体 (Courier New):
  1 → 占宽 x
  2 → 占宽 x   ← 宽度相同
  8 → 占宽 x
  : → 占宽 x

比例字体 (默认):
  1 → 占宽 0.4x  ← 宽度不同,数字跳动
  2 → 占宽 0.5x
  8 → 占宽 0.6x
  : → 占宽 0.2x

用等宽字体时,00:0000:01...59:59 数字宽度始终不变,视觉上文字不会左右晃动

可选的等宽字体:

.fontFamily('Courier New')    // Windows/macOS 经典等宽
.fontFamily('monospace')      // 系统默认等宽(推荐跨平台)
.fontFamily('HarmonyOS Sans Mono')  // 鸿蒙自带等宽

八、生命周期与定时器管理

8.1 当前问题

当前代码没有处理组件销毁时的定时器清理。如果用户:

  1. 启动计时器
  2. 点击返回/跳转到其他页面
  3. Index3 组件被销毁
  4. 定时器仍在运行,每秒 this.seconds++
  5. this 已经指向一个销毁的组件 → 异常

8.2 ArkTS 组件生命周期

@Entry
@Component
struct Index3 {
  // ① 组件创建(内存分配,@State 初始化)
  // ② aboutToAppear() — 组件即将显示
  // ③ build() — 组件渲染
  // ④ onPageShow() — 页面显示
  // ⑤ ... 用户交互 ...
  // ⑥ onPageHide() — 页面隐藏
  // ⑦ aboutToDisappear() — 组件即将销毁
  // ⑧ 组件销毁(内存回收)
}

8.3 修复:在 aboutToDisappear 中清理定时器

@Entry
@Component
struct Index3 {
  @State seconds: number = 0;
  @State isRunning: boolean = false;
  private intervalId: number = -1;

  // ✅ 组件销毁时清理定时器
  aboutToDisappear(): void {
    if (this.intervalId !== -1) {
      clearInterval(this.intervalId);
      this.intervalId = -1;
    }
  }

  // ✅ 页面隐藏时暂停(可选)
  onPageHide(): void {
    this.stopTimer();
  }

  // 其余代码不变...
}

8.4 生命周期完整示例

@Entry
@Component
struct Index3 {
  @State seconds: number = 0;
  @State isRunning: boolean = false;
  private intervalId: number = -1;
  private startTime: number = 0;

  aboutToAppear(): void {
    console.info('计时器页面即将显示');
    // 可在此恢复上次的计时状态(从本地存储读取)
  }

  onPageShow(): void {
    console.info('计时器页面已显示');
  }

  onPageHide(): void {
    console.info('计时器页面已隐藏');
    // 自动暂停计时(避免后台浪费性能)
    this.stopTimer();
  }

  aboutToDisappear(): void {
    console.info('计时器页面即将销毁');
    this.stopTimer();
    // 可在此保存当前计时值到本地存储
  }

  // ...
}

九、计时精度讨论

9.1 setInterval 的不精确性

JavaScript/ArkTS 的 setInterval 并不是精确的 1000ms:

setInterval(() => { this.seconds++; }, 1000);

在以下情况下会延迟

  • 主线程被其他任务阻塞(如渲染、事件处理)
  • 页面切换到后台(部分系统会节流)
  • 设备 CPU 降频

根据 HTML 标准,浏览器/运行时对 后台标签页setInterval 最小间隔限制为 1000ms(Chrome 等)。

9.2 更精确的计时方案

// 方案:基于 Date.now() 差值,而非累积 +1
startTimer(): void {
  if (!this.isRunning) {
    this.isRunning = true;
    this.startTime = Date.now() - this.seconds * 1000;

    this.intervalId = setInterval(() => {
      const elapsed = Date.now() - this.startTime;
      this.seconds = Math.floor(elapsed / 1000);
    }, 200);  // 更高的刷新频率(200ms)
  }
}

对比

方案 精度 性能消耗 适用场景
每秒 +1 低(累计误差) 简单计时
Date.now 差值 高(无累计误差) 精度要求高的计时
requestAnimationFrame 最高(帧同步) 动画、毫秒级显示

十、状态显示与视觉反馈

10.1 底部状态文字

Text(this.isRunning ? '⏱ 计时中...' : '⏹ 已停止')
  .fontSize(16)
  .fontColor(this.isRunning ? '#34C759' : '#8E8E93')
  .margin({ top: 30 })

双条件联动

  • isRunning = true → 显示 ⏱ 计时中... + 绿色文字
  • isRunning = false → 显示 ⏹ 已停止 + 灰色文字

这个单一表达式驱动了两个 UI 属性:文本内容文字颜色,无需额外判断。

10.2 完整的视觉状态映射

状态 时间文字 开始按钮 暂停按钮 重置按钮 状态提示
初始(0) 00:00 可用 禁用 可用 ⏹ 已停止
运行中 00:01 禁用 可用 可用 ⏱ 计时中…
已暂停 00:42 可用 禁用 可用 ⏹ 已停止

十一、常见问题与调试

11.1 定时器停止后仍在计时

// ❌ 问题:intervalId 被覆盖了
startTimer(): void {
  this.intervalId = setInterval(() => { ... }, 1000);
}
stopTimer(): void {
  clearInterval(this.intervalId);  // 正确停止了定时器
}
// 但是如果 startTimer 被连续调用两次:
// 第一次的定时器 ID 丢失,无法停止!

解决方案:在 startTimer() 开头判断是否已经在运行:

startTimer(): void {
  if (!this.isRunning) {     // ← 关键保护
    this.isRunning = true;
    this.intervalId = setInterval(...);
  }
}

11.2 页面切换后定时器还在跑

// 解决方案:在 onPageHide / aboutToDisappear 中清理
onPageHide(): void {
  this.stopTimer();
}

aboutToDisappear(): void {
  this.stopTimer();
}

11.3 定时器回调中的 this 指向

// ✅ 箭头函数:this 指向组件实例
this.intervalId = setInterval(() => {
  this.seconds++;    // this 正确指向 Index3
}, 1000);

// ❌ 普通函数:this 指向 undefined 或 globalThis
this.intervalId = setInterval(function() {
  this.seconds++;    // this 错误!
}, 1000);

十二、扩展:秒表模式

// 毫秒级计时器
@State milliseconds: number = 0;
@State centiseconds: number = 0;  // 百分秒(0-99)
private startTime: number = 0;

startTimer(): void {
  if (!this.isRunning) {
    this.isRunning = true;
    this.startTime = Date.now() - (this.seconds * 1000 + this.centiseconds * 10);

    this.intervalId = setInterval(() => {
      const elapsed = Date.now() - this.startTime;
      this.seconds = Math.floor(elapsed / 1000);
      this.centiseconds = Math.floor((elapsed % 1000) / 10);
    }, 10);  // 每 10ms 刷新
  }
}

formatTime(): string {
  const m = Math.floor(this.seconds / 60);
  const s = this.seconds % 60;
  return (m < 10 ? '0' : '') + m + ':'
       + (s < 10 ? '0' : '') + s + '.'
       + (this.centiseconds < 10 ? '0' : '') + this.centiseconds;
}

十三、总结

通过计时器应用,我们深入掌握了:

知识点 重要程度 关键代码
setInterval/clearInterval 必须掌握 this.intervalId = setInterval(fn, 1000)
private 非状态变量 核心理解 private intervalId: number = -1
enabled 条件禁用 常用技能 .enabled(!this.isRunning)
Math.floor 向下取整 基本数学 Math.floor(seconds / 60)
取模运算 % 基本数学 seconds % 60
等宽字体 fontFamily 用户体验 .fontFamily('Courier New')
组件生命周期 进阶必知 aboutToDisappear() 清理定时器
防重复启动 防御性编程 if (!this.isRunning) 保护
三目表达式驱动 UI 高效技巧 isRunning ? '绿' : '灰'

计时器应用的核心价值在于引入了时间驱动的状态变更模式和生命周期管理的概念。这两个知识是游戏倒计时、番茄钟、秒表、倒计时等所有时间相关应用的基础。


Logo

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

更多推荐