RORK LABEN
TOOLING — Rorkの開発者向けリポジトリが動き続けています。rork-xcodeが7月16日、rork-deviceが7月15日、rork-plistが7月13日に更新されましたOPUS46 — RorkでClaude Opus 4.6が稼働しています。Rork MaxはClaude Codeを土台にアプリを組み立てる設計ですSIM — ブラウザ上で動くクラウドのiOSシミュレータを備え、実機へのインストールは1クリック、App Storeへの提出は2クリックと案内されていますMAX — Rork MaxはReact Nativeではなく純粋なSwiftを出力します。iPhone・iPad・Apple Watch・Apple TV・Vision Pro、そしてiMessageまでが射程ですNATIVE — HealthKit、ARKitとLiDAR、NFC、Dynamic Island、Live Activities、Metalによる3D、Core MLのオンデバイス推論まで扱えますSEED — RorkはLeft Lane Capitalが主導する1,500万ドルのシードラウンドを実施し、Peak XVとa16z Speedrunが参加しましたTOOLING — Rorkの開発者向けリポジトリが動き続けています。rork-xcodeが7月16日、rork-deviceが7月15日、rork-plistが7月13日に更新されましたOPUS46 — RorkでClaude Opus 4.6が稼働しています。Rork MaxはClaude Codeを土台にアプリを組み立てる設計ですSIM — ブラウザ上で動くクラウドのiOSシミュレータを備え、実機へのインストールは1クリック、App Storeへの提出は2クリックと案内されていますMAX — Rork MaxはReact Nativeではなく純粋なSwiftを出力します。iPhone・iPad・Apple Watch・Apple TV・Vision Pro、そしてiMessageまでが射程ですNATIVE — HealthKit、ARKitとLiDAR、NFC、Dynamic Island、Live Activities、Metalによる3D、Core MLのオンデバイス推論まで扱えますSEED — RorkはLeft Lane Capitalが主導する1,500万ドルのシードラウンドを実施し、Peak XVとa16z Speedrunが参加しました
記事一覧/開発ツール
開発ツール/2026-05-28上級

Rork 製 iOS 壁紙アプリ 6 本でインタースティシャル広告疲労を Crashlytics と Remote Config で運用調整した frequency cap 設計ノート

Rork 製の壁紙アプリ 6 本を並行運用する中で、インタースティシャル広告の疲労を Crashlytics の non-fatal シグナルと Remote Config の動的しきい値で運用調整した実装メモ。eCPM・D1 リテンションの実測値と段階リリース手順を Swift コード付きで残します。

AdMob70Crashlytics13Remote Config6interstitialfrequency cap壁紙アプリ28個人開発187

プレミアム記事

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 度経験)でも独立して配信を継続するためです。返り値は最小限の capPerHourcapPerDay だけにしておき、複雑な ML スコアは持ち込まない方針にしました。複雑さは ARPDAU 改善に対して割に合わないからです。

ここまでお読みいただきありがとうございます。

この記事の続きを読む

この先には、実装コードやベンチマーク結果など、実務でお役に立てる内容をご用意しています。このサイトは広告を掲載しておらず、サーバーや開発にかかる費用はメンバーの皆様のご支援で成り立っています。もしお役に立てていましたら、ご支援いただけますと大変ありがたいです。

この記事で得られること
セッション履歴と Crashlytics の non-fatal を 1 つのスコアに合成し、1 秒未満で frequency cap を決める 3 層判定アーキテクチャ
ARPDAU を維持しつつ D1 リテンションを 31.4% → 36.8% まで戻した 4 週間段階リリースの実測値と手順
Remote Config の 6 アプリ横断キーマッピングと、しきい値暴走時に 3 分以内で巻き戻す kill-switch の Swift / Cloudflare Workers コード
Stripe による安全な決済 · いつでもキャンセル可能

この記事を購入する

この先の内容をすべてお読みいただけます。一度のご購入で、いつでも何度でもアクセスできます。このサイトは広告を掲載しておらず、皆さまのご支援がサーバー費用などの運営を支えています。

または
メンバーシップなら全記事が読み放題 →
シェア

お読みいただきありがとうございます

Rork Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • コピー&ペーストで使える実装コード付き
  • 毎日新しい上級ガイドを追加
  • ¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

関連記事

開発ツール2026-05-21
個人開発で『本番障害の自動応答』を仕組み化する ― Crashlytics・Sentry・Slack 通知・段階的ロールバックの設計
個人開発で 12 年・累計 5,000 万 DL を運用してきた中で組み上げた、本番障害の自動応答アーキテクチャをまとめます。Crashlytics と Sentry の二段重ね、Slack 通知の階層化、Remote Config による段階的ロールバックの実装例を共有します。
開発ツール2026-05-26
Rork で生成した壁紙アプリを iPad へ対応させた2週間の所感
2014年から壁紙アプリを iPhone 中心で運用してきた個人開発者として、Rork で立ち上げた新規壁紙アプリを iPad に対応させる作業を2週間続けた記録です。AdMob アダプティブバナーの暴れ方、Safe Area の盲点、グリッド表示の解像感まで、運用の温度のまま書き残しました。
開発ツール2026-05-19
クラッシュ率上昇時にAdMobを自動で絞る『収益を守る自動制動』アーキテクチャ — Rork × Firebase Remote Config × Crashlytics の連動設計
クラッシュ率が跳ね上がった瞬間、ユーザーに広告を出し続けて評価を落とすか、いったん広告を絞って安定性を守るか。12年の個人開発運用で出した答えは、Firebase Remote Config と Crashlytics のシグナルを AdMob に連動させる4状態の自動制動アーキテクチャでした。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →