報酬型動画を見終えた直後にユーザーが戻るボタンを押すと、ごく稀にインタースティシャル広告が続けて出てしまう。個人開発で運用している壁紙アプリの一つで、この症状を 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 なのかが必ず記録されます。低再現の不具合を、レビューではなくログで追えるようになりました。