第11篇:倒计时组件封装与控制器模式

在"柚兔学伴"中,番茄钟倒计时是核心功能之一。倒计时组件需要满足:外部可设置时间、可远程重置、可监听状态变化。这就引出了一个经典的组件设计问题——如何让父组件从外部控制子组件的行为?

本篇从 TimerComponent.ets 的 202 行源码出发,详解控制器模式(Controller Pattern)在 ArkUI 中的实现。


请添加图片描述

一、控制器模式的核心思想

ArkUI 的 @Component 是一个封闭的作用域,@State@Prop 等状态装饰器只能在组件内部修改。如果父组件想要主动触发子组件的某个行为(如重置倒计时),单纯通过属性传递是做不到的。

控制器模式的解法是:子组件在初始化时,将自身的方法注册到控制器对象上;父组件持有控制器,通过调用控制器方法间接驱动子组件

父组件 ──调用──> Controller.reset() ──回调──> 子组件.onResetFromController()

二、TimerComponentController 实现

export class TimerComponentController {
  private resetCallback: (() => void) | null = null;

  setResetCallback(callback: () => void) {
    this.resetCallback = callback;
  }

  reset() {
    if (this.resetCallback) {
      this.resetCallback();
    }
  }
}

控制器非常简洁,只做两件事:

  1. setResetCallback:注册回调函数(由子组件调用)
  2. reset:执行回调函数(由父组件调用)

这种"注册-执行"的模式,本质是将子组件的方法引用"提升"到了控制器中,打破了组件边界对方法调用的限制。


三、TimerComponent 组件详解

3.1 属性定义

@Component
export struct TimerComponent {
  controller: TimerComponentController | null = null;

  @Prop @Watch('onTotalTimeChange') totalTime: number = 60;
  onTotalTimeChange() {
    this.resetTimer();
    this.currentTime = this.totalTime;
  }

  onTimeUp?: () => void;
  onTimeChange?: (currentTime: number) => void;

  @State currentTime: number = 60;
  @State isRunning: boolean = false;
  private timer: number = -1;
  private gradientColor: LinearGradient = new LinearGradient([
    { color: $r('app.color.color_color_1'), offset: 0.5 },
    { color: $r('app.color.color_color_2'), offset: 1.0 }
  ])
}

关键设计点:

属性 装饰器 说明
totalTime @Prop @Watch 从父组件单向传入,变化时自动重置计时器
currentTime @State 当前剩余时间,驱动 UI 刷新
isRunning @State 运行状态,控制按钮文字和颜色
onTimeUp 普通属性 回调函数,时间归零时通知父组件
onTimeChange 普通属性 回调函数,每秒通知父组件时间变化
controller 普通属性 控制器实例,父组件通过它远程控制

@Prop 保证 totalTime 从父组件单向流入;@Watch 则监听其变化,当外部重新设置时间时自动重置。

3.2 控制器绑定

aboutToAppear() {
  this.currentTime = this.totalTime;
  if (this.controller) {
    this.controller.setResetCallback(this.onResetFromController.bind(this));
  }
}

onResetFromController() {
  this.resetTimer();
  this.currentTime = this.totalTime;
}

aboutToAppear 是组件的生命周期回调,在组件首次创建时执行。此时将 onResetFromController 方法通过 bind(this) 绑定正确的 this 指向后,注册到控制器。

为什么需要 .bind(this) 因为 setResetCallback 存储的是函数引用,当控制器调用 resetCallback() 时,this 可能丢失。bind 确保无论谁调用这个函数,this 都指向组件实例。

3.3 倒计时核心逻辑

toggleTimer() {
  if (this.isRunning) {
    clearInterval(this.timer);
    this.isRunning = false;
  } else {
    this.isRunning = true;
    this.timer = setInterval(() => {
      if (this.currentTime > 0) {
        this.currentTime--;
        if (this.onTimeChange) {
          this.onTimeChange(this.currentTime);
        }
      } else {
        this.isRunning = false;
        clearInterval(this.timer);
        if (this.onTimeUp) {
          this.onTimeUp();
        }
      }
    }, 1000);
  }
}

每 1000ms(1秒)执行一次:

  • 剩余时间 > 0:递减 currentTime,触发 onTimeChange 回调
  • 剩余时间 = 0:停止定时器,标记为未运行,触发 onTimeUp 回调

toggleTimer 是一个切换函数——运行时暂停,暂停时开始。clearInterval 确保不会产生多个定时器叠加。

3.4 重置逻辑

resetTimer() {
  clearInterval(this.timer);
  this.isRunning = false;
  this.currentTime = this.totalTime;
}

重置操作包含三个步骤:清除定时器 → 停止运行状态 → 恢复初始时间。无论是本地重置还是通过控制器远程重置,最终都调用此方法。


四、时间格式化

formatTime(seconds: number): string {
  const hours = Math.floor(seconds / 3600);
  const minutes = Math.floor((seconds % 3600) / 60);
  const secs = seconds % 60;

  return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString()
    .padStart(2, '0')}`;
}

将总秒数转换为 HH:MM:SS 格式。padStart(2, '0') 确保个位数补零,如 5 显示为 05


五、UI 构建

5.1 圆形进度条

Stack() {
  Progress({
    value: this.getProgress(),
    total: this.totalTime,
    type: ProgressType.ScaleRing
  })
    .width('100%')
    .height('100%')
    .style({
      strokeWidth: 10,
    })
    .color($r('app.color.app_primary'))

  Column() {
    Text(this.formatTime(this.currentTime))
      .fontSize(20)
      .fontWeight(FontWeight.Bolder)
      .textAlign(TextAlign.Center)

    Text('剩余时间')
      .fontSize(14)
      .fontColor(Color.Gray)
      .margin({ top: 10 })
  }
}

ProgressType.ScaleRing 是圆环进度条,value 为已用时间(totalTime - currentTime),total 为总时间。Stack 布局将时间文字叠加在进度环中心。

5.2 控制按钮

Row({ space: 12 }) {
  Button('重置')
    .fontSize(12)
    .height(30)
    .backgroundColor('#F0F0F0')
    .fontColor(Color.Black)
    .borderRadius(25)
    .onClick(() => {
      this.resetTimer();
    })

  Button(this.isRunning ? '暂停' : '开始')
    .fontSize(12)
    .height(30)
    .backgroundColor(this.isRunning ? $r('app.color.app_alert') : $r('app.color.app_primary'))
    .fontColor(Color.White)
    .borderRadius(25)
    .onClick(() => {
      this.toggleTimer();
    })
}

按钮文字和颜色根据 isRunning 状态动态切换,实现了"开始/暂停"一体化按钮。


六、生命周期清理

aboutToDisappear() {
  if (this.timer !== -1) {
    clearInterval(this.timer);
  }
}

组件销毁时必须清除定时器,否则会持续消耗资源并可能导致对已销毁组件的状态修改。timer 初始值为 -1,仅在有活跃定时器时清理。


七、父组件中的使用

TodoView 中,倒计时组件的完整使用方式:

timerController: TimerComponentController = new TimerComponentController()

@State settingTime: number = 25 * 60;

onTimerFinished = async () => {
  this.alarmVisible = true
  // 播放提醒音
}

onTimeChanged = (currentTime: number) => {
  console.log(`剩余时间: ${currentTime}`);
}

// 在 build 中使用
TimerComponent({
  totalTime: this.settingTime,
  onTimeUp: this.onTimerFinished,
  onTimeChange: this.onTimeChanged,
  controller: this.timerController
})

当用户在弹窗中确认关闭闹钟时,通过控制器重置:

IBestDialog({
  visible: $alarmVisible,
  defaultBuilder: (): void => this.alarmBuild(),
  onConfirm: (() => {
    this.alarmVisible = false
    this.timerController.reset()  // 远程重置倒计时
  })
})

八、控制器模式的适用场景

场景 说明
需要远程重置 弹窗确认后重置计时器
需要远程暂停/恢复 页面切换时暂停,返回时恢复
需要跨组件通信 兄弟组件间通过共享控制器协调行为

控制器模式的扩展方向:

  • 添加更多回调:setPauseCallbacksetResumeCallback
  • 支持多实例:每个控制器绑定一个组件实例
  • 结合 @Watch:控制器修改某个 @State,子组件通过 @Prop @Watch 响应

九、小结

控制器模式在 ArkUI 中解决了"父组件主动驱动子组件行为"的需求,其核心三步:

  1. 定义 Controller 类:声明回调属性和注册/执行方法
  2. 子组件注册回调:在 aboutToAppearcontroller.setResetCallback(this.method.bind(this))
  3. 父组件调用方法controller.reset() 间接触发子组件行为

相比 @Link 双向绑定,控制器模式更语义化——它表达的是"命令"而非"数据同步",适合倒计时重置、播放器控制、动画触发等命令式交互场景。

Logo

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

更多推荐