AtomGit Flutter 鸿蒙客户端:AnimationController.repeat 与视觉呼吸感
.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 内部实际上是:
- 先调用
forward()从当前值向 1.0 推进 - 监听
AnimationCompleted状态(到达 upperBound) - 自动切换方向,调用
reverse()向 0.0 推进 - 到达 lowerBound 后,再次触发
forward() - 循环往复
而 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 中,BoxShadow 和 Color.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。
更多推荐



所有评论(0)