この寄稿はJanic Duplessisによるものです。彼はApp&FlowのHead of Consultingであり、長年のReact Nativeコントリビュータでもあります。
イメージしてみてください。ログイン画面の背後でゆっくりと漂う背景—製品を「作られた」ではなく「磨かれている」と感じさせるようなさりげない動き。単純に見えます。我々はそれをReanimatedで実装してリリースしました。しかし時々、フレームドロップが発生することがありました。わずかに違和感を与える程度ですが、一度気づくと気になるものです。
原因は、Reanimatedが毎フレームUIスレッド上で動作することにあります。フレーム中にアプリが重い処理(再レンダー、リストのスクロール、入力の更新など)を行うと、アニメーションに割ける予算が縮み、そのゆっくり動く背景のtranslateが“ゆっくりしたスタッター”になります。
App & Flowでは、細部にこだわるプロダクトチーム向けにReact Nativeアプリとツールを作っています。ネイティブ感のある滑らかなUIはその大きな一部です。問題の回避策を探す代わりに、より良いアプローチを探しました。
iOSのCore AnimationはアニメーションをOSのレンダーサーバに渡し、以降アプリスレッドには一切触れません。CAAnimationを渡すと、システムがそれを駆動し、アプリはループから完全に外れます。これをReact Nativeでも実現したい――そこで生まれたのがreact-native-easeです。宣言的なアニメーションライブラリで、プラットフォームAPI(iOSではCore Animation、AndroidではObjectAnimator)を直接使い、JSループもワークレットもフレームごとのシャドーツリーコミットも発生しません。
しかしこれを作る過程で正直に答えたい疑問が生まれました:アニメーションライブラリの選択は実際にどれほど影響するのか?そこで測定しました。4つのアプローチ、2つのプラットフォーム、高〜中価格帯デバイスで、フレームごとのUIスレッド負荷を追跡しました。本稿では結果を共有し、実際に重要な疑問に答えます:フレームペナルティはどれくらいか?どんなアプリで気にすべきか?アニメーションライブラリ選定で何を優先すべきか?
注:Reanimatedで観測したフレームドロップは再現のために人工的にUIスレッド負荷を注入したものです。実際のジャンクはワークロード、デバイス、同一フレームでUIスレッド上で何が起きているかに依存します。
テストした4つのReact Nativeアニメーションライブラリ
すべてのベンチマークは2026年4月に、Expo SDK 55、React Native 0.83、Reanimated 4.3.0、react-native-ease 0.7.0で実行しました。比較した4つのアプローチ:
- Ease:
react-native-ease。プラットフォームAPIを直接使用。アニメーションはJS側のpropsとして記述され、ネイティブ側で駆動されるためフレームごとのJS関与はなし。
- Reanimated(Shared Values):標準的なワークレットベースのアプローチ。C++ワークレットランタイム経由でUIスレッド上で値が駆動されるが、各フレームでシャドーツリーを通じてpropsを更新する。
- Reanimated(CSS Animations):Reanimatedの新しいCSSアニメーションAPI。Easeのように宣言的だが、内部はReanimatedのアニメーションエンジンによる。
- RN Animated:React Native組み込みのAnimated APIで
useNativeDriver: trueを使用。値はネイティブで駆動されるが、実装はプラットフォームごとに異なる。
また、Reanimatedを特定の静的フラグ(ANDROID_SYNCHRONOUSLY_UPDATE_UI_PROPS と IOS_SYNCHRONOUSLY_UPDATE_UI_PROPS)を有効にした構成でもテストしました。これらは非レイアウト系props(transformやopacityなど)の更新時にシャドーツリーコミットをスキップできるため、意味のある最適化です。
注:RN 0.85は将来的にこれらフラグを不要にするShared Animation Backendを導入しました。Reanimated側の統合は進行中ですがまだリリースされていません。
ベンチマークの計測方法
ベンチマーク画面は、例アプリ内にN個のビューを同時にループでアニメートするスクリーンを作成しました(translateX、2s、linear、repeat)。フレームごとのオーバーヘッドはカスタムのExpoネイティブモジュールで測定します:
- iOS:
CADisplayLinkのファクトリーメソッドをswizzleして、フレームごとに登録される全てのdisplay linkコールバックを横取りし、タイムスタンプごとにウォールクロック時間を計測して集計します。
- Android:
Window.OnFrameMetricsAvailableListenerを使い、プラットフォームのフレームメトリクスからANIMATION_DURATION、LAYOUT_MEASURE_DURATION、DRAW_DURATIONを取得します。
各テストは5秒間の収集ウィンドウで実行し、複数構成で最悪ケースと最良ケースのReanimated性能を示しました。
ベンチマーク結果:iOSとAndroidでのフレームごとのUIスレッドコスト
Android(Moto G8 Plus)
Androidはライブラリ間の比較が最も公平です。どのアプローチもUIスレッド上で動くため、ここで見えているのは各アニメーションエンジンがフレームごとに追加する作業量の直接的な指標です。
- 16.67msは60fps時のフレーム予算です。デバッグビルドではReanimated(Shared Values)とCSSの両方が50ビューでこの予算を超え、フレームを落としました。リリースビルドでは同じアニメーションが約11msに収まります。結論:デバッグビルドは実情を誤って示します。開発中にジャンクを見たら、慌てる前に必ずリリースビルドで再現してください。
- フラグ(
*_SYNCHRONOUSLY_UPDATE_UI_PROPS)はシャドーツリーコミットをバイパスすることでさらに11〜19%改善をもたらします。ただし一部のアプリで視覚的な不具合を引き起こす可能性があるためオプトインです。
どのようにビュー数でオーバーヘッドがスケールするか(リリース、全フラグ有効):
- 10〜100ビューでは、すべてのアプローチが平均でフレーム予算内に収まりますが、ReanimatedとRN Animatedは100ビューで予算まで残り5ms程度しかなく、フレーム内の他処理にほとんど余裕がありません。
- 500ビューはストレステストで現実的な目標ではありませんが、500ビュー時に予算内に残るのはEaseだけでした。Reanimated(SV)は36msに達し、フレーム予算の2倍以上です(これは最適化済み構成でも同様)。
iOS(iPhone 15 Pro)
iOSではアーキテクチャの違いが明確になります。AndroidではすべてのライブラリがUIスレッドを共有するため比較は公平ですが、iOSではEaseが“合法的にズル”できます。Core Animationは別プロセスのOSレンダーサーバで実行され、アプリ側ではフレームごとの処理が発生しません。一度CAAnimationを登録すればシステムが駆動し、アプリのスレッドは完全に解放されます。
- そのためEaseはどのビュー数でもUIスレッド上のオーバーヘッドがおおよそ
~0.01msと報告されます:実際にフレームごとにUIスレッドで何もしていないからです。
- 他のアプローチはビュー数に関係なく毎フレーム何らかの作業を続けます。
(注:計測上の絶対値はAndroidより低めに出ます。iOSの計測はUIスレッドのコールバック時間のみを捕捉しているためです。)
なぜReact Nativeのアニメーションライブラリはフレームごとのコストで差が出るのか
シャドーツリー税
毎フレーム、Reanimatedのワークレットは新しい値を計算し、プロップ更新をシャドーツリーを通じてコミットします。そのコミットではYogaレイアウト、propのdiff、ビューのミューテーションが走ります。transformやopacityのようにレイアウトに影響しないプロパティをアニメートしているとき、この作業の多くは無駄です。幅を3ピクセル動かすためにフルレイアウトパスの代償を払っているようなものです。
ANDROID_SYNCHRONOUSLY_UPDATE_UI_PROPS / IOS_SYNCHRONOUSLY_UPDATE_UI_PROPSはこれを短絡し、視覚プロップの更新を直接UIレイヤへ押し込むことでレイアウトパスを完全にスキップします。Moto G8 Plusの50ビュー時でReanimated SVは11.87msから10.57ms(-11%)、CSSは11.20msから9.06ms(-19%)になりました。視覚的な不具合を引き起こす可能性があるためオプトインですが、オーバーヘッドを追いかけるなら最初に試す価値があります。
RN Animated
useNativeDriver: trueを使ったRN AnimatedもフレームごとのJSスレッドはスキップしますが、ネイティブ側に独自のアニメーションモジュールがあり、各アニメーションノードごとのブックキーピングオーバーヘッドを持ちます。低〜中ビュー数では十分に耐えますが、アニメートするビュー数が増えるとスケールが悪くなります。部分的には、前述のシャドーツリー最適化(フラグ)がないことが原因です。
本番アプリでアニメーションライブラリの選択が重要になるとき
- 長時間継続する、または遅いアニメーション(スケルトンローダー、漂う背景、常時表示されるUIエフェクト)では重要です。5秒のアニメーションでの単一フレームドロップは目立ちますし、データフェッチや再レンダー、ユーザー操作が同時に起きることが多いです。
- リスト内のアニメーション(数百のアニメートされたアイテムが一度に存在する)でも重要です。低スペック端末では小さなフレームごとのオーバーヘッドが急速に累積し、ユーザーが先に気づきます。
- 一方で、短時間のワンショットトランジション(ボタン押下、トースト、モーダル)はオーバーヘッドが無視できるレベルで、どのライブラリでも問題ありません。
補足:Easeはこの特定のユースケース(宣言的でトリガー駆動、視覚プロパティ)に特化しています。ジェスチャー駆動のアニメーション(スクロール連動、ドラッグ、スワイプ)やレイアウトプロパティ(width、height、padding)を変えるものは、引き続きReanimatedやRN Animatedが必要です。Easeは視覚プロパティに対する宣言的なトリガー型アニメーション向けに作られています。
React Native 0.85 と Shared Animation Backend
React Native 0.85は実験的なShared Animation Backendを搭載しています。これはMetaとSoftware Mansionがレンダラに直接組み込んだ統一アニメーションエンジンです。Reanimatedの統合が出れば、SYNCHRONOUSLY_UPDATE_UI_PROPSは不要になり、シャドーツリーのバイパスがデフォルトパスになります。これにより「デフォルトのReanimated」と「最適化したReanimated」の差は実質的に無くなります。
ただしアーキテクチャ上の違いは残ります。Easeはそもそもフレームごとのアニメーションエンジンを持ちません。より高速なバックエンドが登場しても、Reanimatedは依然として毎フレーム値を計算してプロップ更新を押し出します。そのオーバーヘッドは消えず、ただ小さくなるに過ぎません。統合が出たらベンチマークを更新します。
自分でReact Nativeアニメーションベンチマークを実行する
ベンチマークはexampleアプリに組み込まれています。リポジトリをクローンして、yarn example ios または yarn example android を実行し、デモ画面から Benchmark をタップしてください。ソースは example/src/demos/BenchmarkDemo.tsx、ネイティブモジュールは example/modules/frame-metrics/ にあります。
-
注意点:必ずリリースビルドを使ってください。デバッグモードはReanimatedの数値を大幅に膨らませます。もし数字が異常に見えるなら、それが理由の可能性が高いです。
-
実行コマンド例:
yarn example ios --configuration Release
yarn example android --variant release
react-native-easeは、モントリオール拠点のReact NativeエンジニアリングスタジオであるApp & Flowが構築したライブラリで、Expoの推奨を受けています。