AdMob メディエーションを iOS の4アプリに広げた直後、Firebase のダッシュボードで妙な段差を見つけました。日次の eCPM は安定しているのに、「セッション初回のインプレッション」だけ fill rate が一段低いのです。クラッシュもなく、広告は出ている。ただ、新規インストール直後の最初の1〜2枠だけ、明らかに単価の低い広告で埋まっていました。
原因にたどり着くまで数日かかりました。結論から書くと、ATT(App Tracking Transparency)の許諾ダイアログを出す前に広告SDKを初期化していた、という初期化順序の問題でした。コードはコンパイルも通るし、警告も出ません。だからこそ気づきにくい種類の不具合です。
この記事は、私が個人開発で運用している壁紙・ヒーリング系のアプリ群で実際に踏んだこの順序の罠を、なぜ効くのかという仕組みと、Antigravity に初期化シーケンスを監査させた手順とあわせて記録したものです。
初回セッションだけ収益が落ちる、という違和感
メディエーション構成では、各広告ネットワークが入札のために端末のシグナルを参照します。iOS で最も効くシグナルが IDFA(広告識別子)です。ATT で「トラッキングを許可」が得られた端末では IDFA が返り、得られない端末ではゼロ埋めの値になります。
ここで重要なのは、IDFA の値が「いつ確定するか」です。ATT の許諾結果が出る前に広告リクエストが走ると、その時点では IDFA がまだ取得できません。広告SDKは「トラッキング不可」とみなして入札を組み立てます。結果として、初回リクエストだけ非ターゲティング在庫に寄り、単価が落ちる。これが、私が見た「初回だけ fill rate が低い」の正体でした。
2回目以降のセッションでは、すでに ATT の結果が OS に記録されているため IDFA がすぐ返り、段差は消えます。だから日次平均では埋もれてしまい、セグメントを「初回 / 2回目以降」で割って初めて見える種類の劣化でした。
なぜ順序がそこまで効くのか
関係する4つの登場人物を、依存の向きで並べておきます。
要素 役割 初期化順序での位置
ATTrackingManager トラッキング許諾を取り、IDFA の可否を決める 最初
IDFA 許諾後に確定する広告識別子 ATT の結果に従属
GMA SDK(MobileAds) 許諾結果を見て入札・配信を組む ATT の後
SKAdNetwork 許諾に依らないアトリビューション経路 SDK が自動処理
依存の向きは一方通行です。ATTrackingManager が結果を出す → IDFA が確定する → MobileAds がその状態で入札を組む。この順序が崩れると、MobileAds は「まだ何も決まっていない状態」で走り出します。
公式ドキュメントは「ATT のリクエストは広告をロードする前に」と書いていますが、実際にハマるのは「リクエストを出した直後」と「許諾が返った後」の区別です。requestTrackingAuthorization はコールバックで結果を返す非同期APIなので、呼び出した直後はまだ何も確定していません。許諾のコールバックの中で MobileAds を起動しないと、順序を守ったつもりでレースに負けます。私が最初に書いたコードは、まさにこの「呼んだ直後に MobileAds を起動する」形でした。
壊れていた初期化順序(Before)
最初の実装は、見た目には素直でした。
// ❌ ATT を「呼んだだけ」で、結果を待たずに MobileAds を起動している
func application ( _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: ... ) -> Bool {
if #available ( iOS 14 , * ) {
// リクエストは投げるが、コールバックを待っていない
ATTrackingManager. requestTrackingAuthorization { _ in }
}
// ↑ の許諾がまだ返っていないのに、ここで広告SDKが走り出す
MobileAds.shared. start ( completionHandler : nil )
return true
}
このコードは動きます。広告も出ます。ただ、requestTrackingAuthorization の結果が返るより先に MobileAds.shared.start が走るため、初回の入札は IDFA なしで組まれます。許諾ダイアログにユーザーが答えるのは数秒後ですが、SDK はそれを待ってくれません。
正しい順序(After)
直し方の芯は「MobileAds の起動を、ATT のコールバックの内側へ移す」ことです。
// ✅ 許諾の結果が出てから MobileAds を起動する
func application ( _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: ... ) -> Bool {
requestTrackingThenStartAds ()
return true
}
private func requestTrackingThenStartAds () {
if #available ( iOS 14 , * ) {
ATTrackingManager. requestTrackingAuthorization { status in
// status が .authorized でも .denied でも、ここに来た時点で
// OS の判断は確定している。どちらでも MobileAds を起動してよい
DispatchQueue.main. async {
MobileAds.shared. start ( completionHandler : nil )
}
}
} else {
// iOS 14 未満は ATT が存在しないので即起動
MobileAds.shared. start ( completionHandler : nil )
}
}
ポイントは、.denied(拒否)でもコールバックの中で起動してよい、という点です。目的は「IDFA を必ず取る」ことではなく、「OS の判断が確定した状態で SDK を走らせる」ことです。拒否されたなら、SDK は最初から非トラッキング前提で組み立てるので、初回と2回目で段差が生まれません。段差こそが収益の取りこぼしであって、拒否そのものは問題ではないのです。私はこの「コールバックの内側で起動する」順序を、iOS の広告初期化の既定として推奨します。
もう一つの注意点は、UI のレースです。スプラッシュ画面や初回オンボーディングと ATT ダイアログが同じタイミングで出ようとすると、ダイアログがオンボーディングの裏に隠れて許諾率が落ちます。私は ATT のリクエストを、最初の意味のある画面が描画され、ユーザーが文脈を理解できる位置まで遅らせるようにしました。広告の起動はそのコールバックにぶら下げたままなので、順序は保たれます。
Antigravity に初期化シーケンスを監査させる
4本のアプリで同じ罠を踏んでいないかを目視で追うのは骨が折れます。ここは Antigravity のエージェントに、機械的な照合を任せました。私が握るのは「何を正しい順序とするか」というポリシーで、エージェントには「その順序を満たさない箇所を全部挙げる」読み取り作業だけを渡す、という線引きです。
エージェントに与えたルールは、自然言語でこれだけです。
# 初期化順序の監査ルール(AGENTS.md に追記)
- AppDelegate / SceneDelegate の起動経路で、MobileAds.shared.start の呼び出しが
ATTrackingManager.requestTrackingAuthorization のコールバック内にあることを確認する。
- コールバックの外(同じスコープの直後)で start を呼んでいる箇所を「順序違反」として列挙する。
- iOS 14 未満のフォールバック分岐で start を即時呼びしている箇所は違反としない。
- 判定は列挙のみ。コードの自動修正は行わず、ファイルと行番号だけ報告する。
CLI から非対話で回す場合は、出力を機械可読にしておくと CI に載せやすくなります。
# 起動経路の順序違反を列挙させる(修正はさせない・人が見て直す)
agy run --headless \
--prompt "AGENTS.md の初期化順序の監査ルールに従い、違反箇所をファイル:行で列挙して" \
--paths "Sources/App/*Delegate.swift" \
--format json > att-order-report.json
# 違反が1件でもあれば終了コードを立てて、リリース前ゲートで止める
test "$( jq '.violations | length' att-order-report.json)" -eq 0
エージェントに任せたのは「散らばった start 呼び出しを順序の観点で掃き出す」ところまでです。どの画面で ATT を出すか、拒否時に何を諦めるかという配信ポリシーの判断は、最後まで自分の手元に置きました。ここを渡してしまうと、機械が「動くけれど意図と違う順序」を勝手に選びかねないからです。エージェントは見つける係、線引きは人、という役割分担が、4本を横断して直すときに一番こじれませんでした。
実機での確かめ方
ダッシュボードの平均値では段差が見えないので、確認は実機ログとセグメント分割で行いました。
最初の確認は、起動シーケンスのログを時系列で見ることです。requestTrackingAuthorization のコールバックが、MobileAds の初期化完了ハンドラより前に来ていれば順序は守られています。
ATTrackingManager. requestTrackingAuthorization { status in
print ( "ATT settled: \( status. rawValue ) " ) // ← これが先
DispatchQueue.main. async {
MobileAds.shared. start { _ in
print ( "MobileAds started" ) // ← これが後
}
}
}
次に、計測側を「初回セッション / 2回目以降」で割って、初回だけ低かった fill rate が他のセッションに揃ったかを見ます。私の4アプリでは、修正前の初回セッションの fill rate は2回目以降より概ね8〜12%低く、初回枠の eCPM も1割ほど目減りしていました。修正後はその差が1%未満に収まり、初回セッションの段差は実用上見えなくなって、新規インストール直後の最初の広告枠も他と同じ在庫で埋まるようになりました。劇的な総収益の跳ね上がりではありませんが、取りこぼしていた初回の単価を回収できた、という静かな改善です。
つまずきやすい3点
横展開のなかで、順序以外にも繰り返し引っかかった点が3つありました。
ひとつめは、ATT ダイアログが OS のレート制限で出ないケースです。同一インストールで一度判断が確定すると、二度目以降は requestTrackingAuthorization を呼んでもダイアログは出ず、即座に確定済みステータスが返ります。これは正常動作で、コールバックは必ず呼ばれるので、起動ぶら下げの構造はそのまま機能します。
ふたつめは、Info.plist の NSUserTrackingUsageDescription 漏れです。これが無いと iOS はダイアログを出さずに即 .denied を返します。順序を直しても許諾が常にゼロなら、まずこの文字列の有無を疑うのが早道でした。
みっつめは、メディエーション各社の SDK が独自にトラッキング状態を読むタイミングです。GMA SDK の起動を ATT の後ろに揃えても、アダプタ側が早期に状態を読みに行く構成だと、足並みが乱れることがあります。アダプタの初期化も MobileAds.shared.start 経由に寄せ、個別の前倒し初期化を残さないことで揃いました。
順序の罠は、コードレビューでも気づきにくく、平均値のダッシュボードにも現れません。けれど初回セッションという一番大事な瞬間に静かに収益を削っていきます。もし新規インストール直後だけ広告が弱い感触があるなら、まず requestTrackingAuthorization のコールバックの内側で広告SDKを起動しているか、その一点だけ確かめてみてください。同じ症状に心当たりのある方の手がかりになれば嬉しいです。