kotlin-coroutines について
コーディングエージェント向けの Kotlin Coroutines リファレンススキルです。構造化並行処理、スコープの所有、キャンセル、例外処理を中心に、KMP とテストの指針も扱います。
主な機能
- 01構造化並行処理とスコープの所有
- 02キャンセルを妨げない例外処理
- 03Dispatcher とメインセーフの指針
- 04KMP のスコープ運用
- 05コルーチンテストの要点
活用シーン
- 01コルーチンスコープの所有者の確認
- 02キャンセルと例外の安全な処理
- 03KMP 向けコルーチン API の設計
- 04コルーチンと Dispatcher のテスト計画
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ rcosteira79/ android-skills --skill kotlin-coroutinesインストール後、Claude Code で/kotlin-coroutinesを入力して読み込まれたか確認します。
詳細
Kotlin Coroutines
構造化並行性を基盤としています。各コルーチンはスコープ内で実行され、キャンセルとエラーは親子階層を通じて伝播します。このリファレンスでは、特に誤りやすい2つの規律、つまりスコープを誰が所有するかと、キャンセルを妨げない例外処理に焦点を当てます。
メインセーフティ: ブロッキング処理を行う関数がwithContext(ioDispatcher)を担います。呼び出し元は、サスペンド関数を呼び出す前にディスパッチャーを切り替えません。
スコープの所有権 — suspend funを優先し、呼び出し元にスコープを所有させる
UI以外のクラス(リポジトリ、マネージャー、ユースケース、データソース)にCoroutineScopeを保持させている場合、レビュー時に要注意です。そのクラスはキャンセル、エラー報告、再起動、ライフサイクルを所有していることを証明する必要がありますが、ほとんどのクラスにはできません。ほぼ常に、APIをsuspendにして、呼び出し元にスコープを所有させるのが解決策です。
// DO — suspend fun; the caller owns the scope, cancellation propagates, exceptions surface
class ArticlesRepository(private val dataSource: ArticlesDataSource, private val io: CoroutineDispatcher) {
suspend fun bookmark(article: Article) = withContext(io) { dataSource.bookmark(article) }
}
class BookmarkViewModel(private val repo: ArticlesRepository) : ViewModel() {
fun onBookmark(a: Article) { viewModelScope.launch { repo.bookmark(a) } }
}
// DO NOT — store a scope and launch inside the repository
class ArticlesRepository(private val externalScope: CoroutineScope, /* ... */) {
suspend fun bookmark(a: Article) { externalScope.launch { dataSource.bookmark(a) }.join() }
}
保持されたスコープが危険な理由: スコープがキャンセルされると、そのスコープ上で今後実行されるすべてのlaunchはキャンセル済みとして黙って完了します。例外もログもありません。プロセス終了や設定ミスのあるDIグラフによってキャンセルされた場合、クラスは引き続き呼び出しを受け付けながら、黙って何もしなくなります。
呼び出し元より長く存続させる必要がある処理
ユーザーが画面を離れても継続しなければならない書き込み処理は、リポジトリの責務ではありません。WorkManagerのジョブ、またはスコープを意図的に所有する名前付きのアプリケーションスコープクラス(注入されたapplicationScopeを持ち、プロセス終了時にのみキャンセルされるOfflineBookmarkQueueなど)の責務です。名前付きクラスなら、埋もれたexternalScope.launchとは異なり、存続期間が明確で、テスト可能かつ観測可能になります。
状態ホルダーの例外 — サスペンド関数ではないメソッドからのlaunchが許される場合
UI状態ホルダーがサスペンド関数ではないイベントコールバックから起動できるのは、次の3つすべてを満たす場合に限られます。(1)ビュー層が収集するUI状態を実際に所有している。(2)ライフサイクルに紐づくスコープ(viewModelScope/rememberCoroutineScope)を使用している。(3)トリガーがUIイベント(クリック、スワイプ、ライフサイクル)であり、リポジトリ呼び出し、バックグラウンドタイマー、DIフックではない。いずれかを満たさない場合は、suspend funを公開し、実際の状態ホルダーにスコープを所有させてください。
アンチパターン:init { launch }とDIシングルトンのlaunch
init { viewModelScope.launch { while (isActive) { … } } }は処理を見えなくします。名前付きのトリガーも再起動経路もなく、ナビゲーションで戻って再び進むと、黙って再起動されます。代わりに、stateInを介してStateFlowを公開してください。同様に、コンストラクターから起動する@Singleton(またはHiltのInitializer)は、「DIがインスタンス化した場所」でコルーチンを開始するため、誰にも観測されません。代わりに、WorkManager.enqueueUniquePeriodicWorkを使用する、処理を利用側に移す、または明示的なstartX()メソッドを持つ名前付きクラスに処理を置いてください。
DIに紐づくスコープや保持されたスコープからのlaunchについては、次の点を診断してください。開始時点はどこで定義されていますか?実行中かどうかを誰が観測できますか?プロセスを再起動せずに再起動できますか? 答えが*"どこか/誰もいない/いいえ"*であれば、リファクタリングしてください。
キャンセル
協調的に動作します。長時間のループの先頭でensureActive()を使います(キャンセルされていれば例外をスローします)。例外をスローせずに確認するにはisActiveを使い、スレッドに制御を譲るにはyield()も使います。delay/withContextはすでにキャンセル可能です。キャンセル後も必ず実行する必要があるクリーンアップは、finally { withContext(NonCancellable) { … } }に記述します。NonCancellableを使うのはこの箇所だけにし、一般的な逃げ道として使わないでください。withTimeoutから発生するTimeoutCancellationExceptionもCancellationExceptionです。再スローせずに捕捉してはいけません。(ネットワークタイムアウトにwithTimeoutを使わないでください。OkHttp/Retrofit/Ktorがタイムアウトを管理します。)
例外処理 — キャンセルを妨げない
// DO — specific types
try { repo.login(u, p) } catch (e: IOException) { _state.value = Error("Network") }
// DO NOT — catch (e: Exception) / Throwable swallows CancellationException and breaks structured cancellation
IOExceptionやHttpExceptionなどの具体的な型をキャッチし、ExceptionやThrowableは決してキャッチしないでください。CancellationExceptionをキャッチする場合は、必ず再スローしてください。- サスペンド関数で
runCatchingを使わないでください。CancellationExceptionもキャッチしてしまいます。代わりに、これを再スローするsuspendRunCatchingを使ってください。
suspend inline fun <R> suspendRunCatching(block: () -> R): Result<R> = try {
Result.success(block())
} catch (e: CancellationException) { throw e } catch (e: Throwable) { Result.failure(e) }
try/catchではlaunch {}を囲めません。コルーチンの本体内に配置してください。supervisorScope内の、結果を待たないasync {}は例外を握りつぶします。結果が不要ならlaunchを使ってください。viewModelScopeはすでにSupervisorJobを使っているため、さらに追加しないでください。
KMP
MainScope() は使わないでください。これは構造化されておらず(Dispatchers.Main + SupervisorJob())、GlobalScope と同じ問題があります。プラットフォーム層から、ライフサイクルに紐づいた CoroutineScope を注入してください(Android では viewModelScope、iOS ではビューコントローラーに紐づいたスコープ)。プラットフォーム実装には CoroutineDispatcher を注入してください。ターゲットに Dispatchers.Main.immediate がない場合は、expect/actual で対応してください。
テスト
コルーチンとディスパッチャーのテストについては、android-skills:android-testing を参照してください。特に、2つのスケジューラーの落とし穴(runTest と MainDispatcherRule で同じスケジューラーを共有する必要があること)と、Compose テストにおける StandardTestDispatcher のデフォルトに注意してください。