本文へスキップ
すべての記事

2026年のAndroidにおける構造化並行性:スコープ、キャンセル、そして防がれるリーク

AndroidにおけるKotlin構造化並行性の実践ガイド — viewModelScopeとlifecycleScopeの違い、キャンセルが伝播する理由、そして静かに防がれるコルーチンリーク。

MFKAPPS 1 分で読めます

Androidでデバッグしてきたコルーチンのバグの多くは、suspend関数が間違ったことをしていたのではなく、コルーチンがそれを開始したものより長生きしていたことが原因だった。ネットワーク呼び出しが画面が消えた後に完了し、破棄されたビューを更新しようとしてクラッシュする。Flowのコレクターが、誰も見ていない画面のためにバックグラウンドで動き続け、バッテリーを消費する。構造化並行性は、クリーンアップを覚えておくことによってではなく、構造そのものによってこれらを排除する規律だ — そして、それが実際に何を保証するのかを理解することは、どこでどのScopeを使うべきかを暗記するより価値がある。

核心となる考え方:コルーチンは自分のスコープより長生きできない

KotlinのすべてのコルーチンはCoroutineScopeに属し、そのスコープがコルーチンの寿命を定義する。スコープの中でコルーチンを起動すると、そのスコープのjobの子になる。スコープをキャンセルすると、どれだけ深くネストしていても、いくつのasync呼び出しに枝分かれしていても、すべての子はそれと一緒にキャンセルされる。これが保証のすべてだ。コルーチンを所有するスコープの外に誤って漏らすことはできない — なぜなら、言語がそのための手段を与えないからだ。

class BudgetViewModel(private val repo: TransactionRepository) : ViewModel() {
    fun refreshMonthlyTotal() {
        viewModelScope.launch {
            val total = repo.computeMonthlyTotal() // ここでsuspendする
            _monthlyTotal.value = total
        }
    }
}

ユーザーが別の画面に移動し、computeMonthlyTotal()の途中でViewModelがクリアされると、viewModelScopeは自動的にキャンセルされ、上記のコルーチンは停止する — _monthlyTotalに書き込む行には決して到達しない。手動でチェックするフラグも、書き込み前のisActiveガードも不要だ。なぜなら、キャンセルがサスペンションポイントに到達した後、コルーチンは単純に再開しないからだ。

viewModelScopeとlifecycleScope、正しい方を選ぶ

Androidはコンポーネントのライフタイムに結びついた2つのスコープを提供しており、間違った方を選ぶことが私が見てきた中で最も一般的な構造化並行性のミスだ。

  • **viewModelScope**はViewModelと同じだけ生きる — 構成変更(回転、ダークモード切り替え)を乗り越え、ViewModelがクリアされたとき、典型的にはユーザーが画面を完全に離れたときにのみキャンセルされる。回転後も画面がまだ結果を気にする作業に使う:データの読み込み、リポジトリへの書き込み、派生値の計算。
  • lifecycleScope、特にその中のrepeatOnLifecycle(Lifecycle.State.STARTED)は、ActivityまたはFragmentのビューライフサイクルに結びついている。画面が見えなくなったら本当に止まるべきものに使う — 最も一般的には、UIを更新するためにFlowを収集する場合:
class ReminderListFragment : Fragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.reminders.collect { reminders ->
                    adapter.submitList(reminders)
                }
            }
        }
    }
}

代わりにviewModelScopeで直接収集すると、viewModelScopeはビューが消えたことを知らないため、アプリがバックグラウンドにある間もコレクター — そしてそれがトリガーするあらゆるRoomクエリ — が動き続けることになる。これはクラッシュという意味でのリークではない。もっと静かで遅いコストだ:無駄になるCPU、無駄になるバッテリー、そして誰にも見えないUIに再放出し続けるFlowrepeatOnLifecycleが解決策なのは、まさにSTARTEDに結びついた新しいスコープを再導出し、ライフサイクルがその状態の内外を移動するたびに収集をキャンセルして再開するからだ。

構造化並行性がエラー処理を正直にする

構造化並行性以前、よくあったパターンはGlobalScope.launchで裸のコルーチンを起動することだった — スコープなし、所有者なし、親の監督の外側。例外をスローすれば、それは有用などこにも行かず、終わるころに画面がすでに消えていれば、クラッシュを捕まえるものは何も残っていなかった。構造化並行性はすべてのコルーチンに親を持つことを強制し、それはすべての例外が伝播すべき定義された場所を持つことを意味する:CoroutineExceptionHandlerをインストールしたものまでjob階層を上に、あるいは何もインストールしていなければスコープ自体のキャンセルへ。

private val handler = CoroutineExceptionHandler { _, throwable ->
    _uiState.value = UiState.Error(throwable.message)
}

fun syncPantryFromReceipt(bitmap: Bitmap) {
    viewModelScope.launch(handler) {
        val items = ocrEngine.extract(bitmap) // 例外をスローする可能性がある
        repo.insertAll(items)
    }
}

これはStockyがレシートスキャンの失敗をどう処理するかの背後にあるパターンだ — OCRステップは十数の理由(悪い照明、読めないフォント、見たことのないレシート形式)で失敗しうるが、構造化並行性はその失敗が投げっぱなしのコルーチンの中に静かに消えるのではなく、正確に一箇所に浮かび上がることを保証する。

async/await:キャンセルは双方向に働く

coroutineScope { }asyncビルダーは、同じ保証を兄弟コルーチンにも拡張する:coroutineScopeの一つの子が例外をスローすると、他のすべての子もキャンセルされ、例外はすべてが収束した後にのみ外部に伝播する。これは、一緒になって初めて意味を持つ並行作業を分散させるときに常に重要だ:

suspend fun loadDashboard(): DashboardState = coroutineScope {
    val totalDeferred = async { repo.computeMonthlyTotal() }
    val trendDeferred = async { repo.computeSpendingTrend() }
    DashboardState(totalDeferred.await(), trendDeferred.await())
}

computeSpendingTrend()が例外をスローすると、computeMonthlyTotal()もキャンセルされる — 部分的に失敗したリクエストから静かにレンダリングされた半分のダッシュボードを抱えることは決してない。構造化並行性がなければ、その2番目のasyncは最初のものがクラッシュした後も動き続け、その結果は誰にも待たれず、その作業は無駄になっていただろう。

まとめ

構造化並行性はスタイルの好みではない — 「これをキャンセルし忘れたか」を実行時バグからコンパイル時の不可能性に変えるものだ。実践で重要なルールはこうだ:近くにあるスコープではなく、結果がどれだけ長く必要とされるかに合ったスコープの中で起動する。回転後も画面がまだ気にする作業にはviewModelScope、ビューが見えないときに止まるべきすべてのものにはrepeatOnLifecycleを伴うlifecycleScope、そして失敗がどこにも行かないのではなく定義された場所へ行く必要があるすべての場所にCoroutineExceptionHandlerを。