Contents
スポンサードリンク
1. Structured Concurrency と例外伝搬モデル
1-1. 基本概念
- Structured Concurrency では、
CoroutineScopeがツリー構造になるように子コルーチンは必ず親スコープに紐付けられます。 - 子
Jobが失敗すると、その例外は CancellationException にラップされて親Jobに伝搬し、親がキャンセルされるかどうかは使っているJobの種類に依存します。
1-2. 親子関係の実装例
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
val rootScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) rootScope.launch { // ── 子 A ── launch { delay(100) throw IllegalStateException("A が失敗") } // ── 子 B(A の失敗に影響されない)── launch { delay(200) println("B 完了") } } |
SupervisorJobを用いた場合、子 A の例外は子 B に波及せず、親スコープ自体もキャンセルされません。- 逆に普通の
Job()(デフォルト)を使うと、A が失敗した瞬間に 全体がキャンセル されます。
1-3. まとめ
| Job の種類 | 子の例外が親へ伝搬するか | 他の子への影響 |
|---|---|---|
Job() |
あり(全体がキャンセル) | すべてキャンセル |
SupervisorJob |
なし(例外はローカルに留まる) | 失敗した子だけ停止 |
2. launch と async の例外捕捉パターン
2-1. launch → fire‑and‑forget 系
|
1 2 3 4 5 6 7 8 |
viewModelScope.launch { try { fetchData() // suspend 関数内で IOException が発生したと仮定 } catch (e: IOException) { Log.e("MyViewModel", "ネットワークエラー", e) } } |
launchの戻り値はJobだけ。例外は内部で捕捉しない限り 自動的に親スコープへ伝搬 します。- 呼び出し側で
try { viewModelScope.launch { … } } catch (…)と書いても捕まえられません(Qiita の解説と同様)。
2-2. async → 結果取得型
|
1 2 3 4 5 6 7 8 9 10 11 |
val deferred = viewModelScope.async { heavyComputation() // ArithmeticException が投げられる可能性あり } try { val result = deferred.await() // await 時点で例外が再スローされる println("Result: $result") } catch (e: ArithmeticException) { Log.e("MyViewModel", "計算エラー", e) } |
asyncはDeferred<T>を返し、失敗はawait()が呼ばれた瞬間に再スローされます。- したがって外側の
try / catchが有効です。
2-3. まとめ
| 関数 | 例外捕捉場所 | 主な利用シーン |
|---|---|---|
launch |
コルーチン内部 (try/catch) または CoroutineExceptionHandler |
UI 更新や fire‑and‑forget の副作用 |
async |
呼び出し側 (await) |
計算結果やネットワーク応答を取得したい場合 |
3. SupervisorJob と部分失敗の許容
3-1. 基本動作
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
val supervisorScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) supervisorScope.launch { launch { // Task 1 delay(100) throw IllegalArgumentException("Task1 エラー") } launch { // Task 2(Task1 の失敗に影響されない) delay(200) println("Task2 完了") } } |
SupervisorJobは 子の失敗をローカライズ し、他の子は継続させます。- この特性は「部分失敗だけをリトライ対象にしたい」シナリオで威力を発揮します。
3-2. viewModelScope に付与するときの注意点
viewModelScope は内部で 既に Job が管理 されています。
単純に viewModelScope + SupervisorJob() と書くと、次の問題が起こり得ます。
| 問題 | 内容 |
|---|---|
| 二重 Job の生成 | 元の Job と新たに作られた SupervisorJob が別個に存在し、キャンセル伝搬が期待通りにならないケースがあります。 |
| 例外ハンドリングの混乱 | 失敗した子コルーチンはどちらの Job に紐付くかで挙動が変わるため、デバッグが困難になります。 |
正しい組み合わせ方
SupervisorScopeビルダーを利用
kotlin
viewModelScope.launch {
supervisorScope { // ここだけが SupervisorJob を持つ
launch { fetchUser() }
launch { fetchPosts() }
}
}- カスタムスコープを作成する場合は
viewModelScope.coroutineContextに上書き
kotlin
private val vmScope = CoroutineScope(
viewModelScope.coroutineContext + SupervisorJob()
)
// 以降 vmScope.launch { … } と使用すれば、元の Job が置き換わります。
3-3. まとめ
SupervisorJobは部分失敗を許容 したいときにだけ導入し、既存スコープとの重複 に注意する。- Android の
viewModelScope/lifecycleScopeに追加したい場合は、supervisorScope {}またはviewModelScope.coroutineContext + SupervisorJob()を用いるのが安全です。
4. CoroutineExceptionHandler のベストプラクティス
4-1. ハンドラの登録と適用範囲
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
val globalErrorHandler = CoroutineExceptionHandler { ctx, ex -> Log.e("GlobalError", "未捕捉例外: ${ex.localizedMessage}", ex) // Crashlytics へ送信など } val appScope = CoroutineScope( SupervisorJob() + // 部分失敗許容 Dispatchers.Main + // UI スレッド(必要に応じて切り替え) globalErrorHandler ) appScope.launch { someSuspendingCall() } |
CoroutineExceptionHandlerは 未捕捉例外 のみを受け取ります。async系でawait()を呼び出すと例外はローカルに再スローされるため、ハンドラには届きません。
4-2. SupervisorJob + Handler の実装例
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
val handler = CoroutineExceptionHandler { _, throwable -> Log.e("AppError", "Unhandled coroutine exception", throwable) } // アプリ全体で使うベーススコープ(app‑tatsujin ガイドと同様) val baseScope = CoroutineScope( SupervisorJob() + Dispatchers.IO + handler ) fun fetchResourcesConcurrently() { baseScope.launch { launch { downloadImage() } // 失敗しても他は続行 launch { fetchJson() } launch { loadCache() } } } |
- 部分失敗は
SupervisorJobがローカライズ、未捕捉例外はハンドラが一元管理します。 - この構成は Android だけでなくサーバーサイド(Ktor、Spring)でも同様に有効です。
4-3. まとめ
| 要素 | 目的 |
|---|---|
SupervisorJob |
子の失敗が兄弟や親へ波及しないようにする |
CoroutineExceptionHandler |
未捕捉例外を一元的にロギング・解析ツールへ送信 |
5. Android コンポーネントでの実装とテスト
5-1. ViewModel と Lifecycle のカスタムスコープ
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
class MainViewModel : ViewModel() { // ―― カスタムスコープ(viewModelScope + SupervisorJob + Handler)―― private val vmExceptionHandler = CoroutineExceptionHandler { _, ex -> Log.e("VMError", "未捕捉例外", ex) } private val customScope = CoroutineScope( viewModelScope.coroutineContext + SupervisorJob() + vmExceptionHandler ) fun loadAllData() = customScope.launch { launch { repository.fetchUserProfile() } // try/catch が不要になる launch { repository.fetchPosts() } launch { repository.fetchSettings() } } } |
viewModelScopeの 元々の Job を上書きする形でSupervisorJob()を付与しているため、二重キャンセルは起きません。- 同様に
FragmentではviewLifecycleOwner.lifecycleScope + SupervisorJob() + handlerと組み合わせます。
5-2. コルーチン例外のユニットテスト(kotlinx‑coroutines‑test)
|
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 |
@OptIn(ExperimentalCoroutinesApi::class) class MainViewModelTest { private val testDispatcher = StandardTestDispatcher() private lateinit var viewModel: MainViewModel private val repository = mock<Repository>() @BeforeEach fun setUp() { Dispatchers.setMain(testDispatcher) viewModel = MainViewModel().apply { // Repository のモック注入は省略(DI コンテナ経由想定) } } @AfterEach fun tearDown() { Dispatchers.resetMain() } @Test fun `子コルーチンが例外を投げても他は継続する`() = runTest { // Arrange: fetchUserProfile が例外、fetchPosts は正常応答 whenever(repository.fetchUserProfile()).thenThrow(RuntimeException("Network error")) whenever(repository.fetchPosts()).thenReturn(listOf(Post())) // Act viewModel.loadAllData() testDispatcher.scheduler.advanceUntilIdle() // Assert verify(repository).fetchPosts() // 失敗しても呼び出されたことを確認 // ハンドラに例外が届いたかは、別途 LogCapture 等で検証可 } } |
StandardTestDispatcherにより 即時実行 が保証され、例外の伝搬経路を deterministic にテストできます。SupervisorJobの有無でfetchPosts()が呼び出されるかどうかが判定ポイントになるため、部分失敗の正しさを検証可能です。
5-3. リファクタリングチェックリスト
| 項目 | 確認手順 |
|---|---|
| スコープの統一 | GlobalScope が残っていないか。すべて viewModelScope / lifecycleScope またはカスタムスコープに置き換える。 |
| SupervisorJob の適切な導入 | 同一スコープ内で独立タスクが 2 個以上ある場合、supervisorScope {} か SupervisorJob() を付与しているか。 |
| ExceptionHandler の設定漏れ | ViewModel・Repository 等、例外が流出しうる層にハンドラが設定されているか。 |
| 二重 Job の回避 | viewModelScope + SupervisorJob() と書くときは viewModelScope.coroutineContext に上書きしていることを確認。 |
| テストカバレッジ | ネットワーク・DB 例外シナリオがすべて runTest で網羅されているか。 |
| ログ/Analytics の一元化 | CoroutineExceptionHandler がプロジェクトのロギング基準に沿った出力を行っているか。 |
| Lifecycle 連携 | スコープ破棄時に子コルーチンが確実にキャンセルされていることを LeakCanary 等で検証。 |
5-4. まとめ
viewModelScopeとSupervisorJobの組み合わせは、元の Job を置き換える形で 行うと安全です。- ハンドラ・テスト・チェックリストを併用すれば、例外が原因でアプリ全体がクラッシュするリスクを 実務レベルで低減 できます。
6. 最終まとめ
| 観点 | 推奨パターン |
|---|---|
| 例外伝搬の制御 | 子失敗だけ局所化したい → SupervisorJob(または supervisorScope {}) |
| 結果取得が必要な非同期処理 | async + await で例外を呼び出し側で捕捉 |
| fire‑and‑forget の UI 更新系 | launch 内で try/catch、もしくは全体ハンドラに委譲 |
| 未捕捉例外の一元管理 | アプリ全体のベーススコープに CoroutineExceptionHandler を設定 |
| Android コンポーネントへの適用 | viewModelScope.coroutineContext + SupervisorJob() + handler でカスタムスコープを作成 |
| テスト戦略 | kotlinx‑coroutines‑test の runTest とモック例外で部分失敗シナリオを検証 |
本稿の内容は、実装・レビュー・テストの全フェーズで活用できる 実践的なチェックポイント を網羅しています。ぜひプロジェクトに取り入れ、安全かつ保守性の高いコルーチンコードを書き上げてください。
スポンサードリンク