import { ArticleImage } from "@/components/ArticleImage";
アーティスト・クリエイターの廣川政樹です。
2026 年 5 月に入ってから、壁紙系アプリの最新アップデートで一番時間をかけたのが、AdMob オープンアプリ広告(App Open Ad)のフリークエンシー設計でした。インタースティシャル広告と比べて単価が低めなのに、ユーザーの「アプリを開いた直後」という最も繊細なタイミングを奪うため、設計を一歩間違えると D1 リテンションがじわりと削れていきます。2014 年から個人で iOS / Android アプリを運営してきて累計5,000万DLを超えたなかで、オープンアプリ広告ほど「うまく組むと黒字、雑に組むと赤字」が分かれやすい広告フォーマットを他に知りません。
今回は、6 本ある壁紙アプリのうち 2 本を題材にして頻度設計を作り直した工程を、Rork で生成したコードをベースに整理しておきます。Claude in Chrome でダッシュボードを毎朝確認する運用に切り替えたこともあり、頻度パラメータを Remote Config で配信して挙動を遠隔調整できるようにしました。設計判断と、実装で踏んだ落とし穴を残しておきます。
オープンアプリ広告が他の広告フォーマットと違うところ
AdMob のオープンアプリ広告は、アプリの起動またはフォアグラウンド復帰時に全画面で表示される広告です。インタースティシャルと見た目は似ていますが、表示タイミングと許容される頻度に決定的な違いがあります。
インタースティシャルは「ユーザーが操作の区切りで挟む」のが原則で、ユーザー自身が画面遷移したことを認識している瞬間に出ます。一方でオープンアプリ広告は、ユーザーが「自分の意思でアプリを開いた」直後に出るため、ユーザー側は広告を見るためにアプリを開いたわけではないという意識のずれがあります。このずれを軽く扱うと、たった一度の不快体験が次の起動を止める要因になります。
私が運用している壁紙アプリ(『浮世絵壁紙』など)の実測では、オープンアプリ広告を導入した直後の週、D1 リテンションが 2.1% 落ちる版がありました。一方、同時期に頻度設計を作り込んだ別アプリでは D1 リテンションがほぼ維持されたうえで、ARPDAU が 18% 伸びました。同じ広告フォーマットを同じ規模で出していても、頻度設計の差でここまで結果が分かれます。
私が決めた 4 つの基本ルール
何度かの試行錯誤を経て、現時点で私が壁紙アプリに適用しているルールは次の 4 つです。
- コールドスタート初回は表示しない: インストール直後の初回起動、アップデート直後の初回起動では一切表示しないようにしました。最初の体験を奪うと、その後どれだけ広告が控えめでも信頼を取り戻すのが難しくなります。
- フォアグラウンド復帰の最小間隔を 120 秒: 短すぎる復帰で繰り返し出すと「アプリを行き来するたびに広告」という感覚が積み上がります。実測では 60 秒以下では離脱率がはっきり伸びました。
- モーダル復帰では出さない: 写真ライブラリ選択、共有シート、課金シートからの復帰では絶対に出しません。ユーザーは自発的に外部 UI へ遷移しただけで、復帰したい意思は明確だからです。
- エンゲージメントの低いセッション末尾を避ける: アプリを 1 秒以内に閉じたセッションが連続している場合は、次回の復帰でも表示を 1 回スキップします。
これらは AdMob 公式ドキュメントでは個別に推奨されている要素ですが、組み合わせて運用しないと意味が薄いと感じています。両家の祖父が宮大工だった影響なのか、「全体を支える小さな部材の組み合わせ」を整えるほうが好みです。広告の頻度設計も同じで、ひとつのパラメータだけ強くしても効きません。
Rork で生成されたコードを土台に書き換える
Rork のプロンプトに「オープンアプリ広告を追加してください」と指示すると、最低限のローダーは生成してくれます。ただし、ここまで書いてきたルールを満たすには、生成コードの上に明確な状態管理を載せる必要があります。
まず、最小構成として TypeScript(Expo + React Native)で次のような Hook を用意しました。
// hooks/useAppOpenAd.ts
import { useEffect, useRef, useState } from "react";
import { AppState, AppStateStatus } from "react-native";
import { AppOpenAd, AdEventType, TestIds } from "react-native-google-mobile-ads";
import { useRemoteConfig } from "./useRemoteConfig";
import { useSession } from "./useSession";
type AdState = "idle" | "loading" | "loaded" | "showing" | "error";
export function useAppOpenAd() {
const { minInterval, coldStartSkip, modalRefs } = useRemoteConfig();
const { lastShownAt, lastDismissedExternalAt, sessionStartedAt } = useSession();
const [state, setState] = useState<AdState>("idle");
const adRef = useRef<AppOpenAd | null>(null);
// 初回ロード
useEffect(() => {
const ad = AppOpenAd.createForAdRequest(
__DEV__ ? TestIds.APP_OPEN : "ca-app-pub-XXXXXXXX/YYYYYYYY",
{ requestNonPersonalizedAdsOnly: false, keywords: ["wallpaper", "lifestyle"] }
);
adRef.current = ad;
ad.addAdEventListener(AdEventType.LOADED, () => setState("loaded"));
ad.addAdEventListener(AdEventType.ERROR, () => setState("error"));
ad.load();
setState("loading");
}, []);
// フォアグラウンド復帰で表示判定
useEffect(() => {
const sub = AppState.addEventListener("change", (s: AppStateStatus) => {
if (s !== "active") return;
if (!adRef.current || state !== "loaded") return;
// ルール1: コールドスタート初回はスキップ
if (coldStartSkip && Date.now() - sessionStartedAt < 3000) return;
// ルール2: 最小間隔
if (Date.now() - lastShownAt < minInterval * 1000) return;
// ルール3: モーダル復帰スキップ(写真ライブラリ等の外部UIから戻ったとき)
if (Date.now() - lastDismissedExternalAt < 5000) return;
adRef.current.show();
setState("showing");
});
return () => sub.remove();
}, [state, minInterval, coldStartSkip, lastShownAt, lastDismissedExternalAt]);
return { state };
}
ポイントは、Hook 内で時間判定を一括にまとめ、外部から渡される minInterval と coldStartSkip を Remote Config 由来にしている点です。本番運用で頻度パラメータをコード変更なしに動かしたいので、初期から外部設定で受ける構造にしておきます。
Remote Config で版ごとに頻度を出し分ける
頻度設計は一度で正解にたどり着くことが少ないため、Firebase Remote Config 経由で配信して、A/B 群を分けながら最適点を探っています。私が現在 Beautiful HD Wallpapers 系列で稼働させている設定は次のとおりです。
// hooks/useRemoteConfig.ts
import remoteConfig from "@react-native-firebase/remote-config";
import { useEffect, useState } from "react";
const DEFAULTS = {
app_open_min_interval_sec: 120,
app_open_cold_start_skip: true,
app_open_modal_skip_window_ms: 5000,
app_open_enabled: true,
};
export function useRemoteConfig() {
const [config, setConfig] = useState(DEFAULTS);
useEffect(() => {
(async () => {
await remoteConfig().setDefaults(DEFAULTS);
await remoteConfig().setConfigSettings({ minimumFetchIntervalMillis: 3600 * 1000 });
await remoteConfig().fetchAndActivate();
setConfig({
app_open_min_interval_sec: remoteConfig().getNumber("app_open_min_interval_sec"),
app_open_cold_start_skip: remoteConfig().getBoolean("app_open_cold_start_skip"),
app_open_modal_skip_window_ms: remoteConfig().getNumber("app_open_modal_skip_window_ms"),
app_open_enabled: remoteConfig().getBoolean("app_open_enabled"),
});
})();
}, []);
return {
minInterval: config.app_open_min_interval_sec,
coldStartSkip: config.app_open_cold_start_skip,
modalRefs: config.app_open_modal_skip_window_ms,
enabled: config.app_open_enabled,
};
}
Firebase コンソール側では、条件付き値を「A グループ(ユーザー ID 末尾 0–4)」「B グループ(末尾 5–9)」のように分けて、app_open_min_interval_sec を A 群 60 秒 / B 群 180 秒のように差をつけて配信します。3〜5 日で広告 KPI と D1 リテンションを観測し、優位な群の値に寄せていく流れです。
なお、Remote Config の minimumFetchIntervalMillis を短くしすぎると、配信側の制限に当たります。私は 1 時間(3600 * 1000)を基本にしています。緊急時に即時反映したい場合は、起動時 fetch の後に手動 activate するルートを別途持っておきます。
モーダル復帰スキップを「セッション」側の責務にする
実装で最も時間がかかったのが、モーダル復帰の判定です。写真ライブラリ・共有シート・課金シート・カメラ・OAuth Web View など、ユーザーが自発的に外部 UI へ抜けるルートは複数あり、それらから戻ってきたときにオープンアプリ広告が出るとユーザーは強い違和感を覚えます。
私は、これを「広告側のロジック」ではなく「セッション側の責務」として扱う設計にしました。広告を出すか出さないかの判定は、セッション情報に「最後に外部 UI を呼んだ時刻」だけが渡っていれば成立するからです。
// hooks/useSession.ts
import { create } from "zustand";
interface SessionState {
sessionStartedAt: number;
lastShownAt: number;
lastDismissedExternalAt: number;
markExternalShown: () => void;
markAdShown: () => void;
}
export const useSession = create<SessionState>((set) => ({
sessionStartedAt: Date.now(),
lastShownAt: 0,
lastDismissedExternalAt: 0,
markExternalShown: () => set({ lastDismissedExternalAt: Date.now() }),
markAdShown: () => set({ lastShownAt: Date.now() }),
}));
呼び出し側では、写真ライブラリを開く直前に markExternalShown() を呼びます。
import * as ImagePicker from "expo-image-picker";
import { useSession } from "./hooks/useSession";
export async function pickWallpaper() {
const { markExternalShown } = useSession.getState();
markExternalShown();
return await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images });
}
この構造にしたあと、新しく外部 UI を増やしたときも「呼び出し直前に markExternalShown を入れる」を 1 行追加するだけで広告側の判定が自動的に切り替わります。Rork で生成された画面に追加でモーダルが入っても、責務が分離されているので回帰が起きにくい設計になりました。
数値で見た 3 週間の変化
A 群(min_interval 60 秒)と B 群(min_interval 180 秒)の 3 週間運用で観測した値を、実数のまま残しておきます。本来は A 群が短い分インプレッションが多く稼げる想定でしたが、結果は次のようになりました。
- A 群: インプレッション +27%、CTR -0.3pt、eCPM -8%、D1 リテンション -2.1pt、ARPDAU -3%
- B 群: インプレッション -6%、CTR +0.1pt、eCPM +12%、D1 リテンション ほぼ変化なし、ARPDAU +18%
A 群はインプレッションこそ多いものの、eCPM が下がり、リテンションも削れる結果でした。広告ネットワーク側が「ユーザーへの過剰露出」を CTR の落ち込みで検知し、入札を控えた可能性が高いと見ています。本番運用では、私はこの 3 週間以降は B 群 180 秒に寄せています。一見直感に反する選択ですが、累計5,000万DLの規模で見ると、頻度を下げた版で ARPDAU が伸びる事例は他のアプリでも観測しており、再現性のあるパターンだと感じています。
Claude in Chrome に毎朝のダッシュボード確認を任せる
これだけパラメータが多いと、毎朝 AdMob と Firebase の両方を眺める時間が積み上がります。今回のアップデートを機に、Claude in Chrome に日次の傾向確認を任せる運用に切り替えました。
具体的には、次の 3 ステップを Claude in Chrome に依頼しています。
- AdMob ダッシュボードを開いてアプリ単位のオープンアプリ広告の eCPM・fill rate・CTR を表形式で取り出す
- Firebase コンソールでオープンアプリ広告イベントの impressions と前日比をクロス参照する
- 当日の
app_open_min_interval_sec の Remote Config 値と、CTR の傾向に齟齬がないか確認する
CTR が突然 0.3pt 以上下がっていた場合だけ Slack に通知する仕組みにしてあるため、平常時は毎朝の確認自体が発生しません。私の役割は、通知が来た日にプロンプトを練り直すことに集中しています。アプリ事業 12 年目で気づいたのは、「広告 KPI は毎日見ているうちに鈍る」ことでした。私自身、AdMob のダッシュボードを連日眺めるほどパターン検出力が落ちる感覚があります。それなら、傾向検知だけ AI に任せ、人間は判断に集中するほうが向いていると考えています。
失敗パターン: 「コールドスタートに出さない」をうっかり外したとき
公開直後、ある版で app_open_cold_start_skip を false で出した日がありました。Remote Config の条件設定でうっかり別群に紛れ込ませてしまったのが原因です。半日で 1 ストア評価分の星が落ち、レビューに「開いた瞬間に広告」「これは仕方ないけど次は開かないと思う」と書かれて、ようやく Firebase の値を見直しました。
このときの教訓は 2 つあります。第一に、Remote Config の条件分岐は誰が見ても分かるラベル運用にすること。条件名を cohort_AB などの抽象的な値にすると、後から自分でも誤読します。第二に、コールドスタートに広告を出すリスクは「平均値で測れない」ということです。1 日 1 回起動のユーザーにとって、初回起動の広告体験はそのまま「アプリの第一印象」になります。平均インプレッションが多少増えても、レビュー評価の毀損で簡単に相殺されます。
私が今、頻度設計で重視している順番
ここまで触れてきた要素を整理すると、私が頻度設計を考えるときの優先順位はこの順番です。
- コールドスタート初回を絶対に守る
- モーダル復帰を「セッション側」で正しく扱う
- 最小間隔は Remote Config で 90〜180 秒のレンジで動かす
- CTR / D1 リテンション / ARPDAU を 3 点同時に追う
- AdMob ダッシュボードの確認は AI に任せ、人間は判断に集中する
5,000 万 DL を支えてくれた広告収益は、これからも壁紙アプリ事業の柱です。だからこそ「広告でユーザーを擦り切らせない」ことに、設計の時間を惜しまないようにしています。
オープンアプリ広告は、運用次第で eCPM を取りに行ける優しいフォーマットにもなれば、リテンションを地味に削る厄介者にもなります。設計を作り込む価値は十分にあると感じています。同じテーマで実運用している方の Remote Config キー設計、ぜひ私自身も学ばせていただきたいです。
お読みいただきありがとうございました。