お疲れ様です!IT業界で働くアライグマです!
個人開発しているAndroidアプリ「終業ログ」は、始業・休憩・終業をワンタップで記録し、終業時のチェックリストで一日を締めるアプリです。無料で配信していて、画面の下に広告(Google AdMob)のバナーを出しています。このアプリで、画面が真っ白になって操作できなくなる不具合が起きました。調べると、広告の読み込みや初期化、画面の切り替えのタイミングによって、広告とは関係のない打刻画面まで巻き込んで白くなりうる作りになっていました。
広告の失敗で打刻画面まで消える作りだった
終業ログは、1本目のアプリ「DevPick」と同じExpo(React Native)の構成を流用して作ったアプリです。公開までの経緯は「Expoアプリを4日でGoogle Playに公開した記録」に書きました。広告には react-native-google-mobile-ads というライブラリを使い、ホーム画面と、終業チェックを終えた後の完了画面の2か所にバナーを置いています。
「主要機能は止めない」方針を守れない経路が4つあった
広告の設計ドキュメントには、修正前から「広告の読み込みに失敗・無応答のときは枠を出さず、主要機能はそのまま使えます」「同意情報を取得できずに広告を要求できない間も、打刻・チェックなどの操作は待たせません」と書いていました。打刻アプリは、押したいときに押せないと意味がないからです。ところが、実際に画面が真っ白になったことをきっかけに広告まわりのコードを読み直すと、この方針を守れない経路が4つありました。
- バナーの部品を囲むErrorBoundaryが無い。画面全体のレイアウトにも無い
- 広告SDKの初期化(
initialize())が終わるのを待たずに「広告を要求してよい」状態にしていて、初期化に失敗してもその状態のままだった - ホーム画面から別の画面へ進んでも、ホーム画面のバナーが裏に残り続ける
- バナーの横幅を指定しておらず、読み込みが終わるまで高さ0pxに押し込めた枠の中で広告を準備させていた
描画中の例外1つで、画面全体が外される
一番影響が大きいのは1つ目です。Reactでは、描画中に投げられた例外をどこにも受け止める場所が無いと、画面の一部だけでなく、アプリの画面全体が外されます。Reactの旧公式ドキュメントにも「React 16以降、どのErrorBoundaryにも捕まらなかったエラーは、Reactのコンポーネントツリー全体のアンマウントにつながる」と書かれています。つまり、広告の部品が描画中に1回例外を投げただけで、打刻ボタンもチェックリストも一緒に外されます。利用者から見れば、画面が真っ白になるか、アプリごと使えなくなる状態です。
2つ目から4つ目は、Androidのネイティブ側での描画の問題です。バナー広告の中身は、Androidの WebView(アプリの中でWebページを表示する部品)で描かれます。初期化と広告の要求が同時に走ったり、裏に残ったバナーと前面のバナーの2つが画面の切り替え中に同時に動いたりすると、描画が崩れて白く見える原因になりうる、というのが今回の見立てでした。
4つはどれも白画面につながりうる経路だったので、1つに絞らずにまとめて塞ぐことにしました。
白くなっていたのに、クラッシュの集計は0件だった
調べている途中で気づいたのが、記録の側の穴です。実際に画面が白くなっていたのに、Google Playのクラッシュ・ANR(アプリが応答しなくなる現象)の集計は0件でした。WebViewの描画が崩れて白く見えるだけなら、アプリは落ちていないので、クラッシュとしては数えられません。広告で何が起きたのかを後から追えるように、障害診断ツールのFirebase Crashlyticsに、広告の失敗を記録する項目を足すことにしました。
DevPickは初期化の成功を待っていた
もう1つ、判断の材料になったのがDevPickの実装です。DevPickのアプリでは、広告SDKの初期化が成功してから広告を要求する作りにしていて、失敗したときは広告を出さない側へ倒していました(その設計は「AdMobの同意管理をExpoアプリに組み込んだ記事」に書いています)。同じ構成を流用したはずの終業ログのほうが、その部分だけ簡略化された形になっていました。修正前の、同意を確かめた後の処理は次のとおりです。
// 修正前(実装から抜粋)
function applyConsent(sdk: AdsSdk, info: ConsentInfo) {
setState({
canRequestAds: info.canRequestAds,
privacyOptionsRequired: info.privacyOptionsRequirementStatus === "REQUIRED",
});
if (!info.canRequestAds || initialized) return;
initialized = true;
// 初期化の完了を待たずに広告を要求できます。失敗しても主要機能は止めません。
sdk
.default()
.initialize()
.catch(() => undefined);
}
コメントにある「失敗しても主要機能は止めません」という意図自体は、設計ドキュメントの方針と同じ方向です。ただ実際には、初期化の成功を待たずに「広告を要求してよい」状態を先に公開し、失敗は .catch(() => undefined) で黙って捨てていました。主要機能を止めないつもりで書いた処理が、失敗を見えなくしていたことになります。
4つの修正は、1つのPRの最初のコミットにまとめて入れました。次の章から、1つずつ書きます。
IT女子 アラ美バナーをErrorBoundaryで囲み、壊れたら広告枠だけ消す
最初に入れたのは、広告の部品の中で起きた例外を、広告の部品の外へ出さない仕組みです。Reactでは、この役目を持つ部品を ErrorBoundary と呼びます。子の部品が描画中に例外を投げると、一番近いErrorBoundaryがそれを受け止め、壊れた部分の代わりに別の表示を出せます。受け止める場所を広告のすぐ外側に置けば、壊れるのは広告枠だけで済みます。
// 修正後(実装から抜粋。コメントは記事用に補足)
export class BannerErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
override state: ErrorBoundaryState = { hasError: false };
// 子が描画中に例外を投げたら「壊れた」状態に切り替える
static getDerivedStateFromError(_error: Error): ErrorBoundaryState {
return { hasError: true };
}
// 受け止めた例外をCrashlyticsへ記録する
override componentDidCatch(error: Error, _errorInfo: ErrorInfo) {
reportDiagnostic("ad-render", error);
reportAdLoadError(this.props.unitId, error);
}
override render() {
// 壊れたら広告枠ごと何も描かない
if (this.state.hasError) {
return null;
}
return this.props.children;
}
}
クラスで書き、壊れたら何も描かない
アプリのほかの部品はすべて関数で書いていますが、ここだけはクラスで書いています。ReactのErrorBoundaryは、今のところ関数の部品では書けないからです。React公式ドキュメントにも、関数の部品には componentDidCatch に当たるものがまだ無いと書かれています。
壊れたときに出すものは、エラーメッセージではなく null、つまり何も描かない形にしました。利用者にとって、広告が出ないこと自体は困りごとではありません。広告枠に「読み込みに失敗しました」と出すより、何も無かったことにして、打刻ボタンやチェックリストをいつもどおり使えるほうが自然です。その代わり、何が起きたかは自分が後から追えるように、Crashlyticsに「広告の描画で例外」という種類で記録します。
バナーの部品は、このErrorBoundaryで囲む形に変えました。
// 修正後(実装から抜粋)
return (
<BannerErrorBoundary key={unitId} unitId={unitId}>
<BannerRequest sdk={sdk} unitId={unitId} placement={placement} />
</BannerErrorBoundary>
);
テストでは、描画すると必ず例外を投げる偽の広告部品を用意し、その隣に「打刻ボタン」という文字を置いて描画しています。確かめているのは、例外が起きた後も「打刻ボタン」が画面に残っていること、広告枠が消えていること、Crashlyticsへの記録が呼ばれていることの3点です。
ErrorBoundaryが受け止めない例外
ただし、ErrorBoundaryで何でも受け止められるわけではありません。Reactの旧公式ドキュメントでは、ErrorBoundaryが受け止めないものとして、イベントハンドラの中の例外、setTimeout などの非同期処理の中の例外、ErrorBoundary自身の例外などが挙げられています。広告の部品で言えば、「読み込みが終わった」「読み込みに失敗した」といった知らせを受け取る処理の中で起きた例外は、この仕組みの対象外です。今回のErrorBoundaryが守っているのは、広告の部品を描画しているときの例外に限られます。



SDKの初期化が終わるまで広告を要求しない
同じコミットで、広告SDKの初期化の扱いも変えました。修正前は、同意を確かめた時点で「広告を要求してよい」状態を画面へ知らせ、初期化はその後ろで走らせっぱなしにしていました。これだと、初期化とバナーの準備が同時に始まります。バナーの部品は「要求してよい」状態を見た瞬間に、ネイティブの広告の部品とその中のWebViewを作り、広告を読み込み始めるからです。初期化が失敗した場合も、状態は「要求してよい」のまま残るので、初期化できていないSDKに対してバナーを作り続けることになります。
修正後は、初期化が成功したのを確かめてから「要求してよい」状態にします。最初のコミットでの形は次のとおりです。
// 修正後・最初のコミット(実装から抜粋。コメントは記事用に補足)
async function applyConsent(sdk: AdsSdk, info: ConsentInfo): Promise<boolean> {
const privacyOptionsRequired =
info.privacyOptionsRequirementStatus === "REQUIRED";
if (!info.canRequestAds) {
setState({ canRequestAds: false, privacyOptionsRequired });
return true;
}
if (initialized) {
setState({ canRequestAds: true, privacyOptionsRequired });
return true;
}
try {
// 初期化が終わるまで待ち、成功してから広告の要求を許可する
await sdk.default().initialize();
initialized = true;
setState({ canRequestAds: true, privacyOptionsRequired });
return true;
} catch (error) {
// 失敗したら広告は要求せず、Crashlyticsへ記録する
initialized = false;
reportDiagnostic("ad-init", error);
setState({ canRequestAds: false, privacyOptionsRequired });
return false;
}
}
失敗したら、次に前面へ戻ったときにやり直す
失敗したときに false を返しているのは、次の機会にやり直すためです。広告の準備は、アプリの起動時と、アプリが前面に戻るたびに呼ばれます。準備の途中で失敗したことが分かると、「準備中」の印を消し、次に前面へ戻ったときに同意の確認と初期化をもう一度行います。一度の失敗で、アプリを再起動するまで広告が出ないままになるのを避けるためです。
テストは2つ足しています。1つは、初期化が終わるまで「要求してよい」状態にならず、終わった後に切り替わること。もう1つは、初期化が失敗すると広告を要求せずにCrashlyticsへ記録され、次の準備で初期化が成功すれば「要求してよい」状態になることです。



初期化を待つようにしたら、別の競合が2つ生まれた
4つの修正をまとめた最初のコミットでPRを出したところ、PRに自動で付くAIコードレビューから指摘が2件返ってきました。終業ログのAIレビューは、DevPickと同じ仕組みに合わせて設定したものです(その仕組みは「AIコードレビューを前回からの差分に絞った記事」に書いています)。どちらも本番で起きた不具合ではなく、マージ前に見つかったものです。そして2件とも、前の章で「初期化を待つ」ように変えたことで新しく生まれた問題でした。
前提:同意の問い合わせは10秒で見切る
指摘を説明するには、同意の確認の流れを先に書いておく必要があります。終業ログは起動時にGoogleの同意管理(UMP)へ最新の同意情報を問い合わせ、必要な地域では同意フォームを出します。ただ、この問い合わせを10秒待っても終わらないときは、前回までに端末へ保存された同意情報で先に判断します。問い合わせが後から終わった場合は、その新しい結果で判断し直します。
1件目:古い同意が、新しい同意を上書きする
1件目の指摘は、この「保存済みの同意で先に判断する」経路で起きる競合です。
- 問い合わせが10秒で終わらず、保存済みの同意(広告を要求してよい)で判断を始める
- 保存済みの同意が「要求してよい」なので、SDKの初期化を始め、終わるのを待つ
- その間に、利用者が同意フォームで「同意しない」を選び、問い合わせが終わる。状態は「要求しない」になる
- 初期化が終わり、待っていた保存済みの側が「要求してよい」を書き込む
最後の手順で、利用者が選んだばかりの新しい結果を、古い保存済みの結果が上書きしてしまいます。修正前は初期化を待たずにすぐ状態を書き込んでいたので、この順番の入れ替わりは起きませんでした。「待つ」ようにしたことで、待っている間に別の結果が先に届く隙間ができたわけです。同じ待ち時間の間は、まだ初期化が終わった印も立っていないため、後から届いた結果の側でも初期化がもう一度始まるという問題も、同じ指摘に含まれていました。
2件目:設定画面からの変更では、やり直しが効かない
2件目は、やり直しの経路の抜けです。起動時の同意の確認が「同意しない」で正常に終わった場合、「準備は完了した」という印が残ります。その後、利用者が設定画面の「広告のプライバシー設定」から同意に変え、そこで初期化が失敗すると、広告は出ないままです。前の章で書いたとおり、本来は次にアプリが前面へ戻ったときにやり直すはずでした。ところが「準備は完了した」という印が残っているので、前面へ戻っても何もせずに終わります。設計ドキュメントに書いた「次回復帰時に再試行」が、この経路では効いていませんでした。
初期化を1本にまとめ、待った後にも確かめる
2件は、同じPRの2つ目のコミットで直しました。
// 修正後・2つ目のコミット(実装から抜粋。コメントは記事用に補足)
async function ensureInitialized(sdk: AdsSdk): Promise<boolean> {
if (initialized) return true;
// 初期化中なら、同じPromiseを返して二重に始めない
if (initializing) return initializing;
initializing = (async () => {
try {
await sdk.default().initialize();
initialized = true;
return true;
} catch (error) {
initialized = false;
reportDiagnostic("ad-init", error);
return false;
} finally {
initializing = null;
}
})();
return initializing;
}
async function applyConsent(
sdk: AdsSdk,
info: ConsentInfo,
isSuperseded?: () => boolean,
): Promise<boolean> {
// …(同意が無いときの処理は省略)
const ok = await ensureInitialized(sdk);
// 待っている間に新しい結果が届いていたら、何も書き込まない
if (isSuperseded?.()) {
return ok;
}
// …(成功・失敗に応じて状態を書き込む)
}
1件目に対しては、2つの手を入れました。1つは、初期化中のPromise(処理の完了を表すもの)を1つだけ持ち、初期化中にもう一度呼ばれたら同じものを返すようにしたことです。これで初期化が二重に始まりません。もう1つは、保存済みの同意で判断している側が、初期化を待ち終わった後に「その間に新しい結果が届いていないか」を確かめ、届いていれば何も書き込まないようにしたことです。待つ前だけでなく、待った後にも確かめる点がポイントでした。
2件目に対しては、初期化に失敗したら、どの経路から呼ばれた場合でも「準備中・準備済み」の印を消すようにしました。これで、設定画面から同意に変えたときの失敗でも、次に前面へ戻ったときに初期化をやり直します。
指摘のそれぞれの手順は、テストで再現しています。10秒の時間切れで保存済みの同意を使い、初期化を待っている間に「同意しない」の結果が届き、その後に初期化が終わる、という順番を時計を進めながら作り、最後まで「要求しない」のままであることを確かめています。2つ目のコミットを出した後のレビューは、指摘なしで終わりました。



見えていない画面のバナーを外し、サイズ指定を見直す
残りの2つは、バナーをいつ・どの大きさで画面に置くかの修正です。どちらも最初のコミットで入れています。
見えていない画面のバナーは部品ごと外す
1つ目は、見えていない画面のバナーです。終業ログでは、ホーム画面から終業チェックリストへ進み、チェックを終えると完了画面に移ります。このとき、ホーム画面は画面の切り替えの仕組み上、裏に残ったままで、外されません。そのため、完了画面に着いた時点で、裏のホーム画面のバナーと、前面の完了画面のバナーの2つが同時に生きている状態になっていました。どちらもネイティブの広告の部品で、中ではWebViewが動いています。画面の切り替え中に2つが同時に描画やサイズ変更を行うと、切り替え先の画面が白くなる原因になりうる、というのが今回の見立てです。
そこで、Expo Routerの useIsFocused を使い、その画面が今見えているときだけバナーを置くようにしました。
// 修正後(実装から抜粋)
export function AdBanner({ placement }: { placement: AdPlacement }) {
const isFocused = useScreenFocused(); // 中で useIsFocused を呼ぶ
const { canRequestAds } = useAdsState();
const unitId = getBannerAdUnitId(placement);
const sdk = loadBannerSdk();
// 見えていない画面では、広告の部品そのものを置かない
if (!isFocused || !canRequestAds || !unitId || !sdk) return null;
// …(ErrorBoundaryで囲んだバナーを返す)
}
非表示のスタイルで隠すのではなく、部品そのものを外しているのがポイントです。隠しただけでは、ネイティブの広告の部品とWebViewは裏で生き続けます。その代わり、ホーム画面に戻ってくるたびにバナーが作り直され、広告を読み込み直すことになります。広告の読み込みの回数は増えますが、画面の切り替え中にバナーが2つ同時に動く状態を無くすほうを優先した形です。テストでは、見えていない状態ではバナーが置かれず、見える状態になると置かれ、また見えなくなると外れることを確かめています。
横幅を渡し、高さ0pxの枠に押し込めない
2つ目は、サイズの指定です。修正前は、バナーのサイズを LARGE_ANCHORED_ADAPTIVE_BANNER とし、横幅は指定していませんでした。このライブラリは、横幅を指定しないと端末の画面幅いっぱいで広告を要求します。一方、終業ログの画面は左右に余白を取っているので、広告が親の枠より広くなってはみ出します。加えて、読み込みが終わるまではバナーの枠を高さ0pxにして中身を隠す形にしていたため、ネイティブ側は高さ0pxの枠の中で広告の大きさを決め、WebViewを準備することになっていました。
修正では、次のように変えました。
- サイズの指定をDevPickと同じ
ANCHORED_ADAPTIVE_BANNERにする - 横幅は、画面幅から左右の余白(合計40dp)を引き、画面の最大幅に合わせて536dpを上限にした値を渡す
- 読み込みが終わるまでは、高さ0pxに押し込めず、ほかの部品の上に透明な状態で重ねて待たせる。読み込みが終わったら通常の位置に戻す
読み込み前に透明で重ねているのは、読み込みに失敗したときや読み込み中に、打刻ボタンの位置を動かさないためです。高さを取らない点は修正前と同じで、広告の部品から見て高さ0pxの枠に入れられている状態だけをやめました。
揃えたサイズは、今は非推奨だった
ただ、この記事を書くにあたって、使っているライブラリ(react-native-google-mobile-ads 17.2.0)の型定義を読み直したところ、気になる点が見つかりました。今回切り替えた ANCHORED_ADAPTIVE_BANNER には「非推奨。代わりに LARGE_ANCHORED_ADAPTIVE_BANNER を使うこと」という注記が付いています。元になっているのは、2026年2月17日に出たAndroid版のGoogle Mobile Ads SDK 25.0.0です。このリリースで大きいサイズのアンカー型アダプティブバナーが追加され、従来のアンカー型アダプティブバナーのサイズを求める関数が非推奨になりました。つまり今回の修正は、推奨される側から非推奨の側へ戻したことになります。
非推奨は、すぐに使えなくなるという意味ではありません。リリースノートにも、削除されたとは書かれていません。また、白画面につながりうる点として挙げていたのは「横幅の指定が無いこと」と「高さ0pxの枠」で、サイズの種類そのものではありませんでした。横幅の指定と待たせ方の見直しは、推奨の LARGE_ANCHORED_ADAPTIVE_BANNER と組み合わせても成り立ちます。今回の修正では非推奨の点には手を付けていないので、サイズの種類の選び直しは残った課題です。



まとめ
終業ログで画面が真っ白になったのをきっかけに広告まわりを読み直すと、広告の失敗が打刻画面まで巻き込みうる経路が4つ重なっていました。この4つを、1つのPRでまとめて塞いでいます。
- バナーをErrorBoundaryで囲み、描画中の例外では広告枠だけを消して、Crashlyticsへ記録する
- 広告SDKの初期化が成功してから広告を要求し、失敗したら次にアプリが前面へ戻ったときにやり直す
- 見えていない画面のバナーは部品ごと外し、2つのバナーが同時に動かないようにする
- バナーに横幅を渡し、読み込み前に高さ0pxの枠へ押し込めない
このうち初期化を待つ変更は、待っている間に別の結果が届く隙間を作り、古い同意で新しい同意を上書きする競合と、やり直しが効かない経路を生んでいました。どちらもマージ前のAIコードレビューで見つかり、同じPRの2つ目のコミットで直しています。また、サイズの指定を合わせた先の ANCHORED_ADAPTIVE_BANNER は、今のライブラリでは非推奨になっていて、ここは残った課題です。
今回の修正は、2026年10月22日に版1.0.5としてGoogle Playの審査へ提出しました。
今回いちばん残ったのは、広告のような脇役の部品ほど、失敗したときの影響範囲を最初に決めておくべきだという点です。「失敗しても主要機能は止めない」とコメントに書いていても、例外を受け止める場所が無ければ、その約束は守られません。失敗を握りつぶすのではなく、広告枠の中で止めて記録に残す。その線引きを、部品の境目としてコードに置いておくことが大事だと考えています。











