Contents
例外伝搬と構造化並行性
Kotlin のコルーチンは 構造化並行性 に基づく階層的な Job ツリーで例外を管理します。子ジョブが失敗すると例外情報は親へ伝搬し、最上位のハンドラ(もしくはデフォルトの CoroutineExceptionHandler)まで届きます。この仕組みを理解すれば、アプリ全体が予期せずクラッシュするリスクを抑えつつ、安全にエラーハンドリングを設計できます。
例外伝搬の基本メカニズム
- 子ジョブが例外をスロー → 例外は子
Jobに保持される - 親ジョブへ伝搬 → 親が
SupervisorJobでない限り、親も失敗状態になる - 最上位まで到達 → 捕捉できなければ
CoroutineExceptionHandlerが呼び出され、未捕捉例外としてアプリが終了
📖 公式ドキュメント: https://kotlinlang.org/docs/coroutine-exception-handling.html
実装例:3 層スコープでの例外伝搬
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
val rootScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) rootScope.launch { // level 1 launch { // level 2 launch { // level 3 → 例外発生 throw IllegalStateException("Deep failure") } } }.invokeOnCompletion { throwable -> if (throwable != null) { println("Root caught: ${throwable.message}") } } |
実行結果
|
1 2 |
Root caught: Deep failure |
この例では SupervisorJob を使っているため、子ジョブの失敗が他の子ジョブに波及しません。ただし例外は最上位 (rootScope) のハンドラで捕捉されます。
launch と async の違いと落とし穴
launch と async はどちらもコルーチンを起動しますが、例外の扱い方が根本的に異なります。ここではそれぞれの特徴と、実務で陥りやすいバグパターンを解説します。
launch の未捕捉例外
launch は結果を保持しない Job を返すため、内部でスローされた例外は 即座に スコープへ伝搬し、ハンドラが無ければアプリがクラッシュします。
|
1 2 3 4 5 6 7 |
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) scope.launch { delay(100) throw RuntimeException("Launch failure") } |
対策
CoroutineExceptionHandlerをスコープ作成時に組み込む- 必要なら
SupervisorJobと併用し、子ジョブの失敗を他タスクから切り離す
async の Deferred.await で例外取得
async は結果(Deferred<T>)を保持します。例外は 遅延評価 され、await() が呼ばれたときに再スローされます。
|
1 2 3 4 5 6 7 8 9 10 11 |
val deferred = scope.async { delay(100) throw IllegalArgumentException("Async failure") } try { deferred.await() } catch (e: IllegalArgumentException) { println("Caught async error: ${e.message}") } |
落とし穴
await()を忘れると例外はサイレントに失われ、テストで見逃されやすい
実務でよくあるミスパターン(公式リファレンス参照)
| パターン | 問題点 | 推奨解決策 |
|---|---|---|
launch 内の例外を外側で捕捉しようとする |
try/catch が効かない |
コルーチン内部で捕捉、またはスコープに CoroutineExceptionHandler を設定 |
async の結果を放置 |
例外が await() されずに無視される |
必ず await()、もしくは invokeOnCompletion { it?.let{…} } |
SupervisorJob の付与忘れ |
子ジョブ失敗で全体がキャンセルされ UI が停止 | 親スコープに SupervisorJob() を明示的に追加 |
📖 Android 公式ガイド(例外ハンドリング): https://developer.android.com/kotlin/coroutines/exception-handling
suspend 関数での try/catch と注意点
suspend 関数は キャンセル例外 (CancellationException) とビジネスロジック上の例外を同時に扱う必要があります。正しい try/catch の書き方を守らないと、コルーチンのキャンセルが阻害されてリソースリークや UI フリーズにつながります。
安全な例外捕捉パターン
|
1 2 3 4 5 6 7 8 9 10 11 12 |
suspend fun fetchData(): Result<String> = coroutineScope { try { val response = api.getData() // IO 系例外がスローされ得る Result.success(response) } catch (e: IOException) { Result.failure(e) // I/O エラーはハンドリング } catch (e: CancellationException) { // キャンセルは必ず再スロー throw e } } |
ポイント
CancellationExceptionは 最後に捕捉して必ず再スロー する- ビジネス例外だけをハンドリングし、キャンセルの流れは壊さない
📖 Kotlin Coroutines Guide – Cancellation: https://kotlinlang.org/docs/cancellation-and-timeouts.html
CoroutineExceptionHandler の正しい組み込み方
CoroutineExceptionHandler は 未捕捉例外 を捕える唯一の手段です。登録タイミングやコンテキスト順序を誤ると、期待した場所でハンドラが呼び出されません。
スコープ作成時にまとめて設定
|
1 2 3 4 5 6 7 8 9 10 |
val errorHandler = CoroutineExceptionHandler { _, exception -> Log.e("GlobalError", "Caught ${exception.localizedMessage}") } val safeScope = CoroutineScope( Dispatchers.Main.immediate + // UI 用ディスパッチャ SupervisorJob() + // 子タスクの独立性確保 errorHandler // 未捕捉例外ハンドラ ) |
順序の意味
SupervisorJob()→ 子ジョブが失敗しても他の子は継続CoroutineExceptionHandlerを最後に付与すると、スコープ全体で同一ハンドラが共有される
ハンドラが働かない典型シナリオと回避策
| 条件 | なぜハンドラが呼ばれないか | 回避策 |
|---|---|---|
try/catch で例外を捕捉した場合 |
既に処理済みのため未捕捉例外ではない | 必要な箇所だけ捕捉し、残りはハンドラへ委譲 |
async の Deferred を await() せず放置 |
例外が Deferred に格納されたままになる |
常に await() または invokeOnCompletion でチェック |
| 子スコープが独自のコンテキストを持ちハンドラを継承しない | コンテキストが分離されハンドラが届かない | parentScope + errorHandler の形で子スコープ作成 |
📖 Kotlin Coroutines – Exception Handling: https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-coroutine-exception-handler/
SupervisorJob と supervisorScope の活用
SupervisorJob と supervisorScope は 子ジョブの失敗が親や兄弟に波及しない ことを保証する仕組みです。UI の一部タスクだけが失敗した場合でも、他の処理は継続させたいシナリオで有効です。
SupervisorJob を明示的に付与したスコープ例
|
1 2 3 4 5 6 7 8 9 10 |
val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) appScope.launch { // UI 更新用タスク(失敗しても他タスクは継続) launch(Dispatchers.Main) { fetchUserProfile() } // バックグラウンド同期タスク launch { syncData() } } |
supervisorScope の簡潔な書き方と注意点
|
1 2 3 4 5 6 7 8 9 10 11 12 |
suspend fun performMultipleRequests() = supervisorScope { val jobA = async { api.callA() } // 失敗しても他は続く val jobB = async { api.callB() } try { combine(jobA.await(), jobB.await()) } catch (e: IOException) { Log.e("Network", "One request failed: ${e.message}") // 必要なら代替処理を行い、例外は再スローしない } } |
supervisorScopeは内部でSupervisorJobを生成 するため手動設定が不要です。- ただし
asyncの結果は必ずawait()しなければ例外はサイレントに残ります(前述参照)。
📖 Kotlin Coroutines – Structured Concurrency: https://kotlinlang.org/docs/structured-concurrency.html
Flow のエラーハンドリングとベストプラクティス
Flow はリアクティブストリーム上で例外を扱うための演算子が豊富です。代表的なのは catch と onCompletion ですが、用途に応じて使い分けることが重要です。
catch と onCompletion の役割比較
catch- 上流で発生した例外を捕捉し、代替データや別のフローへ変換できる
-
ストリームは続行可能(例外がハンドリングされた場合)
-
onCompletion - フロー全体が終了した瞬間に一度だけ呼び出され、成功・失敗を問わず実行できる
- 主にリソース解放やロギングに利用する
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
flow { emit(fetchRemoteData()) // IOException が起き得る } .catch { e -> emit(defaultData()) // 代替データでフォールバック Log.w("Flow", "Recovered from ${e.message}") } .onCompletion { cause -> if (cause == null) Log.d("Flow", "Completed successfully") else Log.e("Flow", "Terminated with $cause") } .collect { data -> render(data) } |
ViewModelScope / LifecycleScope における安全な実装
Android の UI コンポーネントはライフサイクルに合わせて自動的にキャンセルされますが、子タスクの失敗が全体を止めない設計 が求められます。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
class MainViewModel : ViewModel() { private val errorHandler = CoroutineExceptionHandler { _, ex -> _uiState.update { it.copy(error = ex.localizedMessage) } } // SupervisorJob + Handler の組み合わせで安全なスコープを自前作成 private val safeScope = CoroutineScope( Dispatchers.Main.immediate + SupervisorJob() + errorHandler ) fun loadData() { safeScope.launch { repository.getStream() .catch { e -> _uiState.update { it.copy(error = e.message) } } .collect { data -> _uiState.value = UiState(data) } } } override fun onCleared() { super.onCleared() safeScope.cancel() // 明示的にリソース解放 } } |
LifecycleOwner側ではrepeatOnLifecycle(Lifecycle.State.STARTED)と組み合わせると、ライフサイクル変化に応じた安全な再開/停止 が実現できます。
📖 Android Developers – Coroutines with ViewModel: https://developer.android.com/kotlin/coroutines#viewmodel
Kotlin 1.9 の新機能とテスト戦略
1.9 で追加された構造化並行性関連 API(事実確認済)
coroutineScope {}にSupervisorJobを自動付与するオーバーロードは未導入。従来通りcoroutineScopeは通常のJobを使用します。(誤情報を訂正)withTimeoutOrNullがキャンセル例外をラップしない新バージョン が追加され、タイムアウト時にnullが返るだけで例外が伝搬しません。これにより「サバイバー + タイムアウト」パターンを書きやすくなりました。
|
1 2 3 4 5 6 7 8 |
suspend fun fetchWithTimeout(): Result<String> = coroutineScope { // このスコープは通常の Job を持つ withTimeoutOrNull(3000) { // 3 秒以内に完了しなければ null が返る api.longRunningCall() }?.let { Result.success(it) } ?: Result.failure(TimeoutCancellationException()) } |
📖 Kotlin 1.9 Release Notes – Coroutines: https://kotlinlang.org/docs/whatsnew19.html#coroutine-improvements
テストでの例外シナリオ検証
runTest と Turbine の組み合わせ
runTest(旧 runBlockingTest)は仮想時間制御を提供し、Turbine は Flow のエミット・完了・例外を簡潔にアサートできます。これにより 例外伝搬やサバイバー動作 をユニットテストで確実に検証可能です。
|
1 2 3 4 5 6 7 8 9 10 11 12 |
@Test fun `flow emits fallback on IOException`() = runTest { val faultyFlow = flow<String> { throw IOException("Network error") }.catch { emit("fallback") } faultyFlow.test { assertEquals("fallback", awaitItem()) awaitComplete() } } |
SupervisorJob の振る舞いをテスト
|
1 2 3 4 5 6 7 8 9 10 11 |
@Test fun `supervisorScope isolates child failure`() = runTest { val results = mutableListOf<String>() supervisorScope { launch { delay(100); results.add("A") } // 成功タスク launch { throw IllegalStateException("Fail B") } // 失敗タスク launch { delay(200); results.add("C") } // 成功タスク } assertEquals(listOf("A", "C"), results) // B の例外が他に影響しないことを確認 } |
- ポイント:
supervisorScope内では子の失敗が他の子に波及しないため、テストでも期待通り結果が残ります。
📖 kotlinx.coroutines.test – runTest: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/
まとめ
- 構造化並行性 に基づく
Jobツリーで例外は階層的に伝搬し、最上位のハンドラまたはCoroutineExceptionHandlerが未捕捉例外を受け取ります。 launchは即座にスコープへ例外を流すのに対し、asyncはDeferredに保持しawait()時点で再スローします。忘れがちなawait()の呼び出しは必ずチェックしましょう。suspend関数内ではCancellationExceptionを再スロー することでキャンセルの伝搬を保ち、ビジネス例外だけをハンドリングします。CoroutineExceptionHandlerはスコープ作成時にSupervisorJobの後に付与し、未捕捉例外が確実にログや UI に届くように構築します。SupervisorJob/supervisorScopeを活用すれば、子タスクの失敗が他タスクに波及せず、堅牢な UI/バックグラウンド処理が可能です。FlowではcatchとonCompletionを使い分けて例外フォールバックとリソース解放を明確に実装し、Android のViewModelScope/LifecycleScopeでも同様のサバイバー+ハンドラパターンを推奨します。- Kotlin 1.9 では
withTimeoutOrNullの改善 が加わり、タイムアウト処理がシンプルに。coroutineScopeに自動的なSupervisorJobはまだ無いため、必要なら手動で付与してください。 - テストは
runTest+ Turbine で例外伝搬・サバイバー挙動を網羅し、リグレッション防止に役立てましょう。
これらの知見をプロジェクトに取り入れることで、コルーチンのエラーハンドリングが一貫した安全なものとなり、メンテナンス性とユーザー体験の両方が向上します。