一、引言

呼吸是我们每时每刻都在做的事,但大多数人不会"正确"呼吸。现代人的呼吸倾向于短促、浅表,这种模式会激活交感神经系统,导致心率加快、血压升高、压力激素分泌增加。而深长、有节奏的呼吸可以激活副交感神经系统,产生相反的效果——降低心率、放松肌肉、减轻焦虑。

这就是呼吸引导器(Breathing Guide)存在的意义。它通过一个视觉动画(通常是扩缩的圆圈)来引导用户的呼吸节奏——圈扩大时吸气,圈收缩时呼气。用户不需要思考"该吸多久、该呼多久",只需要跟随动画。

从技术角度看,呼吸引导器的核心挑战有三个:

第一,平滑动画。一个从 120vp 扩大到 220vp 再缩回 120vp 的圆圈,需要在数秒内完成平滑过渡。不能一帧跳到位,需要用定时器逐帧更新尺寸。

第二,相位管理。不同的呼吸模式有不同的阶段(相位)。4-7-8 法有三个阶段(吸气 4 秒、屏息 7 秒、呼气 8 秒),盒式呼吸法有四个阶段(吸气 4 秒、屏息 4 秒、呼气 4 秒、屏息 4 秒)。每个阶段有不同的动画方向(扩大、保持、缩小)。

第三,相位过渡。一个阶段结束后自动进入下一阶段,最后一个阶段结束后自动回到第一个阶段并增加轮次计数。这个状态机需要在定时器回调中完成,不能有视觉卡顿。

本文将用 ArkUI 从零构建一个呼吸引导器。功能包括:三种呼吸模式(4-7-8 放松法 / 盒式呼吸法 / 简易 5 秒呼吸)、动画呼吸圈(大小渐变 + 颜色变化)、相位指示器(当前阶段高亮 + 进度条)、轮次统计、开始/暂停/重置控制。

阅读完本文,你将能够:

  • 使用 setInterval 实现逐帧动画引擎
  • 设计多相位的状态转换系统
  • 使用比例插值(0→1)驱动动画尺寸变化
  • 构建呼吸引导器的完整 UI(动态圆圈 + 相位指示 + 模式选择)

二、呼吸模式的数据模型

2.1 阶段定义

每个呼吸模式由多个阶段(Phase)组成。每个阶段定义了名称、时长、动画起止比例和颜色:

interface PhaseDef {
  label: string;      // 阶段标签:"吸气" / "屏息" / "呼气"
  duration: number;   // 时长(秒)
  startRatio: number; // 动画起始比例(0=最小圈, 1=最大圈)
  endRatio: number;   // 动画结束比例
  color: string;      // 圆圈颜色(青/蓝/紫)
}

startRatioendRatio 是动画引擎的核心参数。它们定义了圆圈在该阶段内的尺寸变化:

  • 吸气startRatio: 0, endRatio: 1 → 圈从小变大
  • 屏息startRatio: 1, endRatio: 1 → 圈保持最大不变
  • 呼气startRatio: 1, endRatio: 0 → 圈从大变回小
  • 屏息(呼后)startRatio: 0, endRatio: 0 → 圈保持最小不变

2.2 模式定义

模式是阶段的数组,外加元数据:

interface PatternDef {
  name: string;       // 模式名称
  desc: string;       // 简短描述
  phases: PhaseDef[]; // 阶段列表
}

三种预设模式:

4-7-8 放松法(安德鲁·威尔医生推广的经典放松呼吸法):

{
  name: '4-7-8', desc: '经典放松呼吸法',
  phases: [
    { label: '吸气', duration: 4, startRatio: 0, endRatio: 1, color: '#4ECDC4' },
    { label: '屏息', duration: 7, startRatio: 1, endRatio: 1, color: '#1677FF' },
    { label: '呼气', duration: 8, startRatio: 1, endRatio: 0, color: '#7B68EE' },
  ],
}

三个阶段共计 19 秒完成一轮。4 秒吸气(圈扩张,青绿色)→ 7 秒屏息(圈保持最大,蓝色)→ 8 秒呼气(圈缩回最小,紫色)。

盒式呼吸法(四方呼吸,美国海豹突击队用于在高压环境下保持冷静):

{
  name: '盒式', desc: '四方呼吸法',
  phases: [
    { label: '吸气', duration: 4, startRatio: 0, endRatio: 1, color: '#4ECDC4' },
    { label: '屏息', duration: 4, startRatio: 1, endRatio: 1, color: '#1677FF' },
    { label: '呼气', duration: 4, startRatio: 1, endRatio: 0, color: '#7B68EE' },
    { label: '屏息', duration: 4, startRatio: 0, endRatio: 0, color: '#1677FF' },
  ],
}

四个阶段各 4 秒,形成 4×4 的"盒子"。吸气后屏息、呼气后屏息——这种对称的结构让呼吸者感到稳定和控制感。

简易 5 秒呼吸(最简单的模式,适合初学者):

{
  name: '简易', desc: '5秒均匀呼吸',
  phases: [
    { label: '吸气', duration: 5, startRatio: 0, endRatio: 1, color: '#4ECDC4' },
    { label: '呼气', duration: 5, startRatio: 1, endRatio: 0, color: '#7B68EE' },
  ],
}

两个阶段各 5 秒,没有屏息。最简单的吸-呼交替,适合呼吸练习的入门。

2.3 颜色语义

三种颜色对应三种呼吸动作,每个颜色都经过刻意选择:

颜色 色值 阶段 语义
青绿色 #4ECDC4 吸气 新鲜、能量、吸入活力
蓝色 #1677FF 屏息 稳定、沉淀、保持平静
紫色 #7B68EE 呼气 释放、放松、排出压力

三种颜色都是冷色调——没有红色(紧张、危险)或黄色(对比度不足)。冷色调有助于触发副交感神经系统的放松反应,与呼吸引导的放松目标一致。
在这里插入图片描述

三、动画引擎

3.1 定时器驱动的逐帧动画

动画的核心是一个每 80 毫秒触发一次的 setInterval 定时器:

const TICK_MS: number = 80; // 80ms ≈ 12.5 fps

toggleRunning(): void {
  if (this.isRunning) {
    this.stopTimer();
    this.isRunning = false;
  } else {
    this.isRunning = true;
    this.timerId = setInterval(() => { this.tick(); }, TICK_MS);
  }
}

选择 80ms 而非 16ms(60fps)的原因是:呼吸圈的动画速度很慢(4-8 秒完成一次变化),12.5fps 已经足够平滑。帧数过高会浪费 CPU 资源且用户感知不到差异。

aboutToDisappear() 中清除定时器,防止页面离开后继续运行:

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

3.2 tick 函数:逐帧更新

tick() 是动画引擎的心脏,每 80ms 执行一次,负责三件事:更新经过时间、计算圆圈尺寸、检测阶段切换:

tick(): void {
  this.elapsedMs += TICK_MS;          // ① 累加经过时间
  const phase = this.currentPhase();
  const phaseMs = phase.duration * 1000;
  const progress = Math.min(1, this.elapsedMs / phaseMs); // ② 计算进度(0→1)
  const ratio = phase.startRatio + (phase.endRatio - phase.startRatio) * progress; // ③ 插值
  this.circleSize = MIN_SIZE + (MAX_SIZE - MIN_SIZE) * ratio; // ④ 更新尺寸

  if (this.elapsedMs >= phaseMs) {    // ⑤ 检测阶段切换
    const pattern = this.currentPattern();
    if (this.phaseIndex + 1 >= pattern.phases.length) {
      this.phaseIndex = 0;            // 回到第一阶段
      this.totalCycles++;             // 一轮完成
    } else {
      this.phaseIndex++;              // 进入下一阶段
    }
    this.elapsedMs = 0;               // 重置经过时间
  }
}

逐行解析:

elapsedMs += TICK_MS:追踪当前阶段内已经过了多少毫秒。这是所有计算的基准。

progress = Math.min(1, elapsedMs / phaseMs):计算当前阶段的完成比例(0 到 1)。Math.min(1, ...) 确保在定时器延迟导致略微超过时长时不会出现 > 1 的值。

ratio = startRatio + (endRatio - startRatio) * progress:线性插值。在吸气阶段,ratio 从 0 平滑增长到 1(圈扩大);在屏息阶段,startRatio = endRatio = 1,ratio 始终为 1(圈不变);在呼气阶段,ratio 从 1 平滑减小到 0(圈缩小)。

circleSize = MIN_SIZE + (MAX_SIZE - MIN_SIZE) * ratio:将比例映射到实际像素值。MIN_SIZE = 120MAX_SIZE = 220。当 ratio = 0 时 circleSize = 120vp(最小),ratio = 1 时 circleSize = 220vp(最大)。

阶段切换检测:当 elapsedMs 超过阶段时长时,进入下一阶段或回到第一阶段(完成一轮)。elapsedMs 重置为 0。

3.3 进度条

除了圆圈动画,还有一个水平进度条显示当前阶段的完成程度:

cycleProgress(): number {
  const phases = this.currentPattern().phases;
  let totalMs = 0;
  for (let i = 0; i < phases.length; i++) {
    totalMs += phases[i].duration * 1000;
  }
  let doneMs = 0;
  for (let i = 0; i < this.phaseIndex; i++) {
    doneMs += phases[i].duration * 1000;
  }
  doneMs += this.elapsedMs;
  return doneMs / totalMs;
}

cycleProgress() 返回整个呼吸轮次的完成比例(0→1),用于驱动进度条。与圆圈动画的"阶段内进度"不同,这是"跨阶段的全局进度"。

进度条的宽度使用百分比:

Row()
  .width(`${this.cycleProgress() * 100}%`)
  .height(3)
  .borderRadius(2)
  .backgroundColor(this.circleColor())

颜色跟随当前阶段颜色——吸气时为青绿,屏息时为蓝,呼气时为紫。进度条和圆圈使用相同的颜色,形成视觉统一。
在这里插入图片描述

四、状态管理

4.1 六个核心状态变量

@State patternIndex: number = 0;   // 当前模式(0=4-7-8, 1=盒式, 2=简易)
@State phaseIndex: number = 0;     // 当前阶段索引
@State elapsedMs: number = 0;      // 当前阶段已过毫秒数
@State circleSize: number = MIN_SIZE; // 圆圈当前尺寸(vp)
@State totalCycles: number = 0;    // 已完成轮数
@State isRunning: boolean = false; // 是否正在运行

这六个变量组合出呼吸引导器的完整状态。与计算器的状态机不同(四个变量组合出四个离散状态),呼吸引导器的状态是连续的——elapsedMscircleSize 在持续变化。

4.2 模式切换

切换呼吸模式时,需要重置所有状态:

selectPattern(idx: number): void {
  this.stopTimer();
  this.patternIndex = idx;
  this.phaseIndex = 0;
  this.elapsedMs = 0;
  this.circleSize = MIN_SIZE;
  this.totalCycles = 0;
  this.isRunning = false;
}

先停止定时器,再重置所有状态变量,确保新模式的动画从初始状态开始。

4.3 重置

重置操作将所有状态恢复到初始值:

reset(): void {
  this.stopTimer();
  this.isRunning = false;
  this.phaseIndex = 0;
  this.elapsedMs = 0;
  this.circleSize = MIN_SIZE;
  this.totalCycles = 0;
}

重置后圆圈回到最小尺寸、轮次归零、阶段回到第一个。与模式切换不同,重置不改变当前的呼吸模式。

4.4 阶段剩余时间

显示给用户的倒计时:

phaseRemaining(): number {
  const total = this.currentPhase().duration * 1000;
  const remain = total - this.elapsedMs;
  return Math.ceil(remain / 1000);
}

使用 Math.ceil 向上取整。例如 6.2 秒剩余显示为 “7 秒” 而非 “6 秒”。这给用户一种"还有 1 秒缓冲"的心理感受,减少切换阶段的惊讶感。
在这里插入图片描述

五、UI 设计

5.1 整体布局

BreathingPage
├── 深色标题栏(52vp):🧘 呼吸引导 + "N 轮"
├── Scroll(可滚动内容区)
│   ├── 动画呼吸圈(120-220vp,动态尺寸 + 动态颜色 + 阴影)
│   │   └── 阶段标签 + 剩余秒数(白字居中)
│   ├── 全局进度条(3vp 高度,宽度百分比)
│   ├── 相位指示器(圆点 + 标签 + 时长)
│   ├── 模式选择器(3 个卡片并排)
│   └── 控制按钮(开始/暂停 + 重置)

5.2 动画呼吸圈

呼吸圈是整个页面最显眼的元素:

Column()
  .width(this.circleSize)
  .height(this.circleSize)
  .borderRadius(this.circleSize / 2)
  .backgroundColor(this.circleColor())
  .shadow({ radius: 20, color: this.circleColor() + '44' })

borderRadius = circleSize / 2 确保始终是正圆形(宽度和高度的 borderRadius = 50%)。

.shadow() 添加与圆圈颜色匹配的柔和光晕('44' = 约 27% 不透明度)。光晕让圆圈有"发光"的质感,增强了呼吸引导的沉浸感——用户会觉得这个圈"有生命"。

圈内显示阶段标签(“吸气”/“屏息”/“呼气”)和剩余秒数。文字颜色为白色,在所有三种背景色(青绿/蓝/紫)上都清晰可读——这些颜色与白色的对比度均达到 4.5:1 以上。

5.3 相位指示器

呼吸圈下方的相位指示器显示当前模式的所有阶段:

● 吸气    ○ 屏息    ○ 呼气
 4s       7s       8s

当前阶段用实心圆点 + 深色标签 + 阶段颜色高亮显示,已完成阶段用半透明圆点 + 浅色标签,未开始阶段用灰色圆点 + 浅灰标签。

ForEach(this.currentPattern().phases, (phase: PhaseDef, pi: number) => {
  Column()
    .width(10).height(10).borderRadius(5)
    .backgroundColor(pi === this.phaseIndex ? phase.color :
      (pi < this.phaseIndex ? phase.color + '44' : '#E0E0E8'))
})

三种状态的颜色区分让用户一眼就能看到"我现在在哪个阶段、已经完成了哪些阶段、接下来是什么"。

5.4 模式选择器

三个模式卡片并排,选中态蓝色实心 + 白字,未选中态浅灰:

┌──────────┐  ┌──────────┐  ┌──────────┐
│  4-7-8   │  │   盒式   │  │   简易   │
│ 经典放松 │  │ 四方呼吸 │  │ 5秒均匀  │
│  呼吸法  │  │   法     │  │   呼吸   │
└──────────┘  └──────────┘  └──────────┘

每张卡片包含模式名称(加粗)和简短描述(小字)。选中时整个卡片变蓝,与未选中卡片形成鲜明对比。

5.5 控制按钮

两个按钮并排:开始/暂停(主要操作,占 2/3 宽度)和重置(次要操作,占 1/3 宽度)。

开始前按钮显示 “▶ 开始”(蓝色),运行中显示 “⏸ 暂停”(品红色 #EB2F96)。品红色在这里替代了传统方案中可能使用的黄色或橙色,且白字对比度约 4.5:1。

暂停按钮使用品红而非红色,因为"暂停"不是危险操作——它是一种休息,而非停止。品红传达的是"温和的中断",红色则暗示"紧急停止"。

六、完整代码结构

BreathingPage
├── 数据定义
│   ├── PATTERNS[] — 三种呼吸模式及阶段数组
│   ├── MIN_SIZE / MAX_SIZE — 圆圈尺寸范围
│   └── TICK_MS — 动画帧间隔
├── 状态变量
│   ├── @State patternIndex / phaseIndex / elapsedMs
│   ├── @State circleSize / totalCycles / isRunning
│   └── private timerId(非响应式)
├── 动画引擎
│   ├── tick() — 逐帧更新(累加时间 → 计算插值 → 更新尺寸 → 检测阶段切换)
│   └── toggleRunning() / stopTimer() / reset()
├── 计算属性
│   ├── currentPattern() / currentPhase()
│   ├── phaseRemaining() — 当前阶段剩余秒数
│   └── cycleProgress() — 全局轮次进度(0→1)
├── 视图
│   ├── 标题栏(模式名 + 轮次计数)
│   ├── 呼吸圈(动态尺寸 + 动态颜色 + 阶段标签)
│   ├── 全局进度条(百分比宽度)
│   ├── 相位指示器(圆点序列)
│   ├── 模式选择器(3 卡片)
│   └── 控制按钮(开始/暂停 + 重置)
└── 生命周期
    └── aboutToDisappear() — 清除定时器

七、总结

本文从零构建了一个呼吸引导器。与前九篇的功能型应用不同,呼吸引导器的核心是动画引擎 + 相位管理——没有 CRUD,没有数据持久化,只有一个扩缩圆圈的动画和一套精确的相位切换逻辑。

核心要点回顾:

  1. 三种呼吸模式的数据模型PatternDef 包含多个 PhaseDef,每个阶段用 startRatioendRatio 定义动画方向(0 = 最小圈,1 = 最大圈)。三种模式覆盖了放松(4-7-8)、稳定(盒式)、入门(简易)三种场景。

  2. 80ms 定时器驱动的动画引擎tick() 每 80ms 执行一次——累加 elapsedMs → 计算 phase progress → 线性插值 ratio → 更新 circleSize → 检测阶段切换。12.5fps 对于慢速呼吸动画已经足够平滑。

  3. 线性插值驱动尺寸变化ratio = startRatio + (endRatio - startRatio) * progresscircleSize = MIN_SIZE + (MAX_SIZE - MIN_SIZE) * ratio。这种分步计算让动画逻辑清晰可测试。

  4. 相位自动切换elapsedMs >= phaseMs 时自动进入下一阶段。最后一个阶段结束后回到第一阶段,totalCycles++。整个状态机在 tick() 中完成,没有额外的切换延迟。

  5. 三色语义系统:青绿(吸气 = 新鲜能量)、蓝(屏息 = 稳定沉淀)、紫(呼气 = 释放放松)。全冷色调、无黄色、无红色,与呼吸引导的放松目标一致。

  6. 视觉反馈系统:动态圆圈(尺寸 + 颜色 + 光晕阴影)+ 全局进度条(百分比宽度 + 阶段颜色)+ 相位指示器(圆点三态)+ 阶段标签 + 剩余秒数。多重视觉反馈让用户不需要看文字就能跟随呼吸节奏。

呼吸引导器是一个"小而完整"的案例——200 行代码,一个定时器,一个圆圈,三种呼吸模式。但它涉及动画引擎设计、相位状态机、颜色心理学、视觉反馈系统等多个技术层面。它是移动健康类应用的基础模块,也是动画编程的一个优秀练习。

Logo

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

更多推荐