Rork で作ったアプリをリリースしてから数週間後、App Store Connect のダッシュボードを開いて「クラッシュ率が 2.8% になっています」という警告を初めて見たとき、どう対処すればいいかわからず困りました。
Rork は優秀なコードを生成しますが、本番環境の品質管理——クラッシュ率の追跡、起動時間の計測、応答不能(ANR)の検出——は開発者が設計しなければなりません。ここで扱うのはRork アプリを本番品質で維持するためのメトリクス管理の全体像を解説します。
App Store と Google Play の品質基準を理解する
まず、プラットフォームが何を見ているかを把握します。品質指標はランキングに直結するため、ここを無視すると「いいアプリなのに検索で上位に出ない」という事態になります。
App Store の主要品質指標:
クラッシュ率(Crash Rate)は最重要です。Apple は「セッションのうちクラッシュが発生した割合」を測定します。業界標準として 0.5% 未満 を目標にします。1% を超えると App Store のレビューで「クラッシュする」コメントが増え始め、2% を超えると検索順位に明確な影響が出ます。
ハングレート(Hang Rate)も重要です。メインスレッドが 250ms 以上ブロックされた割合です。ユーザーには「操作が固まった」と感じられます。
Google Play の Android Vitals:
ANR(Application Not Responding)レート: 24 時間に一度以上使ったユーザーのうち、ANR が発生した割合。0.47% 未満 が Google Play の「良好なしきい値」です。
クラッシュレート: 24 時間に一度以上使ったユーザーのうち、クラッシュが発生した割合。1.09% 未満 が良好とされます。
これらを超えると Google Play コンソールで警告が表示され、検索順位にペナルティがかかります。
Firebase Crashlytics のセットアップと活用
Firebase Crashlytics は iOS/Android 両方のクラッシュを一元管理できるため、Rork アプリには最適です。基本的な設定は expo-firebase-crashlytics で行いますが、Rork 向けには追加の設定が必要です。
// lib/crashReporting.ts
import crashlytics from '@react-native-firebase/crashlytics';
interface ErrorContext {
userId?: string;
screen?: string;
action?: string;
metadata?: Record<string, string | number | boolean>;
}
class CrashReporter {
private static instance: CrashReporter;
static getInstance(): CrashReporter {
if (!this.instance) {
this.instance = new CrashReporter();
}
return this.instance;
}
async setUser(userId: string): Promise<void> {
await crashlytics().setUserId(userId);
}
async recordError(error: Error, context?: ErrorContext): Promise<void> {
// コンテキスト情報を付与してからエラーを記録
if (context?.screen) {
await crashlytics().setAttribute('screen', context.screen);
}
if (context?.action) {
await crashlytics().setAttribute('action', context.action);
}
if (context?.userId) {
await crashlytics().setUserId(context.userId);
}
// カスタムメタデータを追加
if (context?.metadata) {
for (const [key, value] of Object.entries(context.metadata)) {
await crashlytics().setAttribute(key, String(value));
}
}
crashlytics().recordError(error);
}
async log(message: string): Promise<void> {
crashlytics().log(message);
}
}
export const crashReporter = CrashReporter.getInstance();
重要なのは エラーコンテキストの記録 です。「どの画面で」「何の操作をしていたときに」クラッシュしたかが分からないと、再現も修正もできません。
// app/_layout.tsx
import { ErrorBoundary } from '@/components/ErrorBoundary';
import { crashReporter } from '@/lib/crashReporting';
// React Navigation のナビゲーション追跡
const navigationRef = useNavigationContainerRef();
useEffect(() => {
const unsubscribe = navigationRef.addListener('state', () => {
const currentRoute = navigationRef.getCurrentRoute();
if (currentRoute?.name) {
crashReporter.log(`Screen: ${currentRoute.name}`);
crashlytics().setAttribute('current_screen', currentRoute.name);
}
});
return unsubscribe;
}, []);
ナビゲーション状態を Crashlytics に送り続けることで、「UserProfileScreen でクラッシュ」という情報が取れるようになります。
Error Boundary による React クラッシュの補足
React コンポーネントのエラーはネイティブクラッシュとは別に補足する必要があります。
// components/ErrorBoundary.tsx
import React, { Component, ErrorInfo } from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import crashlytics from '@react-native-firebase/crashlytics';
interface Props {
children: React.ReactNode;
fallback?: React.ReactNode;
screenName?: string;
}
interface State {
hasError: boolean;
error?: Error;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
// Crashlytics にコンテキスト付きで記録
crashlytics().setAttribute(
'component_stack',
info.componentStack?.slice(0, 500) ?? 'unknown'
);
if (this.props.screenName) {
crashlytics().setAttribute('error_boundary_screen', this.props.screenName);
}
crashlytics().recordError(error);
}
render() {
if (this.state.hasError) {
return this.props.fallback ?? (
<View style={styles.container}>
<Text style={styles.title}>問題が発生しました</Text>
<Text style={styles.message}>
申し訳ありません。問題を修正するために情報を送信しました。
</Text>
<TouchableOpacity
style={styles.button}
onPress={() => this.setState({ hasError: false })}
>
<Text style={styles.buttonText}>もう一度試す</Text>
</TouchableOpacity>
</View>
);
}
return this.props.children;
}
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
title: { fontSize: 20, fontWeight: '600', marginBottom: 8 },
message: { fontSize: 15, color: '#666', textAlign: 'center', marginBottom: 24 },
button: { backgroundColor: '#007AFF', paddingHorizontal: 24, paddingVertical: 12, borderRadius: 8 },
buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
});
Xcode Instruments でパフォーマンスを計測する
クラッシュ以外に重要なのが 起動時間 と メモリ使用量 です。これらは Xcode Instruments で計測します。
Rork Max でビルドした iOS アプリに対して、Instruments の Time Profiler を使って起動シーケンスを分析します。
// 起動時間の自前計測(Instruments と併用)
// app/_layout.tsx
const appStartTime = useRef<number>(Date.now());
useEffect(() => {
// 最初のインタラクション可能状態になった時点を記録
const timeToInteractive = Date.now() - appStartTime.current;
// Firebase Analytics に送信
analytics().logEvent('app_launch_time', {
time_to_interactive_ms: timeToInteractive,
platform: Platform.OS,
build_version: Constants.expoConfig?.version ?? 'unknown',
});
// 目標: iOS 2 秒未満、Android 3 秒未満
if (timeToInteractive > 3000) {
crashlytics().log(
`Slow startup detected: ${timeToInteractive}ms`
);
}
}, []);
Instruments で確認すべき主なツール:
Time Profiler: CPU 使用率とスタックトレース。起動時に何の処理に時間がかかっているかが分かります。
Allocations: メモリの割り当て状況。画面遷移を繰り返したときにメモリが増え続けていないか(メモリリーク)を確認します。
Leaks: 参照カウントが 0 にならないオブジェクトを自動検出します。
Rork 生成コードで特に注意が必要なのは、クロージャ内での self の強参照です。
// ⚠️ メモリリークの例(Rork が生成したコードで時々出るパターン)
class ViewModel: ObservableObject {
var timer: Timer?
func startTimer() {
// self を強参照するとタイマーが解放されない
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
self.update() // ← ここが問題
}
}
}
// ✅ 修正版
func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
self?.update()
}
}
Rork に「このコードのメモリリークを確認して」と指示すれば修正してくれますが、Instruments で実際に確認してから指示を出すほうが確実です。
Android Vitals の監視と ANR 対策
Android は iOS より ANR(Application Not Responding)の問題が起きやすいです。主な原因はメインスレッドでの重い処理です。
// React Native での重い処理は InteractionManager で遅延
import { InteractionManager } from 'react-native';
async function processLargeDataset(items: any[]): Promise<void> {
// アニメーション完了後に実行
await new Promise<void>((resolve) => {
InteractionManager.runAfterInteractions(() => {
resolve();
});
});
// ここで重い処理を実行
const processed = items.map(item => expensiveTransform(item));
return processed;
}
より重い処理は expo-task-manager か JavaScript の setTimeout で分割します。
// 大量データの分割処理
async function processInChunks<T>(
items: T[],
processor: (item: T) => T,
chunkSize: number = 100
): Promise<T[]> {
const results: T[] = [];
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
const processed = chunk.map(processor);
results.push(...processed);
// メインスレッドに制御を返す
await new Promise(resolve => setTimeout(resolve, 0));
}
return results;
}
CIパイプラインへの品質ゲートの組み込み
ここまでの計測を手動でやり続けるのは限界があります。GitHub Actions + EAS Build で品質チェックを自動化します。
# .github/workflows/quality-gate.yml
name: App Quality Gate
on:
push:
branches: [main, 'release/*']
jobs:
quality-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: TypeScript type check
run: npx tsc --noEmit
- name: ESLint (no-any strict)
run: npx eslint . --ext .ts,.tsx --rule '{"@typescript-eslint/no-explicit-any": "error"}'
- name: Unit tests with coverage
run: npx jest --coverage --passWithNoTests
env:
COVERAGE_THRESHOLD: '70'
- name: Check bundle size
run: |
npx expo export --platform ios 2>&1 | grep "Bundle size"
# 10MB を超えたらエラー(任意のしきい値)
- name: Crashlytics threshold check
run: |
# Firebase Management API でクラッシュ率を取得
CRASH_RATE=$(curl -s "https://firebaseappdistribution.googleapis.com/..." \
-H "Authorization: Bearer ${{ secrets.FIREBASE_TOKEN }}" \
| jq '.crashRate')
if (( $(echo "$CRASH_RATE > 0.5" | bc -l) )); then
echo "❌ Crash rate ${CRASH_RATE}% exceeds threshold 0.5%"
exit 1
fi
echo "✅ Crash rate ${CRASH_RATE}% within threshold"
クラッシュ率が閾値を超えている状態で新しいビルドをリリースするのを防ぐことで、品質の劣化を早期に止められます。
個人開発者の視点から(実体験メモ)
品質改善サイクルの実際
品質管理は「計測 → 分析 → 修正 → 確認」のサイクルを回すことが本質です。私のルーティンを共有します。
毎日(5分): Firebase Crashlytics のダッシュボードでクラッシュ数トップ 3 を確認。新規クラッシュがあればスタックトレースを Rork に貼って修正依頼。
毎週(30分): App Store Connect Analytics でセッション時間・クラッシュ率・ハングレートの週次トレンドを確認。Google Play Console の Android Vitals も同様に。
リリース前(2時間): Instruments で起動時間計測 → 前バージョンと比較。クラッシュフリーレートが前バージョンを下回っていないことを確認。
この習慣を続けることで、本番環境でのクラッシュ率を 2.8% から 0.3% まで下げることができました。品質が上がると、App Store のレビューの平均点も自然と上がります。
Rork はコードを速く生成してくれますが、品質管理のループを回すのは開発者の仕事です。ここに時間を投資することが、長期的なアプリの成功につながります。