ArkTS 动画控制实战:把 4-7-8 呼吸训练从 UI 中拆出去

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

1. 为什么要拆动画控制器

呼吸训练看起来只是几个文字变化:吸气、屏息、呼气。但如果直接在页面里写一堆 setTimeout(),后期会遇到问题:

  • 页面切换时定时器不容易清理。
  • 多次启动会产生重复循环。
  • UI 逻辑和节奏逻辑混在一起。
  • 想复用到全屏呼吸页时很麻烦。

“律愈”把呼吸节奏封装为 BreathGuideAnimator,页面只负责展示当前帧。

2. 帧模型

呼吸训练每个阶段可以抽象成一帧:

export interface BreathGuideFrame {
  label: string;
  hint: string;
}

export type BreathGuideListener = (frame: BreathGuideFrame) => void;

label 是主文案,例如“吸气”;hint 是辅助文案,例如“4 秒 · 鼻腔缓慢充盈”。

3. 控制器结构

export class BreathGuideAnimator {
  private listener: BreathGuideListener;
  private cycleId: number = 0;
  private timerIds: number[] = [];

  constructor(listener: BreathGuideListener) {
    this.listener = listener;
  }
}

这里有两个关键状态:

  • cycleId:用来识别当前循环,防止旧定时器继续回调。
  • timerIds:保存所有定时器,停止时统一清理。

4. 发出一帧

private emit(label: string, hint: string): void {
  this.listener({ label, hint });
}

控制器不关心 UI 是 Text、Dialog 还是全屏遮罩,它只把当前状态通知出去。

5. 安排定时器

private schedule(
  id: number,
  label: string,
  hint: string,
  delayMs: number,
  next: (() => void) | null = null
): void {
  const timerId = setTimeout((): void => {
    if (id !== this.cycleId) {
      return;
    }
    this.emit(label, hint);
    if (next !== null) {
      next();
    }
  }, delayMs) as number;
  this.timerIds.push(timerId);
}

这段代码最重要的是:

if (id !== this.cycleId) {
  return;
}

当用户切换页面或关闭呼吸训练后,旧定时器即使触发,也不会再更新 UI。

6. 4-7-8 节奏

启动逻辑如下:

start(): void {
  this.stop();
  this.cycleId += 1;
  const id = this.cycleId;
  this.schedule(id, '吸气', '4 秒 · 鼻腔缓慢充盈', 0, (): void => {
    this.schedule(id, '屏息', '7 秒 · 温和守中', 4000, (): void => {
      this.schedule(id, '呼气', '8 秒 · 放松肩背', 7000, (): void => {
        this.start();
      });
    });
  });
}

流程是:

  1. 立即进入吸气。
  2. 4 秒后进入屏息。
  3. 7 秒后进入呼气。
  4. 8 秒后重新开始下一轮。

7. 停止逻辑

stop(): void {
  this.cycleId += 1;
  for (const timerId of this.timerIds) {
    clearTimeout(timerId);
  }
  this.timerIds = [];
}

cycleId += 1clearTimeout() 是双保险:

  • 已经注册但未触发的定时器会被清理。
  • 即使某个定时器刚好触发,也会因为 id 不一致而退出。

8. 页面如何使用

Index.ets 中,页面保存当前帧:

@State private breathLabel: string = '吸气';
@State private breathHint: string = '4-7-8 呼吸 · 跟随圆环';
private breathAnimator: BreathGuideAnimator | null = null;

创建控制器:

private ensureBreathAnimator(): BreathGuideAnimator {
  if (this.breathAnimator === null) {
    this.breathAnimator = new BreathGuideAnimator((frame: BreathGuideFrame): void => {
      this.breathLabel = frame.label;
      this.breathHint = frame.hint;
    });
  }
  return this.breathAnimator;
}

启动和停止:

private startBreathGuide(): void {
  this.ensureBreathAnimator().start();
}

private stopBreathGuide(): void {
  this.breathAnimator?.stop();
}

页面消失时释放:

aboutToDisappear(): void {
  this.stopBreathGuide();
  this.breathAnimator = null;
}

9. 控制流程图

start

stop old timers

cycleId plus one

emit 吸气

4 秒后 emit 屏息

7 秒后 emit 呼气

8 秒后 restart

stop

cycleId plus one

clear all timerIds

10. 这个写法的价值

这个控制器看起来很小,但它体现了一个重要思路:节奏逻辑不应该绑死在 UI 组件里。

这样做以后:

  • 同一套呼吸节奏可以用于疗愈页和全屏呼吸弹层。
  • 页面切换时能可靠停止。
  • 想调整为 3-5-7 或其他节奏,只改控制器。
  • UI 可以自由变化,不影响计时逻辑。

对于 HarmonyOS 应用来说,把这类“有生命周期、有定时器、有复用需求”的逻辑从页面中抽出去,是提升代码质量的有效办法。

9. 为什么 cycleId 是关键

只用 clearTimeout() 并不能覆盖所有边界情况。如果定时器刚好已经进入回调,清理可能来不及。所以项目用 cycleId 作为逻辑取消标记:

s if (id !== this.cycleId) { return; }

这是一种很常见的异步防抖思路:每一轮任务都有自己的 id,过期任务即使执行,也不能修改当前状态。

10. 呼吸训练和 UI 解耦

控制器只发出 frame:

s this.listener({ label, hint });

页面怎么展示完全由 UI 决定。可以是普通文本:

s Text(this.breathLabel) Text(this.breathHint)

也可以是全屏弹层、Canvas 圆环、卡片动画。这个设计让呼吸训练成为一个可复用服务。

11. 页面启动停止策略

在 Index.ets 中,Tab 切换时判断是否启动呼吸:

s .onChange((idx: number) => { this.selectedTabIndex = idx; if (idx === 1 || this.fullBreathOpen) { this.startBreathGuide(); } else { this.stopBreathGuide(); } })

这段代码很适合讲生命周期:用户在疗愈页或全屏呼吸页时才需要计时器,其他页面不需要。

12. 全屏呼吸层的产品意义

律愈不仅在疗愈页显示呼吸提示,还提供 openFullBreath():

s private openFullBreath(): void { this.fullBreathOpen = true; this.startBreathGuide(); }

这让呼吸训练从辅助信息变成独立功能。文章中可以结合配图说明:同一个控制器可以服务两个 UI 场景。

13. 可扩展节奏配置

如果要支持更多呼吸法,可以把 4-7-8 写成配置:

` s
interface BreathPhase {
label: string;
hint: string;
durationMs: number;
}

const phases: BreathPhase[] = [
{ label: ‘吸气’, hint: ‘鼻腔缓慢充盈’, durationMs: 4000 },
{ label: ‘屏息’, hint: ‘温和守中’, durationMs: 7000 },
{ label: ‘呼气’, hint: ‘放松肩背’, durationMs: 8000 }
];
`

这可以作为文章最后的进阶方向。

7000 },
{ label: ‘呼气’, hint: ‘放松肩背’, durationMs: 8000 }
];
`

Logo

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

更多推荐