为 Animated 使用原生驱动
在过去的一年里,我们一直致力于提高使用 Animated 库的动画性能。动画对于创造美观的用户体验非常重要,但也可能难以正确实现。我们希望让开发者能够轻松创建高性能动画,而不必担心某些代码会导致卡顿。
这是什么?
Animated API 在设计时考虑了一个非常重要的限制:它是可序列化的。这意味着我们可以在动画开始之前将所有关于动画的信息发送到原生层,并允许原生代码在 UI 线程上执行动画,而无需在每一帧都通过桥接。这非常有用,因为一旦动画开始,JS 线程即使被阻塞,动画仍能流畅运行。在实践中这种情况经常发生,因为用户代码在 JS 线程上运行,并且 React 渲染也可能长时间锁定 JS 线程。
一些历史...
这个项目大约在一年前开始,当时 Expo 在 Android 上构建了 li.st 应用。Krzysztof Magiera 受聘在 Android 上构建了最初的实现。它最终运行良好,li.st 是第一个使用 Animated 实现原生驱动动画的应用。几个月后,Brandon Withrow 在 iOS 上构建了最初的实现。此后,Ryan Gomba 和我致力于添加缺失的功能,例如对 Animated.event
的支持,以及修复我们在生产应用中发现的错误。这确实是一项社区共同努力的成果,我感谢所有参与者以及 Expo 对大部分开发工作的赞助。现在,它被 React Native 中的 Touchable
组件以及新发布的 React Navigation 库中的导航动画所使用。
它是如何工作的?
首先,我们来看看使用 JS 驱动的 Animated 动画目前是如何工作的。在使用 Animated 时,你声明一个节点图来表示你想要执行的动画,然后使用一个驱动来根据预定义的曲线更新 Animated 值。你也可以通过使用 Animated.event
将 Animated 值连接到 View
的事件来更新它。
以下是动画步骤及其发生位置的细分
- JS:动画驱动器使用
requestAnimationFrame
在每一帧执行,并根据动画曲线计算出的新值来更新它所驱动的值。 - JS:计算中间值并将其传递给附加到
View
的属性节点。 - JS:使用
setNativeProps
更新View
。 - JS 到原生桥接。
- 原生:
UIView
或android.View
被更新。
正如你所看到的,大部分工作发生在 JS 线程上。如果它被阻塞,动画将跳帧。它还需要在每一帧都通过 JS 到原生桥接来更新原生视图。
原生驱动所做的是将所有这些步骤移到原生层。由于 Animated 会生成一个动画节点图,因此可以在动画开始时将其序列化并仅发送到原生层一次,从而无需回调到 JS 线程;原生代码可以在 UI 线程上直接处理每一帧的视图更新。
下面是一个如何序列化动画值和插值节点的示例(并非确切实现,仅供参考)。
创建原生值节点,这是将要动画化的值
NativeAnimatedModule.createNode({
id: 1,
type: 'value',
initialValue: 0,
});
创建原生插值节点,它告诉原生驱动器如何插值一个值
NativeAnimatedModule.createNode({
id: 2,
type: 'interpolation',
inputRange: [0, 10],
outputRange: [10, 0],
extrapolate: 'clamp',
});
创建原生属性节点,它告诉原生驱动器它附加到视图上的哪个属性
NativeAnimatedModule.createNode({
id: 3,
type: 'props',
properties: ['style.opacity'],
});
将节点连接起来
NativeAnimatedModule.connectNodes(1, 2);
NativeAnimatedModule.connectNodes(2, 3);
将属性节点连接到视图
NativeAnimatedModule.connectToView(3, ReactNative.findNodeHandle(viewRef));
有了这些,原生动画模块就拥有了直接更新原生视图所需的所有信息,而无需到 JS 层计算任何值。
剩下要做的就是通过指定我们想要的动画曲线类型以及要更新的动画值来实际启动动画。定时动画也可以通过在 JS 中预先计算动画的每一帧来简化,从而使原生实现更小。
NativeAnimatedModule.startAnimation({
type: 'timing',
frames: [0, 0.1, 0.2, 0.4, 0.65, ...],
animatedValueId: 1,
});
现在,以下是动画运行时发生情况的细分
- 原生:原生动画驱动器使用
CADisplayLink
或android.view.Choreographer
在每一帧执行,并根据动画曲线计算出的新值来更新它所驱动的值。 - 原生:计算中间值并将其传递给附加到原生视图的属性节点。
- 原生:
UIView
或android.View
被更新。
正如你所看到的,不再需要 JS 线程和桥接,这意味着动画更快!🎉🎉
我如何在我的应用中使用它?
对于普通动画,答案很简单,只需在启动动画时将 useNativeDriver: true
添加到动画配置中即可。
之前
Animated.timing(this.state.animatedValue, {
toValue: 1,
duration: 500,
}).start();
之后
Animated.timing(this.state.animatedValue, {
toValue: 1,
duration: 500,
useNativeDriver: true, // <-- Add this
}).start();
Animated 值仅与一个驱动兼容,因此如果你在某个值上开始动画时使用了原生驱动,请确保该值上的每个动画也都使用原生驱动。
它也适用于 Animated.event
,如果你有一个动画必须跟随滚动位置,这会非常有用,因为如果没有原生驱动,由于 React Native 的异步特性,它总是会比手势滞后一帧。
之前
<ScrollView
scrollEventThrottle={16}
onScroll={Animated.event(
[{ nativeEvent: { contentOffset: { y: this.state.animatedValue } } }]
)}
>
{content}
</ScrollView>
之后
<Animated.ScrollView // <-- Use the Animated ScrollView wrapper
scrollEventThrottle={1} // <-- Use 1 here to make sure no events are ever missed
onScroll={Animated.event(
[{ nativeEvent: { contentOffset: { y: this.state.animatedValue } } }],
{ useNativeDriver: true } // <-- Add this
)}
>
{content}
</Animated.ScrollView>
注意事项
目前并非所有可以使用 Animated 实现的功能都受原生 Animated 支持。主要的限制是,你只能动画非布局属性,例如 transform
和 opacity
将会起作用,但 Flexbox 和位置属性则不会。另一个是 Animated.event
,它只适用于直接事件,而不适用于冒泡事件。这意味着它不适用于 PanResponder
,但适用于 ScrollView#onScroll
等。
原生 Animated 已经在 React Native 中存在了一段时间,但由于被认为是实验性的,所以从未被记录。因此,如果你想使用此功能,请确保你使用的是最新版本 (0.40+) 的 React Native。
资源
有关 Animated 的更多信息,我推荐观看 Christopher Chedeau 的这个演讲。
如果你想深入了解动画以及如何将它们卸载到原生层以改善用户体验,也可以观看 Krzysztof Magiera 的这个演讲。