壁紙アプリの Android 版に届いたレビューを読み返していた朝、同じ言葉が三つ並んでおりました。「固まる」「反応しない」「開いたまま白い」——どれにも、落ちたとは書かれていません。
けれども同じ時間帯のクラッシュレポートには、例外がきちんと記録されています。落ちてはいるのです。ただ、利用者の端末には「アプリが停止しました」という、あの見慣れたダイアログが出ていません。
この食い違いを、しばらく私は測定の不備だと思い込んでおりました。見ていた場所が違うのだと気づいたのは、レポートを何度も開き直したあとでした。
「落ちた」と「固まった」が食い違う理由
Expo のリポジトリに、同じ症状の報告が残っています。JS でもネイティブでもクラッシュすると Android が白い画面になる(expo/expo #41543) という Issue で、新アーキテクチャと expo-updates を併用した構成において、クラッシュのあとに Activity だけが残ってしまうという内容です。
残った Activity は、すでに破棄された React インスタンスを抱えています。描くものがありませんので、画面は白いままになります。そしてプロセス自体は生きていますので、OS のクラッシュダイアログも出ません。
利用者の目に映るのは、落ちたアプリではなく、開いたまま何も起きないアプリです。言葉になるときには「固まる」に変わります。
クラッシュを減らす設計と、クラッシュしたことが利用者に伝わる設計は、別々に用意するものです。 この二つを一つの作業だと思っていたあいだ、私はレポートの数字だけを眺めて、画面に何も置いておりませんでした。
JS 側で落ちたとき、白い画面に落ち込む前に掴む
まず手当てできるのは JS 側です。ここでの目的は原因の特定ではなく、利用者の手元に「次にできること」を一つ残すことに絞ります。
以下は、レンダリング中の例外を受け止め、再読み込みのボタンと最後の例外の控えを残す境界です。expo-updates の reloadAsync() は、破棄された 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() なしで直す — プロセス再起動に切り替えた判断に書き残しました。症状が同じでも、疑う層は変わります。
直す順番を、原因の特定から始めない
順番を間違えていたのは私のほうでした。原因が分かってから画面を直そうとしていたのです。けれども原因が分かるまでのあいだも、利用者は白い画面を見続けます。
いまは三段で置いております。
- 復帰の導線を先に置きます。境界と再読み込みのボタンだけなら、原因が未特定でも今日出せます。
- 前回異常終了の印を入れます。数字としてではなく、次の起動での振る舞いを変えるために使います。
- そのうえで原因を追います。クラッシュレポートの整備は、ここでようやく効いてきます。
一つめと二つめは、原因の理解をまったく必要としません。にもかかわらず、利用者の体験にいちばん早く届くのです。もっと巧みなやり方があるのかもしれませんが、いまの私にはこの順番がいちばん素直に感じています。
まず CrashBoundary をルートに一つ置いて、再読み込みのボタンだけ出してみてください。それだけで「固まる」というレビューの多くは、「読み込み直したら戻った」に変わっていきます。
最後までお読みくださり、ありがとうございました。同じ食い違いで手を止めている方に、この順番が届きましたら嬉しく思います。