Skip to content
React Native Reanimated ガイドブック
Esc
navigateopen⌘Jpreview
On this page

workletを深く理解する

workletが動くランタイムの種類、クロージャがスレッドをまたぐときのふるまい、スレッド間でメモリを共有する3つの方法、そしてスレッドをまたいで関数を呼ぶAPIを、第1部で保留した「深掘り」として回収します。

第1部2章で、Reanimatedの滑らかさはUIスレッド・worklet・共有値という3つの登場人物で支えられている、という話をしました。あのときは輪郭をつかむところで止め、workletがどうやって外側の変数を取り込むか、ランタイムをまたぐ実行の細部といったテーマは第6部に回す、と予告しました。ここがその回収の場です。

この章を読み終えると、workletが「ただのUIスレッドで動く関数」ではなく、複数のランタイムをまたいで移動する関数なのだ、という一段深い像が持てるようになります。細かい個別APIをすべて覚える必要はありません。概念の地図と、日々のコードで実際に使う主要APIをつかむことを目指します。

workletは「ランタイムをまたげる関数」

まず言葉を整理しておきます。第1部では「UIスレッドで動く短命な関数」とざっくり説明しましたが、より正確には、workletは複数のJavaScriptランタイムをまたいで移動・実行できる短命なJavaScript関数です。

普通の関数との違いは、先頭に付く'worklet'ディレクティブ一行だけです。この目印を頼りに、Worklets Babelプラグインがその関数を別のランタイムへ持ち運べる形へ変換します。この変換をworkletize(ワークレット化)と呼びます。

function myWorklet() {
  'worklet';
  console.log('Hello from a worklet');
}

useAnimatedStyleやジェスチャーのコールバックに渡す関数は、このディレクティブが自動で付与され、自動でworklet化されます。だから普段はディレクティブを手で書かずに済んでいるわけです。ここで一歩踏み込みたいのは、「別のランタイムへ持ち運べる」がいったい何を意味するのか、という点です。

ランタイムには種類がある

「UIスレッドで動く」と言うとき、正確にはUIスレッドが持つ専用のランタイムでworkletが実行されています。Workletsライブラリは、ランタイムを大きく2種類に分けています。

  • RNランタイム(React Native Runtime)
    • React Nativeが立ち上げる、いつものランタイム。Reactが生き、あなたのアプリのJavaScriptが動く場所です。React NativeのAPIやアプリのstate・コンポーネントに触れるのはここだけで、アプリ内に1つしかありません。JSスレッドで実行されます。
  • Workletランタイム(Worklet Runtime)
    • Workletsライブラリが立ち上げる、workletを実行するための専用ランタイム。RNランタイムとはメモリを一切共有しませんが、専用のAPIを通じて通信できます。

Workletランタイムはさらに2つに分かれます。

  • UIランタイム(UI Runtime)
    • 主にUI(メイン)スレッドで実行される特別なWorkletランタイム。ネイティブイベントへの同期的な応答や、アニメーションの計算といった高優先度のタスクを担います。アプリ内に1つだけ存在します。第1部で「UIスレッドで動く」と言っていたのは、実質このUIランタイムのことです。
  • Workerランタイム(Worker Runtime)
    • createWorkletRuntimeで必要に応じて作る、別スレッド用のランタイム。重い計算やバックグラウンド処理など、UIと即座にやりとりする必要のないタスクを別スレッドに逃がすために使います。複数同時に存在できます。

図にすると次のような関係です。

RN、UI、Workerの3つのランタイムと、それらをまたぐ呼び出しを示す構成図

各ランタイムのメモリは独立しており、関数や値は専用APIを通じて境界を越えます。

大事なのは、それぞれのランタイムがメモリを共有していないという点です。参照を直接手渡すことはできず、間には必ず境界があります。この「境界がある」という事実が、これから見るクロージャとメモリ共有の話すべての土台になります。

クロージャはランタイムをまたぐと写しになる

workletの特別なところは、普通のJavaScript関数と同じように、外側のスコープの変数を取り込めることです。この取り込まれた変数のことを、ここではクロージャと呼びます。

const a = 1;
const b = 2;

function addAB() {
  'worklet';
  // a と b は外側のスコープから取り込まれる = このworkletのクロージャ
  return a + b;
}

ここで注意したいのが、取り込み方が「どのランタイムで呼ぶか」で変わることです。

同じランタイムで呼ぶとき

workletを定義したのと同じランタイム(RNランタイム)でそのまま呼ぶと、クロージャは元の変数への参照の写しを持ちます。プリミティブ値の場合、あとから元の変数を書き換えてもworklet内の値は変わりません。

let count = 0;

function logCount() {
  'worklet';
  console.log(count); // count を後から変えても 0 のまま
}

count = 1;
logCount(); // 0

オブジェクトの場合は、参照の付け替え(再代入)はworkletに伝わりませんが、中身の書き換え(ミューテーション)は同じオブジェクトを指しているので双方向に伝わります。この挙動は普通のJavaScriptのクロージャの感覚に近いものです。

別のランタイムで呼ぶとき

workletを別のランタイム、たとえばUIランタイムへ持ち込んで実行すると、話が変わります。クロージャは、呼び出しの瞬間の変数を丸ごとコピーしたものになります。

let obj = { value: 0 };

function logValue() {
  'worklet';
  console.log(obj.value);
}

obj.value = 1;
scheduleOnUI(logValue); // 1 ではなく 0 が出る

なぜコピーなのか。前の節で見たとおり、ランタイム同士はメモリを共有していないからです。UIランタイムはRNランタイムのヒープ上のオブジェクトを参照できないので、境界を越える瞬間に値のコピーを作って持ち込むしかありません。書き換えを反映させたいなら、参照ではなく共有値のような専用のしくみが要る、という話につながります。

グローバルスコープも別物

同じ理由で、グローバル変数もランタイムごとに別物です。workletの中で使うconsoleは、実行されるランタイムそれぞれが自前で持っているconsoleです。だからglobalに自分で入れた値は、別のランタイムからは見えません。

global.someValue = 42;

function logSomeValue() {
  'worklet';
  console.log(global.someValue);
}

logSomeValue(); // 42(RNランタイム)
scheduleOnUI(logSomeValue); // undefined(UIランタイムの global には無い)

workletの中で自由に使えるのは、そのランタイムに最初から用意されているもの(consoleperformance.nowなど)と、クロージャで取り込んだ値だけ、と覚えておくとよいです。

スレッド間でメモリを共有する3つの方法

ランタイムがメモリを共有しないなら、複数のスレッドで同じデータを見るにはどうすればいいのか。JavaScriptはもともと単一スレッドを前提に設計されていて、この問題を考えずに済ませてきました。1つのコールスタック、1つのイベントループ、同時に動くコードは1つだけ。だからデータ競合も、途中まで書きかけのオブジェクトも起こりません。

並列に動かした瞬間、この安全は崩れます。2つのスレッドを同じランタイムで動かせば、いずれ一方が他方の前提を壊してクラッシュします。それを避けるためにランタイムを分けると、今度は「別々の世界」になってしまう。この矛盾を解くために、Workletsはメモリ共有の方法を3種類用意しています。用途に応じて使い分ける設計です。

  • Serializable(コピーのみ)
    • 値をランタイムAからランタイムBへ、そのままコピーして渡すしくみ。渡したあとの変更はお互いに見えません。設定オブジェクトのように「一度渡したら以降は変化を追わなくていい」データ向きです。共有値の内部でも使われていますが、自分で直接作ることはまずありません。
  • Synchronizable(どこにも属さない共有メモリ)
    • 実体をC++側に置き、各ランタイムはそこへの参照だけを持つしくみ。どのランタイムから読み書きしても互いに反映されます。ただし読み書きのたびにJavaScriptとC++の境界を越えるコストがかかります。複数のランタイムが同じくらいの頻度で読み書きし、互いの更新を見たいデータ向きです。
  • Shareable(特定のランタイムに属する共有メモリ)
    • 実体をあるランタイム(Hostランタイム)に素の値として置き、他のランタイム(Guestランタイム)は参照だけを持つしくみ。Host側からのアクセスは速く、Guest側からのアクセスは境界越えのコストがかかる、非対称な設計です。1つのランタイムがほとんどのアクセスを担い、他はたまに触るだけ、というパターン向き。まさにReanimatedのアニメーションエンジンがこれにあたります。

普段のReanimated開発でこれらを直接触ることはほとんどありません。共有値がこうしたしくみの上に立っている、という背景を知っておくと、なぜ共有値が「勝手に同期される」のかが腑に落ちます。個別の生成API(createSerializableなど)は、付録の公式リソースとその先へにまとめたリファレンスに委ねます。

スレッドをまたいで関数を呼ぶ

ここまでは「データがどう渡るか」の話でした。次は「関数をどのスレッドで実行するか」を明示的に指定するAPIです。多くの場面ではReanimatedが裏でやってくれますが、自分で書く場面もあります。

まず押さえておきたいのが、名前の世代交代です。長らく使われてきたrunOnUI / runOnJSは現在は非推奨(deprecated)となり、それぞれscheduleOnUI / scheduleOnRNという名前に置き換わりました。既存のコードやReanimatedのドキュメントではrunOnJSをまだよく見かけるので、両方を頭に入れておくとよいです。この章では新しい名前で説明します。

scheduleOnUI — UIランタイムで実行する

RNランタイム(JSスレッド)から、UIランタイムでworkletを実行させます。マウント時にアニメーションを開始したいときや、measureのようにUIスレッドにしか実装がない関数を呼ぶときに使います。

import { scheduleOnUI } from 'react-native-worklets';

function myWorklet(greeting) {
  'worklet';
  console.log(`${greeting} from the UI Runtime`);
}

function onPress() {
  scheduleOnUI(myWorklet, 'Howdy');
}

scheduleOnRN — RNランタイムで実行する

逆向きです。UIランタイム側(worklet内)から、RNランタイムで普通の関数を実行させます。ほとんどの外部ライブラリの関数は'worklet'ディレクティブを持たないので、UIスレッドからは直接呼べません。Reactのstateを更新したり、ナビゲーションを操作したりするときの橋渡し役です。

import { scheduleOnRN } from 'react-native-worklets';

function App() {
  const scale = useSharedValue(1);

  const style = useAnimatedStyle(() => {
    if (scale.value > 2) {
      // UIスレッドから React の state を更新する
      scheduleOnRN(setDone, true);
    }
    return { transform: [{ scale: scale.value }] };
  }, [scale]);
}

ひとつ注意点があります。scheduleOnRNに渡す関数は、RNランタイムのスコープ(コンポーネント本体やグローバル)で定義されていなければなりません。worklet内で定義した関数を渡そうとしても動きません。理由はここまで読んでくればわかります。その関数はUIランタイムに属しているからです。

createWorkletRuntime — 自前のランタイムを作る

createWorkletRuntimeは、UIランタイムとは別のWorkletランタイムを新しく作るAPIです。重い処理をUIスレッドからもJSスレッドからも切り離した別スレッドで回したい、といった高度なケースで使います。

import { createWorkletRuntime } from 'react-native-worklets';

const runtime = createWorkletRuntime({
  name: 'background',
  initializer: () => {
    'worklet';
    console.log('Runtime initialized!');
  },
});

日々のアニメーション開発で出番はそう多くありません。まずはuseDerivedValueuseAnimatedReaction(第6部3章)、ジェスチャーのコールバックといった一般的な手段を先に検討し、それでも足りないときの選択肢として頭の隅に置いておけば十分です。

どこから呼べるか — Bundle Modeの話

scheduleOnUIのようなAPIには、「どのランタイムから呼べるか」という制約があります。そしてこの制約は、Bundle Modeという機能を有効にしているかどうかで変わります。

ざっくり言うと、Bundle Modeが無効なとき、Workletランタイムは機能の一部しか持ちません。workletの持ち運びに使うシリアライズの制約があるためです。有効にすると、すべてのランタイムがJavaScriptバンドル全体にアクセスできるようになり、多くのスレッド間APIがどのランタイムからでも呼べるようになります。

たとえばscheduleOnUIは、Bundle Modeが無効ならRNランタイムからしか呼べませんが、有効ならUIランタイムやWorkerランタイムからも呼べます。公式ドキュメントでは各APIのページに、この対応をまとめた「Call table」が載っています。踏み込んで使う段になったら、そこを確認するのが確実です。Bundle Mode自体のセットアップは付録の公式リソースとその先へから公式ガイドを参照してください。

Webでのふるまい

第1部でも触れたとおり、本書のデモはブラウザ上で動いています。ブラウザには独立したUIスレッドがないので、workletはただのJavaScript関数として解決され、scheduleOnUIrequestAnimationFrameに近い形で次のフレームに処理を積みます。

そして本書のデモはWorklets BabelプラグインなしのVite上で動いているため、依存の自動検出が効きません。これまでの章でuseAnimatedStyleuseDerivedValueに依存配列を明示してきたのはそのためです。この背景は、次章のBabelプラグインの回でもう一度掘り下げます。

この章で見えたこと

第1部で保留した「深掘り」を、ここで一通り回収しました。workletはUIスレッドで動く関数というより、メモリを共有しない複数のランタイムをまたいで移動する関数であり、だからこそクロージャは境界を越えるときにコピーになり、データを共有するには専用のしくみが要る。scheduleOnUI / scheduleOnRNは、その境界を越えて関数を実行させるための道具です。

この土台があると、次章以降で扱う測定・リアクション・カスタムイベントといった高度なAPIが、「どのスレッドの、どのランタイムで動いているのか」という視点で読めるようになります。

この章のもとになった公式ドキュメント