React Native (Expo) iOS実機で画面が描画されない問題の原因と対策
はじめに
React Native (Expo) で開発中のアプリで、iOS実機でのみ画面遷移後に描画が大幅に遅延する問題に遭遇しました。シミュレーターでは再現せず、実機でだけ発生するという厄介なバグです。
本記事では、原因の特定から修正までのプロセスを共有します。同じSwiftUI版のアプリが存在していたため、SwiftUIとの比較を通じて「なぜReact Nativeでだけ起きるのか」も深掘りします。
問題の症状
オンボーディングフローの「トピック提案画面」で、以下の症状が発生しました。
- 画面遷移後、数秒間まったく描画されない(真っ暗)
- その後、画面の半分だけが突然描画される
- 最終的に全体が描画されるが、アニメーションのタイミングがずれる
再現環境:
- iOS実機(iPhone)のみ — シミュレーターでは再現しない
- 画面内にLinearGradientを多用したカードコンポーネント(3枚)
- カードにはreact-native-reanimatedによるアニメーション付き
原因調査
調査の結果、3つの根本原因が複合的に描画遅延を引き起こしていることが分かりました。
原因1: useEffect と SwiftUIの .onAppear のタイミング差
これが最も根本的な原因でした。
React Nativeの useEffect は、Reactのcommitフェーズの後に実行されますが、ネイティブビューが実際に画面に描画されたことを保証しません。つまり、描画が完了する前にアニメーションが開始され、初回描画とアニメーション計算がメインスレッドを奪い合います。
一方、SwiftUIの .onAppear はビューが画面に描画された後に発火します。
// SwiftUI — 描画完了後にアニメーション開始
.onAppear {
startCardAnimation()
}
// React Native — 描画完了を待たずにアニメーション開始
useEffect(() => {
const timer1 = setTimeout(() => setCurrentCardIndex(1), 1500);
const timer2 = setTimeout(() => setCurrentCardIndex(2), 3000);
return () => { clearTimeout(timer1); clearTimeout(timer2); };
}, []);
原因2: LinearGradientのGPUシェーダーコンパイル
各カードには3つの LinearGradient が含まれていました。
- 背景グラデーション(purple → teal)
- ボーダーグラデーション(ストローク効果)
- 「Personalized」タグのグラデーション
3枚のカード × 3グラデーション + タップインジケーター = 計10個のCAGradientLayer がiOSネイティブ側で生成されます。
iOS実機では、各 CAGradientLayer の初回レンダリングにGPUシェーダーのコンパイルが必要で、これがレンダリングパイプラインをブロックします。シミュレーターはソフトウェアレンダリングを使用するため、この問題が顕在化しません。
原因3: React.memo の欠如による不要な再レンダー
カードコンポーネントに React.memo が適用されていなかったため、親の currentCardIndex が変わるたびに全3枚のカードが再レンダーされていました。
react-native-reanimated のアニメーション自体はUIスレッドで動作しますが、Reactの差分検出(reconciliation)はJSスレッドで全子コンポーネントに対して実行されるため、不要な負荷がかかっていました。
修正内容
最終的に採用した修正は以下の2つです。
修正A: InteractionManager.runAfterInteractions で描画完了を待つ
React Nativeの InteractionManager.runAfterInteractions は、進行中のアニメーションやトランジションが完了した後にコールバックを実行します。これはSwiftUIの .onAppear に相当する動作です。
加えて、isReady ステートを導入し、描画完了まで各カードのアニメーションを抑止します。
import {
View,
Text,
StyleSheet,
TouchableOpacity,
+ InteractionManager,
} from 'react-native';
親コンポーネント(TopicSuggestionView):
const [currentCardIndex, setCurrentCardIndex] = useState(0);
+const [isReady, setIsReady] = useState(false);
useEffect(() => {
- const timer1 = setTimeout(() => setCurrentCardIndex(1), 1500);
- const timer2 = setTimeout(() => setCurrentCardIndex(2), 3000);
-
- return () => {
- clearTimeout(timer1);
- clearTimeout(timer2);
- };
+ // 初回描画の完了を待ってからアニメーションを開始
+ const handle = InteractionManager.runAfterInteractions(() => {
+ setIsReady(true);
+
+ const timer1 = setTimeout(() => setCurrentCardIndex(1), 1500);
+ const timer2 = setTimeout(() => setCurrentCardIndex(2), 3000);
+
+ handle.cancel = () => {
+ clearTimeout(timer1);
+ clearTimeout(timer2);
+ };
+ });
+
+ return () => {
+ handle.cancel();
+ };
}, []);
子コンポーネント(OnboardingCard):
-const flipRotation = useSharedValue(-10);
+const flipRotation = useSharedValue(0);
const flipScale = useSharedValue(0.95);
const offsetX = useSharedValue(0);
-const offsetY = useSharedValue((index - currentCardIndex) * 8);
+const offsetY = useSharedValue(index * 8);
useEffect(() => {
+ if (!isReady) return; // 描画完了まで何もしない
+
if (isActive) {
flipRotation.value = withTiming(0, { duration: 600 });
// ...
}
-}, [currentCardIndex]);
+}, [currentCardIndex, isReady]);
ポイントは3つです。
-
InteractionManager.runAfterInteractionsで画面遷移アニメーション完了後にアニメーションを開始 -
isReadyガードにより、各カードのアニメーションuseEffectを描画完了まで抑止 -
SharedValueの初期値を安定化 —
flipRotationを-10→0に変更し、初期描画時のレイアウトシフトを防止
修正B: React.memo によるカードの再レンダー防止
-const OnboardingCard: React.FC<{
+const OnboardingCardInner: React.FC<{
card: CardData;
index: number;
currentCardIndex: number;
isFinal: boolean;
showTapIndicator: boolean;
+ isReady: boolean;
-}> = ({ card, index, currentCardIndex, isFinal, showTapIndicator }) => {
+}> = ({ card, index, currentCardIndex, isFinal, showTapIndicator, isReady }) => {
// ...
};
+
+const OnboardingCard = React.memo(OnboardingCardInner);
React.memo でラップすることで、propsが変化していないカードの再レンダーをスキップします。currentCardIndex は全カードに渡されるため完全に再レンダーが止まるわけではありませんが、Reactの差分検出コストを軽減し、初期描画の負荷を下げます。
SwiftUI版との比較
同じ画面をSwiftUIでも実装しており、そちらでは描画遅延は発生しませんでした。両者の違いを比較します。
| 観点 | SwiftUI | React Native(修正前) |
|---|---|---|
| アニメーション開始タイミング |
.onAppear(描画完了後に発火) |
useEffect(描画完了を保証しない) |
| アニメーションシステム |
.animation(.spring(), value:) 1つで制御 |
SharedValue × 5 × 3枚 = 15個を個別に制御 |
| 再レンダー | SwiftUIの差分更新(変化したViewのみ) | 全カードが再レンダー(memo なし) |
| グラデーション |
CAGradientLayer(OS最適化済み) |
expo-linear-gradient経由で同じCAGradientLayer
|
| 画面遷移との連携 | OSレベルで遷移完了を管理 | JSブリッジ経由で非同期 |
SwiftUIでは .onAppear がビュー階層への追加後に発火するため、アニメーション開始と初回描画が競合しません。React Nativeでは useEffect がこの保証をしないため、InteractionManager で明示的に制御する必要がありました。
まとめ
学び
-
useEffect≠.onAppear— React NativeのuseEffectは描画完了を保証しない。iOS実機でアニメーションを伴う画面ではInteractionManager.runAfterInteractionsを使って描画完了を待つべき -
React.memoはアニメーションコンポーネントで特に重要 —react-native-reanimatedのアニメーション自体はUIスレッドで動くが、Reactのreconciliationは全子コンポーネントに波及する -
シミュレーターと実機の差 —
CAGradientLayerのGPUシェーダーコンパイルはシミュレーターのソフトウェアレンダリングでは再現しない。iOS描画パフォーマンスは必ず実機で検証すべき
応用パターン: isReady ガード
今回使用した isReady パターンは、重いコンポーネントの初期表示を最適化する汎用的なテクニックです。
const [isReady, setIsReady] = useState(false);
useEffect(() => {
const handle = InteractionManager.runAfterInteractions(() => {
setIsReady(true);
});
return () => handle.cancel();
}, []);
// isReady が true になるまで重い処理をスキップ
useEffect(() => {
if (!isReady) return;
// アニメーション開始、データフェッチなど
}, [isReady]);
画面遷移直後にアニメーションやデータ取得を行う場面で、この InteractionManager + isReady の組み合わせは広く活用できます。
Discussion