◉RORK LABEN
●EXPO — EAS Observe がネイティブクラッシュも記録(10/07)。SDK 57 は 57.0.21 以降で確認できます●RORK — 09-29 に GPT-6.1 Sol、09-28 に Claude Sonnet 5.5 を追加。モデル選択で使えます●SDK 58 — ベータ(9/15)のまま。stable が出る前に、SDK 57 のアプリで確かめておく点を整理しています●Q&A — CPU が 2 コアだと jest が終わらない。CI で詰まる原因を追った記録が共有されています●WIDGET — iOS のウィジェットが本番ビルドだけ真っ黒になる原因を追った記録が出ています●NEW — 個人アカウントで出した iOS アプリを法人へ移せるか。App Transfer の前に外す5つ●EXPO — EAS Observe がネイティブクラッシュも記録(10/07)。SDK 57 は 57.0.21 以降で確認できます●RORK — 09-29 に GPT-6.1 Sol、09-28 に Claude Sonnet 5.5 を追加。モデル選択で使えます●SDK 58 — ベータ(9/15)のまま。stable が出る前に、SDK 57 のアプリで確かめておく点を整理しています●Q&A — CPU が 2 コアだと jest が終わらない。CI で詰まる原因を追った記録が共有されています●WIDGET — iOS のウィジェットが本番ビルドだけ真っ黒になる原因を追った記録が出ています●NEW — 個人アカウントで出した iOS アプリを法人へ移せるか。App Transfer の前に外す5つ
記事一覧/開発ツール
⬡ 開発ツール/2026-10-14上級

起動広告が、背面から戻った回にしか出ていませんでした。「前面に戻った」合図が、読み込みより先に過ぎていたためです

Android 版の起動広告が、アプリを完全に終了してから開く回だけ一度も出ていませんでした。「前面に戻った」合図を読み込みの完了と切り離し、順番ごとにテストできる形へ組み直した記録です。

AdMob72起動広告AppStateAndroid54設計11

✦ プレミアム記事

起動の順番を一行ずつ書き出していた夜のことです。個人開発で運用している Android 版の壁紙アプリで、起動広告は問題なく出ていると思い込んでおりました。アプリを開き直すたびに広告が出るのですから、疑う理由がありません。

ところが、書き出した順番を上から辿っていくと、アプリを完全に終了してから開いた回——コールドスタート——の流れにだけ、広告を出す判断がどこにも置かれていませんでした。表示の合図は「背面から前面に戻ってきた」ときだけ。起動時にもその合図は来るのですが、広告の読み込みを始めるより前に過ぎてしまうのです。

つまり起動広告は、復帰のときには出ていて、起動のときには一度も出ていませんでした。出ている広告があるせいで、壊れていることに気づけませんでした。そう気づいたのは、書き出しを終えたあとでした。直したのは 7 月の終わりです。

最初にお伝えしたいのは、私が直したのが広告 SDK の使い方ではなく、「いつ出すか」を決める側の考え方だった、という一点です。合図が来たときに処理を書くのをやめて、事実が変わるたびに同じ問いを立てる形にしました。以下はその書き残しです。

合図が、準備より先に過ぎていました

壊れていた実装は、おおよそ次の考え方で動いていました。「前面に戻った」という合図を受け取ったら、広告が読み込み済みかを確かめ、済んでいれば出す、という流れです。Android なら ProcessLifecycleOwner のような仕組みで受ける合図、React Native なら AppState の変化がこれにあたります。

復帰のときは、これで足ります。アプリが背面にいたあいだに、広告はとっくに読み込み終わっているからです。起動のときは順番が逆になります。

順番起動したとき(コールドスタート)背面から戻ったとき
1アプリが前面に来る(合図が来る)アプリが前面に戻る(合図が来る)
2広告の読み込みを始める読み込み済みの広告を出す
3読み込みが終わる(もう合図は来ない)―

起動のときは、合図が来た時点では出す広告がまだありません。読み込みが終わったとき、判断する側はもう何も受け取らないので、広告は読み込まれたまま使われずに残ります。React Native の AppState では、さらに素直です。起動時の状態がすでに前面なので、そもそも変化のイベントが一度も発火しません。

合図ごとに書くのをやめて、事実が変わるたびに問う

最初に思いついた直し方は、「読み込みが終わったとき、前面にいたら出す」を足すことでした。これは危険です。使っている最中に読み込みが終わった広告まで、画面の上に突然出てしまいます。出ないよりも悪い不具合を、自分で作り込むところでした。

線を引き直して、判断に使う事実を三つに分けました。

  1. 広告は読み込み済みか
  2. いま前面にいるか
  3. 「出してよい機会」が生きているか

三つ目が要です。機会は、起動したときと、背面から戻ったときの二つの場面で生まれ、数秒で期限が切れます。起動して数秒たってから、使い始めた画面に広告が出るのは、利用者にとって不意打ちでしかありません。機会が切れたら、その回は出さないことにします。この線引きだけで、先ほどの危険は消えます。

判断を担う部分だけを、広告 SDK にも画面にも依存しない形で切り出したのが次のコードです。

// app-open-gate.ts — 起動広告を「いつ出してよいか」を決める部分だけを切り出したもの。
// 広告 SDK にも画面にも依存しないので、時計を差し替えて順番ごとにテストできます。
 
export const OPPORTUNITY_WINDOW_MS = 4_000; // 機会が生まれてから、読み込みを待つ上限
export const COOLDOWN_MS = 240_000; // 一度出したあと、次を出さない時間
 
export class AppOpenGate {
  private loaded = false;
  private foreground = true; // 起動した瞬間は前面にいる、という前提で始める
  private opportunityAt: number | null; // 「出してよい機会」が生まれた時刻。無ければ null
  private lastShownAt: number | null = null;
  private readonly show: () => void;
  private readonly now: () => number;
 
  constructor(show: () => void, now: () => number = Date.now) {
    this.show = show;
    this.now = now;
    this.opportunityAt = now(); // 起動そのものが、最初の機会
  }
 
  /** 広告の読み込みが終わった */
  onLoaded(): void {
    this.loaded = true;
    this.evaluate();
  }
 
  /** 前面・背面が切り替わった。背面から戻った瞬間が、新しい機会になる */
  onForeground(isForeground: boolean): void {
    const returned = isForeground && !this.foreground;
    this.foreground = isForeground;
    if (returned) this.opportunityAt = this.now();
    this.evaluate();
  }
 
  /** 広告は使い切り。閉じたら次の読み込みを始めるのは呼び出し側 */
  onClosed(): void {
    this.loaded = false;
  }
 
  /** タイマーから呼ぶ。何も起きないまま機会の期限が切れるのを拾う */
  tick(): void {
    this.evaluate();
  }
 
  // 合図ごとに処理を書き分けず、事実(読み込み済みか/前面か/機会があるか)が
  // 変わるたびに、同じ問いをここで立て直す。
  private evaluate(): void {
    if (this.opportunityAt === null) return;
    const t = this.now();
 
    if (t - this.opportunityAt > OPPORTUNITY_WINDOW_MS) {
      this.opportunityAt = null; // 間に合わなかった機会は捨てる。あとから不意に出さない
      return;
    }
    if (!this.loaded || !this.foreground) return; // まだ揃っていない。期限までは待つ
 
    this.opportunityAt = null; // この機会は、出すにしても出さないにしても、ここで使い切る
    if (this.lastShownAt !== null && t - this.lastShownAt < COOLDOWN_MS) return;
 
    this.lastShownAt = t;
    this.loaded = false;
    this.show();
  }
}

なぜこう書くのかを、三つだけ書き留めます。

一つ目は、evaluate() を合図の種類に関係なく毎回呼んでいる点です。読み込み完了も、前面の切り替えも、タイマーも、同じ問いに行き着きます。起動と復帰で別々の分岐を書いていたころは、片方にだけ穴が空いても、もう片方が動いているせいで見えませんでした。

二つ目は、機会を「出す・出さない」にかかわらず、評価した時点で使い切っている点です。クールダウンで出さなかった機会が残り続けると、数秒後にクールダウンが明けたとたん、不意に出てしまいます。

三つ目は、時計を外から渡している点です。Date.now を直接呼んでいると、「読み込みが 4 秒に間に合わなかった順番」を再現するために本当に待つことになります。

✦

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

この記事の続きを読む

この先には、実装コードやベンチマーク結果など、実務でお役に立てる内容をご用意しています。このサイトは広告を掲載しておらず、サーバーや開発にかかる費用はメンバーの皆様のご支援で成り立っています。もしお役に立てていましたら、ご支援いただけますと大変ありがたいです。

この記事で得られること
✦自分のアプリの起動広告が、起動時と復帰時のどちらで判断されているかを、起動順の書き出しから見分けられるようになります
✦「読み込み済みか・前面か・機会があるか」の三つの事実から表示可否を決める部品を、広告 SDK から切り離して手元で自動テストできるようになります
✦直し方を間違えて、使っている最中に広告が突然出る不具合を作り込む前に、機会の期限という線引きで防げるようになります
Stripe による安全な決済 · いつでもキャンセル可能
✦

この記事を購入する

この先の内容をすべてお読みいただけます。一度のご購入で、いつでも何度でもアクセスできます。このサイトは広告を掲載しておらず、皆さまのご支援がサーバー費用などの運営を支えています。

または
メンバーシップなら全記事が読み放題 →
シェア

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

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

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

関連記事

⬡ 開発ツール2026-10-07
日本語の見出しだけ太字にならない — Android で fontWeight が効かないときの切り分けと直し方
Rork で作ったアプリで、iPhone では太字なのに Android では日本語の見出しが細いままになる症状の切り分け記事です。原因の見分け方、fontFamily を重みごとに分ける直し方、確認用コードを載せています。
⬡ 開発ツール2026-09-22
R8 が既定になる前に測っておく4つの数字 — Expo SDK 58 を待つあいだの Android 計測メモ
Expo SDK 58 では Android のリリースビルドで R8 が既定で有効になります。有効化の前後を同じ物差しで比べられるよう、AAB のダウンロードサイズ・DEX 合計・コールドスタート・ビルド時間の4つを測って残す手順をまとめます。
⬡ 開発ツール2026-09-10
画面を暗くする機能を足したら、アプリを閉じたあとも iPhone が暗いままでした
expo-brightness の setBrightnessAsync は、Android では現在のアクティビティにだけ効き、iOS では端末の明るさそのものを変えます。戻す責任が片側にしかない理由と、AppState で後始末をするフックの実装をまとめました。