2026年5月、運営している壁紙アプリの Android 版 v2.1.0 を準備していたとき、相反する2つのシグナルが同時に届きました。ストアレビューには「終了するたびに広告が出る」という声。一方で AdMob の管理画面を見ると、終了時インタースティシャルの表示回数は想定の半分ほどしかありません。「出すぎている」と「出ていない」が、同じバージョンに対して同時に報告されている状態です。
この時点で、原因が広告 SDK 側ではなく自分のコードの分岐ロジックにあることをほぼ確信しました。実際の犯人は、戻るボタンの処理に何年もかけて積み重なっていた入れ子の条件分岐です。ここに記録するのは、その入れ子をゲート単位の並列構造へ作り直し、分岐パスの棚卸しとテスト生成を Claude Code に任せた約2週間の作業内容です。同じように「終了時の広告まわりだけ誰も触りたがらない」状態になっているアプリは少なくないと思いますので、設計の考え方と移行の手順をそのまま共有します。
症状 — 「出ない」と「出すぎる」が同じ終了フローから生まれていた
問題の画面はアプリのトップ画面です。ここで戻るボタンが押されたとき、アプリは次の5つの問いに答える必要がありました。
広告非表示ユーザーか : 買い切りの広告除去を購入済みか、リワード広告視聴による一時的な広告フリー期間中か
他のモーダルが出ていないか : レビュー誘導やお知らせダイアログと重ならないか
レビュー誘導を出す条件を満たしているか : 起動回数や前回表示からの経過日数
表示間隔の上限に達していないか : 終了時広告のフリークエンシーキャップ
インタースティシャルがロード済みか : 未ロードなら何も出せない
5つと書くと整理されて見えますが、実際のコードではこれらが約10年分の機能追加の順番どおりに、入れ子の if 文として埋まっていました。そして観測された不具合は2系統です。ひとつは、レビュー誘導の表示フラグが立ったまま降りない経路があり、その状態に入ったユーザーには終了時広告が一切出なくなるもの。もうひとつは、広告が未ロードだった場合の素通し経路でフリークエンシーキャップの計測が更新されず、次回セッションで本来の間隔を無視して広告が出るものです。前者が AdMob レポート上の「出ない」を、後者がレビュー欄の「出すぎる」を作っていました。
原因 — 入れ子の if は優先度を暗黙のうちに固定する
リファクタリング前のコードを、変数名だけ整えて簡略化するとこういう形でした。
// Before: 戻るボタン処理に積み重なっていた入れ子分岐(実物を簡略化)
override fun onBackPressed () {
if ( ! billingManager.isPurchased) {
if (interstitialAd != null ) {
if ( ! reviewInduction.isShowing) {
if (frequencyCap. canShow ()) {
interstitialAd?. show ( this ) // ようやく広告表示
} else {
super . onBackPressed () // 上限到達 → 素通し
}
}
// reviewInduction.isShowing == true のときは何もしない(バグの温床)
} else {
super . onBackPressed () // 未ロード → 素通し(計測漏れ)
}
} else {
super . onBackPressed () // 課金済み → 素通し
}
}
このコードの問題は、バグが2件あることそのものではありません。構造として次の3つの性質を持ってしまっていることです。
第一に、優先度がネストの深さに埋め込まれていて、仕様として読めない ことです。「課金判定はレビュー誘導より強い」「レビュー誘導は広告より強い」という業務上の優先順位が、コードの字面からは「たまたまその順に書かれている」ようにしか見えません。新しい条件を足すとき、どの深さに差し込むべきかを判断する材料がコードの中にないのです。
第二に、条件が1つ増えるたびに経路が倍々で増える ことです。boolean の条件が5つあれば理論上の状態は32通りで、そのすべてに「何が起きるべきか」の答えがあるはずですが、入れ子構造では書かれていない組み合わせが else の谷間に落ちます。落ちた先が super.onBackPressed() なのか「何もしない」なのかは、たまたまどの else に拾われるか次第です。
第三に、素通しの経路が無言である ことです。広告が出なかったとき、それが課金済みだからなのか、未ロードだからなのか、キャップ到達なのかをログから区別できませんでした。「出ない」系の不具合調査が毎回難航していた直接の理由です。
個人開発を2014年から続けてきて、この種の「動いているから触らない」コードが一番高くつくことは身に染みているのですが、終了フローは確認の手間が大きく、後回しにし続けていました。今回 Claude Code に分岐パスの列挙を任せられたことが、重い腰を上げるきっかけになりました。
再設計 — ゲートを並列・独立・宣言的に並べ直す
作り直しの方針は3行で書けます。各条件を「ゲート」として独立させること。ゲート同士はお互いを知らないこと。優先度は入れ子ではなく1枚のリストで宣言することです。
// After: 終了フローを「並列ゲートの先勝ち評価」に置き換える
interface ExitGate {
val name: String
fun evaluate (ctx: ExitContext ): GateDecision
}
sealed class GateDecision {
object Pass : GateDecision () // 自分の出番ではない → 次へ
data class Consume ( val action: () -> Unit) : GateDecision () // ここで処理して評価終了
}
class ExitFlowDispatcher ( private val gates: List < ExitGate >) {
/** 戻るボタン要求を受け、どれか1つのゲートだけに処理させる。
* どのゲートも消費しなければ false(= 通常の終了に委ねる)を返す。 */
fun onExitRequested (ctx: ExitContext ): Boolean {
for (gate in gates) {
when ( val decision = gate. evaluate (ctx)) {
is GateDecision.Consume -> {
GateLog. record (gate.name) // どのゲートが消費したか必ず残す
decision. action ()
return true
}
GateDecision.Pass -> continue
}
}
GateLog. record ( "(pass-through)" ) // 素通しも記録する
return false
}
}
そして優先度は、コードの形ではなくリストの並び順として一箇所で宣言します。
// 優先度はこのリストの並び順がすべて。コードレビューで「仕様」として読める
val exitGates = listOf (
adFreeGate, // 1. 広告非表示ユーザーは最優先で素通し
modalCollisionGate, // 2. 他のモーダル表示中は何も出さない
reviewInductionGate, // 3. レビュー誘導の条件を満たすならそちらを優先
frequencyCapGate, // 4. 表示間隔の上限に達していたら素通し(ただし記録は残す)
interstitialGate, // 5. ロード済みならインタースティシャルを表示
)
評価は先勝ちです。リストを上から順に見て、最初に Consume を返したゲートだけが仕事をし、残りは評価すらされません。「課金判定はレビュー誘導より強い」という優先順位が、adFreeGate が reviewInductionGate より上の行にあるという見た目そのものに変換されます。条件を足したくなったら、新しいゲートを書いてリストの適切な行に挿入するだけで、既存ゲートのコードには一切触りません。
ここで意識したのは、各ゲートの evaluate を判定だけに限定して副作用を持たせない ことです。たとえば frequencyCapGate は「上限に達しているか」を答えるだけで、表示回数のカウントアップは interstitialGate の action 側、それも広告の表示成功コールバックの中でだけ行います。Before のコードで「未ロード素通しのときに計測が漏れる」バグが起きたのは、判定と記録の責務が分かれていなかったからでした。
広告非表示の判定を単一の Source of Truth に集約する
ゲートを独立させる過程で、もうひとつの構造問題が浮かび上がりました。「広告を出してよいか」の判定が、画面ごとに微妙に違う書き方で散らばっていたことです。買い切り課金の判定は billingManager.isPurchased を直接読み、リワード広告視聴による一時的な広告フリーは別のマネージャが持っていて、参照している画面と参照していない画面がありました。
そこで、広告非表示の判定はこの1クラスだけが答えられる、という形に集約しました。
// 広告非表示の判定はこの1か所だけ。ゲートも画面もここ以外を見ない
class AdFreeStatus (
private val billing: BillingManager ,
private val rewardWindow: RewardAdFreeManager ,
) {
/** 買い切り購入済み、またはリワード視聴による一時解除期間中なら true */
val isAdFree: Boolean
get () = billing.isPurchased || rewardWindow.isActive
}
class AdFreeGate ( private val status: AdFreeStatus ) : ExitGate {
override val name = "AdFreeGate"
override fun evaluate (ctx: ExitContext ): GateDecision =
if (status.isAdFree) GateDecision. Consume { ctx. finishNormally () }
else GateDecision.Pass
}
移行で確実に効いたのは、Claude Code に「billingManager.isPurchased をクラス外から直接参照している箇所をすべて列挙して、AdFreeStatus 経由に書き換える差分を出してください」と依頼したステップです。手元のプロジェクトでは直接参照が9箇所見つかり、そのうち2箇所はリワード解除を考慮し忘れていました。つまり課金していないのにリワード解除中のユーザーへ広告を出し得る画面が、終了フロー以外にも残っていたわけです。grep ベースの棚卸しはこちらでも書けますが、「見つけたうえで書き換え差分まで提示する」ところまで一息に進むのは、レビューに集中できてありがたい分担でした。
リワード解除には有効期限があるため、rewardWindow.isActive は呼ばれるたびに現在時刻と照合します。セッション途中で期限が切れた場合も、次の評価から自然に広告ありへ戻ります。状態をキャッシュしてセッション中固定にする案も考えましたが、私はこの判定だけは毎回計算する側を選びました。期限切れの瞬間を跨ぐ不整合より、getter 1回分のコストのほうがずっと安いからです。
OnBackPressedDispatcher への接続と二度押し対策
onBackPressed() のオーバーライドは API 33 で非推奨になっているため、接続は OnBackPressedDispatcher 側に寄せます。Activity に置くコードはこれだけです。
// Activity 側は Dispatcher に Callback を1つ登録するだけ
onBackPressedDispatcher. addCallback ( this ) {
val consumed = exitFlow. onExitRequested ( buildExitContext ())
if ( ! consumed) {
isEnabled = false // 自分を無効化してから
onBackPressedDispatcher. onBackPressed () // システム既定の戻る処理へ委譲
isEnabled = true
}
}
実機テストで追加したのが二度押し対策です。広告表示の action が走ってからインタースティシャルが実際に画面を覆うまでには数百ミリ秒の隙間があり、その間にもう一度戻るボタンを押せてしまいます。対策として Dispatcher への入口で 600ms のデバウンスを入れ、ゲート評価そのものが連打で多重に走らないようにしました。
もうひとつ、Android 14 以降の予測型戻るジェスチャー(predictive back)を見据えた注意点があります。予測型ではユーザーがジェスチャーを完了する前にプレビューが始まるため、評価のタイミングと確定のタイミングが分離します。evaluate を副作用なしに保つ設計は、ここでも効いてきます。判定は何度走っても安全で、action だけが確定時に1回走る、という分離がそのまま予測型対応の土台になるからです。
Claude Code との分担 — 棚卸し・遷移表・どこで提案を断ったか
今回の作業で Claude Code に任せた領域は3つです。
1つ目は旧コードの分岐パス列挙 です。Before の onBackPressed と関連フラグの実装を渡し、「到達可能な実行経路を、条件の組み合わせと結果の対応表として列挙してください」と依頼しました。返ってきた表では到達可能な経路が22本あり、うち9本は私自身どの仕様にも対応づけられない経路でした。「書いた本人が説明できない経路が9本ある」という事実が、リファクタリングの判断材料として一番重かったと感じています。
2つ目は新設計の決定表とテストの生成 です。依頼文はおおよそ次のような内容でした。
ExitFlowDispatcher と5つのゲート実装を渡します。
ゲートの評価順序を仕様として固定したいので、
(状態の組み合わせ × 消費するゲート名) を網羅する
JUnit5 のパラメータ化テストを生成してください。
状態: isAdFree / isModalShowing / reviewEligible / capReached / adLoaded
期待値の根拠になる決定表も先に出力してください。
3つ目は AdFreeStatus への参照集約 で、これは前述のとおりです。
一方で、提案を断った場面も書いておきます。Claude Code は当初、ゲート間の連携にイベントバス的な仕組みを導入する案と、frequencyCapGate を interstitialGate に統合する案を出してきました。前者はこの規模のアプリには明確に過剰で、依存ライブラリを増やしてまで得るものがありません。後者は一見自然ですが、「間隔の判定」と「表示の実行」を再び1つのクラスに同居させる案であり、せっかく分離した責務を元に戻す方向です。理由を伝えて独立ゲートのまま進めてもらいました。設計の最終判断を手放さないことと、代替案の理由を言語化して返すことは、AI とペアで設計するときほど大事になると感じています。
優先度を仕様として固定するユニットテスト
生成されたテストを整理したものがこちらです。決定表がそのままテストケースになっています。
@ParameterizedTest
@CsvSource (
// adFree, modal, review, cap, loaded, 期待されるゲート
"true, false, false, false, true, AdFreeGate" ,
"false, true, false, false, true, ModalCollisionGate" ,
"false, false, true, false, true, ReviewInductionGate" ,
"false, false, false, true, true, FrequencyCapGate" ,
"false, false, false, false, true, InterstitialGate" ,
"false, false, false, false, false, (pass-through)" ,
)
fun exitFlowConsumesExactlyOneGateInDeclaredOrder (
adFree: Boolean , modal: Boolean , review: Boolean ,
cap: Boolean , loaded: Boolean , expected: String ,
) {
val ctx = exitContext (adFree, modal, review, cap, loaded)
val consumedGate = dispatcher. onExitRequestedRecording (ctx)
assertEquals (expected, consumedGate)
}
代表6ケースに加えて、32通りの全組み合わせを機械生成で回す網羅テストも入れました。初回実行では32本中2本が失敗します。どちらも新実装のバグではなく、旧コードの「意図しない振る舞い」を期待値の側が引き継いでいたものでした。決定表を前にして「この組み合わせのとき本当はどうあるべきか」を1行ずつ確定させていく作業は、テスト生成というより仕様の発掘に近い時間でした。
このテストがあることの実利は、優先度の変更が差分1行 + テスト期待値の更新として現れることです。たとえば「レビュー誘導をフリークエンシーキャップより弱くしたい」と思ったら、リストの行を入れ替え、決定表の該当行を直す。それ以外のコードは動きません。
移行時の落とし穴
実装よりも移行のほうに罠が多かったので、ぶつかった順に残しておきます。
広告ロード完了のタイミング : evaluate 中に adLoaded が変化すると評価が揺れます。ExitContext は評価開始時点のスナップショットとして作り、1回の評価の中では状態が動かないようにしました。
フリークエンシーキャップの保存場所 : 当初メモリ上に置いていたため、プロセス再生成でカウントが消えていました。DataStore へ永続化し、カウント更新は広告の表示成功コールバック内に限定しています。「表示を試みた回数」ではなく「表示できた回数」を数えるのが正しい、というのが実運用での結論です。
レビュー誘導との二重表示 : ゲート以前の世界では、終了時広告とレビュー誘導が同フレームで重なる事故がありました。modalCollisionGate はその再発防止です。モーダルの衝突問題そのものは Android のダイアログ競合を ModalGate で解決した実装メモ に切り出してあります。
予測型戻るジェスチャー : 前述のとおり、evaluate の副作用ゼロを崩さないことが対応の前提になります。ここに将来モーダル表示などの副作用を足すと、プレビュー開始だけで状態が変わる不具合になります。
素通しを記録しないこと : pass-through もログに残す1行を入れて初めて、「出ない」系の問い合わせに対して「そのユーザーはキャップ到達で素通しでした」と数分で答えられるようになりました。無言の経路を残さないことは、ゲート設計と同じくらい効果がありました。
数週間運用してみて
v2.1.0 はこの再設計を含めて段階公開(5% → 25% → 50% → 100%)で配信し、Crash-free users 99.7% 以上を確認しながら進めました。段階公開の監視まわりは Play Store の段階公開とクラッシュ監視を仕組み化した記録 に書いた構成をそのまま使っています。また同じバージョンでは、長年残っていた RecyclerView 起因のクラッシュも別途修正しています。こちらの経緯は 再現できなかった RecyclerView クラッシュを防御的コピーで根治した記録 にまとめました。
公開後、終了時広告に関する不具合報告は今のところゼロです。AdMob 側の終了時インタースティシャル表示回数も、フリークエンシーキャップの設計値から逆算した想定レンジに収まるようになりました。そして体感として一番大きいのは、広告まわりの調査時間です。GateLog にどのゲートが消費したかが残っているため、「この端末で広告が出なかったのはなぜか」という問い合わせに、ログを1行見れば答えられます。以前は再現環境づくりから始めて半日仕事でした。
収益への影響は正直なところまだ評価中です。「出すぎ」の経路を塞いだ分だけ短期の表示回数は微減しましたが、レビュー欄から広告への不満が減っていく方が、AdMob を10年近く運用してきた経験上は長期的に利くと考えています。
次の一歩 — 自分のアプリの「ゲート」を数えることから
もし手元のアプリの終了フローが触りにくくなっていたら、リファクタリングの前にまず、戻るボタンが答えている「問い」を箇条書きにしてみてください。私の場合は5つでした。3つ以上あれば、入れ子のどこかに説明できない経路がほぼ確実に眠っています。問いのリストがそのままゲートのリストになり、ゲートのリストがそのまま優先度の仕様書になります。コードを書き始めるのはその後で十分です。
同じ構造の罠に向き合っている方の参考になれば幸いです。