react-native-ease 如何在没有 JavaScript 循环的情况下运行动画
- 原文链接:www.peterp.me/articles/ho…
- 原文作者:Peter Piekarczyk
AppAndFlow 团队发布了一个很出色的动画库 react-native-ease。下面我们会深入看它底层到底是怎么工作的。
大多数 React Native 动画库在底层的工作方式都差不多:由 JavaScript 定时器驱动一个值,这个值每一帧都通过 bridge(或 JSI)传递,然后由原生视图更新。它能工作,但动画保真度会受 JS 线程健康状况影响。你在处理列表时只要掉一帧,动画就会卡顿。
react-native-ease 采用了不同思路。一旦你把一次 prop 变更交给它,JavaScript 的工作就结束了。动画会完全在原生侧运行:iOS 用 Core Animation,Android 用 ObjectAnimator 和 SpringAnimation,每一帧都不需要 JS 参与。
下面是它实际的工作方式。
JS 层只做一件事:拍平 props
EaseView 组件是一个纯渲染函数。它接收如下这种结构化 props:
<EaseView
animate={{ opacity: 0.5, scale: 1.2 }}
transition={{ type: 'spring', damping: 15, stiffness: 120 }}
/>
然后把它们拍平成单独的标量原生 props:
animateOpacity=0.5
animateScaleX=1.2
animateScaleY=1.2
transitions.defaultConfig = { type:"spring", damping:15, stiffness:120, ... }
这里没有 useEffect,没有 useRef,也没有动画状态。它只是一个 prop 解析器。真正有意思的是 bitmask。
Bitmask
React Native 的 codegen 不能很好地支持可空基础类型。你不能给 Float 类型 prop 传 null。所以当你的 animate 里没有 opacity 时,JS 仍然必须给 animateOpacity 传点什么。它会传恒等值:1.0。
但这时原生侧会遇到一个问题:它无法判断 animateOpacity: 1.0 到底是「用户真的要把 opacity 动画到 1」,还是「用户根本没动画 opacity,这只是默认值」。两者看起来完全一样。
解决方案是额外增加一个 Int32 prop,叫作 animatedProperties,其中每一位 bit 对应一个属性:
bit 0 = opacity
bit 1 = translateX
bit 2 = translateY
bit 3 = scaleX
...
如果 opacity 出现在你的 animate prop 里,就会置位 bit 0。原生侧在处理 opacity 之前,会先检查 mask & kMaskOpacity。如果该 bit 没开,原生侧就会完全忽略这个值,并让 React Native 的常规 style 系统接管它。这样两套系统就能在同一个 view 上并存而不冲突。
iOS:Core Animation 的 key-path 动画
iOS 原生 view(EaseView.mm)是一个 Fabric RCTViewComponentView。它的主要入口是 updateProps:oldProps:,每次 JS 传来新 props 时,Fabric 都会调用它。
模型层 vs. 表现层
Core Animation 会为每个 layer 维护两份并行副本。模型层(model layer)是你在代码里设置的值,始终保存最终目标值。表现层(presentation layer)是动画过程中屏幕上真实显示的值。它们是两个不同对象。
这个区别对「平滑中断」非常关键。如果用户在一个动画尚未结束时触发了新的动画,代码会从表现层读取当前可见位置:
fromValue:[self presentationValueForKeyPath:@"opacity"]
如果它改为从模型层读取,被中断的动画就会先跳到最终值,再从那里重启。读取表现层意味着新动画会从视图当前看起来所在的位置起步,因此中断会更丝滑。
为什么 transform 要拆成独立 key-path
所有 transform 属性(缩放、旋转、平移)理论上可以合并成一个 CATransform3D 矩阵并整体动画。代码明确没有这么做,而且理由很充分。
Core Animation 在插值矩阵时会先把矩阵反分解成各个分量。这个分解在某些场景里有歧义。比如从 0° 旋转到 360°,起点和终点得到的是同一个矩阵,于是 Core Animation 会认为没有变化而不做动画。再比如缩放和旋转混合后,分解结果会受操作顺序影响,导致视觉结果不稳定。
通过分别动画 "transform.rotation.z"、"transform.scale.x"、"transform.translation.x" 这类独立 key-path,每个分量都按纯标量插值。0 到 6.28 弧度就是明确的变化。这样动画就能在 layer 上正确组合,而不依赖矩阵分解。
首次挂载问题
Enter 动画(即 initialAnimate 与 animate 不同)不能在首次挂载时 updateProps: 被调用的瞬间立刻触发。因为在那个时刻,Fabric 还没完成 view 布局,frame 也还没定下来。在布局未稳定前就开启动画,会从错误坐标开始。
因此代码会延迟执行。它在 updateProps: 里先设置一个标记,然后等待 finalizeUpdates: 和 didMoveToWindow(这两个回调都发生在 view 完成布局并挂到 window 之后)再应用 enter 动画。
顺势而为:重写 invalidateLayer
Fabric 在 RCTViewComponentView 上有个方法叫 invalidateLayer。每次 Fabric 需要把 style props 重新同步回 CALayer 时,它都会在内部调用它,比如布局之后回写 opacity、backgroundColor、cornerRadius。这是一个稳定且可靠的时机信号。
问题在于,这次同步会覆盖动画系统写进去的值。一个正在过渡中的背景色会被重置为 style 值。
EaseView 没有跟系统对抗,而是顺着系统走。它重写了 invalidateLayer,先让 super 执行,再把动画值重新盖上去:
- (void)invalidateLayer {
[super invalidateLayer];
// 在上层重新应用我们的动画值
}
Fabric 已经告诉你 layer 何时需要更新,所以就用这个信号。这是两套都想控制同一组属性的系统之间一次干净的握手;它之所以有效,是因为这个 override 遵守了契约,而不是试图阻止 Fabric 运行。
Android:ObjectAnimator、SpringAnimation 以及一些物理学
Android 侧使用 ObjectAnimator 做基于时序的动画,使用 androidx.dynamicanimation.SpringAnimation 做物理弹簧动画。要让它们与 iOS 行为一致,需要处理几个不那么直观的点。
Props 会先批处理,而不是立刻应用
在 Android 上,EaseViewManager 里的 @ReactProp setter 不会直接触发动画。它们会把值写入 view 上的 pending* 字段,然后由 onAfterUpdateTransaction 统一调用 applyPendingAnimateValues() 一次性刷新。
这种批处理是必需的。一次 React 渲染可能同时改了 opacity、scale、translateX。若不批处理,每个 @ReactProp setter 都会独立做一次动画决策。批处理之后,三个改动会被一起看到,这就和 iOS 上 updateProps:oldProps: 一次拿到完整 old/new props 对齐了。
推导弹簧阻尼比
iOS 的 CASpringAnimation 接收原始物理参数:damping(摩擦系数)、stiffness、mass。Android 的 SpringForce 接收的是 dampingRatio,一个 0-1 的无量纲值,1.0 表示临界阻尼。
它们是不同量纲,但描述的是同一套物理。换算公式是:
dampingRatio = damping / (2 * sqrt(stiffness * mass))
这是经典简谐振子力学中的公式。它意味着在两端 API 输入形式不同的情况下,相同的 damping、stiffness、mass 仍可在两端产生视觉一致的弹簧效果。
通过 ViewOutlineProvider 处理 border radius
Android 没有直接等价于 CALayer.cornerRadius 的能力。这个库改用 ViewOutlineProvider:你提供一个轮廓形状,设置 clipToOutline = true,渲染系统就会用这个轮廓做裁剪和阴影绘制。
val animatedOutlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(0, 0, view.width, view.height, _borderRadius)
}
}
这个 outline provider 会动态读取 _borderRadius,并在动画每一帧失效重绘。若当前并未动画 border radius,库会切回 ViewOutlineProvider.BACKGROUND,于是普通 style 驱动的 border radius 仍然照常工作。
camera distance 归一化
对 3D 旋转(rotateX、rotateY)来说,Android API 用 cameraDistance 控制透视。它和 CSS perspective 的换算不是简单直传:
cameraDistance = density * density * perspective * sqrt(5)
这里的 sqrt(5) 对齐的是 React Native 内部做的一项特定归一化,用来让 CSS perspective 在不同屏幕密度下视觉一致。没有这个因子的话,这个 view 上的 rotateY 在同一 perspective 数值下会和标准 React Native view 看起来不一致。
结果
没有 JS 动画循环。没有 worklet。没有 C++ 运行时。当一次 prop 变化到达原生侧时,只会发生两件事之一:要么给 CALayer 加一个 CAAnimation,要么启动一个 ObjectAnimator。从那一刻起,一切都由渲染线程处理。即便 JS 线程完全阻塞,动画也会持续且不中断。
权衡点在于:可动画属性集合是固定的。你只能动画那些原生 layer 本身就理解的属性。但对最关键的属性(opacity、transform、color、border radius)而言,这是在 React Native 中不写自定义渲染器的前提下,能做到的最底层路径之一。
术语表(本篇命中)
| 术语 | 英文 | 释义 |
|---|---|---|
| 渲染线程 | render thread | 负责实际逐帧绘制的线程,动画提交后由其持续推进 |
| 表现层 | presentation layer | Core Animation 中动画期间真实显示在屏幕上的 layer 副本 |
| 模型层 | model layer | Core Animation 中保存目标值的 layer 副本 |
| Shadow tree | shadow tree | React Native 原生侧布局/属性树,提交更新常伴随布局与属性 diff |
| Bitmask | bitmask | 用位标记来表示某属性是否参与动画,避免默认值歧义 |