一个呼吸训练 App 的动画系统——为什么三角函数比 Spring 动画更适合呼吸节奏

5 阅读6分钟

起因

去年做了一款呼吸训练 App「呼吸视界」,核心交互就是用户跟着屏幕上的动画节奏来呼吸。听起来简单,动画这块我原本预计一周搞定,实际迭代了三周才觉得"对味"。

说白了,呼吸训练 App 的动画不是装饰,它就是产品本身。用户盯着屏幕呼吸,动画稍微卡一下或者节奏不对,整个体验就废了。

需求拆解:呼吸动画到底要表达什么

拿最经典的 4-7-8 呼吸法举例:吸气 4 秒、屏息 7 秒、呼气 8 秒。用户需要一个视觉锚点来跟随节奏,我最终选了圆形缩放 + 相位指示器的方案。

几个关键约束:

  • 动画帧率必须稳定,掉帧会让用户呼吸节奏乱掉
  • 不同呼吸法的时间配比完全不同(盒式呼吸是 4-4-4-4,4-7-8 总共 19 秒一个周期)
  • 相位切换(吸气→屏息→呼气)要平滑,不能有突兀的跳变
  • 动画时长必须精确等于 phase 时长,误差不能超过一帧

最后这条是我放弃 Spring 动画和贝塞尔 timing function 的根本原因,后面细说。

动画状态机设计

我把每个呼吸周期抽象成一个状态机,每个相位(phase)对应一段动画曲线。

enum BreathPhase: String {
    case inhale
    case holdIn
    case exhale
    case holdOut
    
    func duration(for pattern: BreathPattern) -> TimeInterval {
        switch (self, pattern) {
        case (.inhale, .fourSevenEight): return 4.0
        case (.holdIn, .fourSevenEight): return 7.0
        case (.exhale, .fourSevenEight): return 8.0
        case (.inhale, .boxBreathing): return 4.0
        case (.holdIn, .boxBreathing): return 4.0
        case (.exhale, .boxBreathing): return 4.0
        case (.holdOut, .boxBreathing): return 4.0
        default: return 4.0
        }
    }
}

每个 phase 驱动一个 CADisplayLink 回调,在回调里根据已经过的时间比例(progress 0→1)计算当前圆的半径。

三角函数 vs Spring 动画 vs 贝塞尔——我怎么选的

这块折腾最久。我的选择路径是这样的:

第一版:UIView.animate + 贝塞尔 timing function

CAMediaTimingFunction(controlPoints:) 自定义曲线,视觉上能做到 ease-in-out。问题是什么?phase 之间串联时,动画完成回调(completion block)的触发时机有微小延迟,一个周期下来累积误差能到 0.2-0.3 秒。4-7-8 呼吸法一个周期 19 秒,三个周期后节奏就明显偏了。

第二版:Spring 动画

想着 Spring 更"有机",试了 UIView.animate(withDuration:delay:usingSpringWithDamping:)。问题更大——Spring 动画的实际完成时间取决于阻尼比和初速度,duration 参数只是"建议时长",系统可能多跑几百毫秒才真正 settle。呼吸训练里,呼气说好 8 秒就得是 8 秒,多 0.5 秒用户会觉得"这一口气怎么还没结束"。

第三版:CADisplayLink + cos 函数手动插值

最终方案。用 (1 - cos(progress * .pi)) / 2 做 ease-in-out 映射,时间完全由我控制,progress 到 1.0 就立刻切 phase,零累积误差。

@objc func tick(_ link: CADisplayLink) {
    let elapsed = CACurrentMediaTime() - phaseStartTime
    let duration = currentPhase.duration(for: currentPattern)
    let rawProgress = min(elapsed / duration, 1.0)
    
    // cos 映射:两头慢中间快,模拟呼吸肌肉发力节奏
    let eased = (1.0 - cos(rawProgress * .pi)) / 2.0
    
    let scale = currentPhase == .exhale
        ? 1.0 - eased * 0.5
        : 0.5 + eased * 0.5
    
    circleView.transform = CGAffineTransform(scaleX: scale, y: scale)
    
    if rawProgress >= 1.0 { advanceToNextPhase() }
}

为什么 cos 变换在体感上比线性插值好?线性的圆形缩放看起来像"机器在匀速运动",而 cos ease-in-out 两头慢中间快,刚好对应真实呼吸的感觉——开始吸气时肺部从空变满比较费力所以慢,中段顺畅所以快,接近满肺时又变慢。呼气同理。

我还试过 sin(progress * π/2) 做 ease-out,单独用在吸气阶段行,但呼气用同一曲线就会让人觉得"突然一口气吐出来然后慢慢收尾"。统一用 cos ease-in-out 两个方向都舒服。

模糊光晕层的预渲染方案

圆形缩放只是基础层。外围叠了一层高斯模糊光晕,跟随缩放但延迟 0.1 秒,产生"呼吸拖尾"的感觉。

一开始用实时 UIVisualEffectView + 动态 blur radius,GPU 占用直接飙到 40%。一次练习 5-10 分钟,手机明显发烫。

后来改成预渲染方案:启动时根据当前主题色,用 Core Graphics 离屏绘制一张带高斯模糊的圆形光晕图(尺寸固定 256x256 pt,@2x 就是 512px),存成 UIImage。运行时这个光晕层就是一个普通 UIImageView,只做 transform 缩放,GPU 开销几乎为零。

缩放策略是:光晕层的 scale 比主圆大 1.3 倍,延迟用一个简单的指数平滑——glowScale += (targetScale * 1.3 - glowScale) * 0.08,每帧逼近目标值但永远有点滞后,产生柔和拖尾。

代价是光晕的模糊半径不能动态变化(因为是预渲染的静态图),但对呼吸动画来说够了——用户注意力在中心圆上,外围光晕只是氛围补充。

课程进度系统和动画的关系

App 里有结构化课程(比如"21 天入门"),每节课的呼吸模式、时长、重复次数都不同。动画系统需要根据课程配置动态调整参数,我用了一个 ProgramProgressRecord 来追踪用户走到哪一步,动画控制器在每次 session 开始时读取当前课程节点的配置。

这样动画逻辑和课程逻辑解耦——动画只关心"给我一个 phase 序列和对应时长",不关心这个序列从哪来。

几个踩过的坑

1. 后台恢复问题

用户练习时来了个通知切到后台,回来后 CADisplayLink 的时间戳跳了一大截。我的处理是:检测到 elapsed > duration * 1.5 时,直接 snap 到当前 phase 结束位置,重新开始下一 phase。这样不会出现"回来后动画疯狂追帧"的情况,但代价是用户会丢失这段时间的训练数据。目前没想到更好的办法。

2. 屏幕常亮

呼吸训练必须保持屏幕常亮,但 isIdleTimerDisabled = true 在 session 结束后忘记关闭的话,用户手机会一直亮着费电。我在 viewWillDisappear 和 session 完成回调里都做了复位。

3. 触觉反馈的时机

每次 phase 切换时加了轻触觉反馈(UIImpactFeedbackGenerator),提醒用户"该换气了"。但如果用户把手机放桌上跟着练,震动声反而是干扰。最后做成可选项,默认开启但可以在设置里关掉。

数据表现

App Store 评分 5 分(满分),目前大概 50+ 条评价。从我自己埋点看,用户平均单次使用时长在 4 分钟左右,说明大部分人至少能跟完一个完整的呼吸周期。7 日回访率 35% 左右,对一个呼吸训练工具来说我觉得还行。

有条评论说"可以跟随练习呼吸,保持稳定的心情"——这验证了动画引导体验的基本面:用户能跟得上节奏,不会觉得动画和自己的呼吸"对不上"。

回头看

做这种"看起来简单"的动画产品,80% 的工作量在打磨细节。圆形缩放谁都会写,但要让它"像呼吸"而不是"像机器在动",需要反复调缓动曲线、调时间、调视觉权重。

我自己反复测试的方式就是:坐在那里跟着动画呼吸 5 分钟,身体会告诉你哪里不对——比看代码有用得多。

有个问题想问同行:你们在 CADisplayLink 驱动的动画里处理后台恢复有更优雅的方案吗?我现在的"超时直接跳过"方案能用但总觉得粗暴了点,尤其是用户只是切出去看了一眼通知就回来的场景。