直近の 2 ヶ月ほど、6 つのアプリで運用している ATT(App Tracking Transparency)と Google UMP(User Messaging Platform)の同意 UX を改めて全部見直していました。理由はとてもシンプルで、AdMob の月次レポートを国別に並べた時に、同意率が 10 ポイント違うだけで eCPM が 1.3〜2.0 倍動いていることに、改めて目の前で観察できたからです。
私が個人開発を始めたのが 2014 年、累計ダウンロード数は 5,000 万を超え、現在も月収ベースで AdMob が事業の柱になっています。1997 年に 16 歳でインターネットに触れた時、海外のフォーラムの誰かが書いてくれたコードを読んで「国境を越えて誰かと一緒に作れる」と感動した記憶があるのですが、いま思えばその感覚は、地域ごとに異なる同意 UX を設計するときにも静かに効いています。同意は単なる法的なチェックではなく、その地域のユーザーとの関係の入り口なので、雑に作るとそのあとの収益・継続率の全てを引きずります。
この記事は、同意率を「観測 → 仮説 → 実装 → 検証」のループで毎週回すために、Antigravity のサブエージェントをどう責務分割し、どこまで自律で動かし、どこから人間に判断を渡したのか、という運用の足場をまとめたメモです。広告 SDK の組み込み手順や法務文言そのものは書きません。あくまで「すでに UMP・ATT を入れている個人開発者が、同意率を改善し続けるための機械学習的でないループを Antigravity に任せる」ための実装記録です。
同意率を AdMob 収益の主要 KPI として扱う理由
ATT で「許可しない」を選んだユーザーには IDFA が渡らず、AdMob は NPA(Non-Personalized Ads)配信になります。UMP で「同意しない」を選んだ EU 圏ユーザーも同様に NPA です。NPA の eCPM は、現場の感覚と公開ベンチマークを突き合わせると、パーソナライズ広告に対しておおむね 0.5〜0.7 倍に収束します。つまり同意率を 60% から 80% に持ち上げられれば、その国の広告収益はかなり乱暴に均すと 10〜15% 上振れする計算です。私の運用では、もっとも改善幅が出た地域で月次の AdMob 収益が約 12% 持ち上がりました。
同意率は技術的には測りやすい数字です。ATT は ATTrackingManager.trackingAuthorizationStatus、UMP は ConsentInformation の consentStatus をそのままイベントに送ればよく、難しいのは「何を比較対象にするか」と「比較を続けるか」のほうです。1 回の改善で 5 ポイント上げて満足してしまい、半年経って気付くと OS バージョンの変化や地域ロール変更で 8 ポイント下がっている、という展開を 2 度経験して以降は、同意率を季節変動のある KPI として扱い、Antigravity に週次で観測させる体制に切り替えました。
ステップ 1: イベント設計と BigQuery エクスポート
最初にやるのは、Antigravity が読める粒度でイベントを設計することです。ここで雑にすると、後段のサブエージェントがいくら賢くても判断材料が足りません。私のアプリでは下記の 6 つを Firebase Analytics に送り、BigQuery エクスポートに乗せています。
// Android (Kotlin) — UMP 同意状態の記録
private fun logUmpStatus (status: Int , geo: String ) {
val statusLabel = when (status) {
ConsentInformation.ConsentStatus.OBTAINED -> "obtained"
ConsentInformation.ConsentStatus.REQUIRED -> "required"
ConsentInformation.ConsentStatus.NOT_REQUIRED -> "not_required"
ConsentInformation.ConsentStatus.UNKNOWN -> "unknown"
else -> "other"
}
firebaseAnalytics. logEvent ( "ump_status_settled" ) {
param ( "status" , statusLabel)
param ( "geo" , geo) // ISO-3166 alpha-2
param ( "os_major" , Build.VERSION.SDK_INT. toString ())
param ( "app_version" , BuildConfig.VERSION_NAME)
param ( "prompt_variant" , currentVariantId) // Remote Config から
}
}
// iOS (Swift) — ATT 同意状態の記録
import AppTrackingTransparency
import FirebaseAnalytics
func logAttStatus ( geo : String ) {
let status = ATTrackingManager.trackingAuthorizationStatus
let label: String
switch status {
case .authorized : label = "authorized"
case .denied : label = "denied"
case .restricted : label = "restricted"
case .notDetermined : label = "not_determined"
@unknown default: label = "other"
}
Analytics. logEvent ( "att_status_settled" , parameters : [
"status" : label,
"geo" : geo,
"os_major" : String (ProcessInfo.processInfo.operatingSystemVersion.majorVersion),
"app_version" : Bundle.main.infoDictionary ? [ "CFBundleShortVersionString" ] ?? "" ,
"prompt_variant" : currentVariantId
])
}
イベント名は _settled で終えるのが個人的な好みです。prompt_shown だけだと表示と結果の対応が崩れやすく、最終確定状態に絞ったほうが後段の集計が安定します。geo は端末の locale ではなく、SDK 側で取得した推定地域を入れます。後述しますが、locale をそのまま地域として扱うと EU 圏判定で何度か事故りました。
BigQuery 側では下記のような正規化ビューを作ります。Antigravity のサブエージェントは生イベントではなく、必ずこのビュー経由で参照させると、後でイベント仕様を変えても影響範囲が限定的になります。
-- BigQuery: 同意率の地域×OS×バリアント集計
CREATE OR REPLACE VIEW analytics .consent_rate_weekly AS
SELECT
DATE_TRUNC( DATE (event_timestamp), WEEK (MONDAY)) AS week_start,
app_id,
platform, -- ios / android
geo,
os_major,
prompt_variant,
COUNTIF(event_name IN ( 'att_status_settled' , 'ump_status_settled' )) AS settled,
COUNTIF(event_name = 'att_status_settled' AND status = 'authorized' ) AS att_yes,
COUNTIF(event_name = 'att_status_settled' AND status = 'denied' ) AS att_no,
COUNTIF(event_name = 'ump_status_settled' AND status = 'obtained' ) AS ump_yes,
COUNTIF(event_name = 'ump_status_settled' AND status IN ( 'required' , 'unknown' )) AS ump_no
FROM analytics . events
WHERE _PARTITIONDATE >= DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY )
GROUP BY 1 , 2 , 3 , 4 , 5 , 6 ;
ステップ 2: Antigravity サブエージェントの責務分割
ここから Antigravity 側の設計に入ります。1 つの巨大なエージェントに全部を任せると、トークンコストもデバッグ難度も跳ね上がるので、私は意図的に 4 つの責務に分けました。AGENTS.md には下記のような構成を書いています。
# AGENTS.md (excerpt)
agents :
- id : consent-observer
role : BigQuery の consent_rate_weekly を読み、地域×OS×バリアントごとの同意率と前週比を計算する。
異常検知(前週比 -5pt 以上)を行い、レポート markdown を生成する。
tools : [ bigquery_query , write_markdown ]
budget : { tokens : 40000 , time_min : 5 }
- id : hypothesis-writer
role : consent-observer のレポートから、改善仮説(最大 3 件)を立てる。
仮説は「対象国 / バリアント変更案 / 期待効果 / リスク」を含む構造化 YAML で出力。
tools : [ read_markdown , write_yaml ]
budget : { tokens : 60000 }
- id : variant-implementer
role : 採択された仮説を Remote Config のパラメータ差分に落とし、PR を作成する。
コピーは i18n キーを差し替えるだけで本体コードは触らない(安全装置)。
tools : [ git , firebase_remote_config_api ]
budget : { tokens : 80000 }
guard : human_approval_required
- id : result-judge
role : 実験開始から 7 日経過後、対照群と実験群の同意率差を計算し、統計的有意性(z-test)を判定。
有意なら本採用 PR、なければロールバック PR を提案。
tools : [ bigquery_query , git ]
budget : { tokens : 50000 }
この分割で大事なのは、variant-implementer だけは必ず人間の承認を要求するように guard: human_approval_required を立てることです。同意 UX は法務領域の文言を含むので、ここを完全自律にすると後悔します。実際、初期に承認ガードを外していた時期に、hypothesis-writer が「『あなたの広告体験を向上させます』というコピーに変えてみては」と提案し、variant-implementer がそれをそのまま PR にしたところ、その表現は App Store 審査でリジェクトの可能性があると後から法務担当の知り合いに指摘されました。それ以降は承認ガード必須です。
ステップ 3: 同意ダイアログの UX を「事前同意画面」で囲む
実装の核心は OS のシステムダイアログを直接出さず、自前の pre-permission 画面でいったん受け止めることです。これは Apple のヒューマンインターフェイスガイドラインでも推奨されていて、システムダイアログは 1 回しか出せないため、その手前で意図を伝えるワンクッションを入れるだけで同意率が大きく動きます。私の運用では、pre-permission 画面の追加だけで主要 4 か国の ATT 同意率が平均 17 ポイント上がりました。
struct AttPrePermissionView : View {
let onContinue: () -> Void
let copyVariant: PrePermissionCopy // Remote Config から
var body: some View {
VStack ( spacing : 24 ) {
Image ( systemName : copyVariant.iconName)
. font (. system ( size : 56 ))
. foregroundStyle (.tint)
Text (copyVariant.title)
. font (.title2). bold ()
. multilineTextAlignment (.center)
Text (copyVariant.body)
. font (.body)
. multilineTextAlignment (.center)
. foregroundStyle (.secondary)
Button ( action : onContinue) {
Text (copyVariant.cta)
. frame ( maxWidth : . infinity ). padding (.vertical, 14 )
}
. buttonStyle (.borderedProminent)
}
. padding (.horizontal, 32 )
. task {
Analytics. logEvent ( "att_pre_permission_shown" , parameters : [
"prompt_variant" : copyVariant.id
])
}
}
}
copyVariant は Remote Config から取得します。Antigravity の variant-implementer が PR で書き換えるのはこの Remote Config の値だけにし、Swift 本体には一切触らせません。これで「PR の安全性レビュー」が文言の妥当性チェックだけに収束し、レビュー時間が大幅に短縮されます。
ステップ 4: 地域別に「いつ・どう聞くか」を変える
同じアプリでも地域によって最適な同意 UX はかなり違います。私が観測している傾向と、それに合わせた対応を整理します。
EU・UK 圏(GDPR / UK GDPR) : 同意の粒度が法的に要求されるので、UMP の標準フォームをベースに細かい目的別チェックを表示。雑なオールイン同意は審査リスクが高い。pre-permission は使うが、文言は「サービス改善のため」より「広告のパーソナライズ」と明示する方がむしろ同意率が高い傾向。
米国・カナダ : ATT の同意率は地域横断で比較的高い。pre-permission を入れると 10〜20 ポイント上がる効果が安定して出る。プライバシー意識は強いがインセンティブが伝われば応じる層が一定いる印象。
日本 : ATT 同意率は世界平均より低めに出る国の一つ。pre-permission の効果は出るが、文言を「広告」より「無料で使い続けるため」に振った方が動く。アプリのコンセプトとの整合性が崩れない範囲で。
東南アジア・インド : トラッキングへの心理的抵抗が比較的低いが、低速回線で pre-permission の画像が遅延すると離脱が増えるので、画像は WebP で 30KB 以下に圧縮。
中国本土 : AdMob / Firebase 自体が動かないリージョンも多いので、地域判定で除外して別系統に振り分けるロジックが必須。ここを間違えると壊れたダイアログだけが出続けます。
地域判定は IP ベースのジオロケーション → OS の Locale.Region → タイムゾーンの三段で見ています。私は当初、Locale.current.regionCode だけで判定して、海外旅行中の日本ユーザーが現地の同意 UX を見てしまう、という事故を起こしました。これは本番運用で必ず複数シグナルで多数決させるのが推奨です。
ステップ 5: 週次の自律ループを回す Antigravity ワークフロー
最後に、上記をどう週次で回すかという話です。私は月曜深夜に Antigravity の Background Agent をスケジュール起動させ、下記の順で動かしています。
# workflow.yaml (excerpt)
schedule : "0 2 * * 1" # 毎週月曜 02:00 JST
steps :
- agent : consent-observer
out : weekly_report.md
on_failure : notify_human
- agent : hypothesis-writer
in : weekly_report.md
out : hypotheses.yaml
constraints :
max_hypotheses : 3
require_geo_specificity : true
- agent : variant-implementer
in : hypotheses.yaml
out : pr_url
guard : human_approval_required
timeout_hours : 24
- agent : result-judge
schedule_after_days : 7
out : judgment.md
on_significant_improvement : open_promotion_pr
on_no_improvement : open_rollback_pr
このワークフローを 2 ヶ月続けた結果、観測対象 6 アプリのうち 4 アプリで主要国の同意率が平均 8〜15 ポイント上がり、AdMob 月次収益で合計約 11% の改善になりました。残り 2 アプリは元々の同意率が既に高水準で、伸びしろが薄かったためほぼ横ばいです。これは「全アプリで効くわけではない」という重要な情報で、伸びしろが薄い段階で実験を続けるとコストが先行するので、result-judge には「3 週連続で有意差なしならその国の実験を停止」というルールを入れています。
実装で踏んだ落とし穴と対処
最後に、本番運用で実際に踏んだ落とし穴をいくつか共有します。
ATT 拒否ユーザーへの再プロンプトは原則できないのですが、これを忘れて「拒否率が高いから再表示しよう」というロジックを書きそうになったことが 2 回ありました。OS の制約上、notDetermined 以外では requestTrackingAuthorization を呼んでも即座に既存ステータスが返るだけで、UI は出ません。これに気付かずに「再プロンプト UI」を試作してしまうと、ユーザーは何度も同じ pre-permission を見せられる地獄になります。Antigravity の hypothesis-writer には「ATT 拒否ユーザーは再プロンプト対象から除外する」というガードを文章で明記してあります。
UMP の同意フォームを表示する前に AdMob SDK の初期化を走らせていたために、最初のセッションのインプレッションが全部 NPA で出ていたという問題もありました。これは Mobile Ads SDK の初期化を requestConsentInfoUpdate の完了コールバック内に閉じ込めることで解決します。BigQuery で「初回セッションの NPA 比率」を集計するクエリを書いておくと早期に気付けます。
EU 圏で UMP に「同意の撤回」導線を設定し忘れて、設定画面に戻る手段がなくなっていたケースも経験しました。EEA / UK では撤回手段の提供が法的に推奨されており、UI に「広告の設定」項目を必ず置く必要があります。撤回は同意率の母数を下げる要因なので一見不利ですが、これを置かないと別の意味で問題になります。
静かな結び
同意 UX は、見落とされやすい割に、個人開発者の収益に最も直接的に効く UI の一つです。私はそれを 10 年以上の運用で何度か学び直してきましたが、毎回「もっと早くに体系化しておけばよかった」と感じます。Antigravity に観測と PR 作成を任せるようになって、ようやくこの仕事が「気が向いたときの改善」から「毎週の運用」に変わりました。
もしすでに ATT・UMP を入れていて、まだ pre-permission を導入していないのであれば、まず 1 か国・1 アプリで pre-permission を入れて 2 週間観測してみるのが、一番費用対効果が高い最初の一歩だと思います。そこから先は、Antigravity のサブエージェントに観測を任せるだけで、改善の打席は自然と増えていきます。
お読みいただきありがとうございました。同じく個人開発でアプリを長く運用している方の参考になれば嬉しいです。