壁紙アプリの実機を Xcode につないで、メモリのゲージを眺めていた夜がありました。起動直後に想定より数十MB多い。落ちるほどではないけれど、以前の版ではこの位置になかったはずの線です。
まず疑ったのは自分のコードでした。画像のキャッシュ上限、プリフェッチの本数、一覧のセル再利用。順番に削っては測り直して、それでも線はほとんど動きませんでした。
原因は、私が書いた行の外にありました。
増えていたのはアニメーションではなく、デバッグ用のメタデータでした
Hermes V1 は旧 Hermes の改良版ではなく、実行のしくみから作り直されたエンジンです。コードを評価するときに、デバッグに役立つメタデータを一緒に持ちます。
react-native-reanimated は、アニメーションの計算を JS スレッドから逃がすために react-native-worklets を土台に使っています。従来の方式(Legacy Eval Mode)では、アニメーション関数のコードを文字列としてもう一つの JavaScript ランタイムへ送り、UI スレッド上でその場で評価します。
つまり、本番ビルドでも eval に相当する経路を通ります。そしてその経路に、Hermes V1 はデバッグ用のメタデータを付けていました。
Reanimated の開発元である Software Mansion の調査によると、その量は 一意な worklet 1つあたり 512KB。同社の記事では、初期化のために評価される worklet だけで100個を超え、起動時点で少なくとも 50MB になると説明されています。Expensify のバンドルには1,000個以上の一意な worklet があり、すべて評価されれば 0.5GB を超えうる、という数字も挙がっています(実際には遅延評価なので、全画面を触り尽くさない限りそこまでは行きません)。
私が削っていた画像キャッシュは、そもそも桁が違う場所でした。
自分のコードを疑う前に、3つ数えます
順番を間違えると、私のように何時間も自分のコードを削ることになります。先に数えるべき数字が3つあります。
1. そもそも worklets が依存に入っているか
npm ls react-native-reanimated react-native-workletsRork が生成したプロジェクトは、画面遷移やジェスチャの実装でこの2つに触れていることがほとんどです。直接 import していなくても、依存の依存として入っていれば対象になります。
2. 自分が書いた worklet の数
grep -rhoE "['\"]worklet['\"];" src | wc -l
grep -rcE "['\"]worklet['\"];" src1行目が総数、2行目がファイル別の内訳です。ただしこれは下限です。Worklets の Babel プラグインは useAnimatedStyle や Gesture のコールバックを、明示的なディレクティブがなくても自動的に worklet 化します。手で 'worklet'; と書いた分しか拾えない点は割り引いて読んでください。
3. 期待される増分の桁
python3 -c "n=3; base=100; print(f'{n+base} worklet → 約 {(n+base)*512/1024:.0f} MB')"
# 103 worklet → 約 52 MBn に自分の数、base にライブラリの初期化分(100前後)を入れます。ここで出る数字と、実機で見えている増分の桁が合うなら、探す場所は自分のコードではありません。
私の場合、この計算をした時点で捜索を打ち切りました。削るべきものが自分の側になかったからです。
直し方は4つあり、選ぶ基準は「何を動かしたくないか」
Hermes 側の修正はすでに取り込まれています。手元でどう受け取るかに、4通りの経路があります。
| 経路 | やること | 向いている状況 |
|---|---|---|
| Expo を上げる | expo@57.0.9 以上へ(React Native 0.86.2 を含む) | SDK 57 系にいて、上げる余地がある場合。最短 |
| Bundle Mode を有効にする | Worklets の Bundle Mode に切り替える | Worklets の新機能にも追随したい場合。Metro 側の設定が必要 |
| Bytecode オプション | Babel プラグインの実験的な hermesBytecode を使う | Metro を触りたくない、RN も上げられない場合 |
| Hermes を固定する | 修正がバックポートされた 0.15 系に固定 | Worklets の内部挙動を一切変えたくない場合 |
Bundle Mode は、コードの文字列を1つずつ送るのではなく、バイトコードのバンドル全体を副ランタイムへ見せる方式です。Hermes は mmap で遅延読み込みするため、追加コストがほとんど発生しません。Software Mansion は、この問題とは関係なく性能面でも Bundle Mode を推奨しています。
上流の React Native 0.87 を待つ、という5つ目の選択肢もあります。ただ「待つ」を選ぶなら、待っている間ずっとその 50MB を抱えたまま配信することになる、という前提だけは置いておきたいところです。
私は「上げる」を選び、上げられない条件を先に潰しました
4つのうち、私が選んだのは Expo を上げる経路でした。理由は単純で、Worklets の設定を変える経路は、後から誰か(未来の自分を含む)が「なぜこの設定があるのか」を思い出せなくなるからです。バージョンを上げただけなら、履歴を見れば理由がわかります。
個人開発でアプリを何本も抱えていると、この「説明の要らなさ」が効いてきます。設定を1つ足すたびに、その理由を覚えておく責任が全リポジトリぶん増えるからです。私はこの判断を、性能ではなく維持のしやすさで決めました。
ただし SDK 57 は expo prebuild の既定が変わっていて、ios と android を消してから再生成します。手で編集したネイティブ設定があると、上げる作業そのものが別の問題を連れてきます。この点はExpo SDK 57 に上げる前に、prebuild で消えるネイティブ変更を洗い出すで手順にしてありますので、上げる前にそちらを通してください。
上げた後に確認するのは、次の2つだけで足ります。
npm ls expo react-native
# expo@57.0.9 以上 / react-native@0.86.2 以上
npx expo-doctor@latestそして実機で、上げる前と同じ画面を同じ順番で開いてメモリを見ます。同じ経路をたどらないと、比較になりません。
数えてから上げると、上げた後の判断が変わります
この件で私が持ち帰ったのは、Hermes の知識よりも順番のほうでした。メモリが増えたとき、最初にすることは自分のコードを削ることではなく、増分の桁を見積もることです。桁が合わなければ、原因は別の階層にあります。
もし今、Reanimated を使うアプリで説明のつかないメモリを抱えているなら、まず npm ls expo react-native を打ってバージョンを見てください。それだけで、これから数時間かける先が決まります。
メモリの上限そのものを設計する話(解放の段階分けや、強制終了として記録されない終了の読み方)は、Rork iOSアプリのメモリプレッシャー対策のほうにまとめてあります。ランタイム側の原因を潰した後で、まだ余裕がないときに読む順序が自然だと思います。
私自身、今回は数える前に手を動かして遠回りをしました。同じ夜を過ごす方が一人でも減れば嬉しいです。お読みいただきありがとうございました。