[React Native] Reanimated workletのネイティブクラッシュで見直したこと
React Native / Expo で作っている天気・UV アプリで、UVグラフ上のバッジやラベルを Reanimated でアニメーションさせていたとき、アプリが突然落ちる問題に遭遇した。グラフ表示直後やタブ切り替え直後など、useAnimatedProps や useAnimatedReaction が初めて動くタイミングで落ちる。以降、これらのフック群をまとめて useAnimated* と表記する。
厄介だったのは、Metro の通常ログに ReferenceError のようなわかりやすいエラーが出ないことだった。JavaScript エラーとして見えるのではなく、iOS / Android 側のネイティブクラッシュとして落ちる。
flowchart TD
A["useAnimatedProps などが初めて動く"] --> B{"worklet 内で<br/>JS 例外が発生?"}
B -->|いいえ| C["正常に UI を更新"]
B -->|はい| D["Hermes が UI スレッドで abort"]
D --> E["ネイティブクラッシュ"]
D -. "Metro には出ない" .-> F["ReferenceError は<br/>表示されない"]
今回の件をかなり雑にまとめると、useAnimated* の初回実行時に worklet 側で参照が崩れ、ネイティブクラッシュのように見えていた。対処としては、hot path をローカル worklet に寄せ、純粋ロジックは src/features/ と Vitest 側へ分ける形に落ち着いた。
そのうえで、Reanimated の worklet から呼ぶ関数・定数・import の扱いを、次の方針に整理した。
useAnimated*から呼ぶ計算は、できるだけコンポーネントファイル内のローカル worklet に置く- worklet から呼ぶ関数には呼び出しチェーン全体で
"worklet"を付ける - worklet 内のデフォルト引数でモジュール定数を参照しない
src/features/の純粋関数を、UI スレッドの worklet hot path から直接呼ばない- 切り出したロジックは Vitest で検証する
何が起きていたか
症状は、useAnimated* が初めて動くタイミングでアプリが突然終了することだった。
- UVグラフを表示した直後
- タブを切り替えた直後
- グラフ上のバッジやラベルのアニメーションが初めて走ったタイミング
通常の JavaScript エラーであれば、Metro 側に ReferenceError や TypeError が表示される。しかし今回の問題では、Metro 側には決定的なエラーが出ず、アプリだけが落ちる状態になった。
クラッシュログ側を見ると、次のような worklet / Hermes 関連のスタックが含まれていた。
worklets::scheduleOnUI
WorkletRuntime::runGuarded
HermesRuntimeImpl::throwPendingErrorこの時点で、通常の React コンポーネントのエラーというより、Reanimated worklet が UI スレッド上で実行されたときの例外を疑った。
原因の見立て
結論は、worklet から参照しているものが UI runtime に載っておらず、worklet 内で JavaScript 例外が起きていたということだった。JS スレッド上の例外として見えるのではなく、Hermes が UI スレッド上で abort するため、ネイティブクラッシュのように見える。
Reanimated の worklet は UI スレッドで実行できる形に変換される。そのため JS スレッドでは普通に見える関数や定数でも、UI runtime では closure に載らず undefined になることがある。
具体的には次のパターンに分かれた。
"worklet"の付け忘れ … worklet から呼ぶ関数に"worklet"がない(例1)- モジュールスコープへの依存 … デフォルト引数や外部定数の参照が closure に載らない(例2)
- import 経路の問題 …
src/features/の関数や barrel export を UI スレッドから直接呼ぶ(例3) - 定義順の問題 … 同一ファイルでも呼び出し元より後ろに定義した関数を worklet から呼ぶ
flowchart TD A["UI runtime に参照が載らない"] --> B["worklet ディレクティブ不足"] A --> C["デフォルト引数で<br/>モジュール定数を参照"] A --> D["features / barrel export を<br/>worklet から直接参照"] A --> E["helper の定義順が後ろ"]
以降、それぞれを実際のコードで見ていく。
例1:worklet から呼ぶ関数に "worklet" がない
最初に疑ったのは、worklet から呼ぶ helper 側に "worklet" が付いていないケースだった。
例えば、次のようなコードは危ない。
function helper(x: number): number {
return x + 1;
}
function compute(x: number): number {
"worklet";
return helper(x);
}compute には "worklet" が付いているが、そこから呼ばれる helper には付いていない。JS スレッドでは普通に見えても、UI スレッド側ではここで崩れる。
修正後は、呼び出しチェーン全体に "worklet" を付ける。
function helper(x: number): number {
"worklet";
return x + 1;
}
function compute(x: number): number {
"worklet";
return helper(x);
}入口だけでなく、そこから呼ばれる関数まで含めて worklet としてそろえる。
例2:デフォルト引数でモジュール定数を参照しない
このパターンは見た目が普通なので、あとから振り返ると厄介だった。
次のようなコードも避けるようにした。
const LABEL_HALF_WIDTH: number = 24;
function clampLabelX(x: number, halfWidth: number = LABEL_HALF_WIDTH): number {
"worklet";
return Math.max(halfWidth, x);
}一見問題なさそうに見えるが、デフォルト引数で参照した LABEL_HALF_WIDTH が UI runtime 側の closure に載らず、未定義になることがある。
そこで、デフォルト引数ではなく関数本体で処理する形に変えた。
const LABEL_HALF_WIDTH: number = 24;
function clampLabelX(x: number, halfWidth?: number): number {
"worklet";
const resolvedHalfWidth: number = halfWidth ?? LABEL_HALF_WIDTH;
return Math.max(resolvedHalfWidth, x);
}モジュール定数を使うなら、この形の方がまだ事故りにくい。
例3:src/features/ の関数を useAnimated* から直接呼ばない
ここは設計としていちばん迷ったところだった。きれいに共通化したくなるが、実機で落ちるならその時点で負けだった。
プロジェクトではビジネスロジックを src/features/ に置いていたが、useAnimated* からその関数を直接 import して呼ぶと、UI runtime 側で関数が undefined になることがあった。
例えば、次のような構成。
import { resolveAnimatedBadgeLayoutFromTarget } from "../../features/uv/uvChartMarkerValueBadge";
const animatedProps = useAnimatedProps(() => {
"worklet";
const layout = resolveAnimatedBadgeLayoutFromTarget(
target.value,
offset.value,
progress.value,
);
return {
x: layout.rectX,
y: layout.rectY,
};
});JS スレッドでは動いても、UI runtime では import した関数がそのまま使えないケースがあった。
ロジックの置き場所を分ける
そこで、ロジックは置き場所ごとに割り切ることにした。
置き場所は次のように分けた。
| 用途 | 置き場所 |
|---|---|
useAnimated* から毎フレーム呼ぶ座標計算 |
コンポーネントファイル内のローカル worklet |
| JS スレッドで使う純粋ロジック | src/features/ |
| テストしたいロジック | src/features/ + Vitest |
| features と UI の両方で必要な最小計算 | features に本体を置き、worklet hot path では最小限だけ複製 |
要するに、UI スレッドで毎フレーム動くものはコンポーネント側、純粋ロジックは features 側という分け方である。
flowchart TD
A["ロジックを追加・移動する"] --> B{"useAnimated* から<br/>毎フレーム呼ぶ?"}
B -->|はい| C["コンポーネント内の<br/>ローカル worklet に置く"]
B -->|いいえ| D["src/features/ に置いて<br/>Vitest でテストする"]
C --> E["実機・シミュレータで<br/>該当 UI を必ず確認"]
修正後のイメージ
useAnimatedProps から直接呼ぶ計算は、同じコンポーネントファイル内に置く。
type BadgeLayout = {
rectX: number;
rectY: number;
};
function computeAnimatedBadgeLayout(
target: BadgeLayout,
offsetStart: BadgeLayout,
progress: number,
): BadgeLayout {
"worklet";
const factor: number = 1 - progress;
return {
rectX: target.rectX + offsetStart.rectX * factor,
rectY: target.rectY + offsetStart.rectY * factor,
};
}
const animatedProps = useAnimatedProps(() => {
"worklet";
const layout: BadgeLayout = computeAnimatedBadgeLayout(
badgeTarget.value,
badgeOffsetStart.value,
badgeProgress.value,
);
return {
x: layout.rectX,
y: layout.rectY,
};
});ここでは 定義順 も気にするようにした。worklet helper は呼び出し元より前に置く。
実装ルール
barrel export 経由の import は避ける
barrel export は便利だが、この用途では切り分けを難しくしやすかった。
// 避ける
import { resolveLayout } from "../../features/uv";worklet としてどうしても別ファイルから呼ぶ必要がある場合は、実装ファイルから直接 import する。
// まだこちらの方が安全
import { resolveLayout } from "../../features/uv/uvChartMarkerValueLabels";ただ、useAnimated* の hot path から呼ぶなら、最後はコンポーネントファイル内にローカル worklet として置く方が安心だった。
worklet に載せる処理と載せない処理を分ける
全部を worklet に載せようとしない、というのも今回決めたことの一つだった。
すべてを worklet に載せる必要はない。今回の整理では、次のように分けた。
| 処理 | 方針 |
|---|---|
| 座標計算 | worklet 向き |
| 補間 | worklet 向き |
| opacity / translate などの軽い計算 | worklet 向き |
| React state 更新 | runOnJS 経由 |
| 複雑な文字列整形 | JS スレッド側 |
| RegExp を使ったパース | JS スレッド側 |
オブジェクト SharedValue の各プロパティへ withTiming |
避ける |
スカラーの progress を withTiming |
採用 |
オブジェクト全体をアニメーションさせるより、progress のようなスカラー値を withTiming し、描画時に補間する方が扱いやすかった。
Vitest で検証する範囲を分ける
テストの分け方も、きれいさより安定性を優先した。
worklet に寄せる処理は UI スレッドで動くため、すべてを通常の単体テストで見切るのは難しい。なので次のように分けた。
- 複雑な判定・座標計算の本体は
src/features/に置いて Vitest でテストする - Reanimated の hot path では、テスト済みロジックを参考にした最小限の worklet をローカルに置く
- worklet 側に置いた計算は、実機またはシミュレータで該当UIを必ず表示して確認する
最終的なチェックリスト
今回の件以降、Reanimated worklet を触るときは次を確認するようにした。
useAnimatedProps/useAnimatedStyle/useAnimatedReaction/useAnimatedScrollHandlerの callback に"worklet"がある- callback から呼ぶ関数にも
"worklet"がある - worklet helper は呼び出し元より前に定義している
useAnimated*からsrc/features/の関数を直接呼んでいない- barrel export 経由で worklet helper を import していない
- worklet 内のデフォルト引数でモジュール定数を参照していない
- worklet 内で
RegExpやオブジェクト SharedValue へのwithTimingを使っていない - 切り出したロジックには Vitest がある
- 実機またはシミュレータで、該当UIを一度表示している
まとめ
今回の問題は、Metro の通常ログに ReferenceError が出ないため切り分けにくいタイプのクラッシュだった。手がかりになったのはクラッシュログの worklets::scheduleOnUI や HermesRuntimeImpl::throwPendingError だった。
最終的な対処は、個別のクラッシュ箇所を直すだけではなく、次の設計ルールに落とし込むことだった。
- UI スレッド の worklet hot path は、React コンポーネント側にローカル worklet として置く
src/features/は JS スレッドの純粋ロジックとテスト対象として扱う- worklet から呼ぶ関数・定数・import 経路を明示的に制限する
- Metro ログだけでなく、ネイティブクラッシュログも見る
Reanimated worklet は、通常の JavaScript と同じ感覚で関数を切り出すと UI runtime 側でだけ壊れることがある。「共通化できるか」よりも、「UI スレッドに安全に載るか」を先に考える方が安定しやすい。


