【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 配合使用。