リワード広告を見終えた直後に、レビュー誘導のダイアログとペイウォールが同時に飛び出してきたことがあります。自分が運営している壁紙アプリ(iOS / Android 合わせて累計5,000万DL超)で、ある更新の段階公開中に起きました。ユーザーから見れば「広告を見せられた上に、二枚重ねで何かを請求された」ようにしか映りません。
原因は単純で、ダイアログを出す show() がコードのあちこちに散らばっていて、誰も「いま画面に何が出ているか」を知らなかったからです。広告のコールバックがレビュー誘導のタイマーと無関係に発火し、両方が「いま出してよい」と判断してしまう。個々のロジックは正しいのに、合わさると壊れます。
以下では、その衝突を優先度つきの中央ゲート(ModalGate)で根治した実装をたどります。コードは Android(Kotlin)ですが、考え方は SwiftUI でも React Native でも同じです。そして、散らばった呼び出しの掃き出しは Antigravity のエージェントに任せ、どのモーダルを優先するかという判断だけは自分で握る、という線引きについても書きます。
「いま何が出ているか」を誰も知らない状態が根本原因です
モーダルが衝突するアプリには、共通の構造があります。表示の判断が呼び出し側に分散しているのです。
ペイウォール: 機能制限に触れた瞬間に PaywallDialog.show()
レビュー誘導: 起動回数が閾値を超えたら ReviewInduction.maybeShow()
リワード広告: 戻るボタンや特定操作の後に RewardedIntersDialog.show()
それぞれは「自分が出てよい条件」しか見ていません。画面全体で一度に一枚だけ、という不変条件はどこにも書かれていない。だから二枚目が前面の一枚に気づかず重なります。さらに厄介なのは、優先度が暗黙だという点です。本来はペイウォール(課金に直結)を最優先にしたいのに、たまたまレビュー誘導のタイマーが先に発火すると、価値の低いモーダルが価値の高いモーダルの表示機会を食い潰します。
解決の方針は一つです。すべてのモーダルを、単一の調停役を必ず経由させる こと。直接 show() を呼ばせない。ゲートが「いま一枚出ているか」「次に出すべきはどれか」を一元的に決めます。
中央ゲートの最小実装 — 単一表示の不変条件と優先度
まず核になる ModalGate です。やることは3つだけです。同時に一枚しか出さない、優先度の高いものを先に出す、出し終わったら次を出す。
// 値が大きいほど優先。課金に直結するものを上に置きます
enum class ModalPriority ( val weight: Int ) {
PAYWALL ( 100 ), // 機能制限・課金導線
REWARDED_AD ( 60 ), // リワード広告
REVIEW_PROMPT ( 20 ), // ストアレビュー誘導
}
// 「出したい」という要求を表す。show は実際の表示処理を遅延実行するラムダ
data class ModalRequest (
val id: String ,
val priority: ModalPriority ,
val show: (onDismiss: () -> Unit) -> Unit,
)
object ModalGate {
private val pending = mutableListOf < ModalRequest >()
private var active: ModalRequest ? = null
private var canPresent = false // フォアグラウンドで安全に出せるか
// 呼び出し側はこれだけを使う。直接 show() は禁止
@MainThread
fun enqueue (request: ModalRequest ) {
// 同じ種類の二重投入を防ぐ
if (pending. any { it.id == request.id } || active?.id == request.id) return
pending. add (request)
pump ()
}
@MainThread
fun setPresentable ( value : Boolean ) {
canPresent = value
if ( value ) pump ()
}
private fun pump () {
if ( ! canPresent || active != null ) return
val next = pending. maxByOrNull { it.priority.weight } ?: return
pending. remove (next)
active = next
next. show { onDismissed (next) }
}
private fun onDismissed (request: ModalRequest ) {
if (active?.id == request.id) active = null
pump () // 次の一枚へ
}
}
呼び出し側はこう変わります。show() を直接叩くのをやめ、要求をゲートに積むだけにします。
// Before: 各所が勝手に表示していた
PaywallDialog (activity). show ()
// After: ゲート経由。出すかどうか・いつ出すかはゲートが決める
ModalGate. enqueue (
ModalRequest (id = "paywall" , priority = ModalPriority.PAYWALL) { onDismiss ->
PaywallDialog (activity). apply { setOnDismissListener { onDismiss () } }. show ()
}
)
これで「同時に二枚」は構造的に起きなくなります。レビュー誘導とペイウォールが同じ瞬間に積まれても、ゲートは weight の高いペイウォールを先に出し、閉じてからレビュー誘導を出すか判断します。優先度をコードに明示した時点で、暗黙の取り合いは消えます。
罠その1 — Activityが背面にいる瞬間に出してしまう
最初に踏んだのがこれです。show() 自体は通っても、Activity が onStop を過ぎたあとに DialogFragment を出そうとすると IllegalStateException で落ちる、あるいは表示が黙って捨てられる。広告のコールバックは数百ミリ秒〜数秒遅れて返ってくるので、その間にユーザーがホームに戻ると、ちょうどこの隙間に刺さります。
対策は、ゲートに「いま安全に出せるか」を持たせ、ライフサイクルと同期させることです。canPresent フラグはそのために用意しました。
// BaseActivity 等で一括登録する
class GateLifecycleObserver : DefaultLifecycleObserver {
override fun onResume (owner: LifecycleOwner ) = ModalGate. setPresentable ( true )
override fun onPause (owner: LifecycleOwner ) = ModalGate. setPresentable ( false )
}
// Activity 側
lifecycle. addObserver ( GateLifecycleObserver ())
こうすると、背面にいる間に積まれた要求は pending に溜まり、フォアグラウンドに戻った瞬間に pump() が走って出ます。表示が消える事故も、例外も止まります。ライフサイクルとモーダルを別々に管理していたのが間違いで、表示可否はライフサイクルの従属変数 だった、というのが学びでした。テーマ切替時の再生成で似た落とし穴を踏んだ話はテーマ切替で白画面が出る問題をAppRestarterで直した記録 にも書きました。
罠その2 — 広告はスロットを「読み込み後」ではなく「要求時」に予約する
二つ目はもっと見えにくい罠でした。リワード広告は、要求してから実際に表示されるまでに読み込みの時間があります。素直に「広告が読み込めたら enqueue する」と書くと、その読み込み待ちの数秒の間にレビュー誘導が割り込んで先に出てしまう。ユーザーがレビュー誘導を閉じた直後に、忘れた頃の広告が出てくる、という最悪の体感になります。
正しくは、広告を読み込む前にゲートのスロットを予約する ことです。要求の時点で枠を取り、読み込みに失敗したら枠を返す。
fun requestRewardedAd (activity: Activity ) {
ModalGate. enqueue (
ModalRequest (id = "rewarded" , priority = ModalPriority.REWARDED_AD) { onDismiss ->
RewardedAd. load (activity, AD_UNIT_ID, object : RewardedAdLoadCallback () {
override fun onAdLoaded (ad: RewardedAd ) {
ad.fullScreenContentCallback = object : FullScreenContentCallback () {
override fun onAdDismissedFullScreenContent () = onDismiss ()
override fun onAdFailedToShowFullScreenContent (e: AdError ) = onDismiss ()
}
ad. show (activity) { /* reward 付与 */ }
}
// 読み込み失敗時も必ず枠を返す。返し忘れるとゲートが固まる
override fun onAdFailedToLoad (e: LoadAdError ) = onDismiss ()
})
}
)
}
ポイントは、show ラムダの中で読み込みまで含めて完結させ、どの経路でも必ず onDismiss() を一度だけ呼ぶ ことです。これを守らないと、active が解放されずゲートが沈黙します。私はここで一度、読み込み失敗のパスで onDismiss() を呼び忘れ、以後すべてのモーダルが出なくなる事故を起こしました。スロットの予約と解放は必ず対で設計してください。
罠その3 — 戻るボタンの広告ゲートとModalGateの二重管理
三つ目は設計の重複です。多くのアプリは「戻るボタンを一定回数押したら広告」という独自のゲートを持っています。これを ModalGate と別に動かすと、戻るボタン側が「出してよい」と判断した広告と、ModalGate が出そうとしているペイウォールがまた衝突します。ゲートを二つ持った時点で、衝突対策をしているつもりで衝突源を増やしています。
解決は、戻るボタンのロジックを「表示する/しない」ではなく「ゲートに積む/積まない」に降格させることです。最終的な表示可否の判断は ModalGate に一本化します。
private var backPressCount = 0
override fun onBackPressed () {
backPressCount ++
if (backPressCount % SHOW_AD_EVERY == 0 && ! adFreeManager.isAdFree) {
requestRewardedAd ( this ) // 出すかどうかはゲートが決める
return
}
super . onBackPressed ()
}
戻るボタンは「広告を出したい」と要求するだけで、優先度の調停はしません。これで判断の主体が一か所に揃います。なお isAdFree のような状態の「正」をどこに置くかは、それ自体が一つの設計判断です。課金状態の一元管理についてはad-free Source of Truthパターンの記録 に詳しく書きました。
散らばった呼び出しの掃き出しはエージェントに、ポリシーは自分に
ここからが Antigravity を使った実務です。ModalGate を入れる作業の本体は、新しいコードを書くことではなく、既存の直接 show() を全部見つけてゲート経由に書き換える ことでした。壁紙アプリは画面数が多く、show() の呼び出しは数十か所に散っていました。手で漏れなく拾うのは現実的ではありません。
ここでエージェントに任せたのは、機械的で網羅性が要る部分だけです。
Dialog・DialogFragment・広告表示の show( 呼び出しを全ファイルから列挙させる
それぞれを ModalGate.enqueue(...) 形式に変換する差分を提案させる
変換後、直接 show() が一つも残っていないこと を確認する小さなチェックを書かせる
3番目は、再発防止として CI に残す価値があります。エージェントに書かせたのはこんな簡単なゲートです。
# 直接の dialog.show() がゲートを迂回していないか検査する
hits = $( grep -rnE '\.(show)\(\)' app/src/main/java \
| grep -iE 'Dialog|Paywall|Review|Rewarded' \
| grep -v 'ModalGate' )
if [ -n " $hits " ]; then
echo "❌ ゲートを迂回した直接表示が残っています:" ; echo " $hits " ; exit 1
fi
echo "✅ すべての表示が ModalGate を経由しています"
一方で、どのモーダルにどの優先度を割り当てるか は、エージェントに渡しませんでした。ペイウォードをレビュー誘導より上に置く、起動直後の数秒は何も出さない、1セッションでモーダルは最大2枚まで——こうした判断は収益とユーザー体験のトレードオフそのもので、アプリの性格に依存します。エージェントは「同じ意味のコードへの一括変換」と「網羅性の検査」が圧倒的に速い。人は「何を優先すべきか」を決める。この線引きは、作品そのものは自分で作り、その周辺の運用を AI に寄せる、という普段のアプリ運営の感覚とそのまま地続きでした。
セッション単位の頻度上限を入れるなら、ゲートにカウンタを足すだけで済みます。
private var shownThisSession = 0
private const val MAX_PER_SESSION = 2
private fun pump () {
if ( ! canPresent || active != null ) return
if (shownThisSession >= MAX_PER_SESSION) return // 出し過ぎを止める
val next = pending. maxByOrNull { it.priority.weight } ?: return
pending. remove (next)
active = next
shownThisSession ++
next. show { onDismissed (next) }
}
段階公開で効果を確かめてから広げる
入れ替えのあと、いきなり全ユーザーには出しませんでした。私はこの種の変更を 5% → 25% → 50% → 100% の段階公開で広げ、Crash-free users 99.7% 以上と ANR 0.20% 未満を維持できているかを各段で確認しています。ModalGate は表示タイミングを変えるので、レビュー誘導の発火率や広告の表示回数が意図せず落ちていないかも合わせて見ました。結果として、「二枚重なった」種類の不具合報告は段階公開の各段でゼロのまま広げられました。数字を誇張するつもりはありません。確かなのは、衝突という構造的な不具合が、構造を変えたことで再現しなくなったという一点です。
導入を始めるなら、まず一番痛い組み合わせ(多くの場合はレビュー誘導とペイウォール)だけをゲートに通してみてください。二か所をゲート経由にして衝突が消えることを確認できれば、残りの show() を掃き出す価値があると判断できます。収益導線を複数持つアプリでの面の設計はApp Clip・Widget の収益化ファネル設計 も参考になるはずです。
お読みいただきありがとうございました。同じように複数の導線が重なって困っている方の、設計の足がかりになれば幸いです。