新しいアプリを段階公開している最中に、AdMob の管理画面で妙なことに気づきました。フィルレートも eCPM もほぼ普段どおりなのに、SKAdNetwork 経由で計測できたインプレッションの割合だけが、以前より明らかに薄いのです。広告は出ているのに、広告の「計測」だけが欠けている、という状態でした。
心当たりを一つずつ潰していって、最後に残ったのが初期化の順序でした。私が個人開発で続けている壁紙アプリでは、起動と同時に広告SDKを開始していました。その時点ではまだ App Tracking Transparency(ATT)の同意ダイアログを出しておらず、広告SDKは IDFA が空のまま立ち上がっていたのです。同意を求めるより先に、計測の土台を組み立ててしまっていました。
原因が腑に落ちるまでは、何が欠けているのかから順にたどるのがいちばんの近道でした。Claude Code に初期化経路の全量を出させ、ATT の解決を待ってから広告を開始する形へ束ね直し、二度と順序が崩れないようにガードを掛けるまでを、実際のコードとともに残しておきます。
同意ダイアログの前に MobileAds を開始すると何が欠けるのか
iOS では、アプリがトラッキング用の識別子(IDFA)にアクセスするために、ATT の許可が必要です。ユーザーが「許可」を選ぶまで、ASIdentifierManager が返す IDFA は全ゼロの値になります。
広告SDKは初期化のタイミングで、利用できる識別子や計測の設定を読み込みます。ここで IDFA がまだ空だと、SKAdNetwork のアトリビューションや、メディエーション各社へ渡る初期化情報が、同意前の状態を前提に組み上がってしまいます。広告の配信そのものは IDFA がなくても成立するため、フィルレートは下がりません。落ちるのは計測の側だけです。だからこそ、収益の数字を見ているだけでは気づきにくいのです。
| 初期化の順序 | 広告配信 | IDFA | 計測(SKAdNetwork 等) |
| ATT より先に広告SDKを開始 | 出る | 空のまま初期化 | 薄くなる |
| ATT の解決後に広告SDKを開始 | 出る | 許可時は取得済み | 揃う |
配信は変わらないのに計測だけがずれる。この「静かなズレ」が、順序バグのいちばん厄介なところでした。
壊れていた初期化順序(Before)
問題のコードは、SwiftUI のアプリ起動時に広告SDKを開始していました。ATT の要求は、別の画面で広告が何度か表示された後に、思い出したように呼ばれていました。
import GoogleMobileAds
@main
struct WallpaperApp: App {
init() {
// ❌ 起動と同時に広告SDKを開始している。
// この時点では ATT の同意が取れておらず、IDFA は空のまま初期化される。
MobileAds.shared.start()
}
var body: some Scene {
WindowGroup { RootView() }
}
// ATT の要求は、この後どこか別の画面で
// requestTrackingAuthorization を呼んでいた(=手遅れ)
}
init() は起動のいちばん早い段階で走ります。ATT のダイアログはアプリがアクティブになってからでないと表示すらされないため、この順序では「同意を取る前に計測基盤を確定させる」ことが構造的に避けられませんでした。
ATT の解決を待ってから広告SDKを開始する(After)
直し方の芯は一つで、ATT の応答(許可でも拒否でも)を待ってから、初めて広告SDKを開始することです。判断と開始を一箇所に閉じ込めるため、専用のコーディネーターに集約しました。
import AppTrackingTransparency
import GoogleMobileAds
@MainActor
final class AdConsentCoordinator {
private var didStartAds = false
/// アプリがアクティブになった後に一度だけ呼ぶ。
/// ATT の応答を待ってから広告SDKを開始する。
func requestConsentThenStartAds() async {
if #available(iOS 14, *) {
// 未決定のときだけダイアログが出る。
// 決定済みなら現在の状態が即座に返るだけで、二重表示にはならない。
let status = await ATTrackingManager.requestTrackingAuthorization()
print("ATT status: \(status.rawValue)") // 0=notDetermined 1=restricted 2=denied 3=authorized
}
startAdsOnce()
}
private func startAdsOnce() {
guard !didStartAds else { return }
didStartAds = true
// ここで初めて広告SDKを開始する。ATT の解決後なので、
// 許可時は IDFA を伴った初期化になり、計測の土台が揃う。
MobileAds.shared.start { status in
for (name, adapter) in status.adapterStatusesByClassName {
print("adapter \(name): \(adapter.state.rawValue)")
}
}
}
}
呼び出し側は、アプリがフォアグラウンドでアクティブになってから要求します。起動直後の非アクティブな段階で呼ぶと、ダイアログが出ないまま notDetermined で素通りしてしまうため、scenePhase を見て .active になったタイミングに寄せています。
@main
struct WallpaperApp: App {
@Environment(\.scenePhase) private var scenePhase
private let adConsent = AdConsentCoordinator()
var body: some Scene {
WindowGroup {
RootView()
.task(id: scenePhase) {
// アクティブになってから ATT を出し、その後に広告を開始する。
// didStartAds ガードがあるので、再アクティブ化で二重開始はしない。
if scenePhase == .active {
await adConsent.requestConsentThenStartAds()
}
}
}
}
}
なお MobileAds は新しめの Google Mobile Ads SDK での名称です。以前の GADMobileAds.sharedInstance().start(...) を使っている場合も、直すべき順序の考え方は同じで、開始呼び出しをコーディネーターの中へ移すだけです。
なぜ「開始」を一箇所に閉じ込めるのか
順序バグが起きる根っこは、広告SDKの開始が複数の入口から呼べてしまうことにあります。起動時にも呼べる、初回の広告表示前にも呼べる、となると、どれが先に走るかがビルドや画面遷移の都合で揺れます。開始の入口を AdConsentCoordinator の一本だけにすれば、「ATT の後」という不変条件を、コードの構造そのもので守れます。
Claude Code に初期化経路の全量を出させる
自分の記憶だけで「広告SDKを開始している箇所」を数え上げると、たいてい取りこぼします。私はまず Claude Code に、開始と ATT の呼び出しが散らばっている全量を洗い出させました。
渡し方はこの三点に絞ると精度が上がりました。
- 探す対象を具体名で挙げる(
MobileAds.shared.start、GADMobileAds、requestTrackingAuthorization)
- 呼び出しの場所だけでなく、それが起動時なのか画面遷移時なのかという「実行タイミング」も一緒に報告させる
- 見つけた各箇所を、コーディネーター経由に置き換えたときの差分案まで出させる
この順で頼むと、「起動時に1回、広告表示前に1回、合計2つの入口から開始していて、ATT はそのどちらより後ろだった」という実態が、コードの位置つきで返ってきました。ここまで見えれば、あとは開始呼び出しを一本へ畳むだけです。Claude Code に本番コードを触らせるときの権限設計は、別記事のApple Privacy Manifest を個人開発で取り回す実装で触れた考え方と同じで、変更はまず差分で受け取り、自分の目で通してから当てるようにしています。
二度と順序が崩れないようにするガード
一度直しても、次の機能追加でうっかり MobileAds.shared.start() を別の場所に書けば、同じバグは戻ってきます。私は二重のガードを掛けました。
一つはデバッグ時のアサーションです。コーディネーター以外から開始が呼ばれていないか、実行時に気づけるようにします。
private func startAdsOnce() {
assert(Thread.isMainThread, "広告SDKの開始はメインスレッドから行う")
guard !didStartAds else {
assertionFailure("広告SDKの開始が二重に呼ばれた — 入口が増えていないか確認する")
return
}
didStartAds = true
MobileAds.shared.start(completionHandler: nil)
}
もう一つは、CI で経路そのものを検査する軽いガードです。コーディネーターのファイル以外に開始呼び出しが増えていたら、ビルドを止めます。
#!/usr/bin/env bash
# scripts/check_ad_init.sh — 広告SDKの開始が想定の1箇所だけかを検査する
set -euo pipefail
HITS=$(grep -rn "MobileAds.shared.start\|GADMobileAds" Sources \
| grep -v "AdConsentCoordinator.swift" || true)
if [ -n "$HITS" ]; then
echo "❌ 想定外の場所で広告SDKを開始しています:"
echo "$HITS"
echo "→ AdConsentCoordinator 経由に寄せてください。"
exit 1
fi
echo "✅ 広告SDKの開始はコーディネーターの1箇所のみ"
このスクリプトはビルドフェーズにも、Claude Code のフックにも仕込めます。私は「壊れない構造を一度作ったら、それを守る検査も一緒に置く」ことを、個人開発では特に大切にしています。手が回らない時期でも、検査だけは黙って番をしてくれるからです。
段階公開で「直った」ことを確認する
計測の欠けは、ローカルでは再現しづらい種類の不具合です。私は段階公開のまま、次の三点を数日ぶんだけ見比べました。
- SKAdNetwork 経由で計測できたインプレッションの割合(薄くなっていた指標が戻るか)
- フィルレートと eCPM(配信側が悪化していないか=直しの副作用がないか)
- クラッシュ率と ANR(順序変更で起動系が壊れていないか。私の基準は Crash-free users 99.7% 以上・ANR 0.20% 未満)
配信側の数字は動かさずに、計測の割合だけが数日かけて元の水準へ戻っていきました。狙いどおり、触ったのは順序だけで、広告の出方そのものには手を入れていないという確認になりました。日々の数字の見方そのものは、AdMob のフィルレート低下を朝に察知する運用や、App Store Connect と AdMob の売上を毎朝集計するパイプラインで組んだ朝の巡回に乗せています。
よくある落とし穴
起動直後に ATT を呼んでしまう。 アプリが非アクティブな段階では、ダイアログが表示されないまま notDetermined で通り過ぎます。アクティブになってから要求するのが確実です。
ATT を拒否されたら広告を止めてしまう。 拒否は「トラッキングをしない」であって「広告を出さない」ではありません。拒否でも広告は出せます。開始を止めるのではなく、識別子を使わない前提で開始します。
同意管理(UMP など)と ATT の順序を混同する。 GDPR 圏向けの同意フォームと ATT は別物です。両方を使う場合は、同意フォーム → ATT → 広告SDK開始、という順で直列に並べ、すべての解決後に一度だけ開始します。
まとめ — まず開始呼び出しを数えることから
もし手元のアプリで「配信は普通なのに計測だけ薄い」と感じたら、最初にやるべきは広告SDKの開始を何箇所から呼んでいるか数えることです。入口が二つ以上あるなら、そのどれかが ATT より前に走っている可能性が高いです。
今日の最初の一歩として、MobileAds.shared.start(あるいは GADMobileAds)を全文検索して、ヒット数を確認してみてください。1 を超えていたら、それを一本に束ねるところから始められます。実装の助けになれば幸いです。お読みいただきありがとうございました。