壁紙を保存しようとすると、端末が「写真への追加を許可しますか」と尋ねてきます。その確認画面と、全画面広告が同じ場面で出ようとして競合することがありました。
そこに届いたのが「広告が閉じられない」という声でした。アプリを強制終了すれば抜け出せますが、使っている方にとっては、広告を見た記憶より「閉じられなかった」記憶のほうが残ります。
全画面広告は、出してよい場面を一か所で決め、閉じる通知が届かなかったときの保険まで同じ場所に置きます。 直したあとの考え方は、この一文に収まりました。
個人開発で運用している iOS の壁紙アプリで起きた話です。Rork が書き出す Expo のアプリでも、写真の保存や通知の許可など、OS の確認画面を使う場面があれば同じ型が起きうると考えて、考え方を TypeScript で組み直し、Node 22 で動かして確かめました。広告 SDK の内部を再現したものではなく、実機での確認とも別です。その線引きは、各所に書き添えます。
声は「閉じられない」の一言でした
手がかりが少ない声ほど、読み取れることを先に表へ置いておくと、疑う順番を間違えにくくなります。ここに書いたのは、声から私が立てた調べる順で、画面で観察した記録ではありません。
AdMob のユニット ID や審査状態といった広告の設定を疑う前に、まず「どの場面で出したか」を確かめる順番にしました。
重なっていた三つのこと
原因は一つではありませんでした。三つが重なって、はじめて「閉じられない」になっていました。
一つ目は、広告を出す呼び出しが、いくつかの場所に散らばっていたことです。それぞれが自分の都合で広告を出し、アプリが前面にいるかどうかを、見ていませんでした。
二つ目は、写真の権限ダイアログとの競合です。許可を尋ねる確認画面と広告の表示が重なる場面がありました。この重なりを、閉じる操作が届かなくなる状況の一つとして、再現の条件に入れています。
三つ目は、閉じられなかったときの保険がなかったことです。「いま表示中」の印は、閉じた通知を受け取って初めて外れる作りになりがちで、通知が来なければ印は残り続けます。再現では、この形で書いています。
直し方は、広告の呼び出しを共通の部品に集めることでした。出してよい場面の判定と、保険を、その部品の中に置きました。
壊れていた形を、手元で再現します
直す前に、起きていた条件を再現しておきます。実時間を待たなくて済むよう、時間を手で進める時計を用意しました。
// clock.ts — 時間を手で進める時計。実時間を待たずに、タイマーの順序まで再現する
type Task = { at: number; fn: () => void; every?: number; cancelled: boolean };
export class FakeClock {
now = 0;
private tasks: Task[] = [];
after(ms: number, fn: () => void): () => void {
const t: Task = { at: this.now + ms, fn, cancelled: false };
this.tasks.push(t);
return () => void (t.cancelled = true);
}
every(ms: number, fn: () => void): () => void {
const t: Task = { at: this.now + ms, fn, every: ms, cancelled: false };
this.tasks.push(t);
return () => void (t.cancelled = true);
}
advanceTo(target: number): void {
for (;;) {
const due = this.tasks
.filter((t) => !t.cancelled && t.at <= target)
.sort((a, b) => a.at - b.at)[0];
if (!due) break;
this.now = due.at;
if (due.every) due.at += due.every;
else due.cancelled = true;
due.fn();
}
this.now = target;
}
}
広告 SDK の代役には、「閉じる操作に届かない条件」だけを持たせています。前面にいない間や、確認画面が出ている間に出した広告は、closed が返らない形です。ここは、実機で起きたことを条件として写したもので、SDK の振る舞いを確かめたものではありません。
// fake-sdk.ts — 全画面広告 SDK の代役。起きていた状況の「条件」だけを再現する
// (SDK の内部を再現したものではありません。閉じるボタンに触れない状況を、closed が来ない形で表します)
import type { FakeClock } from "./clock.ts";
export interface Env {
active: boolean; // アプリが前面か
dialogOpen: boolean; // 権限ダイアログなど、OS の確認画面が出ているか
}
export class FakeInterstitial {
visible = false;
closable = false;
shownCount = 0;
closedNormally = 0;
healthyCuts = 0; // 閉じられる状態だった広告を、アプリ側が打ち切った回数
maxConcurrent = 0;
onClosed: () => void = () => {};
/** true にすると、条件に関係なく closed を返さない(通知の取りこぼしの再現) */
loseClose = false;
private concurrent = 0;
private viewed = 0;
private env: Env;
private viewMs: number;
constructor(clock: FakeClock, env: Env, viewMs = 20_000) {
this.env = env;
this.viewMs = viewMs;
// 広告を見終わるまでの時間は、前面にいる間だけ進む(動画が止まる想定)
clock.every(1_000, () => {
if (!this.visible || !this.closable || !this.env.active) return;
this.viewed += 1_000;
if (this.viewed >= this.viewMs) this.finish(true);
});
}
show(): void {
this.visible = true;
this.viewed = 0;
this.shownCount += 1;
this.concurrent += 1;
this.maxConcurrent = Math.max(this.maxConcurrent, this.concurrent);
// 前面にいない、または確認画面が出ている間に出した広告は、閉じる操作に届かない
this.closable = this.env.active && !this.env.dialogOpen && !this.loseClose;
}
private finish(byUser: boolean): void {
if (!this.visible) return;
if (byUser) this.closedNormally += 1;
this.visible = false;
this.closable = false;
this.concurrent = Math.max(0, this.concurrent - 1);
if (byUser) this.onClosed(); // 取りこぼし(loseClose)のときは、そもそもここに来ない
}
/** アプリ側から強制的に閉じる(保険)。通知は返さない */
dismiss(): void {
if (this.closable) this.healthyCuts += 1;
this.finish(false);
}
}
入口を一か所に集めた部品
直した部品は、次のものです。呼び出し側は request() を呼ぶだけで、出してよい場面かどうかは部品が決めます。出さなかったときは、理由を返します。
// gate.ts — 全画面広告を出す入口を、ここ一か所に集める
import type { Env } from "./fake-sdk.ts";
// 実機では setInterval に置き換える。ここでは時計の代役を差し込めるよう、必要な形だけ切り出す
export interface Ticker {
every(ms: number, fn: () => void): () => void;
}
// 広告 SDK の代わりに差し込める形。dismiss は、強制的に閉じる手段がある場合だけ中身を持たせる
export interface AdLike {
show(): void;
dismiss(): void;
onClosed: () => void;
}
export type Reason = "shown" | "background" | "system-dialog" | "already-showing";
export type WatchdogMode = "none" | "wall" | "foreground";
export interface Policy {
checkEnv: boolean; // 前面か・確認画面が出ていないかを見るか
watchdog: WatchdogMode; // 閉じなかったときの保険の数え方
}
export const WATCHDOG_MS = 45_000;
const TICK_MS = 1_000;
export class AdGate {
private showing = false;
private elapsed = 0;
forceReleased = 0;
history: Reason[] = [];
private env: Env;
private ad: AdLike;
private policy: Policy;
constructor(clock: Ticker, env: Env, ad: AdLike, policy: Policy) {
this.env = env;
this.ad = ad;
this.policy = policy;
ad.onClosed = () => this.release();
clock.every(TICK_MS, () => this.tick());
}
/** 広告を出してよい場面かを判定し、よければ出す。出さなかった理由を返す */
request(): Reason {
const r = this.decide();
this.history.push(r);
return r;
}
private decide(): Reason {
if (this.policy.checkEnv) {
if (!this.env.active) return "background";
if (this.env.dialogOpen) return "system-dialog";
}
if (this.showing) return "already-showing";
this.showing = true;
this.elapsed = 0;
this.ad.show();
return "shown";
}
private release(): void {
this.showing = false;
}
private tick(): void {
if (!this.showing || this.policy.watchdog === "none") return;
// wall: 実時間で数える/foreground: 前面にいる間だけ数える
if (this.policy.watchdog === "foreground" && !this.env.active) return;
this.elapsed += TICK_MS;
if (this.elapsed >= WATCHDOG_MS) {
this.forceReleased += 1;
this.ad.dismiss(); // closed が来なかった広告を閉じ、入口の鍵も外す
this.release();
}
}
}
Policy で「判定を行うか」と「保険の数え方」を切り替えられるようにしてあります。これは直した形だけを見せるためではなく、元の形と、途中の形を同じ条件で並べて走らせるためです。
四つの場面を、三つの作りで走らせます
同じ出来事の並びを、三つの作りに通しました。「鍵のみ」は元の形で、判定も保険もありません。
// scenarios.ts — 同じ出来事の並びを、3つの入口の作りで走らせる
import { FakeClock } from "./clock.ts";
import { FakeInterstitial, type Env } from "./fake-sdk.ts";
import { AdGate, type Policy, type Reason } from "./gate.ts";
const POLICIES: Record<string, Policy> = {
"鍵のみ(元の形)": { checkEnv: false, watchdog: "none" },
"判定+実時間の保険": { checkEnv: true, watchdog: "wall" },
"判定+前面時間の保険": { checkEnv: true, watchdog: "foreground" },
};
interface Result {
trapped: boolean; // 240秒の時点で、閉じられない広告が画面に残っているか
healthyCuts: number; // 健全な広告を、保険が打ち切った回数
closedNormally: number;
firstRequest: Reason; // 最初の要求の結果
forceReleased: number; // 保険が働いた回数
lastRequest: Reason; // 250秒の時点(環境はすべて平常)で出した要求の結果
}
type Script = (c: FakeClock, env: Env, ad: FakeInterstitial, gate: AdGate) => void;
function run(script: Script, policy: Policy): Result {
const clock = new FakeClock();
const env: Env = { active: true, dialogOpen: false };
const ad = new FakeInterstitial(clock, env);
const gate = new AdGate(clock, env, ad, policy);
script(clock, env, ad, gate);
clock.advanceTo(240_000);
const trapped = ad.visible && !ad.closable;
clock.advanceTo(250_000);
const lastRequest = gate.request();
clock.advanceTo(300_000);
return { firstRequest: gate.history[0], forceReleased: gate.forceReleased, trapped, healthyCuts: ad.healthyCuts, closedNormally: ad.closedNormally, lastRequest };
}
const scenarios: Record<string, Script> = {
// A: 写真の保存で権限ダイアログが出ている最中に、保存完了の広告を要求する
"A 権限ダイアログ中": (c, env, _ad, gate) => {
env.dialogOpen = true;
c.after(500, () => void gate.request());
c.after(2_000, () => (env.dialogOpen = false));
},
// B: 裏に回っている間にダウンロードが終わり、広告を要求する
"B アプリが背面": (c, env, _ad, gate) => {
env.active = false;
c.after(1_000, () => void gate.request());
c.after(20_000, () => (env.active = true));
},
// C: 広告は正常に出たが、closed の通知が取りこぼされた
"C 通知の取りこぼし": (c, _env, ad, gate) => {
ad.loseClose = true;
c.after(0, () => void gate.request());
},
// D: 広告の表示中にアプリを2分間裏へ回し、戻ってから再び要求が来る
"D 表示中に背面へ": (c, env, _ad, gate) => {
c.after(0, () => void gate.request());
c.after(10_000, () => (env.active = false));
c.after(130_000, () => {
env.active = true;
gate.request();
});
},
};
export function runAll(): void {
for (const [sname, script] of Object.entries(scenarios)) {
console.log(`\n== ${sname}`);
for (const [pname, policy] of Object.entries(POLICIES)) {
const r = run(script, policy);
console.log(
`${pname.padEnd(16)} 最初の要求=${r.firstRequest.padEnd(15)} 保険の発動=${r.forceReleased} 残る=${String(r.trapped).padEnd(5)} 健全な広告の打ち切り=${r.healthyCuts} 正常に閉じた=${r.closedNormally} 250秒の要求=${r.lastRequest}`,
);
}
}
}
runAll();
出力は次のとおりです。
== A 権限ダイアログ中
鍵のみ(元の形) 最初の要求=shown 保険の発動=0 残る=true 健全な広告の打ち切り=0 正常に閉じた=0 250秒の要求=already-showing
判定+実時間の保険 最初の要求=system-dialog 保険の発動=0 残る=false 健全な広告の打ち切り=0 正常に閉じた=1 250秒の要求=shown
判定+前面時間の保険 最初の要求=system-dialog 保険の発動=0 残る=false 健全な広告の打ち切り=0 正常に閉じた=1 250秒の要求=shown
== B アプリが背面
鍵のみ(元の形) 最初の要求=shown 保険の発動=0 残る=true 健全な広告の打ち切り=0 正常に閉じた=0 250秒の要求=already-showing
判定+実時間の保険 最初の要求=background 保険の発動=0 残る=false 健全な広告の打ち切り=0 正常に閉じた=1 250秒の要求=shown
判定+前面時間の保険 最初の要求=background 保険の発動=0 残る=false 健全な広告の打ち切り=0 正常に閉じた=1 250秒の要求=shown
== C 通知の取りこぼし
鍵のみ(元の形) 最初の要求=shown 保険の発動=0 残る=true 健全な広告の打ち切り=0 正常に閉じた=0 250秒の要求=already-showing
判定+実時間の保険 最初の要求=shown 保険の発動=2 残る=false 健全な広告の打ち切り=0 正常に閉じた=0 250秒の要求=shown
判定+前面時間の保険 最初の要求=shown 保険の発動=2 残る=false 健全な広告の打ち切り=0 正常に閉じた=0 250秒の要求=shown
== D 表示中に背面へ
鍵のみ(元の形) 最初の要求=shown 保険の発動=0 残る=false 健全な広告の打ち切り=0 正常に閉じた=2 250秒の要求=shown
判定+実時間の保険 最初の要求=shown 保険の発動=1 残る=false 健全な広告の打ち切り=1 正常に閉じた=2 250秒の要求=shown
判定+前面時間の保険 最初の要求=shown 保険の発動=0 残る=false 健全な広告の打ち切り=0 正常に閉じた=2 250秒の要求=shown
読み取りやすいように、要点だけを表にします。
元の形では、A・B・C のどれでも、250秒の時点の要求が already-showing のままでした。一度つまずくと、起動が終わるまで広告が出なくなる形です。判定と保険を入れると、250秒の要求は3場面とも通りました。C の「保険の発動 2」は、最初の広告の分と、250秒に出した広告の分です。どちらの広告も、通知が返らない設定にしてあります。
直感に反した点:保険を「実時間」で数えると、健全な広告を切ります
保険は、素直に書けば「表示してから45秒たっても閉じなければ、閉じる」です。C の場面では、実時間で数えても前面時間で数えても、結果は同じでした。
差が出たのは D です。広告を見ている途中に、アプリを2分間裏へ回した場面です。
実時間で数えると、裏に回っているあいだに45秒が過ぎ、まだ見終えていない広告を保険が打ち切ってしまいます。広告は、前面にいる間しか進みません。保険の時計も、広告と同じ時間で数える必要がありました。gate.ts の tick で、前面にいない間は数えないようにした一行が、その対応です。
私は、保険の数え方を最初の設計では決めきれず、再現で三つの作りを並べて初めて差が見えました。保険は、うまく動いたときには見えません。見えないぶん、健全な広告を切っていても気づきにくい部品かもしれません。D のような場面を、テストに一本入れておく価値があると思います。
しきい値は、自分のアプリで測ってから決めます
ここでの45秒は、手元の代役の広告が20秒で終わる前提で選んだ値です。実際の広告の長さを写したものではありません。
決め方はこうです。自分のアプリで出る広告の、いちばん長い表示時間を実機で測ります。そこに余裕を足した値をしきい値にします。短すぎると健全な広告を切り、長すぎると「閉じられない」状態が長く続きます。出発点には、実機で測った最長の表示時間の1.5倍をお勧めします。個人的には、そこから実機で動かして、縮めたり延ばしたりする進め方が安全だと考えています。
Expo につなぐ
部品は広告 SDK を知らない作りにしてあるので、Expo では、次のように差し込みます。再現用のコードでは Env の型を fake-sdk.ts に置いていますが、本番では gate.ts に移してください。react-native-google-mobile-ads を想定していますが、この接続部分は自動テストをしておらず、実機で確かめてから使ってください。名前や挙動は、導入している版で確認をお願いします。
// ad-entry.ts — 実機用のつなぎ込み(未テスト。実機で確認してください)
import { AppState } from "react-native";
import { InterstitialAd, AdEventType } from "react-native-google-mobile-ads";
import { AdGate } from "./gate";
const env = { active: AppState.currentState === "active", dialogOpen: false };
AppState.addEventListener("change", (s) => (env.active = s === "active"));
const interstitial = InterstitialAd.createForAdRequest("<ユニット ID>");
const adLike = {
onClosed: () => {},
show: () => interstitial.show(),
// 強制的に閉じる API がない版では、中身を空にします。その場合の保険は「鍵を外す」までです
dismiss: () => {},
};
interstitial.addAdEventListener(AdEventType.CLOSED, () => adLike.onClosed());
interstitial.addAdEventListener(AdEventType.ERROR, () => adLike.onClosed());
const realTicker = {
every: (ms: number, fn: () => void) => {
const id = setInterval(fn, ms);
return () => clearInterval(id);
},
};
export const gate = new AdGate(realTicker, env, adLike, {
checkEnv: true,
watchdog: "foreground",
});
// 権限ダイアログを出す操作は、この二つで挟みます
export async function withSystemDialog<T>(run: () => Promise<T>): Promise<T> {
env.dialogOpen = true;
try {
return await run();
} finally {
env.dialogOpen = false;
}
}
写真への保存の許可は、たとえば await withSystemDialog(() => MediaLibrary.requestPermissionsAsync()) のように挟みます。ここで見落としやすいのは、dismiss の中身です。広告の画面を、アプリ側から閉じる API が見当たらない版もあります。その場合、保険にできるのは入口の鍵を外すところまでで、画面に残った広告そのものは閉じられません。だからこそ、先に「出さない」判定を入れるほうが、効きます。
落とし穴:直したつもりで残りやすい三か所
本番運用に出してから気づくと、対処が後手に回りやすい箇所です。
- 呼び出しが一か所に集まっていないこと。 部品を作っても、ほかの画面が SDK を直接呼んでいれば、判定を迂回します。集めたつもりのあとに、呼び出しの数をもう一度数えてください。
- 確認画面の種類。 写真の許可だけを挟んでも、通知の許可や、位置情報の許可で同じ重なりが起きます。確認画面を出す操作は、一覧にしておくと抜けません。
- 「見送った」広告の扱い。 見送った広告を後から出し直すかは、別の判断です。許可を押した直後に広告が出ると、押した操作と結びついて、かえって不快になります。再現のコードでは出し直さず、見送るだけにしています。ここは、アプリの性格によって答えが変わると思います。
今日できる最初の一歩
自分のアプリで、全画面広告を出している箇所の数を、まず数えてみてください。
grep -rnE "\.show\(\)|showAd|presentAd|interstitial" app src --include="*.ts" --include="*.tsx" | wc -l
1より大きければ、判定が散らばっている可能性があります。次に、requestPermissionsAsync や通知の許可を出している箇所を同じように探し、広告の呼び出しと同じ画面に並んでいないかを見ます。並んでいる組が、今回の型の候補です。
同じ「長生きする状態」の問題は、画面を行き来したあと、全画面広告が出なくなりました でも扱っています。起動時の広告の判断は 起動広告が、背面から戻った回にしか出ていませんでした、回数の数え方は 起動回数のカウンタが、1回の起動で2ずつ進んでいました にまとめています。
最後に一つだけ。広告を「出さない」と決める判定は、収益を削るように感じる方もいると思います。ただ、元の形の再現では、閉じられない広告が一つ残ると、その起動の広告がすべて止まりました。出す場面を選ぶほうが、結果として広告を出し続けられる、というのが今回の学びでした。