.repeat(reverse: true) 是 Flutter 动画中最被低估的方法之一。本文拆解 E-Brufen 白噪音脉冲动画的完整实现。

一、AnimationController 的四种运行模式

_controller.forward();          // 0→1,一次
_controller.reverse();          // 1→0,一次
_controller.repeat();           // 0→1→0→1→...,循环(跳回)
_controller.repeat(reverse: true); // 0→1→0→1→...,往返(平滑)
模式 动画路径 视觉感受
forward() 0 → 1 单次推进
repeat() 0→1→(跳回0)→1→… 不连续的"跳帧"感
repeat(reverse: true) 0→1→0→1→… 平滑的"呼吸"感

repeat() 的幕后:Ticker 与生命周期

repeat(reverse: true) 看起来只是简单地让动画来回跑,但它的内部机制值得了解。

每个 AnimationController 在创建时,会通过 vsync 参数注册一个 Ticker。Ticker 是 Flutter 引擎每帧回调的桥梁——每当引擎准备渲染新的一帧,Ticker 会收到回调,AnimationController 根据 elapsed 时间计算当前应该处于动画曲线的哪个位置。

当调用 repeat(reverse: true) 时,Controller 内部实际上是:

  1. 先调用 forward() 从当前值向 1.0 推进
  2. 监听 AnimationCompleted 状态(到达 upperBound)
  3. 自动切换方向,调用 reverse() 向 0.0 推进
  4. 到达 lowerBound 后,再次触发 forward()
  5. 循环往复

repeat()(不带 reverse)的区别是:到达 upperBound 后直接 reset() 跳回 lowerBound,再 forward()。这就是表格中提到的"跳帧感"的来源——动画值从 1.0 瞬间变为 0.0,视觉上产生了不连续的断裂。

理解这一机制后,你就能判断:凡是需要"来回摆荡"的效果(呼吸、心跳、潮汐),用 repeat(reverse: true);凡是需要"单向循环"的效果(进度条无限滚动、loading 旋转),用 repeat()

一个重要的生命周期细节:repeat() 返回的是一个 TickerFuture,但与 forward() 不同,这个 Future 永远不会 complete——除非你主动调用 stop()dispose()。这意味着你不能用 await controller.repeat() 来等待动画结束,因为它本就没有"结束"的概念。

★ Insight ─────────────────────────────────────
在 Widget 的 initState() 中创建 AnimationController 后,不要立即调用 repeat()。Flutter 的渲染管线在首帧之前会有一个初始化阶段——如果 Controller 在首帧渲染前就开始 tick,可能导致首帧的动画值计算异常(例如跳过一个较大的 delta)。推荐在 addPostFrameCallback 中启动循环动画,或者在 didChangeDependencies 中启动。
─────────────────────────────────────────────────

二、白噪音脉冲配置

// soundscape_page.dart
_pulse = AnimationController(
  vsync: this,
  duration: const Duration(seconds: 2),  // 2 秒一个完整周期
);

void _togglePlay() {
  if (_isPlaying) {
    _pulse.repeat(reverse: true);  // 启动往返循环
  } else {
    _pulse.stop();
    _pulse.reset();               // 回到初始位置
  }
}

★ Insight ─────────────────────────────────────
.stop() 后调用 .reset() 很重要。.stop() 只是停止动画更新,但 value 保持在停止时的位置(可能是 0.73)。.reset() 将 value 重置为 0(lowerBound)。下一次 .repeat() 从 0 开始,而不是从 0.73 突然跳到 0——避免视觉上的"跳闪"。
─────────────────────────────────────────────────

三、2 秒周期的人因设计

duration: const Duration(seconds: 2)

为什么是 2 秒?

  • 成年人静息呼吸频率约为 12-20 次/分钟
  • 2 秒周期 = 15 次/分钟 ≈ 缓慢呼吸(放松状态)
  • 比正常呼吸稍慢,引导用户放慢呼吸节奏

如果改成 1 秒 → 太急促(焦虑感);4 秒 → 太慢(沉闷感)。

时长选择的心理学维度

2 秒对应缓慢呼吸,但不同的应用场景需要不同的时长策略:

时长 频率 心理效应 适用场景
0.5-0.8s 75-120 bpm 紧迫、警觉、能量感 计时器倒计时、运动类 App
1.0-1.5s 40-60 bpm 轻快、愉悦、专注 音乐播放器可视化、加载动画
1.5-2.5s 24-40 bpm 平静、放松、呼吸感 冥想引导、白噪音、助眠
3.0-5.0s 12-20 bpm 深度放松、催眠感 深度冥想、瑜伽休息术

关键原则:脉冲频率直接影响用户的自主神经反应。快速脉冲(<1s)激活交感神经,提升警觉度;慢速脉冲(>2s)激活副交感神经,引导放松。E-Brufen 作为白噪音助眠应用,选择 2 秒的慢脉冲是功能驱动的决策,而非偶然。

如果你在做一个"番茄钟专注模式",1.2 秒的轻快脉冲可能比 2 秒的慢脉冲更合适——它不会让用户昏昏欲睡。如果你在做"紧急警报",0.6 秒的快速闪烁会天然地引发用户的注意力和紧张感。

更进一步,你可以在运行时动态调整时长:

void _setBreathingRate(double breathsPerMinute) {
  _pulse.duration = Duration(
    milliseconds: (60000 / breathsPerMinute).round(),
  );
}

这样可以根据用户的实时状态(心率数据、冥想阶段)来调节动画节奏,让动画真正"响应式"。

四、脉冲值驱动三重复合动画

AnimatedBuilder(
  animation: _pulse,
  builder: (context, _) {
    return Container(
      // 大小:120 → 170 (0% → 100%)
      width: 120 + _pulse.value * 50,
      height: 120 + _pulse.value * 50,
      decoration: BoxDecoration(
        shape: BoxShape.circle,
        // 不透明度:0.25 → 0.45
        color: scene.color.withValues(
          alpha: 0.25 + _pulse.value * 0.2),
        // 光晕模糊:20 → 50
        boxShadow: [BoxShadow(
          color: scene.color.withValues(alpha: 0.3),
          blurRadius: 20 + _pulse.value * 30,
          spreadRadius: 5,
        )],
      ),
    );
  },
)

一个动画值驱动三个视觉属性:

_pulse.value 0.0 (呼气) 0.5 1.0 (吸气)
直径 120px 145px 170px
不透明度 0.25 0.35 0.45
光晕 20px 35px 50px

三条属性曲线完全同步——因为它们都来源于同一段动画。这种"单信号多输出"的设计确保了动画的一致性。

用 Curves 塑造脉冲的"质感"

默认情况下,repeat(reverse: true) 的动画值是线性变化的——从 0 到 1 再到 0,速度均匀。但真实的呼吸(或任何自然节律)并不是线性的:吸气和呼气的速度不同,在顶点和底点通常有短暂的停顿感。

通过 CurvedAnimation 包装,可以为脉冲赋予不同的"质感":

_curvedPulse = CurvedAnimation(
  parent: _pulse,
  curve: Curves.easeInOut,  // 两端慢、中间快——模拟自然呼吸的"停顿→加速→减速→停顿"
);

以下是不同 Curve 对脉冲动画的视觉影响:

Curve 动画特征 感觉
Curves.linear 匀速 机械、人工
Curves.easeInOut 两端慢中间快 自然、有机、呼吸感
Curves.easeInSine 逐渐加速 轻柔的"吸入"感
Curves.easeOutSine 逐渐减速 轻柔的"呼出"感
Curves.elasticOut 末尾弹性回弹 俏皮、轻快

对于白噪音脉冲,easeInOut 是最自然的选择——它模拟了呼吸时胸腔在极限位置的自然减速。但不要盲目使用:如果你在做"心跳"动画,easeOut 可能更合适(快速收缩、缓慢舒张);如果做"水波纹",easeInOut 可以模拟波纹的能量衰减。

★ Insight ─────────────────────────────────────
当使用 repeat(reverse: true) 时,CurvedAnimation 在 reverse 阶段的曲线是正向曲线的镜像。例如 easeInOut 的 reverse 行为就是先 easeOut(从 1 出发减速)再 easeIn(接近 0 时加速)。这个行为是自动的,不需要手动处理。但如果你想要反向阶段使用完全不同的曲线,你需要自定义 Animation 子类或使用 TrainHoppingAnimation
─────────────────────────────────────────────────

多重 AnimationController 构建层叠动画

单一的脉冲能产生呼吸感,但复杂的视觉体验往往需要多层动画叠加。E-Brufen 目前使用一个 Controller 同时驱动大小、透明度、光晕——这保证了同步,但牺牲了层次感。

如果你想让"光晕"比"呼吸"慢半拍,形成一种"余韵"效果,可以引入第二个 Controller:

// 主脉冲:2 秒周期(呼吸节奏)
_breathController = AnimationController(
  vsync: this,
  duration: const Duration(seconds: 2),
);

// 光晕脉冲:3.5 秒周期(缓慢扩散/收缩)
_glowController = AnimationController(
  vsync: this,
  duration: const Duration(milliseconds: 3500),
);

// 启动时
_breathController.repeat(reverse: true);
_glowController.repeat(reverse: true);

在 AnimatedBuilder 中同时监听两个动画:

AnimatedBuilder(
  animation: Listenable.merge([_breathController, _glowController]),
  builder: (context, _) {
    return Container(
      width: 120 + _breathController.value * 50,
      height: 120 + _breathController.value * 50,
      decoration: BoxDecoration(
        shape: BoxShape.circle,
        color: scene.color.withValues(
          alpha: 0.25 + _breathController.value * 0.2),
        boxShadow: [BoxShadow(
          color: scene.color.withValues(alpha: 0.3 * _glowController.value),
          blurRadius: 20 + _glowController.value * 40,  // 由光晕 Controller 驱动
          spreadRadius: 5 + _glowController.value * 10,
        )],
      ),
    );
  },
)

这产生了一种"呼吸球在呼吸,光晕在更慢地弥散"的复合效果——两个不同频率的节律交织在一起,视觉层次更丰富。

关键技巧是用 Listenable.merge() 将多个 Controller 合并为一个 Listenable,这样一次 AnimatedBuilder 就能响应所有动画的变化。注意 Listenable.merge 接受的是 Listenable 列表,而 AnimationController 实现了 Listenable 接口,因此可以直接传入。

五、播放状态下的条件渲染

boxShadow: _isPlaying
  ? [BoxShadow(...)]  // 播放时:脉冲光晕
  : [],               // 停止时:无光晕

停止播放时球仍然可见(颜色 alpha 从 0.25 降为 0.08),但光晕完全消失——视觉上"暗淡下来",暗示非活跃状态。

动画性能排查与 60fps 保障

脉冲动画对流畅度极为敏感——任何掉帧都会破坏"呼吸感",让用户潜意识感到不适。以下是几个关键的排查维度:

1. 确认你跑的是 Profile 模式,不是 Debug 模式

Debug 模式下 Flutter 会做大量断言检查和热重载支持,动画性能可能只有 Profile 模式的 30%-50%。用以下命令在真机上跑 Profile:

flutter run --profile

2. 使用 Performance Overlay 定位瓶颈

在 MaterialApp 中临时开启:

MaterialApp(
  showPerformanceOverlay: true,
  // ...
)

关注顶部的 GPU 柱状图(绿色为正常,红色为超 16ms)。如果红线频繁出现,说明有帧超过了 16ms 预算。

3. 避免在 AnimatedBuilder 的 builder 中创建复杂对象

上面第四节的 builder 中,BoxShadowColor.withValues() 每帧都会创建新对象——这在 60fps 下每秒产生 60 个 Color 对象和 60 个 BoxShadow 对象。虽然 Dart 的垃圾回收很快,但在低端设备上仍可能触发 GC 暂停。

优化策略是将能提取的计算提取到外部,或使用 Tween 预处理:

// 不好的做法:每帧新建
color: scene.color.withValues(alpha: 0.25 + _pulse.value * 0.2),

// 更好的做法:用 Tween 预处理
final _opacityTween = Tween<double>(begin: 0.25, end: 0.45);
// 在 builder 中:
color: scene.color.withValues(alpha: _opacityTween.transform(_pulse.value)),

Tween.transform() 是一个简单的数学运算,不创建额外对象,比 builder 中的手动计算更高效,也更容易维护。

4. 善用 RepaintBoundary 隔离重绘区域

如果脉冲球是页面中的一部分,而页面其他部分(如控制按钮、曲目列表)不需要每帧重绘,用 RepaintBoundary 包裹脉冲球:

RepaintBoundary(
  child: AnimatedBuilder(
    animation: _pulse,
    builder: (context, _) {
      return PulseCircle(pulseValue: _pulse.value);
    },
  ),
)

这样 Flutter 引擎会把脉冲球的绘制缓存到一个独立的 layer 中,其他部分不参与重绘。在复杂的页面布局中,这可以显著降低 GPU 负载。

5. 检查 vsync 的 TickerProvider 是否正确

AnimationController 的 vsync 参数应该使用 SingleTickerProviderStateMixin(单 Controller 时)或 TickerProviderStateMixin(多 Controller 时)。确保你的 State 类正确 mixin 了这些类型。错误的 vsync 配置可能导致 tick 不同步,产生微妙的视觉抖动。另外,永远不要在 StatelessWidget 中创建 AnimationController——它必须有 State 来管理生命周期。

六、资源释放


void dispose() {
  _pulse.dispose();         // AnimationController
  _countdown?.cancel();     // Timer
  super.dispose();
}

拓展应用:心跳双脉冲动画

掌握了上述技术后,让我们实现一个更复杂的模式——心跳。生理上的心跳是"lub-dub"双脉冲:一次强收缩(lub)紧跟着一次弱收缩(dub),然后是一个较长的间歇。

class HeartbeatAnimation extends StatefulWidget {
  
  _HeartbeatAnimationState createState() => _HeartbeatAnimationState();
}

class _HeartbeatAnimationState extends State<HeartbeatAnimation>
    with SingleTickerProviderStateMixin {
  late AnimationController _heartController;
  late Animation<double> _heartSequence;

  
  void initState() {
    super.initState();
    _heartController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 1000), // 1秒一个完整心跳周期
    );

    // 使用 TweenSequence 定义双脉冲节奏
    _heartSequence = TweenSequence<double>([
      // lub: 0.0 → 1.0,占周期的 15%
      TweenSequenceItem<double>(
        tween: Tween(begin: 0.0, end: 1.0)
            .chain(CurveTween(curve: Curves.easeOut)),
        weight: 15,
      ),
      // lub 回落: 1.0 → 0.3,占 10%
      TweenSequenceItem<double>(
        tween: Tween(begin: 1.0, end: 0.3)
            .chain(CurveTween(curve: Curves.easeIn)),
        weight: 10,
      ),
      // dub: 0.3 → 0.8,占 10%
      TweenSequenceItem<double>(
        tween: Tween(begin: 0.3, end: 0.8)
            .chain(CurveTween(curve: Curves.easeOut)),
        weight: 10,
      ),
      // dub 回落 + 间歇: 0.8 → 0.0,占 65%
      TweenSequenceItem<double>(
        tween: Tween(begin: 0.8, end: 0.0)
            .chain(CurveTween(curve: Curves.easeInOut)),
        weight: 65,
      ),
    ]).animate(_heartController);

    _heartController.repeat();
  }

  
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _heartSequence,
      builder: (context, child) {
        final scale = 1.0 + _heartSequence.value * 0.3; // 最大放大 30%
        return Transform.scale(
          scale: scale,
          child: child,
        );
      },
      child: const Icon(Icons.favorite, color: Colors.red, size: 80),
    );
  }

  
  void dispose() {
    _heartController.dispose();
    super.dispose();
  }
}

TweenSequence 是关键——它将一个动画周期切分为多个阶段,每个阶段有独立的 Tween 和权重。权重决定了该阶段占总时长的比例。上面的配置创建了一个"强→弱→间歇"的典型心跳节奏。

这里用的是 repeat() 而不是 repeat(reverse: true),因为 TweenSequence 本身已经定义了完整的 0→1 循环——"心跳"不是来回摆荡,而是一次次重复同一段节奏模式。

你还可以将心跳效果与前面的呼吸脉冲结合:在呼吸页面的某个角落放一个心跳图标,用不同的频率和节奏来增强页面的"生命力"。两个 Controller 可以完全独立运行,互不干扰,为页面营造出"呼吸 + 心跳"的双重生命感。

小结

.repeat(reverse: true) + 2 秒周期 + 三重复合动画 = E-Brufen 的脉冲呼吸感。从底层 Ticker 机制到上层时长心理学,从单一 Controller 到多重层叠动画,从线性曲线到 TweenSequence 编排——AnimationController 是 Flutter 动画体系中最核心的类,而 repeat(reverse: true) 是让它"活起来"的关键方法。理解这些技术之后,你不仅能实现 E-Brufen 同款呼吸脉冲,还能在心跳动画、波纹扩散、潮汐节律等任何需要"周期性生命感"的场景中游刃有余。


作者简介:E-Brufen Dev,Flutter & 鸿蒙开发者,专注于跨平台移动应用开发与心理健康数字化,项目地址:AtomGit - E-Brufen

Logo

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

更多推荐