[Android] Kotlin Coroutinesの例外処理を正しく書く ― try-catch / CoroutineExceptionHandler / supervisorScopeの使い分け

Kotlin Coroutinesで「なぜか try-catch で例外が捕まえられない」「1つの通信が失敗しただけで、関係ないはずの処理まで全部止まってしまう」といった経験はありませんか? コルーチンの例外処理は、同期的なコードの感覚のままだと直感に反する挙動をします。

この記事では、コルーチンの例外がどう伝播するのかという根本から出発し、try-catch が効く場面と効かない場面、CoroutineExceptionHandler の役割、そして supervisorScope による失敗の隔離までを順に整理します。読み終えるころには、「この失敗はどこで捕まえるべきか」を自分で判断できるようになるはずです。

スポンサーリンク

まず押さえる:launch と async で伝播が違う

例外処理を理解する前提として、コルーチンビルダーによって例外の扱いが根本的に違うことを知っておく必要があります。

  • launch:例外が発生すると、その場で即座に親へ伝播する(throw される)。
  • async:例外は Deferred の中に保持され、await() を呼んだときに再スローされる。

この違いを知らずに asyncawait()try-catch で囲み忘れると、例外が握りつぶされたように見えてしまいます。

// async の例外は await() のタイミングで飛んでくる
val deferred = scope.async {
    throw IllegalStateException("計算失敗")
}
// ここではまだ例外は飛ばない
try {
    val result = deferred.await()   // ← ここで初めて再スローされる
} catch (e: IllegalStateException) {
    Log.e("Sample", "捕捉: ${e.message}")
}

try-catch が効く場面・効かない場面

もっとも多い誤解が、「コルーチンビルダーを try-catch で囲めば中の例外を捕まえられる」というものです。これは効きません。

// 効かない例:launch を try-catch で囲んでも捕まらない
try {
    scope.launch {
        throw RuntimeException("ここで発生")
    }
} catch (e: Exception) {
    // ここには来ない。例外は launch の中で伝播する
}

try-catch はあくまで「その場で実行される処理」を囲むものです。コルーチンの本体は別のタイミングで動くため、ビルダーを囲んでも意味がありません。捕まえたいなら、ブロックの内側で囲みます。

// 正しい例:ブロックの内側で囲む
scope.launch {
    try {
        val data = repository.fetch()   // suspend 関数
        render(data)
    } catch (e: IOException) {
        showError(e)
    }
}

CoroutineExceptionHandler ― 捕まえ損ねた例外の最後の砦

個別の try-catch ではなく、スコープ全体で「捕まえ損ねた例外」をまとめて処理したい場合は CoroutineExceptionHandler を使います。これは launch で発生した未処理例外に対して有効です。

val handler = CoroutineExceptionHandler { _, throwable ->
    Log.e("Sample", "未処理例外: ${throwable.message}")
}
 
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
 
scope.launch(handler) {
    throw RuntimeException("ハンドラで受け止められる")
}

ここで2つ注意点があります。

  • async には効かない。 async の例外は await() で再スローされる設計なので、CoroutineExceptionHandler ではなく await() 側で try-catch する。
  • ハンドラはルート(最上位)のコルーチンにだけ有効。 子コルーチンに handler を渡しても無視される。スコープ生成時か、最上位の launch に渡す。

coroutineScope と supervisorScope の違い

複数の子コルーチンを並行実行するとき、「1つが失敗したら全部止めたいのか、それとも失敗を隔離したいのか」で使うスコープが変わります。ここが例外処理の設計上いちばん重要なポイントです。

coroutineScope ― 1つ失敗したら全滅

coroutineScope は、子のどれか1つが失敗すると、残りの子をすべてキャンセルし、例外を呼び出し元へ再スローします。「全部そろって初めて意味がある」処理に向きます。

suspend fun loadAll() {
    try {
        coroutineScope {
            launch { loadProfile() }   // これが失敗すると
            launch { loadSettings() }  // こちらもキャンセルされる
        }
    } catch (e: Exception) {
        // どちらかが失敗すればここで捕まえられる
        showError(e)
    }
}

supervisorScope ― 失敗を隔離する

supervisorScope は、子の失敗が他の子に伝播しません。片方が失敗しても、もう片方は動き続けます。「一部が失敗しても、成功した分は表示したい」ようなケースに向きます。

suspend fun loadEach() {
    supervisorScope {
        launch {
            try {
                loadProfile()
            } catch (e: Exception) {
                showProfileError(e)   // profile だけ失敗表示
            }
        }
        launch {
            try {
                loadSettings()        // profile が失敗しても動く
            } catch (e: Exception) {
                showSettingsError(e)
            }
        }
    }
}

supervisorScope を使うときは、各子コルーチンで個別に try-catch するか、ハンドラを設定するのが原則です。隔離される代わりに、面倒を見るのは各自の責任になります。

CancellationException は握りつぶさない

コルーチンのキャンセルは CancellationException という特別な例外で実現されています。これを catch (e: Exception) でまとめて捕まえて握りつぶすと、キャンセルが正しく伝わらなくなります。

// 悪い例:CancellationException まで飲み込んでしまう
try {
    doWork()
} catch (e: Exception) {
    Log.e("Sample", "error", e)   // キャンセルもここに来て握りつぶされる
}
 
// 良い例:CancellationException は再スローする
try {
    doWork()
} catch (e: CancellationException) {
    throw e   // キャンセルはそのまま伝播させる
} catch (e: Exception) {
    Log.e("Sample", "error", e)
}

Androidでの実践:ViewModelでの書き方

Androidでは viewModelScope を使うのが定番です。画面表示に必要なデータ取得の失敗は、UIに反映できるよう try-catch で捕まえて状態として持たせます。

class SampleViewModel(
    private val repository: SampleRepository
) : ViewModel() {
 
    private val _uiState = MutableLiveData<UiState>()
    val uiState: LiveData<UiState> = _uiState
 
    fun load() {
        viewModelScope.launch {
            _uiState.value = UiState.Loading
            try {
                val data = repository.fetch()
                _uiState.value = UiState.Success(data)
            } catch (e: CancellationException) {
                throw e                       // キャンセルは通す
            } catch (e: Exception) {
                _uiState.value = UiState.Error(e.message)
            }
        }
    }
}

使い分けの早見まとめ

迷ったら、次の順で考えると整理しやすいです。

  • 特定の処理の失敗をその場で扱いたい → ブロックの内側try-catchasync なら await() を囲む)
  • スコープ全体の未処理例外を最後にまとめて処理したいCoroutineExceptionHandlerlaunch のルートに設定)
  • 並行処理で1つでも失敗したら全体を止めたいcoroutineScope
  • 並行処理で失敗を隔離し、成功分は活かしたいsupervisorScope(各子で個別ハンドリング)
  • どのcatchでもCancellationException は握りつぶさず再スロー

まとめ

コルーチンの例外処理は、「launch は即伝播・asyncawait() で再スロー」という伝播の違いを起点にすると、一気に見通しがよくなります。try-catch はビルダーの外ではなくブロックの内側に、スコープ全体の受け皿には CoroutineExceptionHandler、並行処理の失敗を止めるか隔離するかで coroutineScopesupervisorScope を選ぶ ―― この4点を押さえれば、たいていの場面で正しく判断できます。

そして最後に CancellationException を握りつぶさないこと。これを守るだけで、キャンセル周りの不可解なバグをかなり防げます。まずは既存の viewModelScope.launch の中の catch (e: Exception) を見直して、キャンセルを通す形に直すところから始めてみてください。

コメント

タイトルとURLをコピーしました