起動の順番を一行ずつ書き出していた夜のことです。個人開発で運用している Android 版の壁紙アプリで、起動広告は問題なく出ていると思い込んでおりました。アプリを開き直すたびに広告が出るのですから、疑う理由がありません。
ところが、書き出した順番を上から辿っていくと、アプリを完全に終了してから開いた回——コールドスタート——の流れにだけ、広告を出す判断がどこにも置かれていませんでした。表示の合図は「背面から前面に戻ってきた」ときだけ。起動時にもその合図は来るのですが、広告の読み込みを始めるより前に過ぎてしまうのです。
つまり起動広告は、復帰のときには出ていて、起動のときには一度も出ていませんでした。出ている広告があるせいで、壊れていることに気づけませんでした。そう気づいたのは、書き出しを終えたあとでした。直したのは 7 月の終わりです。
最初にお伝えしたいのは、私が直したのが広告 SDK の使い方ではなく、「いつ出すか」を決める側の考え方だった、という一点です。合図が来たときに処理を書くのをやめて、事実が変わるたびに同じ問いを立てる形にしました。以下はその書き残しです。
合図が、準備より先に過ぎていました
壊れていた実装は、おおよそ次の考え方で動いていました。「前面に戻った」という合図を受け取ったら、広告が読み込み済みかを確かめ、済んでいれば出す、という流れです。Android なら ProcessLifecycleOwner のような仕組みで受ける合図、React Native なら AppState の変化がこれにあたります。
復帰のときは、これで足ります。アプリが背面にいたあいだに、広告はとっくに読み込み終わっているからです。起動のときは順番が逆になります。
起動のときは、合図が来た時点では出す広告がまだありません。読み込みが終わったとき、判断する側はもう何も受け取らないので、広告は読み込まれたまま使われずに残ります。React Native の AppState では、さらに素直です。起動時の状態がすでに前面なので、そもそも変化のイベントが一度も発火しません。
合図ごとに書くのをやめて、事実が変わるたびに問う
最初に思いついた直し方は、「読み込みが終わったとき、前面にいたら出す」を足すことでした。これは危険です。使っている最中に読み込みが終わった広告まで、画面の上に突然出てしまいます。出ないよりも悪い不具合を、自分で作り込むところでした。
線を引き直して、判断に使う事実を三つに分けました。
- 広告は読み込み済みか
- いま前面にいるか
- 「出してよい機会」が生きているか
三つ目が要です。機会は、起動したときと、背面から戻ったときの二つの場面で生まれ、数秒で期限が切れます。起動して数秒たってから、使い始めた画面に広告が出るのは、利用者にとって不意打ちでしかありません。機会が切れたら、その回は出さないことにします。この線引きだけで、先ほどの危険は消えます。
判断を担う部分だけを、広告 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 秒に間に合わなかった順番」を再現するために本当に待つことになります。
順番を入れ替えて確かめる
この部品の良いところは、起動の順番を何通りでも作れることです。壊れていた実装も、わざとテストの中に再現しておきました。
// app-open-gate.test.ts — 実行: node --test app-open-gate.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { AppOpenGate, COOLDOWN_MS, OPPORTUNITY_WINDOW_MS } from "./app-open-gate.ts";
function setup() {
let t = 0;
let shown = 0;
const gate = new AppOpenGate(() => { shown += 1; }, () => t);
return { gate, advance: (ms: number) => { t += ms; }, shown: () => shown };
}
// 旧実装の再現: 「復帰した」合図でだけ、読み込み済みなら出す
class NaiveGate {
loaded = false;
shown = 0;
onLoaded() { this.loaded = true; }
onReturn() { if (this.loaded) { this.shown += 1; this.loaded = false; } }
}
test("旧実装: 起動直後に読み込みが終わっても、復帰の合図が来ないので一度も出ない", () => {
const naive = new NaiveGate();
naive.onLoaded(); // 起動して1.2秒後に読み込み完了。復帰の合図はまだ一度も来ていない
assert.equal(naive.shown, 0);
});
test("起動から機会の期限内に読み込みが終われば、一度だけ出る", () => {
const { gate, advance, shown } = setup();
advance(1_200);
gate.onLoaded();
gate.tick();
assert.equal(shown(), 1);
});
test("読み込みが期限に間に合わなければ、その起動では出さず、あとからも不意に出さない", () => {
const { gate, advance, shown } = setup();
advance(OPPORTUNITY_WINDOW_MS + 1);
gate.onLoaded();
advance(60_000);
gate.tick();
assert.equal(shown(), 0);
});
test("使っている最中に読み込みが終わっても出ない(復帰という機会が無い)", () => {
const { gate, advance, shown } = setup();
advance(OPPORTUNITY_WINDOW_MS + 1);
gate.tick(); // 起動の機会は期限切れ
advance(120_000);
gate.onLoaded();
assert.equal(shown(), 0);
});
test("背面から戻り、広告が用意できていれば出る。クールダウン中の復帰では出ない", () => {
const { gate, advance, shown } = setup();
advance(1_000);
gate.onLoaded(); // 起動時に1回目
assert.equal(shown(), 1);
gate.onClosed();
advance(10_000);
gate.onForeground(false);
advance(5_000);
gate.onLoaded(); // 閉じたあとに読み直した広告
gate.onForeground(true); // クールダウン内の復帰
assert.equal(shown(), 1);
advance(COOLDOWN_MS);
gate.onForeground(false);
gate.onForeground(true); // クールダウンを過ぎてからの復帰
assert.equal(shown(), 2);
});
test("復帰した1秒後に読み込みが終わる順でも出る", () => {
const { gate, advance, shown } = setup();
advance(OPPORTUNITY_WINDOW_MS + 1);
gate.tick();
advance(300_000);
gate.onForeground(false);
gate.onForeground(true);
advance(1_000);
gate.onLoaded();
assert.equal(shown(), 1);
});
test("背面で起動された場合は、前面に来るまで出さない", () => {
const { gate, advance, shown } = setup();
gate.onForeground(false); // 背面で起動
gate.onLoaded();
advance(30_000);
gate.tick();
assert.equal(shown(), 0);
gate.onForeground(true);
assert.equal(shown(), 1);
});
Node 22.18 以降なら、node --test app-open-gate.test.ts だけで走ります。私の手元では 7 件すべて通りました。1 件目が、壊れていた実装の再現です。起動の直後に読み込みが終わっても、復帰の合図が来ないので、表示は 0 回のまま残ります。
4 件目は、直し方を間違えたときの安全網です。使っている最中に読み込みが終わった広告は、出してはいけません。最初に思いついた「読み込みが終わったとき前面にいたら出す」を実装していたら、このテストが赤くなったはずです。
Expo アプリへの繋ぎ込みの骨子
広告 SDK 側は、薄い橋渡しだけにします。次は react-native-google-mobile-ads の AppOpenAd を使う場合の骨子です。
// startAppOpenAd.ts — 接続の骨子。react-native-google-mobile-ads の AppOpenAd を使う想定です。
import { AppState } from "react-native";
import { AdEventType, AppOpenAd, TestIds } from "react-native-google-mobile-ads";
import { AppOpenGate } from "./app-open-gate";
// 開発中は必ずテスト用ユニット。本番の ID は環境変数から渡します
const UNIT_ID = __DEV__
? TestIds.APP_OPEN
: (process.env.EXPO_PUBLIC_APP_OPEN_UNIT_ID ?? "YOUR_APP_OPEN_UNIT_ID");
let ad = AppOpenAd.createForAdRequest(UNIT_ID);
let needsReload = false; // 直前の読み込みが失敗したか
const gate = new AppOpenGate(() => ad.show());
function load(): void {
ad = AppOpenAd.createForAdRequest(UNIT_ID);
ad.addAdEventListener(AdEventType.LOADED, () => gate.onLoaded());
ad.addAdEventListener(AdEventType.CLOSED, () => {
gate.onClosed();
load(); // 広告は使い切り。閉じたら次を用意する
});
ad.addAdEventListener(AdEventType.ERROR, () => {
gate.onClosed();
needsReload = true; // その場で読み直さない。次の復帰まで待てば、失敗が続いても無限ループにならない
});
ad.load();
}
export function startAppOpenAd(): () => void {
gate.onForeground(AppState.currentState === "active"); // 背面で起動されたなら、前面に来るまで待たせる
load();
const sub = AppState.addEventListener("change", (state) => {
gate.onForeground(state === "active");
if (state === "active" && needsReload) {
needsReload = false;
load();
}
});
const timer = setInterval(() => gate.tick(), 1_000); // 何も起きないまま機会が切れるのを拾う
return () => {
sub.remove();
clearInterval(timer);
};
}
先にお断りしておきますと、この橋渡しは実機では通しておりません。API 名は公式ドキュメントに合わせてありますが、動作の確認は上の判断部分だけです。ご自身の環境で、テスト用ユニットを使って起動の順番を一度通してください。
読み込みや収益の設計そのものは、Rork アプリで AdMob Bidding を本番投入した実装メモにまとめています。この記事は、その手前の「いつ出すか」の話に絞りました。
つまずきやすい 4 か所
背面で起動されたとき。 通知や定期処理でアプリが背面のまま立ち上がることがあります。この場合、起動の機会は前面に来ないまま期限が切れ、利用者が実際に開いたときの「復帰」が新しい機会になります。テストの 7 件目がこの場面です。
広告は使い切りです。 閉じたら次を読み込む処理を、必ず自分で書く必要があります。失敗したときにその場で読み直すと、通信が不調な端末で読み込みを繰り返し続けます。上の骨子では、失敗したら次の復帰まで待つ形にしました。
4 秒という数字は、私の線引きです。 起動画面の長さや、広告が読み込み終わるまでの実際の時間に合わせて動かしてください。広告を出す位置については、Google の App Open 広告のガイドラインに、読み込み画面のあとに出す、利用者が中身を見る前に覆わない、といった趣旨の決まりがあります。細部は公式の最新の記述でご確認ください。
時計は単調なものに。 Date.now は端末の時刻を変えられると前後に動きます。React Native なら performance.now のような単調な時計を、コンストラクタの第二引数に渡せば差し替えられます。
明日、最初にやること
自分のアプリの起動順を、4 行で書き出してみてください。書き出すには、次の数行をアプリに足して、実機で一度だけ起動するのがいちばん早いと思います。
// launch-trace.ts — 起動順を、起動からの経過時間つきでログに残す
const t0 = performance.now();
export function trace(label: string): void {
console.log(`[launch +${Math.round(performance.now() - t0)}ms] ${label}`);
}
// 使う場所の例:
// trace("前面に来た") ← AppState の購読を始めた直後に、現在の状態と一緒に
// trace("広告の読み込みを開始") ← ad.load() の直前
// trace("広告の読み込みが完了") ← LOADED のリスナー内
// trace("広告を表示") ← show() の直前(理由として launch か return かも添える)
並んだ 4 行を、実際にログで見た順に確かめ、表示の判断が置かれている場所に印を付けます。コールドスタートの列に印が一つもなければ、私と同じ穴が空いている可能性があります。
もう一つ、表示のたびに「起動で出たのか、復帰で出たのか」を理由として記録しておくと、同じ偏りに気づくまでの時間が短くなります。復帰の件数だけが並んで、起動の件数がゼロの日が続いていたら、それが合図です。
合図で動かさず、事実が変わるたびに同じ問いを立てます。 この線引きは、広告に限った話ではありません。判定できないあいだは、どちらの結論も見せないという考え方は、起動のたびに一瞬ログイン画面が出る expo-router アプリを組み直した記事の「まだ分からない」の三状態と、同じ根っこにあります。
出ている広告に安心して、出ていない回を数えていなかった——その反省は、いまも順番を書き出す習慣として残っています。