報酬型動画を見終えた直後にユーザーが戻るボタンを押すと、ごく稀にインタースティシャル広告が続けて出てしまう。個人開発で運用している壁紙アプリの一つで、この症状を Crashlytics ではなくユーザーレビューで知りました。再現率が低く、手元では一度も起きません。
原因はクラッシュではなく、戻るボタン押下時の「広告を出すか出さないか」の判定ロジックでした。頻度上限・クールダウン・課金済みか・広告がロード済みか、という4つの条件が入れ子の if 文で書かれていて、どの条件が優先されるのかがコードの構造に暗黙的にしか現れていなかったのです。本稿は、この判定を「理由を返す独立したガードの並び」に作り替え、決定表からテストを生成して再発を止めた記録です。
入れ子の if は、優先度を構造に隠してしまう
問題のコードは、おおよそこういう形でした。
fun onBackPressed () {
if ( ! billing.isAdFree) {
if (interstitial.isLoaded) {
if (System. currentTimeMillis () - lastShownAt > COOLDOWN_MS) {
if (backPressCount % SHOW_EVERY == 0 ) {
interstitial. show (activity)
lastShownAt = System. currentTimeMillis ()
}
}
}
}
super . onBackPressed ()
}
一見すると正しく見えます。実際、ほとんどの場合は正しく動きます。問題は、報酬型動画を見た直後の状態でした。報酬視聴で lastShownAt を更新し忘れていた経路があり、クールダウン判定がすり抜けることがあったのです。さらに backPressCount は別の画面でもインクリメントされていて、戻る操作以外の数え上げが混入していました。
入れ子の構造では、こうした「条件同士の関係」が見えません。クールダウンと頻度上限のどちらを先に評価しているか、ad-free のチェックが本当に最優先になっているか、コードを目で追って初めて分かります。条件が4つで済んでいるうちはまだしも、同意状態(UMP)やアプリ内レビュー誘導との競合が増えると、入れ子は急速に追えなくなります。
判定を「理由を返すガードの並び」に分解する
作り替えの方針はシンプルです。「表示してよいか」を一つの大きな条件式で表すのをやめ、それぞれが独立にブロック理由を返す小さなガードの並び にしました。どれか一つでもブロックすれば表示しない。全ガードを通過したときだけ表示する、という構造です。
sealed interface AdDecision {
data object Allow : AdDecision
data class Block ( val reason: String ) : AdDecision
}
// 各ガードは「ブロックする理由」を返す。なければ null(=このガードは通過)。
private val guards: List <( AdContext ) -> String ? > = listOf (
{ ctx -> if (ctx.isAdFree) "ad_free" else null },
{ ctx -> if ( ! ctx.isLoaded) "not_loaded" else null },
{ ctx -> if (ctx.sinceLastShownMs < COOLDOWN_MS) "cooldown" else null },
{ ctx -> if (ctx.backPressCount % SHOW_EVERY != 0 ) "frequency" else null },
)
fun decideOnBackPress (ctx: AdContext ): AdDecision {
val reason = guards. firstNotNullOfOrNull { it (ctx) }
return if (reason == null ) AdDecision.Allow else AdDecision. Block (reason)
}
呼び出し側は、判定と実行をきれいに分けられます。
fun onBackPressed () {
when ( val d = decideOnBackPress ( adContext ())) {
is AdDecision.Allow -> {
interstitial. show (activity)
lastShownAt = clock. now ()
}
is AdDecision.Block -> log. d ( "interstitial blocked: ${d.reason}" )
}
super . onBackPressed ()
}
この形にして良かったのは、ブロック理由がログに残ることです。以前は「出なかった」という事実しか分かりませんでしたが、いまは cooldown なのか frequency なのかが必ず記録されます。低再現の不具合を、レビューではなくログで追えるようになりました。
評価順序は「隠す」のではなく「明示する」
ガードをリストにすると、評価順序が listOf(...) の並びとしてそのまま現れます。ここが入れ子との決定的な違いです。順序に意味があること自体は変わりませんが、その意味がコードの一箇所に集約されます。
私はこの並びに、責務の優先度をそのまま反映させました。最優先は ad-free です。課金してくれた方には、他のどの条件よりも先に広告を止める。次にロード可否で、これは「ポリシー以前の物理的な可否」なので早い段階で弾きます。その後にクールダウン、頻度上限というポリシー系を置きます。
ロード可否をポリシーより前に置いたのには理由があります。両者を混ぜると、「頻度的には出してよいのにロードが間に合わず出なかった回」を frequency としてカウントするのか not_loaded とするのかが曖昧になります。これは本番運用で何度か数字を読み違えて気づいた落とし穴で、責務を分けておくことが唯一の回避策でした。責務を分けておけば、頻度カウンタを進めるのは「実際に表示した時だけ」と一意に決められます。私はこの並び順を、複数アプリ共通のデフォルトとして推奨します。
ガード 責務の種類 ブロック理由 カウンタを進めるか
isAdFree 課金状態(最優先) ad_free 進めない
isLoaded 物理的な可否 not_loaded 進めない
cooldown 時間ポリシー cooldown 進めない
frequency 回数ポリシー frequency 表示時のみ進める
決定表を起点に、Antigravity へテストを生成させる
ガードを分解したことで、テストが書きやすくなりました。各ガードは入力(AdContext)に対して理由を返すだけの純粋な関数なので、Activity やインスタンス化の難しい AdMob SDK を一切立ち上げずに判定そのものを検証できます。
私は決定表をそのまま Antigravity に渡しました。実際のプロンプトはこういう趣旨です。
decideOnBackPress(ctx) のパラメータ化テストを書いてください。
以下の決定表の各行を1ケースにし、期待する AdDecision を検証します。
- isAdFree=true なら他の条件に関わらず Block("ad_free")
- isLoaded=false なら Block("not_loaded")
- sinceLastShownMs < COOLDOWN_MS なら Block("cooldown")
- backPressCount が SHOW_EVERY の倍数でなければ Block("frequency")
- 上記すべてを通過したら Allow
評価順序が表の上から順であることを示す「複数条件が同時に偽」のケースも含めてください。
返ってきたのは JUnit5 の @ParameterizedTest でした。とくに価値があったのは、私が見落としていた「ad-free かつ未ロード」のような複数条件同時のケースです。このとき期待値は ad_free でなければなりません。ロード前に課金状態が勝つことを、テストが固定してくれます。順序を後から誰かが入れ替えたら、このケースが落ちて気づけます。
@ParameterizedTest
@MethodSource ( "cases" )
fun `back press decision follows the table` (c: Case ) {
assertEquals (c.expected, decideOnBackPress (c.ctx))
}
@JvmStatic
fun cases () = listOf (
Case ( adFree ( true ). loaded ( false ), Block ( "ad_free" )), // 順序: ad_free が勝つ
Case ( adFree ( false ). loaded ( false ), Block ( "not_loaded" )),
Case ( ready (). sinceLastShownMs ( 1_000 ), Block ( "cooldown" )),
Case ( ready (). backPressCount ( 3 ), Block ( "frequency" )), // SHOW_EVERY=2 のとき
Case ( ready (), Allow),
)
エージェントにテストを書かせるときは、コードを渡して「テストを考えて」と頼むより、決定表という形で期待挙動を先に固定してから渡す 方が精度が出ます。表が仕様の役割を果たし、エージェントは表の機械的な変換に集中できるからです。これは公式の使い方として明示されているわけではありませんが、私自身、何度か繰り返して安定して効くと感じている進め方です。
複数アプリへ展開するときに効いた
私は同じ系統のアプリを複数運用しているので、この decideOnBackPress と決定表のペアを共通モジュールに切り出しました。アプリごとに違うのは COOLDOWN_MS と SHOW_EVERY の値、そしてアプリによっては「初回起動から24時間は出さない」ガードを一つ足すかどうか、くらいです。ガードがリストである以上、追加は listOf に1要素を挟むだけで済みます。入れ子だったら、新しい条件をどの階層に入れるかで毎回悩んでいたはずです。
段階公開で各アプリに配るときも、ブロック理由のログがそのまま観測点になりました。配信初期に not_loaded の比率が想定より高ければ、それはポリシーの問題ではなくロード戦略(プリロードの開始タイミング)の問題だと切り分けられます。理由を持つ判定は、リリース後の調整の解像度を上げてくれます。
次の一歩
もし戻るボタンやダイアログ表示の広告判定が大きな一つの条件式になっているなら、まず「ブロック理由を返す関数の並び」に書き直すところから始めてみてください。表示するかどうかのロジックと、実際に表示する副作用を分けるだけでも、ログで追える状態になります。そのうえで決定表を一枚作れば、テストはエージェントに任せられます。
広告の出し分けは、収益と体験のどちらにも直結する繊細な部分です。私自身まだ調整を続けている領域ですが、判定を「理由を持つ小さな部品」に保っておくことが、長く運用するうえでいちばん効いていると感じています。