個人開発で運営している壁紙アプリに App Open 広告を足そうとして、実装そのものは Antigravity のエージェントに任せました。出てきたコードは数十行で動き、ホーム画面から戻ると広告が出ます。ところが実機で触っていると、自分のリワード動画を見終えて戻った瞬間にも App Open 広告が重なって出る のです。課金シートを閉じた直後にも出ます。読者からすれば「広告を見たのに、また広告」という最悪の体験です。
原因はエージェントを責められません。App Open 広告の素朴な実装は「アプリが前面に戻ったら出す」であり、エージェントはその通りに書いただけです。抜けていたのは、前面に戻った理由を問わない という一点でした。ここを埋めないと、AdMob のポリシー(自社の全画面コンテンツの直後に App Open を出さない、という趣旨)にも抵触します。
「前面復帰のたびに出す」がなぜ事故になるのか
App Open 広告は本来、ユーザーがアプリを離れて戻ってきたとき、つまりコンテンツを待つ自然な隙間 に出すための枠です。問題は、Android の Activity から見ると「ユーザーがホームに行って戻った」も「自分が出した全画面広告から戻った」も、同じ onStart として観測される点にあります。
私が踏んだ事故を整理すると、出してはいけない復帰は主に4種類でした。
自前のインタースティシャル/リワード動画から戻ったとき(広告→広告の二重表示・ポリシー抵触)
Google Play の課金シートを閉じたとき(購入直後に広告は不信感に直結します)
Chrome Custom Tabs で外部リンクを開いて戻ったとき
コールドスタート時(起動スプラッシュと App Open が二重に走る)
逆に出してよいのは、ユーザーが自分の意思でアプリを離れて戻ってきた ときだけです。この区別を onStart 単体では作れないのが核心です。
エージェントが書く素朴な実装と、その盲点
Antigravity に「App Open 広告を入れて」と頼むと、おおむね次の骨格が出てきます。ProcessLifecycleOwner でプロセス全体の前面化を監視し、戻ってきたら show() を呼ぶ形です。
// エージェントが最初に出してくる典型形(このままだと事故る)
class MyApp : Application (), DefaultLifecycleObserver {
private lateinit var appOpenAdManager: AppOpenAdManager
override fun onCreate () {
super < Application > . onCreate ()
MobileAds. initialize ( this )
appOpenAdManager = AppOpenAdManager ( this )
ProcessLifecycleOwner. get ().lifecycle. addObserver ( this )
}
// ★ ここが盲点: 「なぜ前面に戻ったのか」を一切問わない
override fun onStart (owner: LifecycleOwner ) {
appOpenAdManager. showAdIfAvailable (currentActivity)
}
}
このコードは、ホーム復帰でもリワード動画からの復帰でも等しく onStart で発火します。エージェントの出力をレビューするとき、私は「動くか」より先に「どの経路で前面に戻ってもこれが正しいか 」を問うようにしています。App Open 広告に関してはここで必ず引っかかります。
解決の軸: 「前面復帰の理由」を記録する調停役を置く
抑制条件を onStart の中に if で書き足していくと、条件が増えるたびに分岐が入れ子になり、どのフラグが効いているのか追えなくなります。私が落ち着いたのは、**前面を離れた理由を一箇所で記録する調停役(arbiter)**を置き、onStart はその判断を参照するだけにする構造でした。
調停役が持つ状態は3つです。
いま自分の全画面コンテンツ(広告・課金シート等)を表示中か
直前に内部フロー(Custom Tabs 等)へ意図的に離脱したか
まだコールドスタート直後か
これらを Application.ActivityLifecycleCallbacks と、各全画面表示の開始・終了フックで更新します。
// 前面復帰の「理由」を一元管理する調停役
object ForegroundArbiter {
// 自前の全画面コンテンツ(広告・課金シート・全画面ダイアログ)を表示中
@Volatile var isShowingFullScreenContent = false
// Custom Tabs や外部アプリへ意図的に遷移した(戻りで広告を出さない)
@Volatile var isEnteringInternalFlow = false
// プロセス起動後まだ一度も自然な前面化を終えていない
@Volatile var isColdStart = true
/** App Open 広告を出してよい復帰かを判定する唯一の入口 */
fun shouldShowAppOpenAd (): Boolean {
if (isColdStart) return false
if (isShowingFullScreenContent) return false
if (isEnteringInternalFlow) return false
return true
}
/** 全画面広告・課金シートを開く直前に必ず呼ぶ */
fun beginFullScreenContent () { isShowingFullScreenContent = true }
/** 閉じた直後に呼ぶ。次の onStart 1回ぶんは抑制し、取りこぼしを防ぐ */
fun endFullScreenContent () { isShowingFullScreenContent = false }
/** Custom Tabs など外部遷移の直前に呼ぶ */
fun beginInternalFlow () { isEnteringInternalFlow = true }
fun endInternalFlow () { isEnteringInternalFlow = false }
}
ポイントは、調停役が「いつ広告を出すか」を一切知らないことです。広告の表示判断は shouldShowAppOpenAd() という1つの述語に集約され、onStart 側はそれを読むだけになります。テストするときも、この述語に状態を流し込めば全経路を机上で確認できます。
App Open 広告マネージャ本体(4時間期限とロードを含む)
調停役と分離したうえで、広告の読み込みと表示は専用クラスに閉じ込めます。App Open 広告は読み込みから4時間で失効する ため、キャッシュした時刻を必ず持たせます。失効していたら出さずに再ロードする、という判断もここに置きます。
class AppOpenAdManager ( private val app: Application ) {
private var appOpenAd: AppOpenAd ? = null
private var isLoading = false
private var loadTimeMs = 0L
private val adUnitId = "ca-app-pub-XXXXXXXX/XXXXXXXX" // 実際の広告ユニットIDに置換
fun loadAd () {
if (isLoading || isAdAvailable ()) return
isLoading = true
val request = AdRequest. Builder (). build ()
AppOpenAd. load (app, adUnitId, request,
object : AppOpenAd . AppOpenAdLoadCallback () {
override fun onAdLoaded (ad: AppOpenAd ) {
appOpenAd = ad
isLoading = false
loadTimeMs = System. currentTimeMillis ()
}
override fun onAdFailedToLoad (error: LoadAdError ) {
isLoading = false // 次の機会に再試行
}
})
}
// 4時間(広告の有効期限)を超えていないか
private fun isAdAvailable (): Boolean {
val fourHours = 4 * 60 * 60 * 1000L
return appOpenAd != null &&
(System. currentTimeMillis () - loadTimeMs) < fourHours
}
fun showAdIfAvailable (activity: Activity ?) {
// 表示判断は調停役に委譲する。マネージャは「出せる状態か」だけを見る
if ( ! ForegroundArbiter. shouldShowAppOpenAd ()) { loadAd (); return }
if (activity == null || ! isAdAvailable ()) { loadAd (); return }
appOpenAd?.fullScreenContentCallback = object : FullScreenContentCallback () {
override fun onAdShowedFullScreenContent () {
// 自分が全画面を出している間は、戻りで二重に出さない
ForegroundArbiter. beginFullScreenContent ()
}
override fun onAdDismissedFullScreenContent () {
ForegroundArbiter. endFullScreenContent ()
appOpenAd = null
loadAd () // 次回ぶんを先読み
}
override fun onAdFailedToShowFullScreenContent (e: AdError ) {
ForegroundArbiter. endFullScreenContent ()
appOpenAd = null
loadAd ()
}
}
appOpenAd?. show (activity)
}
}
onAdShowedFullScreenContent で beginFullScreenContent() を呼ぶのは、App Open 広告自身が全画面である以上、その表示中に別の onStart が走っても再表示しないためです。リワード動画やインタースティシャルを出すコードでも、同じ beginFullScreenContent() / endFullScreenContent() を表示前後に挟みます。
ON_START ゲートで調停役を参照する
Application 側は、現在の Activity を追跡しつつ、前面化のたびに調停役へ判断を仰ぎます。ActivityLifecycleCallbacks で currentActivity を更新し、最初の onStart を消化したらコールドスタートのフラグを下ろします。
class MyApp : Application (), DefaultLifecycleObserver {
private lateinit var appOpenAdManager: AppOpenAdManager
private var currentActivity: Activity ? = null
override fun onCreate () {
super < Application > . onCreate ()
MobileAds. initialize ( this ) {}
appOpenAdManager = AppOpenAdManager ( this )
appOpenAdManager. loadAd ()
ProcessLifecycleOwner. get ().lifecycle. addObserver ( this )
registerActivityLifecycleCallbacks ( object : SimpleActivityLifecycleCallbacks () {
override fun onActivityResumed (activity: Activity ) { currentActivity = activity }
override fun onActivityPaused (activity: Activity ) {
if (activity == currentActivity) { /* keep ref for show() */ }
}
})
}
override fun onStart (owner: LifecycleOwner ) {
if (ForegroundArbiter.isColdStart) {
// 起動直後の最初の前面化は消化するだけ(スプラッシュと二重化させない)
ForegroundArbiter.isColdStart = false
return
}
appOpenAdManager. showAdIfAvailable (currentActivity)
}
}
これで onStart のロジックは「コールドスタートを1回飲み込み、あとは調停役に従う」だけになりました。広告を出すか出さないかの本当の判断は ForegroundArbiter.shouldShowAppOpenAd() の中だけにあり、課金フローやリワード動画の実装側は表示前後に2行のフックを挟むだけで連携します。
検証: 復帰要因ごとに「出る/出ない」を確かめる
ここがいちばん大事です。App Open 広告の不具合は「たまに出る」「特定の経路でだけ出る」という形で現れるため、復帰経路を1つずつ手で確認する検証表を用意しました。私自身、ここを省いて本番に出したことで購入直後の広告表示が残ってしまい、レビュー評価を下げた経験があります。
復帰の経路 期待 確認に使うフラグ
ホーム→アプリ再前面化 出す すべて false
リワード動画を見終えて戻る 出さない isShowingFullScreenContent
課金シートを閉じて戻る 出さない isShowingFullScreenContent
Custom Tabs から戻る 出さない isEnteringInternalFlow
コールドスタート直後 出さない isColdStart
机上の単体テストでは、調停役の述語にそのまま状態を流して期待値を固定します。
@Test
fun rewarded_dismiss_does_not_show_app_open () {
ForegroundArbiter.isColdStart = false
ForegroundArbiter. beginFullScreenContent () // リワード動画 表示開始
assertFalse (ForegroundArbiter. shouldShowAppOpenAd ())
ForegroundArbiter. endFullScreenContent () // 動画を閉じた直後
// この直後の onStart では出さない設計のため、1回ぶん抑制を入れている場合はそれも検証
assertTrue (ForegroundArbiter. shouldShowAppOpenAd ())
}
実機では adb logcat に各フラグの遷移ログを流し、上の表の5経路を実際に踏んで「出る/出ない」を目視確認します。Antigravity のエージェントに検証まで任せる場合も、この表をそのまま受け入れ条件として渡す と、抜けのある実装をそのまま通してしまう事故を減らせます。
実運用で効いた変化と、エージェントへの指示の固定化
復帰要因の識別を入れる前は、リワード動画と課金シートからの復帰で App Open 広告が重なり、1セッションあたり最大3回の不要表示が出ていました。計測してみると前面化イベントのうち約60%が内部フロー由来で、そのほとんどが「出してはいけない復帰」だったのです。つまり素朴な実装では、本来の表示回数のおよそ2.5倍まで広告が膨らんでいた計算になります。調停役を挟んでからは、この不要表示が消え、購入直後の広告という最悪の体験もなくなりました。AdMob のポリシー観点でも、自社の全画面コンテンツ直後の App Open を構造的に防げる形になっています。
もう一つ実運用で効いたのは、エラーや失効まわりの分岐を1箇所に集めたことでした。広告の読み込み失敗や4時間の失効は本番環境でこそ頻繁に起きますが、表示判断が shouldShowAppOpenAd() の1述語に集約されていると、失効時は静かに再ロードへ回し、表示は見送るという回避が素直に書けます。以前は onStart の中に失効チェックと抑制条件が混在し、どちらの分岐で広告が消えたのか追えませんでした。私はこうした「出さない理由」が散らばる状態を、本番で最も嫌な不具合の温床だと考えており、述語への集約を強く推奨します。
学びとして残ったのは、エージェントに広告系の実装を頼むときは「出す実装」ではなく「出さない条件 」を仕様の主語にする、ということでした。私は今、Antigravity への依頼文に「App Open 広告は、自社の全画面コンテンツ・課金フロー・外部遷移・コールドスタートからの復帰では出さないこと。前面復帰の理由を一元管理する述語を1つ作り、表示判断はそれだけに集約すること」という制約を最初から書き添えています。生成されたコードのレビューも、この述語が存在するかをまず見ます。
次の一歩として、お使いのアプリでまず shouldShowAppOpenAd() に相当する述語が1つに集約されているかを確認してみてください。分岐が onStart の中に散らばっているなら、そこが将来の二重表示の温床になります。