環境
- OS: Android 12+
- 言語: Kotlin 1.9
- ライブラリ: kotlinx.coroutines 1.7
問題
アプリが約40分使うと音が出なくなる。再起動で直るが、また40分後に再発する。
WebSocket・STT・TTSのロジックは生きているのに、オーディオだけが無音になる。
原因
ToneGenerator.release() を delay() の後に書いていた。
// NG: CancellationException が投げられると release() に到達しない
private suspend fun playBeep() {
try {
val toneGen = ToneGenerator(AudioManager.STREAM_MUSIC, 80)
toneGen.startTone(ToneGenerator.TONE_PROP_BEEP2, 150)
delay(200) // ← Coroutineキャンセル時に CancellationException を投げる
toneGen.release() // ← ここに到達しない → リーク
} catch (e: Exception) {
// CancellationException も Exception のサブクラスなのでここで捕捉される
// release() が呼ばれないまま関数が終了する
}
}
delay() はただのスリープではなく suspend 関数。Coroutine がキャンセルされると CancellationException を投げる。catch (e: Exception) で捕捉されるため、delay() の後に書いたコードは実行されない。
10分間隔で動く機能が毎回 2〜4個の ToneGenerator をリークさせ、40分後(4サイクル)に Android のオーディオリソース上限に到達して全音声が無音になっていた。
解決策
try-finally に移す。finally は CancellationException が投げられても必ず実行される。
// OK: finally で確実に release する
private suspend fun playBeep() {
var toneGen: ToneGenerator? = null // try の外で宣言する(finally からも参照できるように)
try {
toneGen = ToneGenerator(AudioManager.STREAM_MUSIC, 80)
toneGen.startTone(ToneGenerator.TONE_PROP_BEEP2, 150)
delay(200) // CancellationException が来ても…
} catch (e: Exception) {
// エラーハンドリング
} finally {
toneGen?.release() // …finally は必ず実行される
}
}
変更は各箇所4行、計8行。40分で止まるバグが完全に解消した。
注意点
-
ToneGeneratorはCloseableを実装していない →use {}は使えない。try-finallyで手動解放するしかない -
上限超過時に例外が出ない →
ToneGeneratorのコンストラクタは失敗しても例外をスローせず、startTone()が単に無音になるだけ。logcat にも何も出ない -
AudioTrack同時生成上限はデバイス依存(目安: 32〜64本)。TTSなどが常時数本使っているため、実効的な空きはさらに少ない
ルール化
delay()の後にリソース解放を書いてはいけない。必ずtry-finallyで囲む
Thread.sleep() であれば InterruptedException でコンパイルエラーになるが、Kotlin の delay() は見た目がシンプルすぎて例外を投げる事実を忘れやすい。長時間稼働するアプリでは特に注意が必要。
参考
全体像はnoteに → https://note.com/yamashita_aidev/n/na527d8e4ba8b