跳到主要内容

使用原生驱动动画

·6 分钟阅读
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 库中的导航动画使用。

它是如何工作的?

首先,让我们看看目前使用 Animated 和 JS 驱动的动画是如何工作的。使用 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 节点,这告诉原生驱动程序它附加到视图的哪个 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.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 的一部分很长时间了,但由于被认为是实验性的,从未被记录。因此,如果您想使用此功能,请确保您使用的是最新版本 (0.40+) 的 React Native。

资源

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

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