2026年5月、運営している壁紙アプリの Android 版を v2.1.0 へ更新する作業の終盤で、テスト端末のスクリーンショットに目を疑いました。ペイウォールの上にレビュー誘導ダイアログが重なり、その背後ではリワード広告の案内まで開こうとしていたのです。
個人開発を2014年から続けてきて、モーダルの出し分けは長らく「動いているからよし」で済ませてきた部分でした。表示物がペイウォール1つだけの時代は、それで何も起きません。ところが今回の更新でペイウォール(PaywallDialog)、レビュー誘導(ReviewInduction)、リワード広告案内(RewardedIntersDialog)と3つに増えた途端、この暗黙の前提が崩れました。
最終的には ModalGate と名付けた小さな中央管理クラスを1つ導入して解決したのですが、その過程で Claude Code に設計レビューを依頼したことが想像以上に効きました。「ダイアログが重なる」「一度閉じたら以後何も表示されなくなる」といった症状に悩んでいる方に向けて、見つかった罠と実装をまとめます。
モーダルが重なる3つの罠
調べていくと、症状は1つではなく3つの別々の罠が絡み合っていました。
罠1: 各ダイアログが互いの存在を知らない
ペイウォールは「未課金かつ起動3回目以降」、レビュー誘導は「インストール7日経過かつ未レビュー」、リワード案内は「広告閲覧権が未消化」と、それぞれが自分の表示条件だけを見て自己判断していました。条件が独立している以上、すべてが同時に真になる日が必ず来ます。私の場合はインストール7日後に課金せず使い続けたユーザーがその交差点でした。
罠2: 優先度が呼び出し順に暗黙依存する
3つの表示判定は MainActivity の onResume に並んでいたため、「先に書いてある方が先に出る」という呼び出し順だけが事実上の優先度でした。後日、初期化処理の整理でこの順番を入れ替えたところ、出るダイアログが変わってしまいました。コードのどこにも「ペイウォールが最優先」とは書かれていないので、レビューでも気づけません。
罠3: dismiss 連鎖とフラグ解放漏れ
一時しのぎとして「表示中フラグ」を bool で持たせた時期もありました。ところがダイアログを閉じる経路は OK ボタンだけではありません。戻るキーや画面外タップで閉じた場合にフラグが下りない経路があり、その状態になると以後どのモーダルも一切表示されなくなります。ペイウォールが出ないのは収益に直結するので、これは重なるよりも深刻でした。
根本原因は「自己判断するダイアログ」
3つの罠に共通する根は、表示の可否を各ダイアログが自分で判断していることです。登場人物が増えるたびに「他の全員の状態を確認するコード」が各所に増えていき、確認漏れが競合になります。
そこで方針を反転させました。各ダイアログは「出たい」と申請するだけにして、出てよいかどうかの判断は1箇所に集めます。これが ModalGate です。
ModalGate の設計 — 優先度付き中央ゲート
設計方針は3つだけです。
- 表示可否の判断は ModalGate だけが行う(各ダイアログは申請するだけ)
- 優先度を数値で明示する(ペイウォール > リワード案内 > レビュー誘導)
- 却下されたモーダルは持ち越さない(閉じた直後に別のモーダルを連鎖表示しない)
以下が実装のほぼ全体です。何かを解決するコードというより、判断を1箇所に集約するための器に近い小ささです。
// ModalGate.kt — モーダル表示の中央管理(シングルトン)
object ModalGate {
// 表示中モーダルのタグ。null なら何も表示されていない
private var activeTag: String? = null
// 優先度の定義(数値が小さいほど優先)
enum class Priority(val rank: Int) {
PAYWALL(0), // ペイウォール
REWARDED_PROMO(1), // リワード広告案内
REVIEW_INDUCTION(2) // レビュー誘導
}
/**
* 表示要求。許可された場合のみ true を返す。
* 呼び出し側は true のときだけ実際に show() する。
*/
@Synchronized
fun request(tag: String, priority: Priority): Boolean {
val current = activeTag
if (current != null) {
Log.d("ModalGate", "denied: $tag (active=$current)")
return false // 表示中なら無条件で却下。連鎖表示もしない
}
activeTag = tag
Log.d("ModalGate", "granted: $tag (priority=${priority.rank})")
return true
}
/** DialogFragment.onDismiss() から必ず呼ぶ */
@Synchronized
fun release(tag: String) {
if (activeTag == tag) {
activeTag = null
Log.d("ModalGate", "released: $tag")
}
}
/** プロセス再生成後の復元用(後述の罠対策) */
@Synchronized
fun restore(tag: String?) {
activeTag = tag
}
}呼び出し側はこうなります。
// レビュー誘導の表示判定(呼び出し側)
if (shouldShowReviewInduction() &&
ModalGate.request("review", ModalGate.Priority.REVIEW_INDUCTION)) {
ReviewInductionDialog().show(supportFragmentManager, "review")
}ペイウォールとレビュー誘導の条件が同時に真になった場合、ログには次のように出ます。
D/ModalGate: granted: paywall (priority=0)
D/ModalGate: denied: review (active=paywall)
D/ModalGate: released: paywall
1点だけ、迷った末の判断を書いておきます。「却下されたレビュー誘導を、ペイウォールが閉じた直後に出す」連鎖方式も検討しましたが、採用しませんでした。ユーザーから見ると「閉じても閉じても何か出てくるアプリ」になるからです。却下されたモーダルは次回起動時に改めて条件判定されるだけ、と割り切った方が体験は静かになります。レビュー誘導が1日遅れても失うものはほとんどありません。
Claude Code とのレビューで見つかった「閉じ漏れ」の3経路
release() を onDismiss で呼ぶところまでは自力で書けたのですが、罠3の経験があったので、Claude Code に「release が呼ばれない経路をすべて列挙してください」と設計レビューを依頼しました。返ってきた指摘は3つです。
- 戻るキー・画面外タップ: setOnDismissListener だけに依存すると onCancel 経由のパスで漏れる場合があります。DialogFragment の onDismiss() オーバーライドに一本化することで、どの閉じ方でも必ず通る経路になりました
- プロセス再生成: 開発者オプションの「アクティビティを保持しない」を有効にした端末では、復帰時に FragmentManager がダイアログを復元する一方、シングルトンの activeTag は初期化されて null に戻ります。ダイアログは出ているのにゲートは空いている、という不整合です。onCreate で FragmentManager のタグを走査して restore() する処理を足しました
- 画面回転: 再生成のタイミングで表示判定が再実行されると、復元されたダイアログと新規のダイアログが二重に出ます。判定を onResume から「ユーザー操作起点 + 初回起動フロー」に移して回避しました
正直に言うと、2つ目のプロセス再生成は自分だけでは出てこなかった観点です。シングルトンの寿命と FragmentManager の復元タイミングのずれは、実機で「アクティビティを保持しない」を有効にして初めて再現できました。レビューを依頼するときは「正しいか見てください」ではなく「呼ばれない経路を列挙してください」のように、反例を探す形で質問すると精度が上がると感じています。
導入して変わったこと
v2.1.0 のリリースから約1ヶ月、モーダルの同時表示はテスト・本番レポートともに見ていません。罠3で起きていた「以後何も表示されない」状態も、フラグ管理が release() の1経路に集約されたことで根が消えました。リワード広告案内が意図したタイミングで確実に出るようになったのは、AdMob の収益面でも地味に効いています。
副次的な効果として、モーダルを増やすときのレビューが楽になりました。新しいダイアログを足す場合も、Priority に1行足して request() を通すだけで、既存のモーダルとの競合を個別に考える必要がありません。
なお、この v2.1.0 ではモーダル整理と並行してクラッシュ対応も進めていました。経緯は Claude Code でAndroid の RecyclerView クラッシュを28日間で根治した記録 に、リリース後の監視体制は Play Store 段階公開を Claude Code で自動監視する — v2.1.0 クラッシュ対応から学んだリリース戦略 にまとめています。
まとめ — 最初の一歩
ModalGate の実装自体は50行に満たない小さなクラスで、効果の大半は「表示可否の判断を1箇所に集める」という方針決定から来ています。まずは手元のプロジェクトで .show( を grep して、モーダルを出している箇所を列挙してみてください。それぞれが他のモーダルの表示状態を確認していないなら、表示物が3つに増える前にこのパターンを入れておく価値があります。
同じ課題に取り組んでいる方の参考になれば幸いです。