審査結果の通知を開くと、「Guideline 2.1 - Performance - App Completeness」の下に「起動後、白い画面のまま先へ進めませんでした」と書かれています。手元の端末では普通に動いているのに、です。胃が痛くなる瞬間だと思います。
私自身、個人開発でアプリを何本も出してきて、審査で差し戻されたことは一度や二度ではありません。そのたびに最初にやってしまうのが、同じビルドをもう一度アップロードすることでした。結果は芳しくありませんでした。審査員の画面が白いのには理由があり、理由を特定しないまま再提出しても、同じ画面がもう一度返ってくるだけなのです。
最初にお伝えしたいのは、疑う順番です。環境変数の environment、secret の可視性、審査員の環境差。 この3か所を上から順に潰していくと、多くのケースで原因に行き着きます。
なお、この記事の確認コマンドや挙動は、Apple と Expo の公式ドキュメントの記述に沿って整理したものです。Apple Developer Forums に実際に投稿されていた同様の相談(Expo SDK 53 + EAS Build、Expo Go では地図のピンが出るのに TestFlight ビルドが白い画面)を読んだうえで、私が点検する順番に並べ直しています。投稿者の環境そのものを再現したわけではない点は、先にお断りしておきます。
差し戻し文面から読み取れること、読み取れないこと
Guideline 2.1 の (a) は、提出物が最終版で、実機でテストされ、ログインがあるならデモアカウントを入れ、バックエンドも稼働させておくこと、そしてクラッシュするバイナリや明らかな技術的問題があるものは差し戻す、という内容です。つまり「白い画面」という指摘は、クラッシュなのか、通信待ちで止まっているのか、設定値が空なのかを区別してくれていません。
読み取れるのは、審査員の環境で、あなたのビルドが最初の画面を描けなかったという事実だけです。原因が JavaScript 側にあるのか、ネイティブ側にあるのかも、この一文からは分かりません。
そこで私は、原因を次の3つに分けて考えています。
- 設定の問題:ビルドに入るはずの値が入っていない
- 可視性の問題:値は入っているが、config の解決時点で読めていない
- 環境差の問題:ビルドは正しいが、審査員の環境ではデータが取れない
上から順に、症状の見分け方と直し方を書いていきます。
開発時と審査時で、何が変わっているのか
Expo Go や開発ビルドで動いた事実は、リリースビルドが動く証拠になりません。動かす主体が変わるからです。
| 観点 | 手元(Expo Go・開発ビルド) | 審査(TestFlight・ストアビルド) |
|---|---|---|
| JS バンドル | Metro が開発サーバから配信 | ビルド時に埋め込み済み |
| 環境変数 | ローカルの .env を読む | EAS の environment から取り込まれる |
| ログイン状態 | 開発者のアカウントで通過済み | 未ログインの新規インストール |
| 権限 | 許可済みのことが多い | 初回で未許可、または拒否 |
| バックエンド | 開発用 API に接続 | 本番 API(停止・IP 制限があると応答しない) |
この表の中で、白い画面に直結しやすいのは「環境変数」の行です。Expo のドキュメントによれば、EXPO_PUBLIC_ で始まる変数は、バンドルする時点で値そのものに置き換えられます。ビルドの時点で値が無ければ、アプリの中でも値は無いままです。しかも、そのこと自体はエラーとして表に出てきません。
疑う1: 値が「本番用の environment」に入っているか
症状の見分け方は単純です。Expo Go では動くのに、ストア向けビルドだけが白くなります。この組み合わせなら、まず環境変数を疑います。
EAS Build は、ビルドプロファイルの environment で、どの環境の変数を使うかを決めます。公式のドキュメントでは、このフィールドを省略した場合、distribution が store なら production、developmentClient が true なら development、それ以外は preview が自動で選ばれる、と説明されています。
ここに落とし穴があります。開発中に development や preview の環境にだけ変数を作っていた場合、TestFlight に出すストア向けビルドは production を見にいくため、値が1つも入りません。手元では動いていたのですから、気づくきっかけがないのです。
まず、現在の状態を確かめます。
# production 環境に登録されている変数の名前と可視性を一覧する
eas env:list --environment production
# preview と見比べて、片方にしか無い名前を探す
eas env:list --environment previewproduction 側に、コードが読んでいる名前(たとえば EXPO_PUBLIC_API_URL や地図の API キー)が無ければ、それが原因の最有力です。足すときは次のようにします。
# 値は例です。実際のキーは YOUR_API_KEY の場所に入れてください
eas env:create --name EXPO_PUBLIC_API_URL --value "https://api.example.com" --environment production --visibility plaintextプロファイルに environment を明示しておくと、あとで読み返したときに迷いません。
{
"build": {
"production": {
"distribution": "store",
"environment": "production",
"autoIncrement": true
},
"preview": {
"distribution": "internal",
"environment": "preview"
}
}
}「省略しても自動で選ばれる」ことと「自分で選んでいる」ことは別物です。私は、審査に出すプロファイルだけは、省略せずに書く線引きにしています。
疑う2: secret は config の解決中には読めない
環境変数を production に入れたのに、まだ白いままの場合、次の候補は可視性です。
EAS の環境変数には、plain text、sensitive、secret の3種類があります。公式のドキュメントには、secret の変数は EAS CLI での config の解決中には使えない、と書かれています。ここで言う config の解決とは、app.config.js や app.config.ts が実行される段階のことです。
たとえば、ネイティブの地図を使うアプリで、API キーを app.config.js から注入しているとします。
// app.config.js
export default ({ config }) => ({
...config,
ios: {
...config.ios,
config: {
// secret で登録した値は、この時点では undefined になりうる
googleMapsApiKey: process.env.GOOGLE_MAPS_API_KEY,
},
},
});この値を secret で登録していると、config の解決時には undefined が入る可能性があります。ビルドは通ります。起動もします。ただ、地図の描画だけが静かに失敗して、画面が空になります。
直し方は2通りです。config の解決時に必要な値は sensitive か plain text で登録するか、値をビルド後に取得する設計へ変えるか。どちらを選ぶかは、その値が「アプリに同梱されて困るものか」で決まります。地図 SDK のキーのように、そもそも端末側で使う前提の値は、キー側で制限(バンドル ID の制限など)をかけたうえで同梱するのが普通です。本当に隠したい値は、アプリに入れず、サーバ側(Edge Function など)に置いてください。この線引きについては、接頭辞と可視性を別々の軸として整理した記事があります。EAS の secret は「アプリに入れない」設定ではありませんは、プレミアム側の記事ですが、迷ったときの地図として使っていただけます。
もう1つ、見落としやすい点があります。Expo のドキュメントは、process.env.EXPO_PUBLIC_[VARNAME] のようにドット記法で参照することを求めていて、代替の書き方はサポートされないと明記しています。
// ✅ ドット記法。バンドル時に値へ置き換えられる
const apiUrl = process.env.EXPO_PUBLIC_API_URL;
// ❌ 名前を引数にした汎用ヘルパー。サポートされる書き方ではない
const getEnv = (name: string) => process.env[name];
const apiUrl2 = getEnv("EXPO_PUBLIC_API_URL");「環境変数を読む共通関数」を作りたくなる気持ちは分かります。私もそう書きかけました。ただ、置き換えの対象は文字列として書かれた参照そのものです。便利さのために一段包むと、置き換えの網から外れる恐れがあります。読む場所は、1つのモジュールにドット記法で並べておくのが安全です。
疑う3: 審査員の環境では、データが取れない
環境変数にも可視性にも問題がなく、それでも白い場合は、ビルドではなく環境差を疑います。
審査員は、新規インストール、未ログイン、権限は初回、というまっさらな状態でアプリを開きます。そして、あなたのバックエンドが応答しない、位置情報の許可を拒否される、といった条件に、アプリの側が耐えられるかが問われます。
ここで効くのが、白い画面を作らないという設計です。データが取れないときに、何も描かずに待ち続けるのではなく、何が起きているかを画面に出しておきます。そうすれば、審査員にとっても私たちにとっても、白い画面が「理由のある画面」に変わります。
まず、必須の設定値を起動時に検査します。
// config.ts - 値の読み取りはここだけに集約する(ドット記法)
export const config = {
apiUrl: process.env.EXPO_PUBLIC_API_URL,
mapsKey: process.env.EXPO_PUBLIC_MAPS_KEY,
};
export function missingConfigKeys(): string[] {
const missing: string[] = [];
if (!config.apiUrl) missing.push("EXPO_PUBLIC_API_URL");
if (!config.mapsKey) missing.push("EXPO_PUBLIC_MAPS_KEY");
return missing;
}次に、通信にタイムアウトを付け、失敗を画面に出します。
// useSafeFetch.ts - 応答しない API でも、一定時間後にエラー状態へ移る
import { useEffect, useState } from "react";
type State<T> =
| { status: "loading" }
| { status: "error"; message: string }
| { status: "ok"; data: T };
export function useSafeFetch<T>(url: string | undefined, timeoutMs = 8000): State<T> {
const [state, setState] = useState<State<T>>({ status: "loading" });
useEffect(() => {
if (!url) {
setState({ status: "error", message: "接続先が設定されていません" });
return;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<T>;
})
.then((data) => setState({ status: "ok", data }))
.catch((e: Error) =>
setState({ status: "error", message: e.name === "AbortError" ? "通信がタイムアウトしました" : e.message })
)
.finally(() => clearTimeout(timer));
return () => {
clearTimeout(timer);
controller.abort();
};
}, [url, timeoutMs]);
return state;
}8 秒という値は、私の感覚で置いた初期値です。回線の細い環境で審査される可能性を考えると、短すぎず長すぎない範囲だと考えています。皆さまのアプリの性質に合わせて調整してください。
こうしておくと、再提出のときに差し戻しの文面が変わります。「白い画面」ではなく「接続先が設定されていません」と表示される状態なら、スクリーンショットを見ただけで、私たち自身が原因を言い当てられます。
あわせて、App Store Connect の審査メモにも書いておきます。ログインが必要ならデモアカウント、バックエンドが特定の条件でしか応答しないならその条件、位置情報が必要な画面ならその旨です。Guideline 2.1 は、まさにここを求めています。
直らないときの次の一手
ここまでで特定できないときは、証拠を取りにいきます。
- 実機にストア向けビルドを入れ、Mac に接続してコンソールを見ます。 Xcode の Devices and Simulators から該当端末を選び、Open Console で起動直後のログを確認します。クラッシュなのか、例外を握りつぶしているのかが、ここで分かれます。
- TestFlight のクラッシュフィードバックと、Xcode の Organizer を確認します。 クラッシュなら、ログが残っています。残っていなければ、クラッシュではなく「描けていない」側です。
- 審査員の端末と条件を想像して、手元で寄せます。 新規インストール、未ログイン、権限はすべて拒否、機内モード。この条件で起動し、画面に何が出るかを見ます。
再提出のときに、ビルド番号の壁に当たることもあります。以前に上げたビルドより大きい番号でなければ受け付けられないため、eas build:version:get で EAS 側の現在値を確認し、必要なら eas build:version:set で合わせます。これは白い画面の原因ではありませんが、原因を直した直後に足止めされると、気持ちが折れやすいところです。
最後に、原則を1つだけ書き残します。
白い画面は、何も描かないことではなく、理由を描かないことが問題です。
まずは、お手元のアプリで eas env:list --environment production を実行して、コードが読んでいる名前がすべて並んでいるかを確かめるところから始めていただければと思います。私も、審査に出す前の最後の確認として、この1行を習慣にしています。