2014 年から個人開発で iOS アプリを続けてきた中で、累計 5,000 万 DL のうち AdMob のインタースティシャル広告は最も大きな収益源でした。ただし、6 本の壁紙アプリを並行運用する Dolice の体制では、ある時期から D1 リテンションが 38% → 31% へ沈むサイクルが繰り返し起きるようになりました。原因を絞り込むと、インタースティシャルの「広告疲労」が想定より早く現れていたのです。
公式ドキュメントの推奨である「セッション開始から 30 秒以降に表示・1 セッション最大 2 回」という固定 frequency cap は、長く使ってくれるヘビーユーザーほど早く疲労する曲線を生みます。固定値でなく、ユーザーの直近の挙動と苛立ちサインを観測し、表示頻度を動的に絞り直す仕組みが必要でした。その経験を踏まえ、6 本の Rork 製アプリ横断で動かしている 3 層 frequency cap アーキテクチャを、Swift と Cloudflare Workers のコードと、実測値・段階リリース手順としてまとめておきます。
観測されていた症状と仮説
まず最初の 2 週間で観測した数字を並べておきます。手元の壁紙アプリ 4 本平均(iOS 17.5 〜 18.4・MAU 2.3 万)です。
- 平均 eCPM: $9.6 → $11.2(インタースティシャル単独・段階改善前)
- D1 リテンション: 38.4% → 31.4%(4 週間で 7pt 沈下)
- D7 リテンション: 17.1% → 12.3%
- Crashlytics non-fatal「アプリが応答しないまま広告閉じ操作で復帰」: 週 47 件 → 週 312 件
- ユーザーレビュー★1 比率: 全レビューの 2.1% → 4.7%
eCPM が上がっているのは入札最適化の効果ですが、リテンションと non-fatal、★1 レビューが同期して悪化していました。仮説は単純で、「ヘビーユーザーが 1 日の中で広告を見すぎている」というものです。AdMob の Reporting は時間帯別の表示数しか出してくれないため、ユーザー単位の「直近 N 時間の表示数 × 強制終了率」を独自に観測することにしました。
3 層 frequency cap の判定アーキテクチャ
Rork 製アプリの共通モジュール AdManager.swift で、以下の 3 層で表示可否を決めています。
- 第 1 層: ローカルセッション履歴(
UserDefaults+ メモリ) - 第 2 層: Crashlytics non-fatal を「苛立ちシグナル」として読み取り
- 第 3 層: Cloudflare Workers 経由で Remote Config のしきい値を動的に取得
公式の AdMob SDK には frequency cap がプラットフォーム側にあります。ただし、これは「セッション内 / 日次の固定上限」しか持たないため、ヘビーユーザー個別の疲労曲線に追従できません。クライアント側で独自に判定したうえで、AdMob SDK には「表示可能」と判断したタイミングだけ load → present を渡す方針です。
第 1 層: セッション履歴の局所キャッシュ
直近 24 時間の「インタースティシャル表示数」「ユーザー操作までの遅延」「アプリのアクティブ秒数」を UserDefaults に圧縮して保持します。容量が肥大化しないよう、24 時間より古いレコードは追記時に間引きます。
import Foundation
struct AdImpression: Codable {
let timestamp: TimeInterval
let activeSeconds: Int
let dismissDelayMs: Int
}
final class AdHistoryStore {
static let shared = AdHistoryStore()
private let key = "ad.history.v1"
private let queue = DispatchQueue(label: "ad.history.q")
func record(activeSeconds: Int, dismissDelayMs: Int) {
queue.async {
var all = self.load()
all.append(AdImpression(
timestamp: Date().timeIntervalSince1970,
activeSeconds: activeSeconds,
dismissDelayMs: dismissDelayMs
))
let cutoff = Date().timeIntervalSince1970 - 24 * 3600
all = all.filter { $0.timestamp >= cutoff }
if let data = try? JSONEncoder().encode(all) {
UserDefaults.standard.set(data, forKey: self.key)
}
}
}
func load() -> [AdImpression] {
guard let data = UserDefaults.standard.data(forKey: key),
let arr = try? JSONDecoder().decode([AdImpression].self, from: data)
else { return [] }
return arr
}
func recentCount(within seconds: TimeInterval) -> Int {
let cutoff = Date().timeIntervalSince1970 - seconds
return load().filter { $0.timestamp >= cutoff }.count
}
}dismissDelayMs を毎回記録しているのが見落とされがちなポイントです。閉じるまでの時間が短くなり続けているユーザーは、広告を「素早く処理する対象」として認識し始めており、疲労の初期兆候として観測できます。
第 2 層: Crashlytics non-fatal を「苛立ちシグナル」に転用
これは公式ドキュメントには載っていない使い方ですが、本番運用で最も効きました。インタースティシャル閉じ操作の前後で発生する以下の事象を Crashlytics.crashlytics().record(error:) で記録し、サーバー側で「広告ストレス指標」として集計しています。
- 広告閉じ操作中に呼び出された
UIApplication.willResignActive(途中タップでアプリを離脱した可能性) - 広告表示中の
MemoryWarning(壁紙の高解像度プレビュー直後など) - 広告閉じ直後 3 秒以内のアプリ強制終了(
willTerminate観測)
公式の「Custom Keys + Custom Logs」を組み合わせ、Crashlytics 上で「ad.fatigue.signal=1」のフラグを立てたユーザーグループに segmentation できます。月次で 5,000 ユーザー前後がこのセグメントに入り、その大半が D7 直前で離脱していました。
第 3 層: Remote Config の動的しきい値
クライアント側にハードコードされたしきい値(例えば「直近 1 時間に 3 回まで」)は、6 アプリ横断で最適化しづらいため Remote Config に外出ししています。Cloudflare Workers で薄いラッパーを置き、ユーザー国・OS バージョン・直近の non-fatal カウントに応じてしきい値を返します。
// cloudflare-worker: /api/ad/freq-cap
export default {
async fetch(req: Request, env: Env) {
const url = new URL(req.url);
const country = req.headers.get("CF-IPCountry") ?? "JP";
const app = url.searchParams.get("app") ?? "wallpaper-a";
const nonFatal = Number(url.searchParams.get("nf") ?? 0);
// Remote Config の本体は KV にミラーしている
const baseRaw = await env.AD_CONFIG_KV.get(`base:${app}:${country}`);
const base = baseRaw ? JSON.parse(baseRaw) : { capPerHour: 3, capPerDay: 8 };
let { capPerHour, capPerDay } = base;
if (nonFatal >= 2) {
capPerHour = Math.max(1, Math.floor(capPerHour * 0.5));
capPerDay = Math.max(2, Math.floor(capPerDay * 0.6));
} else if (nonFatal === 1) {
capPerHour = Math.max(1, capPerHour - 1);
}
return Response.json({ capPerHour, capPerDay, ttlSec: 600 });
},
};KV にミラーしている理由は、AdMob 側の Remote Config 障害(過去 2 度経験)でも独立して配信を継続するためです。返り値は最小限の capPerHour と capPerDay だけにしておき、複雑な ML スコアは持ち込まない方針にしました。複雑さは ARPDAU 改善に対して割に合わないからです。