跳到主要内容

使用原生驱动实现动画

·7 分钟阅读
Janic Duplessis
App & Flow 软件工程师

在过去的一年里,我们一直在努力提高使用 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 到原生桥接器。
  • 原生:UIViewandroid.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,
});

现在,这是动画运行时发生的事情的分解

  • 原生:原生动画驱动程序使用 CADisplayLinkandroid.view.Choreographer 在每一帧上执行,并使用基于动画曲线计算的新值更新它驱动的值。
  • 原生:计算中间值并将其传递给附加到原生视图的 props 节点。
  • 原生:UIViewandroid.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 完成的操作目前都在原生 Animated 中得到支持。主要的限制是您只能动画非布局属性,例如 transformopacity 将起作用,但 Flexbox 和 position 属性将不起作用。另一个是 Animated.event,它仅适用于直接事件,而不适用于冒泡事件。这意味着它不适用于 PanResponder,但适用于 ScrollView#onScroll 等。

原生 Animated 也已经成为 React Native 的一部分很长时间了,但从未被记录下来,因为它被认为是实验性的。因此,如果您想使用此功能,请确保您使用的是 React Native 的最新版本(0.40+)。

资源

有关动画的更多信息,我建议观看 Christopher Chedeau这个演讲

如果您想深入了解动画以及将动画卸载到原生端如何改善用户体验,还可以观看 Krzysztof Magiera这个演讲