RORK LABEN
SDK58BETA — Expo SDK 58 はベータ期間の最中です。公式が言っているのは「3〜4週間」だけで、安定版の日付は一次情報に出ていません。日付を決め打ちしない方が安全ですRN0.88RC1 — React Native 0.88 は9月16日の rc.1 まで来ました。正式リリース予定は10月12日で、SDK 58 の型まわりの変化はこれと対になっています11/01 — Google Play の対象 API レベル、延長申請組の配信期限は11月1日です。残り39日で、延長は Play Console のポリシー ステータスから一度だけ申請できますAUDIO — expo-audio が一定回数の再生のあと止まり、status.didJustFinish が来なくなるという報告です。例外は飛ばず、次が鳴らないだけですので気づきにくい形ですNEW — 1つの Checkout に4種類の商品を載せたあとの分岐設計LSAQS — lsapplicationqueriesschemes は検索での表示が22件あってクリックが0です。このサイトで最も大きい実装系の需要が、まだ誰にも答えられていませんSDK58BETA — Expo SDK 58 はベータ期間の最中です。公式が言っているのは「3〜4週間」だけで、安定版の日付は一次情報に出ていません。日付を決め打ちしない方が安全ですRN0.88RC1 — React Native 0.88 は9月16日の rc.1 まで来ました。正式リリース予定は10月12日で、SDK 58 の型まわりの変化はこれと対になっています11/01 — Google Play の対象 API レベル、延長申請組の配信期限は11月1日です。残り39日で、延長は Play Console のポリシー ステータスから一度だけ申請できますAUDIO — expo-audio が一定回数の再生のあと止まり、status.didJustFinish が来なくなるという報告です。例外は飛ばず、次が鳴らないだけですので気づきにくい形ですNEW — 1つの Checkout に4種類の商品を載せたあとの分岐設計LSAQS — lsapplicationqueriesschemes は検索での表示が22件あってクリックが0です。このサイトで最も大きい実装系の需要が、まだ誰にも答えられていません
記事一覧/アプリ開発
アプリ開発/2026-09-23中級

落ちたはずなのに「固まる」と書かれる — Android のクラッシュが白い画面のまま残るとき

クラッシュレポートには例外が並ぶのに、利用者からは「固まる」としか報告されないことがあります。expo-updates と新アーキテクチャの組み合わせで Android の画面が白いまま残る仕組みと、復帰導線・異常終了の検知・原因の特定を順番に置いていく手順をまとめます。

Expo212Android52クラッシュ5expo-updatesNew Architecture4

壁紙アプリの Android 版に届いたレビューを読み返していた朝、同じ言葉が三つ並んでおりました。「固まる」「反応しない」「開いたまま白い」——どれにも、落ちたとは書かれていません。

けれども同じ時間帯のクラッシュレポートには、例外がきちんと記録されています。落ちてはいるのです。ただ、利用者の端末には「アプリが停止しました」という、あの見慣れたダイアログが出ていません。

この食い違いを、しばらく私は測定の不備だと思い込んでおりました。見ていた場所が違うのだと気づいたのは、レポートを何度も開き直したあとでした。

「落ちた」と「固まった」が食い違う理由

Expo のリポジトリに、同じ症状の報告が残っています。JS でもネイティブでもクラッシュすると Android が白い画面になる(expo/expo #41543) という Issue で、新アーキテクチャと expo-updates を併用した構成において、クラッシュのあとに Activity だけが残ってしまうという内容です。

残った Activity は、すでに破棄された React インスタンスを抱えています。描くものがありませんので、画面は白いままになります。そしてプロセス自体は生きていますので、OS のクラッシュダイアログも出ません。

利用者の目に映るのは、落ちたアプリではなく、開いたまま何も起きないアプリです。言葉になるときには「固まる」に変わります。

クラッシュを減らす設計と、クラッシュしたことが利用者に伝わる設計は、別々に用意するものです。 この二つを一つの作業だと思っていたあいだ、私はレポートの数字だけを眺めて、画面に何も置いておりませんでした。

JS 側で落ちたとき、白い画面に落ち込む前に掴む

まず手当てできるのは JS 側です。ここでの目的は原因の特定ではなく、利用者の手元に「次にできること」を一つ残すことに絞ります。

以下は、レンダリング中の例外を受け止め、再読み込みのボタンと最後の例外の控えを残す境界です。expo-updatesreloadAsync() は、破棄された React インスタンスごと JS を読み直しますので、白い画面から抜けられます。

// components/CrashBoundary.tsx
import React from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as Updates from 'expo-updates';
 
type Props = { children: React.ReactNode };
type State = { failed: boolean };
 
const LAST_ERROR_KEY = 'diag:last_js_error';
 
export class CrashBoundary extends React.Component<Props, State> {
  state: State = { failed: false };
 
  static getDerivedStateFromError(): State {
    return { failed: true };
  }
 
  async componentDidCatch(error: Error, info: React.ErrorInfo) {
    // 次回起動で読み直せるように、端末側へ控えを残す
    await AsyncStorage.setItem(
      LAST_ERROR_KEY,
      JSON.stringify({
        at: new Date().toISOString(),
        message: error.message,
        stack: (error.stack ?? '').slice(0, 2000),
        componentStack: (info.componentStack ?? '').slice(0, 2000),
      }),
    );
  }
 
  handleReload = async () => {
    try {
      await Updates.reloadAsync();
    } catch {
      // 開発ビルドでは reloadAsync が使えないことがあるため、表示だけ戻す
      this.setState({ failed: false });
    }
  };
 
  render() {
    if (!this.state.failed) return this.props.children;
 
    return (
      <View style={styles.wrap}>
        <Text style={styles.title}>画面の読み込みに失敗しました</Text>
        <Text style={styles.body}>
          もう一度読み込みます。直らないときは、アプリを開き直してください。
        </Text>
        <Pressable style={styles.button} onPress={this.handleReload}>
          <Text style={styles.buttonLabel}>再読み込み</Text>
        </Pressable>
      </View>
    );
  }
}
 
const styles = StyleSheet.create({
  wrap: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 },
  title: { fontSize: 17, fontWeight: '600', marginBottom: 8 },
  body: { fontSize: 14, lineHeight: 21, textAlign: 'center', marginBottom: 20 },
  button: { paddingVertical: 12, paddingHorizontal: 24, borderRadius: 8, borderWidth: 1 },
  buttonLabel: { fontSize: 15 },
});

置き場所は、ナビゲーションよりも外側です。ルート直下で包んでおかないと、画面遷移の途中で落ちたときに境界の外へ抜けてしまいます。

控えを AsyncStorage に書いているのは、送信の成否に頼らないためです。通信が細い場所で落ちたときほど、レポートは届きません。手元に残しておけば、次の起動で読み直せます。

ネイティブ側で落ちたときは、次の起動でしか気づけません

JS の境界は、JS の例外しか受け止められません。ネイティブ層で落ちた場合、境界の render はそもそも呼ばれません。

ですので、前回の起動が正常に終わったかどうかを、自分で記録しておきます。起動時に印を置き、背景へ回るときに消しておきます。次の起動で印が残っていれば、前回は途中で終わったということです。

// lib/sessionMarker.ts
import { AppState, type AppStateStatus } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
 
const OPEN_KEY = 'diag:session_open';
 
/** 起動直後に一度だけ呼び、前回が異常終了だったかを返す */
export async function beginSession(): Promise<boolean> {
  const leftover = await AsyncStorage.getItem(OPEN_KEY);
  const endedBadly = leftover !== null;
 
  await AsyncStorage.setItem(OPEN_KEY, new Date().toISOString());
 
  const onChange = async (next: AppStateStatus) => {
    if (next === 'background' || next === 'inactive') {
      // 正常に離れたので印を消す
      await AsyncStorage.removeItem(OPEN_KEY);
    } else if (next === 'active') {
      await AsyncStorage.setItem(OPEN_KEY, new Date().toISOString());
    }
  };
 
  AppState.addEventListener('change', onChange);
  return endedBadly;
}

呼び出す側は、起動処理の中で一度だけ受け取ります。

// app/_layout.tsx の初期化部分
const endedBadly = await beginSession();
if (endedBadly) {
  // 前回が途中で終わっています。復帰動作をここに寄せる
  await AsyncStorage.removeItem('cache:last_render_state');
}

この印だけで原因は分かりません。分かるのは「前回は普通に終わらなかった」という一点です。それでも、白い画面のまま何度も開き直している利用者に対して、キャッシュを捨てて開き直すといった一手を自動で入れられます。

強制終了と異常終了の区別が付かない点は、そのまま残ります。私は区別を諦めて、どちらであっても安全側に倒すという扱いにしております。

レビューの言葉と、手元のログを突き合わせる

利用者は症状を技術の言葉では書きません。届いた言葉から、どの層を疑うかだけ先に決めておくと、調べ始めるまでが短くなります。

届いた言葉 画面の見え方 疑う層 最初の一手
開くと白い 起動直後から空 起動時の JS 例外 境界の控えを読む
途中で固まる 操作後に空へ変わる JS またはネイティブのクラッシュ 前回異常終了の印を確認
勝手に閉じる ホーム画面へ戻る プロセス終了・メモリ 端末のログを取得
更新してから変 版によって差が出る 配信中の更新内容 配信を止めて前版へ戻す

同じ白い画面でも、原因がまったく別のところにある例は珍しくありません。テーマの切り替えで白くなるときの話はテーマ切替の白画面を recreate() なしで直す — プロセス再起動に切り替えた判断に書き残しました。症状が同じでも、疑う層は変わります。

直す順番を、原因の特定から始めない

順番を間違えていたのは私のほうでした。原因が分かってから画面を直そうとしていたのです。けれども原因が分かるまでのあいだも、利用者は白い画面を見続けます。

いまは三段で置いております。

  1. 復帰の導線を先に置きます。境界と再読み込みのボタンだけなら、原因が未特定でも今日出せます。
  2. 前回異常終了の印を入れます。数字としてではなく、次の起動での振る舞いを変えるために使います。
  3. そのうえで原因を追います。クラッシュレポートの整備は、ここでようやく効いてきます。

一つめと二つめは、原因の理解をまったく必要としません。にもかかわらず、利用者の体験にいちばん早く届くのです。もっと巧みなやり方があるのかもしれませんが、いまの私にはこの順番がいちばん素直に感じています。

まず CrashBoundary をルートに一つ置いて、再読み込みのボタンだけ出してみてください。それだけで「固まる」というレビューの多くは、「読み込み直したら戻った」に変わっていきます。

最後までお読みくださり、ありがとうございました。同じ食い違いで手を止めている方に、この順番が届きましたら嬉しく思います。

シェア

お読みいただきありがとうございます

Rork Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • コピー&ペーストで使える実装コード付き
  • 毎日新しい上級ガイドを追加
  • ¥580/月 または ¥2,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

アプリ開発2026-09-16
消したはずのトークンが再起動で戻る — SecureStore の削除失敗を検知する設計
サインアウト直後に getItemAsync が null を返しても、Android のディスクには値が残っていることがあります。削除の成否を読み出しで確かめていた実装を、戻り値で判断する形へ書き直した記録です。
アプリ開発2026-08-18
Rork のプロジェクトで targetSdkVersion 36 を通すまでに直した3か所
app.json に targetSdkVersion 36 と書いてあるのに、ビルドが読んでいた値は 35 でした。実効値を報告するスクリプトと、引き上げ・更新停止・延長申請の三択の分け方を残します。
アプリ開発2026-06-25
リリースビルドだけ落ちる — Expo(Android) で R8 が剥がしたクラスを keep ルールで救う
AAB を小さくしようと R8 のコード圧縮を有効化したら、本番だけ特定画面でクラッシュ。剥がされたクラスを mapping.txt から突き止め、expo-build-properties で keep ルールを当てるまでの手順をまとめました。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます