【React Native进阶】React Native Reanimated的使用

语言: CN / TW / HK

上一篇文章讲解了React Native性能优化的总体思路,并进一步讲解了React Native Gesture Handler的使用。本文讲解另外一个库 React Native Reanimated ,这个库旨在解决React Native在动画方面的性能问题,让我们能够创建运行在UI线程上的顺滑动画和流畅交互。

Reanimated实现动机

上一篇文章讲了React Native中业务逻辑和计算都是在JavaScript线程中,渲染是在UI线程中,两个线程是通信又是异步的,因此渲染并不是实时的,至少会有1桢的延迟,在动画方面也是同样的。

Reanimated将JavaScript线程上的动画和事件处理逻辑转移到了UI线程。它通过定义Reanimated worklet(可以被移动到一个单独的JavaScript 虚拟机并在UI线程上同步运行的一小段JavaScript代码)来实现。这种机制让我们的触摸事件可以立即被响应并在同一桢上更新UI,不必再担心JavaScript加载和同步这些问题。

注意:本文讲解的是当前最新的版本2.0.0-alpha.9,它与版本1有较大的差异。

当前版本的问题和限制

Reanimated 第二个版本当前还处于早期。由于制作这个库的团队想尽早向公众分享它,这个库还存在一些瑕疵和限制,他们计划很快解决。但有一些限制是来自Reanimated 2所依赖的 React Native 的TurboModules 架构的成熟。这个版本计划解决的一些问题可能需要全面支持TurboModules,而TurboModules尚未向公众开放。

下面就是这个版本的一些问题:

  • 安装步骤比较复杂。这源于TurboModules尚未在React Native应用程序模板中推出;
  • 目前只在Android上支持Hermes JS VM;
  • 由于这个库使用了JSI进行同步本机方法访问,这导致远程调试就没办法使用了。可以使用Flipper调试JS代码,但不支持将调试器连接到UI线程上运行的JS上下文;
  • 库在开发模式下重新加载JS捆绑包或热加载时偶尔会崩溃。
  • 在 worklets 中抛出的JavaScript异常有时会产生非描述性错误,并可能导致应用程序崩溃;
  • 从React Native传递给 worklets 的对象在 JavaScript 中没有正解的 prototype。因此,此类对象不可枚举,即不能使用“for in”构造、扩展运算符(三个点)或Object.assign等函数。

安装

Reanimated 2主要使用Turbo Modules架构并在C++中构建,该架构尚未完全部署在React Native(特别是在Android上)。因此,安装新的Reanimated除了向package.json添加依赖项外,还需要额外的步骤。

由于上述原因,React Native的最低支持版本是v0.62。在继续安装之前,请确保我们的项目正在运行在受支持的React Native版本上。

安装包

首先在项目中安装 react-native-reanimated alpha 依赖:

> yarn add react-native-reanimated@alpha

配置 Android

修改 android/app/build.gradle ,打开 Hermes 引擎

project.ext.react = [
  enableHermes: true  // <- here | clean and rebuild if changing
]

MainApplication.java 中插入 Reanimated

import com.facebook.react.bridge.JSIModulePackage; // <- add
import com.swmansion.reanimated.ReanimatedJSIModulePackage; // <- add
...
private final ReactNativeHost mReactNativeHost = new ReactNativeHost(this) {
...

    @Override
    protected String getJSMainModuleName() {
      return "index";
    }

    @Override
    protected JSIModulePackage getJSIModulePackage() {
      return new ReanimatedJSIModulePackage(); // <- add
    }
  };
...

配置iOS

在 iOS 上的安装是自动的,不需要额外配置。

核心概念

Worklets

worklets 的最终目标是定义一小段运行在 UI 线程用来更新视图属性和响应事件的 JavaScript 代码。正常这种结构用 JavaScript 来实现就会是一个简单的方法。在这个版本中有一个次级的运行在 UI 线程的 JS 上下文,JavaScript 代码能够在这个上下文里面运行。实现这个 worklets 方法只需要在方法内部第一行加上”worklet”命令即可:

function someWorklet(greeting) {
  'worklet';
  console.log("Hey I'm running on the UI thread");
}

我们在使用这些方法的时候还可以传递参数。每个 worklet 方法如果你直接在代码里面调用就会运行在 React Native 的主线程上,如果使用 runOnUI 方法调用就可以运行在 UI 线程上。注意这种调用在使用者的视角上看是异步的(即调用与运行不在同一个线程上)。当你传递了参数,那这个参数会被复制到 UI 线程的 JS 上下文中。

function someWorklet(greeting) {
  'worklet';
  console.log(greeting, 'From the UI thread');
}

function onPress() {
  runOnUI(someWorklet)('Howdy');
}

如果你在 worklet 方法外部定义了一个变量并在方法里使用了它,那么这个变量同样会被复制进来:

const width = 135.5;

function otherWorklet() {
  'worklet';
  console.log('Captured width is', width);
}

worklet 也可以从其他的 worklet 方法中获取参数,当这些方法被调用时,它们是在 UI 线程同步运行的:

function returningWorklet() {
  'worklet';
  return "I'm back";
}

function someWorklet() {
  'worklet';
  let what = returningWorklet();
  console.log('On the UI thread, other worklet says', what);
}

这个特性也同样适用于普通方法。需要注意的是, console.log 只在 React Native 上下文中定义了的,在 UI 线程是没有这个方法的,因此上面的这些例子中 console.log 都是运行在 React Native 主线程上的。

function callback(text) {
  console.log('Running on the RN thread', text);
}

function someWorklet() {
  'worklet';
  console.log("I'm on UI but can call methods from the RN thread");
  callback('can pass arguments too');
}

使用勾子函数(hooks)

在平常使用时,我们很少自己去去写”worklet”命令去定义 worklet 方法,一般情况都是直接使用这个库中已经定义好的勾子函数,比如: useAnimatedStyle , useDerivedValue , useAnimatedGestureHandler 等。当我们使用这些勾子函数时,系统会自动识别到这是一个 worklet 并运行到 UI 线程上。

const style = useAnimatedStyle(() => {
  console.log("Running on the UI thread");
  return {
    opacity: 0.5
  };
});

Shared Values

Shared Values 是 Reanimated 2.0 最基础的理念之一。它有点类似于 React Native 内置的 Animated.API 。它们都服务于相似的目标:携带动画所需要的数据,提供响应式和驱动式的动画。下面几个小节会详细介绍 Shared Values 的这些关键角色。后面也会有表格详细对照 Shared Values 与 Animated.Value 区别。

携带数据

Shared Values 的主要目的是提供共享内存的概念。在前面学习 worklet 时我们了解到 Reanimated 2.0 的动画代码是使用单独的 JS VM 上下文运行在单独的线程中的。Shared Values 就能够对可变数据保持引用以便这些数据能够在不同的线程中被读取和修改。

Shared Value 对象对这些共享数据提供了引用,这些共享数据可以通过对象的 .value 属性来获取和修改。记住无论是获取数据还是修改数据,都需要使用 .value (最经常看到的错误就是直接使用 Shared Value 来获取和修改数据而不是使用它的 .value 属性)。

为了兼顾安全和速度,Reanimated 2.0 在设计的时候会做一些权衡。使用 worklet 在主线程读取和修改的数据能够立即更新渲染到屏幕上。而在 JavaScript 线程上的更新操作不会立刻执行,变成一个更新计划之后再提交到 UI 线程上执行。这种方式类似于 React Native 的状态管理:我们更新了状态,这些状态不会立即被执行,而是在下一个 re-render 的时候执行。

创建一个 Shared Value 需要使用勾子函数 useSharedValue

const sharedVal = useSharedValue(3.1415)

这个 Shared Value 构造器勾子函数需要传入一个参数作为初始变量值。这个初始数据可以是对象、数组、数字、字符串或者布尔值。

更新 Shared Value 需要使用 .value 赋一个新的值:

import { useSharedValue } from 'react-native-reanimated';

function SomeComponent() {
  const sharedVal = useSharedValue(0);
  return (
    <Button
      onPress={() => (sharedVal.value = Math.random())}
      title="Randomize"
    />
  );
}

上面这个例子我们是在 JavaScript 线程上更新的数据,这个更新是异步的。使用 worklet 能够让这个更新变成同步的:

import Animated, { useSharedValue, useAnimatedScrollHandler } from 'react-native-reanimated';

function SomeComponent({ children }) {

  const scrollOffset = useSharedValue(0);

  const scrollHandler = useAnimatedScrollHandler({
    onScroll: event => {
      scrollOffset.value = event.contentOffset.y;
    },
  });

  return (
    <Animated.ScrollView onScroll={scrollHandler}>
      {children}
    </Animated.ScrollView>
  );
}

上面的 scroll handler 就是一个 worklet,它的滚动事件是在 UI 线程上运行的。因此它里面的更新也是同步的。

Shared Values 的响应性

Shared Values 第二个非常重要的特性就是为 Reanimated 提供了响应性的理念。基于这个特性,Shared Value 可以驱动相应的代码在 UI 线程执行,也可以执行开始动画、更新视图等操作。

当前两种方法创建反应式的 worklet,分别是 useAnimatedStyleuseDerivedValue 。当这样的勾子函数捕获了一个 Shared Value,每当 Shared Value的数据被更新时,这些勾子函数都会重新运行。Reanimated 引擎会创建一个 Shared Value 与 worklet 对应关系的表以保证我们只执行需要更新的代码以及执行的顺序。比如,当我们有一个 Shared Value x 、一个基于 x 值变化的变量 y 和同时使用 xy 的 animated style,那么当 x 的值更新时,只会重新运行起源于 x 的 worklet。在这个例子中,由于 animated style 会基于 y 的值, y 的值会优先更新以保证 animated style 的更新。

示例代码如下:

import Animated, { useSharedValue, useAnimatedStyle } from 'react-native-reanimated';

function Box() {
  const offset = useSharedValue(0);

  const animatedStyles = useAnimatedStyle(() => {
    return {
      transform: [{ translateX: offset.value * 255 }],
    };
  });

  return (
    <>
      <Animated.View style={[styles.box, animatedStyles]} />
      <Button onPress={() => (offset.value = Math.random())} title="Move" />
    </>
  );
}

在上面的代码中,我们定义了 Shared Value offset ,并把它使用在了 useAnimatedStyle 这个 worklet 里。 offset 的初始值是0,然后我们添加了一个按钮通过 Math.random() 函数更新 offset 的值。因此每当我们点击一次按钮, offset 的值就会更新为一个 01 区间中的平均数。由于 animated style 的 worklet 是响应式的,在这个例子中它是基于 offset 的值响应,只有初始化的时候或者 offset 值更新的时候这个 worklet 才会运行。由于在 worklet 里作了一个 * 255 的计算,因此实际的 translateX 在按钮的点击下在 0255 变动。

操作动画

动画是 Reanimated 2里的重中之重,在这个库中有大量帮助我们运行和自定义动画的实用方法。其中一种动画的方式就是使 Shared Value 的值进行动态变化。它可以通过用 reanimated 库里的方法(例如: withTimingwithSpring )把目标值包装起来来实现:

import { withTiming } from 'react-native-reanimated';

someSharedValue.value = withTiming(50);

在上面的代码中 offset 的值没有直接被设定成 50 ,而是随着时间推移从当前值渐变到 50 。当然,这种动画形式可以在 UI 线程上实现也可以在 React Native 主线程上实现。下面是完整的从上面小节例子上修改之后的代码:

import Animated, { withSpring } from 'react-native-reanimated';

function Box() {
  const offset = useSharedValue(0);

  const animatedStyles = useAnimatedStyle(() => {
    return {
      transform: [{ translateX: offset.value * 255 }],
    };
  });

  return (
    <>
      <Animated.View style={[styles.box, animatedStyles]} />
      <Button
        onPress={() => {
          offset.value = withSpring(Math.random());
        }}
        title="Move"
      />
    </>
  );
}

上面的代码中我们所做的修改仅仅是将 Math.random() 包裹在了 withSpring 方法中。加上这个方法后动画会更平滑:

关于 withTimingwithSpring 等方法的更多信息可以参考官方文档的介绍。

动画进度

我们可以通过 .value 来获取基于 Shared Value 的动画的当前状态。当 Shared Value 的

过渡动画开始之后, .value 的值将会与动画的进度同步。也就是说,当动画开始时的初始值为 0 而且使用了 withTiming(50) 方法,完成这个过渡默认是300毫秒,我们可以在动画进行时通过 .value 来获取到从 050 之间动画的进度。

中断动画

由于 Shared Value 保持其动画过渡状态,我们可以使所有的动画都完全中断。这意味着即使 Shared Value 当前正在运行动画,我们也可以对 Shared Value 进行更新,而不必担心这会导致意外和突然的动画故障。在这种情况下,重新赋值会导致之前的动画中断。如果新分配的值是一个数字(或其他任何常量值),则该新值将立即分配给 Shared Value,之前运行的动画将被取消。如果新分配的值也是动画,那么之前运行的动画将顺利过渡到新的动画中。速度等动画参数也会转变,这在基于 spring 的动画中尤为重要。这种行为模式在下面的动图中就可以看出,我们只是更频繁地点击按钮,这样新动画就会在前一个动画仍在运行时启动(与前一个示例相比没有代码更改)。

取消动画

我们可以通过使用 cancelAnimation 方法实现不开始新动画的情况直接取消当前动画:

import { cancelAnimation } from 'react-native-reanimated'

cancelAnimation(someSharedValue);

动画可以在 UI 线程被取消,也可以在 React Native 的 JS 线程上被取消。

Shared Values 与 Animated.Value 对比

特性 Animated Value Shared Value
Payload

仅支持数值或字符串类型

任何原始或嵌套数据结构(如对象、数组、字符串、数字、布尔值)
连接到视图的属性 直接把 Animated.Value 当作属性传递 Shared Value 不能直接与视图的属性进行锚定。我们应该使用 useAnimatedStyle 或者 useAnimatedProps 并在这些方法里面获取到 Shared Value 的值并将计算后的 styles 返回回去
更新值 使用 value.setValue 方法(如果使用了 native driver 值的更新就是异步的) 通过更新 .value 属性,如果在 UI 线程进行更新就是同步的,其他线程更新就是异步的
读取值 通过 value.addListener 来注册监听器来动态获取更新的值 直接通过 .value 属性就能获取存储在 Shared Value 里的值(UI 线程和 React Native 的 JS 线程都可以)
运行动画 使用 Animated.springAnimated.timing 或其他方法,将 Animated Value 作为参数,通过 .start() 方法启动动画。 把目标值用动画方法(例如: withTiming )包装起来并更新它的值即可
停止动画 通过 Animated.timing 的返回值获取动画对象的引用,并让它调用 stopAnimation() 方法 把 Shared Value 作为参数传递给 cancelAnimation 即可
插值 使用 Animated Value 的 interplate() 方法 使用带数字和配置参数的方法 interpolated ,并从这个方法返回插值。如果你需要让一个 Shared Value 自动跟踪另一个 Shared Value 的插值也可以单独使用 useDerivedValue

动画

接下来讲一下如何使用各种辅助方法进一步自定义动画。

useAnimatedStyle

除了在给 Shared Value 赋值的时候使用类似 withSpring 的过渡方法制作动画以外,还可以直接在 useAnimatedStyle 方法里面使用这些过渡动画方法:

const animatedStyles = useAnimatedStyle(() => {
  return {
    transform: [
      {
        translateX: withSpring(offset.value * 255),
      },
    ],
  };
});

上面的代码中,我们将 offset 的值转换后再包裹在 withSpring 方法中。效果与之前给 offset 赋值之前就使用 withSpring 这个方法相同。这种写法的好处是将动画逻辑的代码都写在一起,在其他地方只需要给 Shared Value 赋值即可。经过上面的修改后,按钮部分的代码就可以改成下面这样:

<Button onPress={() => (offset.value = Math.random())} title="Move" />

自定义动画

Reanimated 目前内置了三个动画辅助方法: withTiming withSpring withDecay 。下面介绍一下前两种方法的常用配置选项。

这些动画辅助方法都有类似的结构。方法的第一个参数是目标值,第二个参数是配置选项,第三个参数是回调函数。回调函数会在动画完成或动画被中断或取消时运行,函数里有一个布尔值的参数,代表动画是否顺利完成而没有被取消:

<Button
  onPress={() => {
    offset.value = withSpring(Math.random(), {}, (finished) => {
      if (finished) {
        console.log("ANIMATION ENDED");
      } else {
        console.log("ANIMATION GOT CANCELLED");
      }
    });
  }}
  title="Move"
/>

Timing

配置选项这个参数根据运行的动画不同也存在不同。对于 timing 动画而言,我们可以设置持续时间和 easing 方法(缓动方法)。你可能希望动画先快速加速然后减速,或者缓慢开始,然后在结束时再次加速和减速。我们可以通过 Reanimated 包中的 Easing.bezier 方法使用贝塞尔曲线来描述这种 easing 。但大多数情况,使用 Easing.inEasing.out 或者 Easing.inOut 分别调整起点、终点或两端的时序曲线就足够了。Timing 动画默认持续时间为300毫秒,默认为平滑进出的曲线( Easing.inOut(Easing.quad) ):

下面就是如何自定义 timing 动画的配置:

import { Easing, withTiming } from 'react-native-reanimated';

offset.value = withTiming(0, {
  duration: 500,
  easing: Easing.out(Easing.exp),
});

你也可以查看 easings.net 这个网站来查看不同 timing 动画的 easing 效果。Reanimated 所有的 easing 方法都是在 Easing.js 文件里定义的,如果在使用的有问题可以参考这个文件。

Spring

与 Timing 动画不同的是,Spring 动画不将持续时间作为参数。Spring 动画的持续时间由 spring 物理特性、初始速度和行进距离决定。下面我们通过例子来了解如何自定义 spring 动画并将它与默认的 spring 动画设定进行对比:

import Animated, {
  withSpring,
  useAnimatedStyle,
  useSharedValue,
} from 'react-native-reanimated';

function Box() {
  const offset = useSharedValue(0);

  const defaultSpringStyles = useAnimatedStyle(() => {
    return {
      transform: [{ translateX: withSpring(offset.value * 255) }],
    };
  });

  const customSpringStyles = useAnimatedStyle(() => {
    return {
      transform: [
        {
          translateX: withSpring(offset.value * 255, {
            damping: 20,
            stiffness: 90,
          }),
        },
      ],
    };
  });

  return (
    <>
      <Animated.View style={[styles.box, defaultSpringStyles]} />
      <Animated.View style={[styles.box, customSpringStyles]} />
      <Button onPress={() => (offset.value = Math.random())} title="Move" />
    </>
  );
}

与前面的例子不同,这里使用了 useAnimatedStyle 函数。这样就可以使用一个 Shared Value 来驱动两个不同的动画效果。

动画修饰器

除了自定义配置参数以外,另外一种自定义动画的方法就是使用动画修饰器。目前,Reanimated 有三个修饰器: withDecay withSequence withRepeat 。顾名思义, withDelay 修饰器让动画在指定的延时之后开始, withSequence 修饰器允许传入多个动画作为参数,并让它们依次运行, withRepeat 修饰符可以让动画重复执行。

修饰器通过将一个或多个动画作为参数传入,并返回一个修改后的动画对象。这样就可以让这些动画方法嵌套,或者让这些动画修饰器组成一个修饰链。

现在让我们来练习一下动画修饰器的使用。下面的例子我们来实现单击按钮触发矩形按钮的摆动效果。首先我们先定义需要渲染的视图和需要用到的 Shared Value。

import Animated, { useSharedValue, useAnimatedStyle } from 'react-native-reanimated';

function WobbleExample(props) {
  const rotation = useSharedValue(0);

  const animatedStyle = useAnimatedStyle(() => {
    return {
      transform: [{ rotateZ: `${rotation.value}deg` }],
    };
  });

  return (
    <>
      <Animated.View style={[styles.box, animatedStyle]} />
      <Button
        title="wobble"
        onPress={() => {
          // will be filled in later
        }}
      />
    </>
  );
}

在上面的示例中我们定义的 Shared Value 将会用来代表视图的旋转。然后,在 useAnimatedStyle 我们通过添加 “deg” 后缀将变量的单位更改为度。下面在按钮的 onPress 方法中添加修饰器代码:

rotation.value = withRepeat(withTiming(10), 6, true)

上面的代码表示视图将从初始角度 0 到目标角度 10 之间重复旋转6次,第三个参数设置代表动画运行到终点时是否需要反向回到起点。将第三个参数设置为 true 将使旋转进行完整三个循环,最终回到原点。当我们点击按钮时,效果如下:

上面的代码让旋转只在 0 度和 10 度之间进行。为了让视图也向左摆,我们可以从角度 -10 旋转到 10 度。但如果我们直接把初始值更改为 -10 ,那个矩形一开始就会是斜的。解决这个问题的方法就是使用 withSequence ,从 0 度开始,将第一个动画最终值设置为 -10 度,然后视图从 -10 度到 10 度摆动6次,最后再从 -10 度回到初始位置 0 度。下面是修改后的代码:

rotation.value = withSequence(
  withTiming(-10, { duration: 50 }),
  withRepeat(withTiming(ANGLE, { duration: 100 }), 6, true),
  withTiming(0, { duration: 50 })
);

上面的代码对三个动画设置了不同的持续时长,以保证矩形以相同的速度旋转,下面就是最后的实现效果:

总结

至此,关于 React Native Reanimated 的使用就已经学习完了,如果想要进一步学习可以查看 官方文档 。后面文章会继续讲解 React Native Gesture Handler 与 React Native Reanimated 配合使用。