Kotlin コルーチンでアプリのパフォーマンスを改善する

Kotlin コルーチンを使用すると、アプリの応答性を保ちながら、ネットワーク呼び出しやディスク オペレーションなどの長時間実行タスクを管理する非同期コードを、クリーンでシンプルに記述できます。

このトピックでは、Android のコルーチンについて詳しく説明します。コルーチンになじみがない場合は、このトピックを読む前にAndroid の Kotlin コルーチンを必ずお読みください。

長時間実行タスクを管理する

コルーチンは、長時間実行タスクを処理する 2 つのオペレーションを標準の関数に追加することによって構築されています。invoke(または call)と return に加え、コルーチンは suspendresume を追加します。

  • suspend は、現在のコルーチンの実行を一時停止し、すべてのローカル変数を保存します。
  • resume は、中断されたコルーチンの実行を中断箇所から再開します。

suspend 関数は、他の suspend 関数から、または、launch などのコルーチン ビルダーを使って新しいコルーチンを開始することによってのみ呼び出せます。

次に、長時間実行タスクを想定した場合のコルーチン実装のシンプルな例を示します。

suspend fun fetchDocs() {                             // Dispatchers.Main
    val result = get("https://developer.android.com") // Dispatchers.IO for `get`
    show(result)                                      // Dispatchers.Main
}

suspend fun get(url: String) = withContext(Dispatchers.IO) { /* ... */ }

上記の例では、get() は引き続きメインスレッドで実行されますが、ネットワーク リクエストを開始する前にコルーチンを停止させます。ネットワーク リクエストが完了したら、get は、コールバックを使用してメインスレッドに通知するのではなく、停止したコルーチンを再開します。

Kotlin は、スタック フレームを使用して、どの関数をローカル変数とともに実行するか管理します。コルーチンを停止すると、現在のスタック フレームがコピーされ、後で使用するために保存されます。再開すると、保存された場所からスタック フレームのコピーが戻されて、関数の実行が再開されます。コードは通常のシーケンシャル ブロック リクエストと同じように見えるかもしれませんが、コルーチンでは、ネットワーク リクエストによるメインスレッドのブロックが確実に回避されます。

メインセーフティでコルーチンを使用する

Kotlin のコルーチンは、ディスパッチャを使用して、コルーチンの実行に使用するスレッドを決定します。コードをメインスレッドの外部で実行するには、Kotlin コルーチンに対して、デフォルトまたは IO のいずれかのディスパッチャで処理を実行するよう指示できます。Kotlin では、すべてのコルーチンは、メインスレッド上で実行している場合であっても、ディスパッチャ内で実行する必要があります。コルーチンは自身を中断させることができ、ディスパッチャはそれを再開させる役目を担います。

コルーチンを実行すべき場所を指定するために、Kotlin では、デベロッパーが使用できる次の 3 つのディスパッチャを用意しています。

  • Dispatchers.Main - このディスパッチャを使用すると、コルーチンはメインの Android スレッドで実行されます。UI を操作して処理を手早く作業する場合にのみ使用します。たとえば、suspend 関数の呼び出し、Android UI フレームワーク オペレーションの実行、LiveData オブジェクトのアップデートを行う場合などです。
  • Dispatchers.IO - このディスパッチャは、メインスレッドの外部でディスクまたはネットワークの I/O を実行する場合に適しています。たとえば、Room コンポーネントの使用、ファイルの読み書き、ネットワーク オペレーションの実行などです。
  • Dispatchers.Default - このディスパッチャは、メインスレッドの外部で CPU 負荷の高い作業を実行する場合に適しています。ユースケースの例としては、リストの並べ替えや JSON の解析などがあります。

前の例に続けて、ディスパッチャを使用して get 関数を再定義できます。get の本文の中で、withContext(Dispatchers.IO) を呼び出して、IO スレッドプールで実行するブロックを作成します。そのブロック内のコードはすべて、常に IO ディスパッチャ経由で実行されます。withContext 自体が中断関数であるため、関数 get も中断関数になります。

suspend fun fetchDocs() {                      // Dispatchers.Main
    val result = get("developer.android.com")  // Dispatchers.Main
    show(result)                               // Dispatchers.Main
}

suspend fun get(url: String) =                 // Dispatchers.Main
    withContext(Dispatchers.IO) {              // Dispatchers.IO (main-safety block)
        /* perform network IO here */          // Dispatchers.IO (main-safety block)
    }                                          // Dispatchers.Main
}

コルーチンによって、スレッドのディスパッチを細かく制御しながら実行できます。withContext() ではコールバックを導入することなくコードの任意の行のスレッドプールを制御できるため、データベースからの読み取りやネットワーク リクエストといった極めて小さい関数に適用できます。withContext() を使って、すべての関数をメインセーフにして、関数をメインスレッドから呼び出せるようにすることをおすすめします。そうすることで、呼び出し側において、関数を実行するためにどのスレッドを使用すべきか検討する必要がなくなります。

前の例では、fetchDocs() はメインスレッドで実行されます。ただし、get を安全に呼び出すことができ、それによりネットワーク リクエストはバックグラウンドで実行されます。 コルーチンは suspendresume をサポートしているため、メインスレッド上のコルーチンは、withContext のブロックが完了するとすぐに、get の結果を使用して再開します。

withContext() のパフォーマンス

withContext() では、コールバックをベースとする同等の実装に比べて、余分なオーバーヘッドが増えません。さらに、状況によっては、コールバックをベースとする同等の実装以上に withContext() 呼び出しを最適化することが可能です。たとえば、関数がネットワークに対して 10 回の呼び出しを行う場合、外部の withContext() を使用することによりスレッドを 1 回だけ切り替えるよう Kotlin に指示できます。このとき、ネットワーク ライブラリが withContext() を複数回使用しても、それは同じディスパッチャにとどまり、スレッドの切り替えは回避されます。さらに、Kotlin では、Dispatchers.DefaultDispatchers.IO の切り替えの最適化により、スレッドの切り替えを可能な限り回避できます。

コルーチンを開始する

コルーチンは次の 2 つの方法のいずれかで開始できます。

  • launch は、新規コルーチンを開始し、呼び出し元に結果を返しません。「ファイア アンド フォーゲット」とみなされるあらゆる作業は、launch を使用して開始できます。
  • async は、新規コルーチンを開始し、await と呼ばれる中断関数で結果を返せるようにします。

一般に、標準の関数は await を呼び出せないので、標準の関数から新規コルーチンを launch する必要があります。async は、別のコルーチン内部にいる場合、あるいは中断関数内部で並列分解を行う場合のみ、使用します。

並列分解

suspend 関数内で開始されたコルーチンはすべて、その関数が戻るときに停止する必要があるため、戻る前にそれらのコルーチンが確実に終了するようにしておく必要があります。Kotlin での構造化された同時実行では、1 つ以上のコルーチンを開始する coroutineScope を定義できます。次に、await()(コルーチンが 1 つの場合)または awaitAll()(コルーチンが複数の場合)を使用して、関数から戻る前にこれらのコルーチンが終了することを保証できます。

例として、2 つのドキュメントを非同期的にフェッチする coroutineScope を定義してみましょう。それぞれの遅延参照で await() を呼び出すことにより、値が返される前に両方の async オペレーションが終了することが保証されます。

suspend fun fetchTwoDocs() =
    coroutineScope {
        val deferredOne = async { fetchDoc(1) }
        val deferredTwo = async { fetchDoc(2) }
        deferredOne.await()
        deferredTwo.await()
    }

次の例のように、コレクションで awaitAll() を使用することもできます。

suspend fun fetchTwoDocs() =        // called on any Dispatcher (any thread, possibly Main)
    coroutineScope {
        val deferreds = listOf(     // fetch two docs at the same time
            async { fetchDoc(1) },  // async returns a result for the first doc
            async { fetchDoc(2) }   // async returns a result for the second doc
        )
        deferreds.awaitAll()        // use awaitAll to wait for both network requests
    }

たとえ fetchTwoDocs()async で新規コルーチンを起動しても、関数は awaitAll() を使用して、起動されたコルーチンが、戻る前に終了するまで待ちます。ただし、awaitAll() を呼び出していない場合でも、coroutineScope ビルダーは、新規コルーチンがすべて完了するまでは、fetchTwoDocs を呼び出したコルーチンを再開しません。

さらに、coroutineScope が、コルーチンによってスローされた例外をキャッチし、呼び出し元に戻します。

並列分解の詳細については、中断関数の作成をご覧ください。

コルーチンの概念

CoroutineScope

CoroutineScope は、launch または async を使用して作成したコルーチンをすべて追跡します。実行中の作業(実行中のコルーチン)は、いつでも scope.cancel() を呼び出してキャンセルできます。Android では、一部の KTX ライブラリが特定のライフサイクル クラスに独自の CoroutineScope を提供しています。たとえば、ViewModel には viewModelScopeLifecycle には lifecycleScope があります。ただし、ディスパッチャとは異なり、CoroutineScope ではコルーチンは実行されません。

viewModelScope は、コルーチンを使用した Android のバックグラウンド スレッドにある例でも使用されています。ただし、独自の CoroutineScope を作成してアプリの特定のレイヤでコルーチンのライフサイクルを制御する必要がある場合は、次のように作成できます。

class ExampleClass {

    // Job and Dispatcher are combined into a CoroutineContext which
    // will be discussed shortly
    val scope = CoroutineScope(Job() + Dispatchers.Main)

    fun exampleMethod() {
        // Starts a new coroutine within the scope
        scope.launch {
            // New coroutine that can call suspend functions
            fetchDocs()
        }
    }

    fun cleanUp() {
        // Cancel the scope to cancel ongoing coroutines work
        scope.cancel()
    }
}

キャンセルされたスコープは、コルーチンをそれ以上作成できません。したがって、scope.cancel() は、そのライフサイクルを制御するクラスが破棄されている場合にのみ呼び出す必要があります。viewModelScope を使用すると、ViewModel クラスは ViewModel の onCleared() メソッドで自動的にスコープをキャンセルします。

Job

Job はコルーチンのハンドルです。launch または async で作成した各コルーチンは、コルーチンを一意に識別し、そのライフサイクルを管理する Job インスタンスを返します。また、次の例に示すように、JobCoroutineScope に渡してライフサイクルを詳細に管理することもできます。

class ExampleClass {
    ...
    fun exampleMethod() {
        // Handle to the coroutine, you can control its lifecycle
        val job = scope.launch {
            // New coroutine
        }

        if (...) {
            // Cancel the coroutine started above, this doesn't affect the scope
            // this coroutine was launched in
            job.cancel()
        }
    }
}

CoroutineContext

CoroutineContext は、次の要素のセットを使用してコルーチンの動作を定義します。

スコープ内に作成された新規コルーチンの場合、新しい Job インスタンスが新規コルーチンに割り当てられ、他の CoroutineContext 要素はそれを含むスコープから継承されます。継承された要素をオーバーライドするには、新しい CoroutineContextlaunch ファンクションまたは async ファンクションに渡します。なお、新規コルーチンには新しい Job インスタンスが常に割り当てられるため、Joblaunch または async に渡しても効果はありません。

class ExampleClass {
    val scope = CoroutineScope(Job() + Dispatchers.Main)

    fun exampleMethod() {
        // Starts a new coroutine on Dispatchers.Main as it's the scope's default
        val job1 = scope.launch {
            // New coroutine with CoroutineName = "coroutine" (default)
        }

        // Starts a new coroutine on Dispatchers.Default
        val job2 = scope.launch(Dispatchers.Default + CoroutineName("BackgroundCoroutine")) {
            // New coroutine with CoroutineName = "BackgroundCoroutine" (overridden)
        }
    }
}

コルーチンに関する参考情報

コルーチンに関するその他の参考情報については、次のリンクをご覧ください。