mcpField
K
kotlin-coroutines

kotlin-coroutines

github.com/rcosteira79開発165162 回インストール6 週間前に更新
リポジトリ

kotlin-coroutines について

コーディングエージェント向けの Kotlin Coroutines リファレンススキルです。構造化並行処理、スコープの所有、キャンセル、例外処理を中心に、KMP とテストの指針も扱います。

主な機能

  1. 01構造化並行処理とスコープの所有
  2. 02キャンセルを妨げない例外処理
  3. 03Dispatcher とメインセーフの指針
  4. 04KMP のスコープ運用
  5. 05コルーチンテストの要点

活用シーン

  1. 01コルーチンスコープの所有者の確認
  2. 02キャンセルと例外の安全な処理
  3. 03KMP 向けコルーチン API の設計
  4. 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 のデフォルトに注意してください。

コメント