跳至主要内容

使用原生驱动器进行 Animated 动画

·阅读时间: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 值连接到 View 的事件(使用 Animated.event)来更新它。

以下是动画步骤的细分以及发生位置

  • 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 节点,这告诉原生驱动器它附加到哪个视图的哪个属性上

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 值仅与一个驱动器兼容,因此如果您在启动某个值的动画时使用原生驱动器,请确保该值上的每个动画也使用原生驱动器。

它也适用于 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 执行的操作都目前在 Native Animated 中受支持。主要的限制是您只能对非布局属性进行动画处理,例如 transformopacity 将起作用,但 Flexbox 和位置属性将不起作用。另一个是 Animated.event,它仅适用于直接事件,而不适用于冒泡事件。这意味着它不适用于 PanResponder,但适用于 ScrollView#onScroll 等。

Native Animated 也已成为 React Native 的一部分有一段时间了,但从未记录在案,因为它被认为是实验性的。因此,如果您想使用此功能,请确保您使用的是最近的 React Native 版本(0.40+)。

资源

有关动画的更多信息,我建议观看 此演讲,由 Christopher Chedeau 主讲。

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