使用 Native Driver 实现动画
在过去的一年中,我们一直在努力改进使用 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
的事件来更新 Animated 值。
以下是动画步骤及其发生位置的细分
- JS:动画驱动程序使用
requestAnimationFrame
在每一帧上执行,并使用基于动画曲线计算的新值更新其驱动的值。 - JS:计算中间值并将其传递给附加到
View
的 props 节点。 - 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',
});
创建原生 props 节点,这告诉原生驱动程序它附加到的视图上的哪个 prop
NativeAnimatedModule.createNode({
id: 3,
type: 'props',
properties: ['style.opacity'],
});
将节点连接在一起
NativeAnimatedModule.connectNodes(1, 2);
NativeAnimatedModule.connectNodes(2, 3);
将 props 节点连接到视图
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
在每一帧上执行,并使用基于动画曲线计算的新值更新其驱动的值。 - 原生:计算中间值并将其传递给附加到原生视图的 props 节点。
- 原生:
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.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 的所有功能目前都在原生动画中受支持。主要的限制是您只能动画非布局属性,例如 transform
和 opacity
将起作用,但 Flexbox 和 position 属性将不起作用。另一个是 Animated.event
,它仅适用于直接事件,而不适用于冒泡事件。这意味着它不适用于 PanResponder
,但适用于 ScrollView#onScroll
等。
原生动画也已经成为 React Native 的一部分很长时间了,但从未被记录下来,因为它被认为是实验性的。因此,如果您想使用此功能,请确保您使用的是最近版本 (0.40+) 的 React Native。
资源
有关动画的更多信息,我建议观看 Christopher Chedeau 的演讲。
如果您想深入了解动画以及将动画卸载到原生端如何改善用户体验,还可以观看 Krzysztof Magiera 的演讲。