前两周帮同事review一个弹窗动画,他把新产品引导里的“弹性进入”换成了纯线性,理由是“弹一下太吵,而且我调不好参数”。我问他有没有调过阻尼比,他愣了一下。这种情形在做UI动效的时候太常见了:大家每天都在跟 duration、scale、opacity、timing function 打交道,可真要解释“为什么减速曲线更自然”“为什么弹簧会过冲”“为什么透明视频在部分手机上出现黑边”,大多数人只能“调一调、试一试”。
把这些问题拍扁了看,底层都是一些不算高深的数学:一个从 0 到 1 的归一化时间 t,一条三次贝塞尔曲线,一个阻尼振动方程,一组对顺序敏感的变换矩阵,甚至是一条像素混合公式。这篇就顺着 UI 动效里最常见的几个场景,把背后的数学原理逐个拆开。适合前端、客户端开发,也适合想深入理解动效逻辑的 UI/UX 设计师——看完至少能明白:每次“随手一调”,到底在调什么,以及为什么这样调是有效的。
1. 时间轴上的那个t:动效世界的基本货币
1.1 从一条最朴素的插值公式开始
所有时间驱动型动效,不管做出来多复杂,本质都在做同一件事:让某个数值从起点状态,在指定时长内逐步移动到终点状态。这个“逐步”必须被一个时间参数驱动,通常记作 t,并且被归一化到 0 和 1 之间:
// startTime 是动画开始时刻,now 是当前时刻 // duration 是动画总时长 let t = (now - startTime) / duration; // 防止浏览器掉帧导致 t 超过 1 t = Math.min(Math.max(t, 0), 1);拿到 t 之后,把任意可动属性做线性映射:
function lerp(from, to, t) { return from + (to - from) * t; }位置可以用 lerp,透明度可以用 lerp,颜色可以用 lerp——颜色的每个通道 R、G、B、A 各自插值一次,再拼回去。一个简单的淡入淡出、位移动画,核心代码就这么短。之所以强调 t 是“基本货币”,是因为后面所有高级动效——贝塞尔缓动、弹簧、圆周运动、路径动画——都建立在“对 t 做二次加工”这件事上。
很多人写动画时会忽略 clamp,这是一个常见的隐患。在一台性能忽高忽低的安卓设备上,帧率抖动导致 now 远远落后于预期,如果 t 没被截断在 1 以内,动画会直接冲到“目标值之外十几帧”的位置再跳回来,表现就是界面闪一下。动画结束时也必须手动回调 onDone,不能依赖“t 大于 1 就什么都不做”。
1.2 直接用线性插值,为什么总觉得“像机器”
线性 lerp 意味着属性在整段时间里匀速变化:每帧前进一样的步幅。可现实世界几乎没有匀速运动,一个物体从静止到运动,必然要先加速;从运动到停止,必然要减速。一个 toast 从屏幕底部滑上来,如果全程匀速,视觉上就像仓库里的液压杆,冷冰冰的。
所以动画领域引入了 easing 的概念:不是直接用 t 去插值,而是先把 t 映射成另一个值:
const easedT = easing(t); onUpdate(lerp(from, to, easedT));这样位移曲线的“密度”就可以随时间变化。最常用的 easeOut 让 t 越接近 1,easedT 增长越慢,效果就是开头快速冲出去、结尾慢慢停稳。人眼对这种模式非常熟悉,因为它符合有摩擦的真实世界。反过来,easeIn 适合元素“被推出去”的场景,比如底部抽屉从屏幕外被快速推进来,先慢后快更有“被外力作用”的感觉。
1.3 duration 的选择本身也是一个数学问题
duration 直接决定每帧 t 的步长。60Hz 刷新率下每帧间隔约 16.7ms,如果动画时长是 200ms,每帧 t 约跳 0.083,人眼能感知到明显的分段;如果动画时长是 800ms,每帧只跳 0.021,动画细腻,但对渲染稳定性更敏感——任何一帧卡顿,都会因为“期望位移与实际位移的差值”被放大而被感知成抖动。
所以我的经验是:小于 100ms 的动画不适合承载信息变化,只适合做“按钮按下”这种即时反馈;100~300ms 适合小元素位移和透明度变化;400~800ms 适合页面级转场;再长就要非常克制,否则用户会认为系统变慢了。现在很多设计系统里把这个写成了规范,比如 Material Design 的 duration 档位,本质上就是拿数学上 t 的步长和用户感知做了权衡。
2. 缓动函数不是玄学:多项式、贝塞尔和那点过冲
2.1 easeOutQuad 这类名字里藏着物理含义
Robert Penner 那套经典缓动函数,到今天依然统治着绝大多数代码库。它们表面上是一堆公式,实际里每一项都有运动学含义。比如你可能背过 easeInQuad 是t * t,easeOutQuad 是1 - (1 - t) * (1 - t),可你想过为什么t²会有“加速感”吗?
把position = t²对时间求导,速度是2t,即速度随时间线性增加;再求导,加速度是个常数 2。换句话说,easeInQuad 模拟的是匀加速直线运动。easeOutQuad 则相反,开始速度大,随后均匀减速,模拟的是“带摩擦滑到目标点停下”的状态。速度和加速度的概念,在调 UI 动效时很少被直接提起,但每一个缓动曲线其实都隐式定义了速度和加速度曲线。这解释了为什么有些动画“位置看着没问题,但总觉得一顿一顿”——大概率是速度曲线有突变,加速度在某帧突然变为无穷大。
常用的缓动函数和感觉可以对照看:
| 函数 | 公式 | 体感 |
|---|---|---|
| linear | t | 匀速,机器感,适合旋转类循环动画 |
| easeInQuad | t² | 从静止匀加速启动,适合被弹出的抽屉 |
| easeOutQuad | 1-(1-t)² | 快速启动再减速停稳,引导类动画首选 |
| easeInOutCubic | t<0.5 ? 4t³ : 1-(-2t+2)³/2 | 柔和的缓入缓出,页面转场常用 |
实际项目里我建议不要背公式,而是记住三条规律:幂次越高,缓入缓出的“曲率”越明显;easeOut 是 UI 里用得最多的,因为绝大多数元素是“进入”或“回复”而不是“被推走”;easeInOut 适合两个静止状态之间的过渡,比如 Tab 切换后的卡片重排。
2.2 设计师和工程师共用的一套语言:三次贝塞尔
设计师丢给你一个“弹一下”的动效,不会给你公式,而是给一段cubic-bezier(0.34, 1.56, 0.64, 1)。这条曲线背后的数学是三次贝塞尔:
B(t) = (1-t)³P0 + 3(1-t)²tP1 + 3(1-t)t²P2 + t³P3
在 CSS 里,P0 固定为 (0,0),P3 固定为 (1,1),真正可调的是中间两个控制点 P1 和 P2。横轴代表时间,纵轴代表进度。为了动画在时间上不倒退,CSS 规范限制控制点的 x 必须在 0 到 1 之间;但 y 不受限,可以大于 1,也可以小于 0。当 y 大于 1,意味着动画进度先冲过目标值,再回落——这就是 over-shoot,也就是“弹一下”的来源。
所以在 css 里cubic-bezier(0.34, 1.56, 0.64, 1)控制点 P1 的 y 为 1.56,进度在中间某个时刻超过 100% 大约 10~15%,随后回落到 100%。这个“超过一点点再回来”的幅度,就是卡片按下去之后反弹效果的数学本质。
几个常见的标准曲线:
| 名称 | 等价 cubic-bezier | 说明 |
|---|---|---|
| ease | cubic-bezier(0.25, 0.1, 0.25, 1.0) | CSS 默认 |
| ease-out | cubic-bezier(0, 0, 0.58, 1) | 快速进入,平稳停止 |
| ease-in-out | cubic-bezier(0.42, 0, 0.58, 1) | 平滑过渡 |
| 强调弹跳 | cubic-bezier(0.34, 1.56, 0.64, 1) | 小幅度过冲 |
工程师拿到设计稿里的曲线,不要只看“长什么样”,要用可视化工具体验速度变化:横轴时间是均匀的,曲线越陡的位置,单位时间进度变化越大,也就是速度越快。同样的曲线,如果中间有一段几乎平坦,那一带就是“缓慢蠕动”,放在列表里会显得拖沓。
2.3 back、elastic、bounce:别让橡皮筋捅用户的眼睛
除了贝塞尔,还有三类特殊缓动:back 系列产生一次明显的过头回落;elastic 是指数衰减乘以正弦波,模拟弹簧阻尼振荡;bounce 是分段抛物线模拟球体落地反弹。三者都很有表现力,但也很容易滥用。
以 penner 的 easeOutBack 为例:
const c1 = 1.70158; const c3 = c1 + 1; return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);这个函数在 t=0 时为 0,t=1 时为 1,但中间会超过 1,从而在视觉上造成“冲过头再收回来”的效果。c1 越大,冲过头越多。默认的 1.70158 大概会产生 10% 的过冲,适合点赞、收藏这类需要强调的小反馈。elastic 和 bounce 则更适合图标强调、或者游戏化场景。正式界面上建议少用完整 elastic,因为多段大幅振荡会直接拉走用户注意力,而且如果是在一个高频出现的元素上,比如列表刷新图标,反复弹跳三四个周期,非常容易晕。
我现在的处理原则是:可打断的、高频出现的交互用小过冲甚至无过冲;需要短暂吸引注意的一次性反馈才允许 elastic/bounce。安全性上,任何情况下都不建议让动画产生超过 1.5 倍的缩放,容易造成亲密感缺失,甚至引发部分敏感用户的眩晕。
3. 物理型动效:弹簧背后的阻尼振动方程
3.1 easing 曲线做不出“可打断”的弹簧感
为什么说 duration + 贝塞尔曲线模拟弹簧是“假弹簧”?因为真弹簧的价值不在最终效果,而在过程中的可中断性。想象一个卡片被手指拖动,松手后回弹到中心,这中间系统必须感知“手指松开那一刻的位置和速度”,然后把它们当作初始条件继续积分。duration-based 缓动做不到这一点,因为它的 t 完全由开始时间和总时长决定,你没法中途灌入一个速度;强行在松开时重新播放曲线,就会出现位置跳变,也就是你看到的“闪一下”。
真正的物理弹簧,只认三个量:目标位置、当前速度、当前加速度。任意时刻被打断,它都能从当前状态继续计算。这就是为什么 iOS 的 SwiftUI、Android 的 SpringAnimation、以及 Framer Motion 里的 spring() 如今越来越流行——不是因为它曲线更“自然”,而是因为它能在任意事件点无缝接管。
3.2 阻尼比 ζ:决定过冲、临界和慢吞吞的关键
一维弹簧的动力学方程是典型的二阶常微分方程。如果把目标位置当作原点,用 x 表示当前相对目标的偏移,则:
m·x'' + c·x' + k·x = 0
其中 m 是质量,c 是阻尼系数,k 是刚度。为了工程上容易讨论,通常把它改写为:
x'' + 2ζω0·x' + ω0²·x = 0
这里的 ω0 = √(k/m),称为无阻尼自然角频率,决定“多快弹回”;ζ = c / (2√(mk)) 是无量纲的阻尼比,决定“怎么弹回”。方程的解按 ζ 分为三种形态:
| ζ 范围 | 行为 | UI 体感 |
|---|---|---|
| 0 < ζ < 1 | 欠阻尼,振荡过冲后收敛 | 弹性进入、回弹卡片 |
| ζ = 1 | 临界阻尼,最快无过冲收敛 | 列表项移入,干净利落 |
| ζ > 1 | 过阻尼,慢吞吞爬回目标 | 笨重,基本不用 |
欠阻尼解的形式是 x(t) = e^(-ζω0t)·(Acos(ωdt) + Bsin(ωdt)),其中 ωd = ω0√(1-ζ²)。指数项 e^(-ζω0t) 决定衰减速度,后面的正弦项决定振荡频率。这段话你可能已经还给大学物理了,但那些调参工具里的 dampingRatio 参数,本质就是在调 ζ。值越小,晃得越多;值越大,越早停住。0.8 会产生轻微过冲,1.0 几乎不过冲,0.4 以下就开始明显“橡皮筋”了。
3.3 把方程变成可运行的代码:半隐式欧拉
实际写代码时,不需要解解析解,直接用数值积分即可。最常用的是一阶半隐式欧拉——先更新速度,再更新位置:
let velocity = 0; let position = 1; // 初始偏移量,比如 1 表示距离目标 100% function springStep(dt, target, stiffness, damping) { // 加速度:刚度拉的回来,阻尼推得回去 const acceleration = -stiffness * (position - target) - damping * velocity; velocity += acceleration * dt; position += velocity * dt; return position; }每一步先算加速度,用加速度更新速度,再用新速度更新位置。这个顺序比“先更新位置再用旧速度”的显式欧拉稳定得多。具体来说,显式欧拉在步长稍大时会让系统不断累积能量,弹簧越弹越远;半隐式欧拉则不会那么夸张。想要进一步稳定,可以把一个大 dt(比如一帧 16.7ms)拆成 2 个 8.35ms 子步进,迭代两次再输出,效果会好很多。
3.4 数值稳定性:dt 一抖,弹簧就抖
物理动效调试中最隐蔽的坑是:动画在某些手机上抖、在开发者工具里好得很,原因是设备帧率不稳定导致 dt 一高一低。帧率 60 时 dt 约 16.7ms,掉到 30 时 dt 变 33ms,两倍的步长直接让积分结果偏差变大,表现就是弹簧在低帧设备上“像没阻尼一样乱颤”。
解决思路有三个方向:第一是 clamp dt,超过 50ms 就按 50ms 算,并且只更新一帧,而不是把跳帧的累计时间全部塞进下一步;第二是子步进,把大 dt 切成若干固定小步进,用 1/120s 作为基本步长,一帧 16.7ms 就走两步;第三是限制初始速度绝对值,避免卡片被“用力甩出”后从屏幕外飙回来时因为速度过大而过冲成神经系统。
实际项目中我会再加一个最大位移 clamp:弹簧动效允许短暂越过目标 10%~20%,但禁止越过 50% 以上。数学上这会让收敛曲线不再严格符合原方程,但每次都是可行的简化——UI 是设计的产品,不是物理仿真报告,你需要的只是“像弹簧”而不是“真的是弹簧”。
4. 圆与波:三角函数出场的场景比想象中多
4.1 一个公式描述所有重复运动
UI 里凡是“循环往复”的动效,几乎都可以用同一个模型描述:
y(t) = A·sin(2πf·t + φ) + y0
- A 是振幅,决定摆动或缩放的幅度;
- f 是频率,决定一个周期多快;
- φ 是相位,决定从哪个起点开始;
- y0 是基准值,决定摆动的中心位置。
呼吸灯、加载小点、脉冲按钮、提示气泡,全都是这个公式的变体。举个例子,一个按钮的“呼吸”效果可以写成:
opacity.value = 0.5 + 0.5 * Math.sin(2 * Math.PI * 0.8 * elapsedTime);0.5 是基准透明度,0.5 是振幅,频率 0.8Hz 表示约 1.25 秒呼吸一个完整周期。这样写出来的动画只有一行代码,但能保证无限循环、平滑可预测,而不是随便硬编码一堆关键帧。
4.2 相位 offset:做“错峰”动画最可靠的数学手段
设计师常要求“四个点依次弹跳”,不少人的第一反应是给每个点写一个独立的 setTimeout,每个延迟 100ms。但 setTimeout 回调受主线程 load 影响,三个定时器之间的真实间隔会慢慢漂移,动画执行几分钟后错峰就乱了。正确的做法是所有点共用同一个全局时钟,用相位差区分:
function dotOffset(i) { // 四个点均匀分布在 2π 相位上 const phase = (2 * Math.PI * i) / 4; return 0.6 + 0.6 * Math.sin(2 * Math.PI * 1.5 * t - phase); }加载指示器里四个点依次起伏,本质是同一个正弦波在四个不同相位上被采样。相位差 2π/4、2π/2、3π/2,视觉上就成了“波”在点与点之间推进。这个方法比设定不同 delay 更可靠,因为不管掉不掉帧,点与点之间的相对关系永远由同一时钟决定,不会累积误差。
4.3 路径动画:从直线插值升级到参数方程
很多高层次动效不再满足于“A 点平移到 B 点”,而是让元素沿弧线、圆环这样运动,比如一个按钮展开成弧形菜单、一个加载进度绕圆环填充。这些场景的底层也是三角函数。
圆上任意一点可以写成极坐标:x = cx + R·cosθ,y = cy + R·sinθ。只要让 θ 随归一化时间变化,就能得到圆周运动;让 θ 经过缓动函数处理,就能得到“加速旋转后暂停”的效果;让不同按钮的 θ 起始角错开,就能排出一圈扇形菜单。
路径类动效里另有一个高频题是“沿路径画线”:SVG 线条被绘制出来。实现时计算整条路径的周长 L,然后用 stroke-dasharray 和 stroke-dashoffset 控制显示长度:
progress = easedT; // 0 到 1 ctx.strokeDasharray = `${L * progress} ${L}`; ctx.strokeDashoffset = L * (1 - progress);这里的 progress 同样可以先过一遍缓动、甚至弹簧,让“画线”也有手感。路径本身如果是贝塞尔曲线,则本质上是在多个控制点坐标之间做插值,原理和前面讲过的贝塞尔完全一致,只是参数从“时间 vs 进度”变成了“路径坐标”。
5. 像素上的数学:Alpha 混合与透明视频的兼容性
5.1 为什么 Android 礼物动效要把 RGB 和 Alpha 拆成两路视频
UI 动效不只是“时间轴上的 t”。在安卓直播场景里,礼物动效常要求高性能播放带透明通道的动画,比如一个超人从屏幕右侧飞过,最好还是 30fps、带特效的复杂视频。普通视频编码标准(H.264/HEVC)没有透明通道的概念,所以腾讯开源的 VAP 这类方案就把透明信息拆出来单独存成一个灰度视频:一路是 RGB 彩色画面,另一路是 Alpha 通道。播放时 GPU 拿到两路帧,逐像素做 alpha 混合。
这套方案的数学基础只有一个:像素叠加公式。
5.2 src-over 的混合公式,以及 premultiplied 的坑
把一张半透明贴纸叠加到画面上,最常见的叠加方式是 src-over,即目标色 = 源色 × 源Alpha + 背景色 × (1 - 源Alpha):
outColor = srcColor · srcAlpha + dstColor · (1 - srcAlpha) outAlpha = srcAlpha + dstAlpha · (1 - srcAlpha)
这条公式本身不难,难在设备实现上。OpenGL 里做 alpha 混合时,能否直接套这个公式,取决于纹理里的 RGB 分量是否已经“预乘过 Alpha”。如果纹理是 straight alpha,即每个像素的 RGB 还没乘 alpha,那么混合函数应该设置成:
// straight alpha:源颜色在片元里是未预乘的,混合单元需要帮你乘一次 glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);如果纹理已经预乘,RGB 等于原色乘以 alpha,公式可以省掉一次乘法:
// premultiplied alpha:RGB 已经乘过 Alpha,混合单元直接加就行 glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA);很多 VAP 兼容性问题的根源就是这里。在 A 手机上,视频解码输出的是 straight alpha 纹理,在 B 手机上,引擎预处理成了 premultiplied,如果你用同一份 glBlendFunc 去处理,就会看到边缘出现一圈黑边或者白边。黑边的原因是 straight alpha 的 RGB 没有乘 alpha,导致混合后边缘多出了半透明的暗色;白边则常见于 premultiplied 纹理被当作 straight alpha 处理,边缘被过度抬亮。
5.3 选型不只看热闹,还要看像素成本和兼容策略
用 RGB+Alpha 双视频流做礼物动效,好处是硬解码效率高,坏处是双流同步和纹理格式五花八门的设备兼容问题。出现黑边、掉帧、闪帧时,排查顺序基本是这样:
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| 边缘发黑 | 混合函数设置错误 / 未预乘 | 检查纹理是否预乘,切换 glBlendFunc |
| 边缘发白 | 预乘纹理被当直通纹理 | 统一预处理流程 |
| 掉帧 | 解码或像素拷贝超过了预算 | 切硬解;降分辨率;低端机走 CPU 合成 |
| 音画/双流不同步 | 时间戳对齐出问题 | 用同一时钟驱动两台解码器 |
从工程角度看,透明视频方案并不是无脑最优解。礼物动效时长通常 3~6 秒,帧率 30,一个 720×1280 的双流视频每帧要处理两张位图,内存开销不小。复杂方案在高端机上流畅,不代表入门机上也能抗住。一个稳妥的降级是:检测到设备不支持硬解或 GPU 混合时,把动画预渲染成序列帧或者 Lottie,屏幕空间尽量控制在 720p 以内,并降低帧率到 20fps——数学上,像素数量和混合次数是线性关系,这是最直接的优化杠杆。
6. 变换矩阵与一帧16.7ms:流畅度的底层账本
6.1 你写的每个 transform,都是矩阵乘法
前端写 CSS 做动效,随手就是transform: translate(20px, 40px) rotate(45deg) scale(1.2)。形式上它是三个函数的组合,底层其实是三个 3×3 矩阵依次相乘:
- 平移:[[1,0,tx],[0,1,ty],[0,0,1]]
- 旋转:[[cosθ, -sinθ, 0],[sinθ, cosθ, 0],[0,0,1]]
- 缩放:[[sx,0,0],[0,sy,0],[0,0,1]]
二维齐次坐标把 (x, y) 扩成 (x, y, 1),一次变换就是一次矩阵乘法。多个变换串联,就是多个矩阵相乘。这里有个新手几乎都踩过的坑:矩阵乘法不满足交换律,所以transform: translate(100px,0) scale(2)和transform: scale(2) translate(100px,0)的最终位置不一样。前者先移动再放大,位移也会被放大;后者先放大再移动,位移保持不变。从数学上理解这件事之后,就不用靠试错来猜了。
6.2 透视不是魔法,是除法
3D 变换里的 perspective 看着挺玄,其实就是把三维点投影到屏幕时做一个除法。CSS 里设置perspective: 800px,意思是视距 800px。一个三维点 (x, y, z) 投影后的屏幕坐标大约为:
screenX = x / (1 - z / 800) screenY = y / (1 - z / 800)
分母越小,缩放倍数越大。当元素某一部分的 z 接近甚至超过 800 时,画面会无限放大或者直接“穿模”。这就是为什么把 perspective 调得太小(比如 200)旋转卡片时会看到撕裂感——数学上就是很多像素除法的分母接近 0,结果在数值上爆发。做 3D 卡片翻转时,perspective 建议从视距 600~1000px 起步,再根据效果向下试。
6.3 动效成本预算:16.7ms 里没有多余的懒散
做高性能 UI 动效,心里要有一本 16.7ms 的账。60Hz 每帧预算大约是:
| 环节 | 预算参考 | 说明 |
|---|---|---|
| JavaScript | 4ms | 所有动画计算尽量少 |
| style / layout | 3ms | 改几何属性容易超支 |
| paint | 5ms | 大片绘制占大头 |
| composite | 2ms | GPU 合成 |
| 系统 vsync 等 | 剩余 | 喘息的余地 |
为什么强调改用 transform 而不是 left/top?因为改动 left/top 会触发 style、layout、paint 三个阶段,尤其是 layout 溢出后,整棵渲染树都要重排;而 transform 的多数情况只触发 composite,GPU 直接拿矩阵乘一下像素,把原有纹理摆到新位置,成本低一个量级。一次按钮位移,前者可能花掉 8ms 做布局,后者可能连 1ms 都用不到,差距就是这样累积成卡顿的。
另一个容易过度优化出反效果的是will-change: transform。它确实会为元素创建独立合成层,让变换不碰 render tree,但合成层的本质是一张纹理,每个都占显存。一个列表里 50 个元素全部加 will-change,等于 50 张纹理绑定在 GPU 上,低端机直接内存告急。正确做法是只在动画开始前加上,动画结束后的下一帧移除,或者干脆交给浏览器的自动优化,除非你能证明它确实需要。
把“感觉”翻译成数字之后,动效就好聊了
这两年我做动效养成的最大习惯是:永远盯着速度曲线看,而不是只看位移曲线。位移曲线圆润,不代表速度曲线平滑——有时候中间有一个速度尖峰,肉眼看就是“卡了一帧”。调任何动画,先用工具把曲线画出来,cubic-bezier.com、Framer Motion 的可视化页面、或者自己在 desmos 里敲一个方程都行,设计师说“要弹一点”,你就把过冲参数从 0 提到 0.1;说“硬一点”,就把阻尼比从 0.8 调到 0.95。这些对话一旦变成了曲线上的数字,评审和交付都会省很多力气。
真机验证也别只看裸眼,我习惯用手机慢动作录屏回放,240fps 的视频会把真实帧序列拉长四倍,掉帧和跳变一清二楚。搞懂 UI 动效背后的数学原理,不是写几个看起来很专业的公式,而是让你在做每一次“随手调一下”的时候,都知道自己在调什么。