Contents
1. 例外伝搬の基本構造(CancellationException を含む)
Kotlin のコルーチンは 構造化並行性 に基づき、子コルーチンが例外を送出するとその例外は直近の共通スコープまで上がります。
このとき CancellationException は「正常なキャンセル」とみなされ、通常の例外とは別に扱われます。
1‑1. キャンセルと例外の違い
| 項目 | 通常例外 (Throwable) |
CancellationException |
|---|---|---|
| 発生タイミング | 任意(ロジックエラー、IO エラー等) | コルーチンが キャンセル されたときに自動的にスロー |
| 伝搬先 | 例外ハンドラまたは最上位の CoroutineExceptionHandler に届く |
キャンセル対象の子・親コルーチン全体で捕捉され、invokeOnCancellation が呼び出される |
| デフォルト動作 | 未処理ならアプリがクラッシュ | コルーチンは即座に終了し、例外としては扱われない(isActive が false になる) |
ポイント
キャンセルは「失敗」ではなく「途中で止める」意図のシグナルです。
したがって、catch (e: CancellationException)と書くとキャンセル自体を誤ってエラー扱いしてしまうことがあります。
1‑2. 実装例(インポート・スコープの明示)
|
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 26 27 28 29 30 31 32 33 34 35 36 37 |
import kotlinx.coroutines.* fun main() = runBlocking { // 親スコープ val parentJob = launch(CoroutineName("parent")) { // 子1:例外を投げる launch(CoroutineName("child1")) { delay(100) throw IllegalStateException("子コルーチンでエラーが発生") } // 子2:キャンセルされることを確認 launch(CoroutineName("child2")) { try { repeat(10) { i -> delay(50) println("child2: $i") } } finally { // キャンセル時に必ず実行されるブロック println("child2 がキャンセルされた") } } } parentJob.invokeOnCompletion { cause -> if (cause != null) { println("親スコープが終了: ${cause::class.simpleName} - $cause") } else { println("親スコープは正常に完了") } } // 少し待ってから明示的にキャンセル(デモ用) delay(250) parentJob.cancel(CancellationException("手動キャンセル")) } |
runBlockingが最上位スコープになるので、例外はここまで伝搬します。finallyブロックで子2 のキャンセルが確認でき、invokeOnCompletionではCancellationExceptionが原因として渡されます。
2. CoroutineExceptionHandler の正しい使い方
2‑1. 基本概念とスコープの選択
- 構造化されたスコープ(
viewModelScope,lifecycleScope,supervisorScope)内でハンドラを設定すれば、未捕捉例外は一元管理できます。 GlobalScopeは トップレベルかつライフサイクルが切り離された スコープのため、ハンドラを付与しても UI コンポーネントから制御できません。
2‑2. 実装例(ViewModel)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.launch import android.util.Log class MyViewModel : ViewModel() { // UI スレッド上でのログ出力を行うハンドラ private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> Log.e("MyViewModel", "未捕捉例外: ${throwable.localizedMessage}", throwable) // 例えば Snackbar や Dialog に渡すことも可能 } fun loadData() { viewModelScope.launch(exceptionHandler) { repository.fetchRemote() // この内部でスローされた例外はハンドラが捕捉 } } } |
viewModelScopeが自動的に ViewModel の破棄と同時にキャンセルされるため、リークの心配がありません。- ハンドラは コルーチン単位 で設定でき、複数ハンドラを組み合わせても問題ありません(
+演算子で結合可能)。
2‑3. GlobalScope の落とし穴
|
1 2 3 4 5 6 7 8 9 10 11 |
import kotlinx.coroutines.* val globalHandler = CoroutineExceptionHandler { _, throwable -> Log.e("Global", "未捕捉例外: ${throwable.message}", throwable) } // 悪い例:UI ライフサイクルと無関係に実行される GlobalScope.launch(globalHandler) { // 長時間走るバックグラウンドタスク… } |
GlobalScopeのコルーチンは アプリ全体のライフサイクル に紐付かないため、画面が破棄されたあとも残り続けます。- 例外がハンドラで捕捉されても UI が存在しないケースではユーザーへの通知手段が失われる点に注意してください。
3. SupervisorJob と supervisorScope の使いどころ
3‑1. 子コルーチンの失敗を分離する仕組み
| スコープ | 子の例外が親に伝搬するか | 同一スコープ内の他子への影響 |
|---|---|---|
CoroutineScope(Job())(デフォルト) |
Yes(キャンセル連鎖) | Yes(全体がキャンセル) |
SupervisorJob() または supervisorScope |
No(例外は親に伝搬しない) | No(他の子はそのまま実行) |
3‑2. 実装例
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
import kotlinx.coroutines.* suspend fun supervisorDemo() = coroutineScope { // SupervisorJob を明示的に付与したスコープ val supScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) supScope.launch { launch { delay(100) println("子A 開始") throw IllegalArgumentException("子A のエラー") } launch { repeat(5) { i -> delay(200) println("子B 実行中 $i") } } }.join() // supScope 内の全ジョブが完了するまで待機 } |
- 子A が例外を投げても、子B はキャンセルされずに最後まで実行されます。
supervisorScope { … }を使うと同様の効果が得られ、スコープ生成がシンプルになります。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
suspend fun supervisorBlockDemo() = supervisorScope { launch { delay(50) throw IllegalStateException("失敗 A") } launch { repeat(3) { i -> delay(100) println("成功 B $i") } } } |
4. try‑catch と runCatching / Result の併用
4‑1. Result は クラス ではなく 型エイリアス
Kotlin 標準ライブラリの Result<T> には Success/Failure クラスは存在しません。代わりに次の拡張関数を利用します。
| 関数 | 説明 |
|---|---|
isSuccess / isFailure |
成功・失敗判定 |
getOrNull() |
成功時だけ値を取得(失敗時は null) |
exceptionOrNull() |
失敗時の例外を取得 |
fold(onSuccess, onFailure) |
成功と失敗それぞれにハンドラを提供 |
4‑2. 正しいサンプルコード
|
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 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
import kotlinx.coroutines.* import kotlin.Result // API の疑似実装 object Api { suspend fun getUser(id: String): User { delay(100) if (id == "error") throw IOException("ネットワークエラー") return User(id, "Alice") } } data class User(val id: String, val name: String) // Result を返す suspend 関数 suspend fun fetchUser(id: String): Result<User> = runCatching { Api.getUser(id) // 例外が発生すると Result が失敗に変換される } // ViewModel 側の呼び出し例(UI スレッドで安全に扱える) class UserViewModel : ViewModel() { private val handler = CoroutineExceptionHandler { _, ex -> _uiState.update { it.copy(errorMessage = ex.localizedMessage) } } fun loadUser(id: String) { viewModelScope.launch(handler) { when (val result = fetchUser(id)) { // Kotlin 1.9 以降は isSuccess / isFailure が推奨 else -> if (result.isSuccess) { val user = result.getOrNull()!! _uiState.update { it.copy(user = user, errorMessage = null) } } else { val e = result.exceptionOrNull() _uiState.update { it.copy(errorMessage = e?.message ?: "不明エラー") } } } } } // UI の状態を保持する例(StateFlow など) private val _uiState = MutableStateFlow(UserUiState()) val uiState: StateFlow<UserUiState> = _uiState.asStateFlow() } data class UserUiState( val user: User? = null, val errorMessage: String? = null ) |
runCatchingが例外を捕捉し、Resultに変換します。- 呼び出し側は 例外処理と成功時ロジックを分離 できるため、テストが容易になります。
4‑3. try‑catch と併用すべきケース
| シナリオ | 推奨手法 |
|---|---|
| 非同期 API の失敗だけをハンドルしたい | runCatching → Result |
| 複数の例外型に対して個別処理が必要 | 従来の try‑catch(when (e) { is IOException -> … }) |
| キャンセル例外は無視したい | catch (e: CancellationException) { throw e } で再スロー |
5. Android 標準スコープ活用のベストプラクティス
5‑1. viewModelScope と lifecycleScope の共通パターン
|
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 26 27 28 29 30 31 |
import androidx.lifecycle.* import kotlinx.coroutines.* class MainViewModel : ViewModel() { private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> // UI に通知(StateFlow / LiveData へ流す等) _state.update { it.copy(error = throwable.localizedMessage) } } fun refreshPosts() { viewModelScope.launch(exceptionHandler) { repository.loadPosts() .onSuccess { posts -> _state.update { it.copy(posts = posts, error = null) } } .onFailure { e -> _state.update { it.copy(error = e.message ?: "エラーが発生しました") } } } } private val _state = MutableStateFlow(UiState()) val state: StateFlow<UiState> = _state.asStateFlow() } data class UiState( val posts: List<Post> = emptyList(), val error: String? = null ) |
viewModelScopeが ViewModel の寿命に合わせて自動キャンセルされ、メモリリークが防げます。CoroutineExceptionHandlerとResultを組み合わせると 例外情報の一元管理 と 成功時データ取得 が同時に実現できます。
5‑2. lifecycleScope の使用例(Fragment)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
class PostListFragment : Fragment(R.layout.fragment_post_list) { private val viewModel: MainViewModel by viewModels() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { // UI を StateFlow で監視 lifecycleScope.launchWhenStarted { viewModel.state.collect { uiState -> // RecyclerView の更新やエラーメッセージ表示を行う updateUi(uiState) } } // 手動リロードボタンのクリックハンドラ view.findViewById<Button>(R.id.refreshBtn).setOnClickListener { viewModel.refreshPosts() } } } |
lifecycleScopeは Fragment の ViewLifecycle に合わせて自動キャンセルされます。launchWhenStartedを使うと、Fragment が STARTED 状態になるまでコレクションが保留され、不要な UI 更新を防げます。
5‑3. GlobalScope は原則使用しない
| 問題点 | 具体例 |
|---|---|
| ライフサイクルと切り離された実行 | アプリがバックグラウンドに回ってもタスクが残る |
| メモリリークの温床 | Context を保持したまま解放できない |
| 例外ハンドリングが困難 | UI が存在しないため通知手段が失われる |
代替案
短時間タスク →viewModelScope/lifecycleScope
長時間バックグラウンド処理 →WorkManager(またはForegroundService)
6. まとめと次のアクション
| 項目 | キーポイント |
|---|---|
| 例外伝搬 | スコープ階層に沿って上がり、CancellationException はキャンセルフローとして別扱い。 |
| ハンドラ | CoroutineExceptionHandler は UI スコープで設定し、未捕捉例外を一元管理。 |
| SupervisorJob | 子の失敗が他に波及しない構造化並行性を実現できる。 |
| Result API | runCatching と標準的な Result 拡張関数で、成功・失敗を型安全に扱う。 |
| Android 標準スコープ | viewModelScope/lifecycleScope を活用し、GlobalScope は避ける。 |
| キャンセル例外 | 必要がなければ捕捉せず再送出し、意図しないエラー扱いを防止する。 |
今すぐできること
- プロジェクトの全コルーチン呼び出し箇所 を
viewModelScope/lifecycleScopeに統一する。 - 未捕捉例外があるファイルに
CoroutineExceptionHandlerを追加し、ログや UI 通知を実装。 - 失敗が他タスクに影響しないケースは
SupervisorJob(またはsupervisorScope)へ置き換える。 - API 呼び出し層で
runCatching { … }を導入し、戻り値をResult<T>に統一する。 CancellationExceptionが捕捉されたら必ず再スローし、キャンセルが正しく伝搬することを確認。
最新情報は公式ドキュメント(kotlinlang.org/docs/coroutines-exception-handling.html)や Kotlin 1.9 のリリースノートで随時チェックしてください。