壁紙を開いては戻り、別の壁紙を開いては戻ります。壁紙アプリの画面は、使われているあいだ何度も作られては閉じられます。
その行き来の途中で、全画面広告が出なくなる不具合が見つかりました。最初に開いた画面では出ます。ところが、いったん戻って開き直すと、広告を出すはずの場面で何も起きません。クラッシュも、エラーの表示もありませんでした。
広告の部品は作り直さずに使い回します。ただし、その部品が知らせる先は、画面が変わるたびに今の画面へ差し替えます。 直した考え方は、この一文に収まりました。
個人開発で運用しているネイティブの壁紙アプリで起きた話ですが、Rork が書き出す Expo のアプリにも、同じ型は作りやすいと思います。以下は、その型を TypeScript で組み直し、Node 22 で動かして確かめた書き残しです。
症状は「二度目から出ない」だけでした
手がかりが少ない不具合ほど、観察を表にして、何が違うのかを先に押さえておくと迷いません。この型の不具合では、次の組み合わせになります。
広告の設定ばかりを疑っていると、遠回りになりやすい症状です。私は、まず通知の宛先から確かめる順番を好みます。設定が合っているのに二度目から出ないなら、まず「通知が誰に届いているか」を疑います。
起きていたこと:作り直さない設計の中に、最初の画面が残っていました
全画面広告の部品を画面ごとに作ると、読み込みが毎回やり直しになります。そこで、アプリ全体で一つだけ持つ静的な保持に置き、使い回す形にしておりました。ここまでは、悪くない判断だったと感じています。
問題は、その保持の中に、最初に使った画面への参照も一緒に入っていたことでした。画面を閉じても、保持は閉じた画面を握ったままです。新しい画面は広告を取りに行き、同じ部品を受け取ります。けれど、広告が読み込めたという知らせは、閉じた画面のほうへ飛んでいきます。新しい画面は、いつまでも「準備中」のままです。
直し方は、部品を作り直すことではありませんでした。作り直さずに、参照だけを最新の画面に差し替える形にしました。のちにリワード広告の部品でも、同じ型の不具合が出ました。使い回す部品に、画面への参照を直接持たせないことを、私は点検の最初に置いております。見つけたときの対処は、参照を外すのではなく、差し替えられる形にすることです。
壊れていた形を、まず手元で再現します
直す前に、壊れていた形をそのまま再現しておきます。元のコードの引用ではなく、考え方の再現です。広告 SDK の代わりに、イベントを後から受け取る性質だけを持つ代役を用意しました。
// fake-ad.ts — 全画面広告 SDK の代役。「イベントを後から受け取る」性質だけを再現する
export type AdEvent = "loaded" | "closed";
type Listener = () => void;
export class FakeInterstitial {
private listeners: Record<AdEvent, Set<Listener>> = {
loaded: new Set(),
closed: new Set(),
};
loadCalls = 0;
showCalls = 0;
addListener(event: AdEvent, cb: Listener): () => void {
this.listeners[event].add(cb);
return () => this.listeners[event].delete(cb);
}
load(): void {
this.loadCalls += 1;
}
show(): void {
this.showCalls += 1;
}
// テストから「SDK がイベントを返した」ことにするための入口
emit(event: AdEvent): void {
for (const cb of [...this.listeners[event]]) cb();
}
}
画面の代役には、閉じたあとに届いた通知を数える欄を付けています。
// screen.ts — 画面の代役。閉じた(alive=false)画面に届いた通知を数える
export interface Host {
name: string;
onReady(): void;
onClosed(): void;
}
export class FakeScreen implements Host {
alive = true;
ready = false;
deadDeliveries = 0; // 閉じたあとに届いた通知の数
name: string;
constructor(name: string) {
this.name = name;
}
onReady(): void {
if (!this.alive) return void (this.deadDeliveries += 1);
this.ready = true;
}
onClosed(): void {
if (!this.alive) return void (this.deadDeliveries += 1);
this.ready = false;
}
unmount(): void {
this.alive = false;
}
}
壊れていた形は、次のとおりです。作り直さない点は正しいのですが、通知先を、最初に呼んだ画面に固定してしまっています。
// holder-buggy.ts — 壊れていた形。「作り直さない」は正しいが、通知先を最初の画面に固定している
import { FakeInterstitial } from "./fake-ad.ts";
import type { Host } from "./screen.ts";
let ad: FakeInterstitial | null = null;
export function getAd(host: Host): FakeInterstitial {
if (ad) return ad; // 2回目以降は、作り直さずに同じものを返す
const created = new FakeInterstitial();
created.addListener("loaded", () => host.onReady()); // host は最初の画面のまま固定される
created.addListener("closed", () => {
host.onClosed();
created.load();
});
created.load();
ad = created;
return created;
}
画面を5回、順に開いては閉じる場面を走らせました(demo.ts)。各回で、新しい画面が「広告を出せる状態」になったかを見ています。
壊れた形 1回目 ready=true
壊れた形 2回目 ready=false
壊れた形 3回目 ready=false
壊れた形 4回目 ready=false
壊れた形 5回目 ready=false
直した形 1回目 ready=true
直した形 2回目 ready=true
直した形 3回目 ready=true
直した形 4回目 ready=true
直した形 5回目 ready=true
壊れた形: 5回中 1 回表示 / 閉じた画面に届いた通知 4 件
直した形: 5回中 5 回表示 / 閉じた画面に届いた通知 0 件
壊れた形では、5回のうち表示できたのは1回目だけでした。2回目から5回目までの読み込み完了の通知4件は、すべて閉じた1枚目の画面に届いていました。画面は一つも落ちていません。通知の宛先が違うだけで、広告はまったく出なくなります。
直した形:広告は作り直さず、通知先だけを差し替えます
直した部品が、次のものです。変えたのは三点でした。
// holder-fixed.ts — 直した形。広告は作り直さず、通知先だけを最新に差し替える
import { FakeInterstitial } from "./fake-ad.ts";
import type { Host } from "./screen.ts";
let ad: FakeInterstitial | null = null;
let host: Host | null = null; // 今の通知先(いなければ null)
let ready = false; // 広告の状態は、画面ではなく部品が持つ
function ensureAd(): FakeInterstitial {
if (ad) return ad;
const created = new FakeInterstitial();
created.addListener("loaded", () => {
ready = true;
host?.onReady();
});
created.addListener("closed", () => {
ready = false;
host?.onClosed();
created.load();
});
created.load();
ad = created;
return created;
}
export function attach(next: Host): void {
ensureAd();
host = next;
if (ready) next.onReady(); // すでに読み込み済みなら、新しい画面にもすぐ伝える
}
export function detach(prev: Host): void {
if (host === prev) host = null; // 自分が今の通知先のときだけ外す
}
export function show(): boolean {
if (!ready || !ad) return false;
ad.show();
return true;
}
一つ目は、広告の「読み込み済みか」を、画面ではなく部品が持つようにしたことです。画面が入れ替わっても、状態は部品の側に残ります。
二つ目は、attach の中で、すでに読み込み済みなら新しい画面へすぐ伝えることです。これがないと、読み込みが済んでから開いた画面は、次の通知が来るまで準備中のままになります。
三つ目が、detach を「自分が今の通知先のときだけ外す」形にしたことです。次の節でお伝えするとおり、ここが今回いちばん意外だった点です。
直感に反した点:古い画面の後片付けが、新しい画面の接続を消すことがあります
画面を閉じるときに通知先を外す、と素直に書けば、detach は無条件に通知先を空にしたくなります。ところが、画面の切り替えでは、新しい画面の attach が先に走り、古い画面の detach があとから来る並びになることがあります。並びは、ライブラリや版によって変わりえますので、どちらの順でも壊れない形にしておけば、順序の罠は回避できます。新しい画面が先の順番だと、無条件に外す書き方では、新しい画面の接続まで消えてしまいます。
2つの書き方を、同じ並びで試しました。
// 比較用: 条件なしで外す書き方
export function detachUnconditional(): void {
host = null;
}
上は、テストで確かめた結果です。
先に attach(B)、後から detach(A) が来る並びで、条件なしで外すと通知先は空になります。広告は読み込めるのに、誰にも知らせが届かず、やはり出なくなります。detach に「自分が今の通知先のときだけ」という一行を足すと、どちらの順で走っても通知先が B のまま残りました。
もう一点、多くのスタック型の画面遷移では、戻っても前の画面の「作られたとき」の処理が走り直しません。前の画面は、裏で作られたままだからです。「作られたときに attach、閉じるときに detach」では、戻った画面が接続されないままになります。見えているあいだだけ接続する書き方が必要です。
Expo Router につなぐ
画面側は、見えている間だけ接続する書き方にします。Expo Router の useFocusEffect を使いました。
import { useCallback, useState } from "react";
import { useFocusEffect } from "expo-router";
import { attach, detach, show } from "./holder-fixed";
export function useInterstitial() {
const [ready, setReady] = useState(false);
useFocusEffect(
useCallback(() => {
const host = {
name: "screen",
onReady: () => setReady(true),
onClosed: () => setReady(false),
};
attach(host);
return () => detach(host); // 同じ host のときだけ外れる
}, [])
);
return { ready, show };
}
この接続部分は、上の部品のようには自動テストしておりません。実機で、画面を何度か行き来して確かめてから使ってください。開発中の二重実行(接続→解除→接続)に対しては、部品側で attach → detach → attach のあとも通知先が残ることを、テストで確かめてあります。
もう一つ、同じ型:作り直す前に空にしない静的な集合
ネイティブの壁紙アプリでは、似た型の不具合が、画像の一覧でも出ました。一覧を保持する静的な集合を、空にしないまま作り直したため、再起動をまたいで項目が積み上がり、スライダーの上限が実際の件数の4倍に膨らんだのです。
この形も、手元で再現しました。一覧の件数を、モジュールが持つ配列の長さから出しています。
// catalog.ts — 一覧の件数を、モジュールが持つ配列から出している例
let items: string[] = [];
// 壊れた形: 読み込みのたびに足していく
export function loadCatalogBuggy(fresh: string[]): void {
items.push(...fresh);
}
// 直した形: 作り直す前に、いったん空にする(まとめて置き換える)
export function loadCatalogFixed(fresh: string[]): void {
items = [...fresh];
}
export function sliderMax(): number {
return items.length;
}
5件の一覧を、4回読み込んだ結果です。
4回目で上限は 20 になり、実際の件数 5 の4倍です。静的な集合は、作り直す前に空にします。 「足す」と書いた時点で、読み込みの回数だけ増える前提になっていることに、気づきにくいのが厄介でした。
落とし穴:直したつもりで残りやすい三か所
show() が false を返したときの扱い。 部品は、まだ広告がないときに黙って false を返します。呼ぶ側が戻り値を見ずに先へ進むと、「広告ボタンを押しても何も起きない」だけが残ります。広告なしでそのまま次へ進める経路を、画面側に用意しておきます。
- リワード広告の部品。 全画面広告だけ直して、リワードの部品は旧来のままという状態が起きやすいと思います。私自身、リワードで同じ型が再発しました。保持を作るなら、同じ
attach と detach の形に揃えておきます。
- ほかの静的な保持。 広告の部品のほかにも、モジュールの最上位に置いたものは、画面より長生きします。次の節の検索で洗い出せます。
今日できる最初の一歩
自分のアプリに、画面より長生きするものがいくつあるかを、まず数えてみてください。モジュールの最上位にある let と、空の Map・Set・配列を探します。
grep -rnE "^(export )?(let|var) |^(export )?const \w+(: [^=]+)? = (new (Map|Set)|\[\])" app src --include="*.ts" --include="*.tsx"
出てきた行のうち、画面への参照や、画面のコールバックを持っているものが、今回の型の候補です。広告の部品に限らず、課金の状態や、位置情報の購読にも同じ並びは起こりえます。
一緒に、起動回数を一か所で数える書き方を 起動回数のカウンタが、1回の起動で2ずつ進んでいました にまとめています。購入状態の判定は 購読が切れても広告が戻りませんでした をご覧ください。
最後に一つだけ。通知の宛先を確かめる作業は地味ですが、広告が出ない理由を設定の側に探し続けるより、ずっと短い道のりでした。同じ症状でお困りの方の、切り分けの一助になれば幸いです。