取り組みの背景:パフォーマンスがアプリの命運を決める
Rork でアプリを構築する際、機能の実装に集中するあまり、パフォーマンス最適化を後回しにしてしまうことは珍しくありません。しかし、App Store のレビューや競合との差別化において、「速さ」と「滑らかさ」は決定的な要素です。実際、Google の調査によれば、読み込み時間が 1 秒から 3 秒になるだけで直帰率は 32% 上昇し、ユーザー定着率にも大きな影響を与えます。
第1章:React Native のレンダリングを深く理解する
React のレンダリングサイクル
React Native は、JavaScript スレッドと UI スレッド(メインスレッド)を分離した架け台構造を持っています。状態(state)や props が変更されると、React は仮想 DOM を再計算し、差分(diff)を UI スレッドに送信します。この「ブリッジ通信」がボトルネックになりがちです(新アーキテクチャの JSI では改善されていますが)。
最も頻繁に起こる問題は「不要な再レンダリング」です。親コンポーネントが再レンダリングされると、デフォルトでは子コンポーネントもすべて再レンダリングされます。これを防ぐのが React.memo と useCallback / useMemo です。
React.memo で不要な再描画を防ぐ
// ❌ 悪い例:親が更新されるたびに Card も再描画される
const Card = ({ title, onPress }) => {
console.log('Card rendered:', title);
return <TouchableOpacity onPress={onPress}><Text>{title}</Text></TouchableOpacity>;
};
// ✅ 良い例:propsが変わらない限り再描画しない
const Card = React.memo(({ title, onPress }) => {
console.log('Card rendered:', title);
return <TouchableOpacity onPress={onPress}><Text>{title}</Text></TouchableOpacity>;
});
// 親コンポーネントでの useCallback が必須
const ParentScreen = () => {
const [count, setCount] = useState(0);
// ❌ これがあると Card は毎回再描画される(関数が毎回新しく作られるため)
// const handlePress = () => console.log('pressed');
// ✅ useCallback で関数を安定させる
const handlePress = useCallback(() => {
console.log('pressed');
}, []); // 依存配列が空なら初回のみ作成
return (
<View>
<Button onPress={() => setCount(c => c + 1)} title="Increment" />
<Card title="Item" onPress={handlePress} />
</View>
);
};
useMemo で重い計算をキャッシュする
const ProductList = ({ products, searchQuery }) => {
// ❌ 毎レンダリング時にフィルタリングが走る
// const filtered = products.filter(p => p.name.includes(searchQuery));
// ✅ searchQuery か products が変わった時だけ再計算
const filtered = useMemo(() => {
return products.filter(p =>
p.name.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [products, searchQuery]);
return <FlatList data={filtered} renderItem={/* ... */} />;
};
第2章:FlatList の徹底チューニング
Rork で生成されるリスト表示の多くは FlatList を使用しています。FlatList のパフォーマンスチューニングは、アプリ全体の体験に直結します。
getItemLayout で高さを事前宣言する
const ITEM_HEIGHT = 72; // アイテムの固定高さ(px)
const SEPARATOR_HEIGHT = 1;
<FlatList
data={items}
keyExtractor={(item) => item.id.toString()}
renderItem={({ item }) => <ListItem item={item} />}
// ✅ アイテム高さを事前計算することで、スクロール計算が高速化される
getItemLayout={(data, index) => ({
length: ITEM_HEIGHT,
offset: (ITEM_HEIGHT + SEPARATOR_HEIGHT) * index,
index,
})}
// ✅ 画面外のアイテムをどれだけプリレンダリングするか(デフォルト10)
// 数値を下げると初期表示が速くなるが、スクロール時に空白が出やすくなる
maxToRenderPerBatch={5}
initialNumToRender={8}
windowSize={5} // 表示領域の何倍をレンダリングするか(デフォルト21)
// ✅ リストアイテムの内側でさらに最適化
removeClippedSubviews={true}
/>
リストアイテムを React.memo + useCallback でラップする
// ✅ アイテムコンポーネントを純粋にする
const ListItem = React.memo(({ item, onPress }) => {
return (
<TouchableOpacity onPress={() => onPress(item.id)}>
<Text>{item.name}</Text>
</TouchableOpacity>
);
}, (prevProps, nextProps) => {
// カスタム比較関数:idとnameが同じなら再描画しない
return prevProps.item.id === nextProps.item.id &&
prevProps.item.name === nextProps.item.name;
});
// 親側でonPressをuseCallbackに
const onPressItem = useCallback((id) => {
navigation.navigate('Detail', { id });
}, [navigation]);
第3章:Flipper と Hermes Profiler でボトルネックを特定する
Flipper のセットアップ
Flipper は React Native 専用のデバッグツールで、パフォーマンス分析に欠かせません。Rork Max 環境でも標準的な Expo / React Native の設定がベースになるため、以下の手順で利用できます。
# Flipper のインストール(Mac)
brew install --cask flipper
# iOS シミュレーターで起動後、Flipper が自動検出
# React Native Performance Monitor プラグインを追加する
Flipper の「React DevTools」タブでコンポーネントツリーを確認し、「Hermes Debugger」タブでプロファイリングを実行します。
JavaScript スレッドの計測
// パフォーマンスマーカーで重い処理を計測
import { performance } from 'react-native-performance';
const measureHeavyOperation = async () => {
performance.mark('operation-start');
// 重い処理(例:大量データのソート)
const sorted = largeDataset.sort((a, b) => b.score - a.score);
performance.mark('operation-end');
performance.measure('heavy-operation', 'operation-start', 'operation-end');
const measure = performance.getEntriesByName('heavy-operation')[0];
console.log(`処理時間: ${measure.duration.toFixed(2)}ms`);
// 期待する出力: 処理時間: 3.45ms(16ms以下であれば60fps維持可能)
};
Flipper で見るべき主要指標
以下の数値を基準にパフォーマンスを評価します。
- JS FPS: 常時 60fps が理想。スクロール中に 45fps 以下に落ちる場合は要改善
- UI FPS: 60fps が必須。アニメーション中に落ちると体感的に「カクつく」
- JS Thread CPU: ピーク時 80% 超は危険。重い計算を
InteractionManager.runAfterInteractions か setTimeout で遅延実行する
- Memory Usage(Hermes Heap): セッション中に線形増加し続ける場合はメモリリークの可能性がある
第4章:メモリリークを根絶する
メモリリークはクラッシュの主要原因であり、特に長時間使用するアプリで致命的です。
useEffect のクリーンアップを必ず実装する
const ChatScreen = () => {
useEffect(() => {
// WebSocket接続
const socket = new WebSocket('wss://api.example.com/chat');
socket.onmessage = (event) => {
setMessages(prev => [...prev, JSON.parse(event.data)]);
};
// ✅ クリーンアップ関数でリソースを解放(コンポーネントアンマウント時に実行)
return () => {
socket.close();
};
}, []);
// ❌ よくある間違い:タイマーのクリーンアップ忘れ
// useEffect(() => {
// setInterval(() => fetchData(), 5000); // リークする!
// }, []);
// ✅ タイマーは必ずクリア
useEffect(() => {
const intervalId = setInterval(() => fetchData(), 5000);
return () => clearInterval(intervalId);
}, []);
};
イベントリスナーのリーク対策
import { AppState, Keyboard } from 'react-native';
const MyScreen = () => {
useEffect(() => {
// AppState変化の監視
const subscription = AppState.addEventListener('change', handleAppStateChange);
const keyboardShow = Keyboard.addListener('keyboardDidShow', handleKeyboardShow);
return () => {
// ✅ 購読解除
subscription.remove();
keyboardShow.remove();
};
}, []);
};
メモリリーク検出スクリプト
// 開発環境でのメモリ使用量の定期ログ(Hermes 環境)
if (__DEV__) {
setInterval(() => {
if (global.gc) global.gc(); // ガベージコレクション強制実行
// Flipper Memory プラグインで確認
console.log('[Memory Check] Interval running');
}, 10000);
}
第5章:アニメーション 60fps を守る
Reanimated 3 のワークレットで JS スレッドを回避する
Rork で生成されるアニメーションは Animated API か react-native-reanimated を使います。60fps を安定維持するには、アニメーション計算を UI スレッドで完結させる Reanimated 3 のワークレットが必須です。
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
runOnJS,
} from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
const SwipeCard = ({ onSwipeLeft, onSwipeRight }) => {
const translateX = useSharedValue(0);
const rotate = useSharedValue(0);
const panGesture = Gesture.Pan()
.onUpdate((event) => {
// ✅ ワークレット内で完結:JSスレッドを介さず UI スレッドで処理
translateX.value = event.translationX;
rotate.value = event.translationX / 20; // 傾き計算
})
.onEnd((event) => {
const threshold = 120;
if (event.translationX > threshold) {
translateX.value = withTiming(500, { duration: 300 });
runOnJS(onSwipeRight)(); // JSスレッドのコールバックを安全に呼ぶ
} else if (event.translationX < -threshold) {
translateX.value = withTiming(-500, { duration: 300 });
runOnJS(onSwipeLeft)();
} else {
// 元の位置に戻す(スプリングアニメーション)
translateX.value = withSpring(0);
rotate.value = withSpring(0);
}
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: translateX.value },
{ rotate: `${rotate.value}deg` },
],
}));
return (
<GestureDetector gesture={panGesture}>
<Animated.View style={[styles.card, animatedStyle]}>
{/* カードの中身 */}
</Animated.View>
</GestureDetector>
);
};
// 期待する動作:スワイプ時に JS スレッドのドロップなく 60fps を維持
第6章:バンドルサイズの削減
バンドルサイズが大きいと初回起動時間が伸び、App Store での審査も遅くなります。
Metro Bundler の設定で未使用コードを除外する
// metro.config.js
const { getDefaultConfig } = require('expo/metro-config');
const config = getDefaultConfig(__dirname);
// ✅ ソースマップを本番では除外(APK/IPAサイズ削減)
config.transformer.minifierConfig = {
keep_fnames: false,
mangle: { keep_fnames: false },
compress: {
drop_console: true, // console.log を本番で除去
},
};
module.exports = config;
ライブラリの選択でサイズをコントロールする
# バンドルサイズを分析する(Expo)
npx expo export --dump-sourcemap
npx source-map-explorer dist/_expo/static/js/web/*.js
# よくある肥大化の原因と代替
# ❌ moment.js(約300KB) → ✅ date-fns(ツリーシェイク可能)
# ❌ lodash 全体 → ✅ lodash-es または個別インポート
# ❌ 高解像度 SVG アイコンセット全体 → ✅ 使用するアイコンのみ import
画像最適化
import { Image } from 'expo-image'; // react-native の Image より高速
// ✅ expo-image は blurhash プレースホルダー・キャッシュ・WebP 対応
<Image
source={{ uri: 'https://example.com/photo.jpg' }}
placeholder={{ blurhash: 'L6PZfSi_.AyE_3t7t7R**0o#DgR4' }}
contentFit="cover"
transition={300}
cachePolicy="memory-disk" // メモリとディスクの両方にキャッシュ
style={{ width: 200, height: 200 }}
/>
// ✅ ローカル画像は @2x @3x を用意して解像度を最適化
// assets/images/logo.png
// assets/images/logo@2x.png (2倍密度ディスプレイ用)
// assets/images/logo@3x.png (3倍密度ディスプレイ用)
第7章:起動時間の最適化
アプリの起動時間は最初の印象を左右します。「3秒以内」を目標にしましょう。
起動時の重い処理を遅延させる
import { InteractionManager } from 'react-native';
const HomeScreen = () => {
const [isReady, setIsReady] = useState(false);
useEffect(() => {
// ✅ アニメーション完了後に重い処理を実行(起動時のカクつき防止)
const task = InteractionManager.runAfterInteractions(() => {
// 分析SDK初期化、キャッシュのウォームアップなど重い処理
initAnalytics();
prefetchRecommendations();
setIsReady(true);
});
return () => task.cancel();
}, []);
return (
<View>
{/* スプラッシュ画面的な最初の表示は軽く保つ */}
<HeroSection />
{isReady && <HeavyContentSection />}
</View>
);
};
コード分割と動的 import
import { lazy, Suspense } from 'react';
// ✅ 重いスクリーンを動的インポートで分割(Web / Expo Router の場合)
const HeavyScreen = lazy(() => import('./screens/HeavyScreen'));
const App = () => (
<Suspense fallback={<LoadingSpinner />}>
<HeavyScreen />
</Suspense>
);
第8章:ネットワーク・API パフォーマンス最適化
TanStack Query でキャッシュとサーバー状態を最適化する
Rork アプリで API 連携する場合、TanStack Query(React Query)を使うとデータのキャッシュ・再取得・最適化が劇的に改善します(TanStack Query × Rork データフェッチ完全ガイドも参照してください)。
import { useQuery, useQueryClient } from '@tanstack/react-query';
// ✅ キャッシュ設定でAPI呼び出しを最小化
const { data: products } = useQuery({
queryKey: ['products', categoryId],
queryFn: () => fetchProducts(categoryId),
staleTime: 5 * 60 * 1000, // 5分間はキャッシュが「新鮮」とみなされる
gcTime: 30 * 60 * 1000, // 30分間キャッシュを保持
});
// ✅ プリフェッチで次の画面を先読み
const queryClient = useQueryClient();
const handleProductHover = (productId) => {
queryClient.prefetchQuery({
queryKey: ['product', productId],
queryFn: () => fetchProductDetail(productId),
});
};
画像のプリロードとキャッシュ
import { Image } from 'expo-image';
// ✅ リスト表示前にアイテム画像をプリロード
useEffect(() => {
const urls = items.map(item => ({ uri: item.imageUrl }));
Image.prefetch(urls);
}, [items]);
第9章:実機でのパフォーマンス検証チェックリスト
最適化の成果を確認するために、実機テストのチェックリストを設けます。シミュレーターでは再現しない問題が実機では多く発生するため、必ず実機で検証してください。
起動時間(コールドスタート)
- アプリを完全にバックグラウンドから削除
date コマンドで起動時刻を記録し、コンテンツが表示されるまでの時間を計測
- 目標: 3 秒以内(理想は 1.5 秒以内)
スクロール FPS 検証
- Xcode の Instruments(Time Profiler)か Android Profiler でスクロール中の FPS を測定
- 大量アイテムリスト(100件以上)を連続スクロール
- 目標: 常時 55fps 以上
メモリ使用量の監視
- Xcode Instruments(Allocations / Leaks)を使って 10 分間アプリを操作
- メモリが右肩上がりに増え続けないか確認
- 目標: セッション中のメモリ増加が 50MB 以内
個人開発者の視点から(実体験メモ)
まとめ
Rork アプリのパフォーマンス最適化は、一度で完成するものではなく、継続的な計測と改善のサイクルです。本記事で解説した手法をまとめると:
React.memo + useCallback + useMemo でレンダリングコストを削減
- FlatList の
getItemLayout + windowSize 調整でスクロールを滑らかに
- Flipper + Hermes Profiler でボトルネックを数値で特定
useEffect のクリーンアップを徹底してメモリリークを根絶
- Reanimated 3 のワークレットでアニメーションを 60fps に安定化
- Metro 設定と
expo-image でバンドルサイズと画像読み込みを最適化
InteractionManager で起動時の重い処理を適切に遅延
どれか1つでも適用するだけで、ユーザーが体感できる改善が得られます。まずは Flipper でプロファイリングを行い、最もインパクトの大きいボトルネックから着手することをおすすめします。
React Native のパフォーマンス最適化をより体系的に