17本のFlutterアプリのAdMob頻度を自社サーバーから調整する

Firebase Remote Configを141行のPHPエンドポイントに置き換えた理由、アプリ側のフォールバック、そしてリビルドなしで広告間隔を変えたときにCTRがどう動いたか。

モバイルアプリの広告設定は、たいてい定数です。インタースティシャルの間隔、1セッションの上限、アプリ起動時広告の待ち時間。どれもDartのコードの中にあり、変えるたびにビルド、ストア審査、ユーザーの更新待ちが必要です。アプリが17本あれば、結局一度も変えないことになります。

私たちはこの数字を自社サーバーに移しました。この記事では、約束事(コントラクト)、それを安全にするアプリ側のフォールバック、管理側のガードレール、そして実際に使ってみて何が起きたかを書きます。

Remote Configではなく自前のエンドポイントにした理由

Firebase Remote Configは「リリースなしで値を変える」を解決します。それでも置き換えたのは、同じリクエストにさらに2つの仕事をさせられたからです。

  • 時間帯×曜日のトラフィック。 AdMobのレポートAPIには時間の軸がありません。どのアプリも起動のたびに設定エンドポイントを1回呼ぶので、その回数を数えれば、解析SDKなしでアプリごとの7×24の起動ヒートマップになります。
  • 保存前のポリシーチェック。 危険な広告パターンになる値は、「推奨しない」ではなく「保存できない」ようにしたかったのです。

切り替え後、firebase_remote_config はアプリから削除しました。

約束事

アプリ起動ごとに、認証なしのGETを1回送ります。

GET /tlabo-ad/api/config.php?app=takarakuji&tz=540
パラメータ 扱い
app 小文字化して [a-z0-9_] だけ残す。空なら 400 {"error":"app parameter required"}
tz 端末のUTCオフセット(分)。−720〜840に収める。なければUTCで集計し、no_tz_hits として別に数える
push_id, ping=1 プッシュの開封計測用。ping=1 は起動として数えない

レスポンスは、常に同じ形の整数だけのオブジェクトです。

{
  "ad_interstitial_max_per_session": 3,
  "ad_interstitial_interval_sec": 240,
  "ad_interstitial_first_delay_sec": 60,
  "ad_app_open_cooldown_min": 2,
  "ad_app_open_gap_sec": 2,
  "ad_inactivity_trigger_min": 3,
  "ad_new_user_grace_days": 3,
  "ad_interstitial_tap_delay_ms": 700
}

ヘッダーは Cache-Control: no-store なので、CDNやプロキシが古いポリシーを返すことはありません。

旧バージョンのアプリを守るルールは3つです。

  1. 形を変えない。 キーは追加するだけで、削除も型の変更もしません。8月にビルドしたアプリは、11月に追加されたキーを無視するだけです。
  2. 失敗したら既定値を返す。 未登録のアプリ、停止中のアプリ、データベースの例外。どの場合もステータス200で既定値を返し、アプリが解釈しなければならないエラー本文は返しません。
  3. 副作用でレスポンスを壊さない。 トラフィックの集計と開封カウンターはtry/catchで包んでいます。集計の書き込みが失敗しても、設定は返ります。

値は決まった順にマージします。コードの既定値、アプリごとに保存した値、そのアプリで時間帯スケジュールが有効ならそのプリセット。スケジュールのルールは曜日のビットマスクと、日をまたいでもよい時間帯を持ち、優先度順で最初に一致したものが適用されます。

アプリ側:待たない、例外を投げない

Flutter側の値は3層です。コードの既定値、SharedPreferencesにキャッシュした前回の値、そしてサーバー。

Future<void> init() async {
  await _loadCache();                 // 速い、ローカル
  unawaited(_refreshFromServer());    // 5秒タイムアウト、エラーは握りつぶす
}

int get interstitialIntervalSec =>
    _values['ad_interstitial_interval_sec'] ?? _defaults['ad_interstitial_interval_sec']!;

コードより大事な判断が2つあります。

  • 新しい値は次の起動から使う。 取得した値はキャッシュに書くだけで、その場では差し替えません。セッションの途中で広告の挙動が変わることはなく、遅い回線でスプラッシュ画面が、まだ要らないポリシーのために最大5秒待つこともなくなりました。
  • 0は正しい値。 初期の版は v > 0 の値しか受け付けず、機能をサーバーからオフにできませんでした。v >= 0 に直しました。

リモート設定がきっかけで見つかったバグがもう1つあります。アプリ起動時広告のクールダウンをメモリに持っていたため、OSがバックグラウンドでアプリを終了するとクールダウンがリセットされ、次の復帰で予期しない広告が出ていました。今はタイムスタンプをSharedPreferencesに保存しています。

何も変えずに導入する

アプリごとに、ハードコードされた数字がばらばらにありました。あるアプリはクールダウン20分、別のアプリは240分と1800秒。最初のアプリをリモートの値に切り替える前に、サーバーにはそのアプリの以前の定数を入れておきました。最初のリリースで変わったのは数字の出どころだけで、挙動は変わっていません。調整はそのあと、1回に1つずつ意図して行いました。

許容範囲はサーバー側で丸めます。上限は初日に3回引き上げました。実際のアプリがすでに想定を超える値(間隔1800秒、クールダウン360分、ギャップ120秒)を使っていたからです。下限は自分たちのポリシーの閾値より上に置いています。AdMobは数値の上限を公開していないので、これは控えめな社内ルールです。たとえば、インタースティシャルの間隔が60秒未満なら拒否、120秒未満なら注意を出します。

管理側のガードレール

  • すべての書き込みは、コミット前にポリシーチェックを通します。違反ならロールバックして409を返します(明示的な override_policy フラグがない限り)。
  • すべての変更は前後のスナップショット付きで記録し、ワンクリックで戻せます。
  • 時間帯スケジュールは、トラフィックのデータが7日分または1,000起動に満たないと有効にできません。週末2日分のデータで作ったヒートマップはノイズだからです。
  • アプリの削除には、そのアプリのIDの入力が必要です。

サーバーから値を変えて、実際に起きたこと

以下はすべて、アプリをリビルドせずサーバー側で行った変更です(1行目のみアプリ更新を含みます)。

変更 結果
アプリ起動時広告の押し間違い対策(アプリ更新で配信)、2週間後に計測 調べた4つのアプリ×OSの組み合わせすべてでCTRが低下:23.14%→17.29%、17.39%→14.98%(p=0.0074)、7.07%→4.06%(p=0.0098)、5.82%→4.55%(p=0.0034)。誤タップが減った。インタースティシャルのCTRは改善せず
インタースティシャルを閉じる前のタップ遅延:0/700/2500ミリ秒 測れる効果なし。700ミリ秒に戻した
8本のアプリで間隔240→900秒、1セッションの上限3→2 リリースなしで即日適用。インタースティシャルは収益の約11%だったので、見込まれる減少は小さく許容した
あるアプリで間隔900→1800秒、続けて初回の待ち時間60→300秒 期間あたりの表示94→66→49、CTR 57.4%→53.0%→28.6%。効いたのは主に初回の待ち時間

最後の行には限界があります。300秒は許容範囲の上限なので、そのアプリではこのレバーを使い切りました。次の改善には、サーバーの値ではなくアプリの変更が必要です。

作る価値があるのはどんなとき

状況 選択
アプリが1〜2本で、値はほとんど変えない コード内の定数で十分
リモートで値を変えられればよい Firebase Remote Config
アプリが多く、頻繁に調整し、起動時刻のデータとポリシーチェックも欲しい このような小さなエンドポイント

エンドポイント自体はPHPで141行、管理画面とAPIを合わせて約1,100行が加わります。価値の大半は地味なルールから生まれました。レスポンスの形を変えないこと、失敗したら既定値を返すこと、値はキャッシュして次の起動から使うこと。

よくある質問

Firebase Remote Configではだめだった?

動きはしますが、同じリクエストにもう2つの役割を持たせたかったのです。時間帯×曜日のトラフィックのヒートマップ(AdMobのレポートAPIには時間の軸がない)と、値を保存する前のサーバー側のポリシーチェックです。自社サーバーの小さなエンドポイントで両方を実現し、firebase_remote_config はアプリから外しました。

設定サーバーが落ちたらどうなる?

目に見える変化はありません。アプリはキャッシュの値(なければコードの既定値)で起動し、裏で5秒のタイムアウト付きで取得し、例外を投げず、新しい値は次の起動から使います。サーバーのエラー時も、同じ形のJSONで既定値を返します。

サーバーから設定を変えて、本当に結果は変わった?

数字で変わりました。あるアプリでは、サーバー側で2段階(間隔を延ばし、次に初回の待ち時間を延ばす)の変更をすると、期間あたりのインタースティシャル表示が94→66→49、CTRが57.4%→53.0%→28.6%になりました。タップの遅延の実験(0・700・2500ミリ秒)は効果がなく、元に戻しました。

  • #AdMob
  • #Flutter
  • #リモート設定
  • #PHP
  • #広告
  • #モバイルアプリ

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑