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

コルーチンとは、Android で使用できる同時実行設計パターンです。これを使用すると、非同期実行するコードを簡略化できます。 コルーチンは、他の言語ですでに確立されている概念をベースにしており、バージョン 1.3 で Kotlin に追加されました。

Android では、コルーチンは次の 2 つの主要な問題の解決に役立ちます。

  • メインスレッドをブロックしてアプリをフリーズさせる可能性のある、長時間実行タスクを管理します。
  • メインセーフティ(メインスレッドからの、ネットワークまたはディスク オペレーションの安全な呼び出し)を提供します。

このトピックでは、Kotlin コルーチンを使用してこれらの問題に対処する方法を説明し、より洗練された簡潔なアプリコードを記述できるようにします。

長時間実行タスクの管理

Android では、すべてのアプリにメインスレッドがあり、それによってユーザー インターフェースを処理し、ユーザー操作を管理します。アプリがメインスレッドに割り当てる作業が多くなりすぎると、アプリがフリーズしたり速度が大幅に遅くなったりすることがあります。ネットワーク リクエスト、JSON の解析、データベースの読み書きや、さらには大きなリストの単なる繰り返し処理だけでも、アプリの動作が遅くなって明らかなジャンクが生じ、UI が低速化またはフリーズしてタッチイベントへの反応が遅くなる場合があります。これらの長時間実行オペレーションは、メインスレッドの外部で実行する必要があります。

仮想長時間実行タスク向けの単純なコルーチン実装例を次に示します。

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) { /* ... */ }
    

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

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

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

上記の例では、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 の切り替えの最適化により、スレッドの切り替えを可能な限り回避できます。

CoroutineScope を指定する

コルーチンを定義するときは、その CoroutineScope も指定する必要があります。 CoroutineScope は、1 つ以上の関連コルーチンを管理します。また、CoroutineScope を使用して、そのスコープ内で新しいコルーチンを開始することもできます。ただし、ディスパッチャとは異なり、CoroutineScope ではコルーチンは実行されません。

CoroutineScope の重要な機能の一つとして、ユーザーがアプリ内のコンテンツ領域から退出したときにコルーチンの実行を停止することができます。 を使用すると、実行中のあらゆるオペレーションを、確実に正しく停止できます。

Android Architecture コンポーネントで CoroutineScope を使用する

Android では、CoroutineScope の実装をコンポーネントのライフサイクルと関連付けることができます。そうすることで、メモリリークや、ユーザーとの関連性がなくなったアクティビティやフラグメントに関する余分な処理の実行を防止できます。Jetpack コンポーネントを使用すると、スムーズに ViewModel に適合します。設定変更(たとえば画面の回転)中に ViewModel が破棄されることはないので、コルーチンのキャンセルまたは再起動について心配する必要はありません。

スコープは、開始対象とするすべてのコルーチンについて理解しています。つまり、スコープで開始されたコルーチンはすべて、いつでもキャンセルできます。スコープは伝承されます。したがって、コルーチンによって別のコルーチンが開始された場合、スコープはどちらのコルーチンでも同じになります。つまり、他のライブラリにより自分のスコープからコルーチンを開始されても、いつでもキャンセルできます。これは、ViewModel でコルーチンを実行する場合に特に重要です。ユーザーが画面を離れたために ViewModel が破棄される場合、それにより実行されている非同期作業をすべて停止する必要があります。そうしないと、リソースが浪費され、メモリリークが発生する可能性があります。ViewModel 破棄後に続行すべき非同期作業がある場合は、アプリのアーキテクチャの下位層で行う必要があります。

Android アーキテクチャ コンポーネント向け KTX ライブラリでは、拡張プロパティ viewModelScope を使用して、ViewModel の破棄まで実行できるコルーチンを作成することも可能です。

コルーチンを開始する

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

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

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

前の例を元にして、launch を使用して標準の関数からコルーチンに切り替える viewModelScope KTX 拡張プロパティを持つコルーチンを、次に示します。

fun onDocsNeeded() {
        viewModelScope.launch {    // Dispatchers.Main
            fetchDocs()            // Dispatchers.Main (suspend function call)
        }
    }
    

並列分解

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 が、コルーチンによってスローされた例外をキャッチし、呼び出し元に戻します。

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

サポートが組み込まれたアーキテクチャ コンポーネント

ViewModelLifecycle など、一部のアーキテクチャ コンポーネントには、独自の CoroutineScope メンバーを介したコルーチン向け組み込みサポートが含まれています。

たとえば、ViewModel には viewModelScope が組み込まれています。これにより、次の例に示すように、ViewModel のスコープ内でコルーチンを起動する標準的な方法が提供されます。

class MyViewModel : ViewModel() {

        fun launchDataLoad() {
            viewModelScope.launch {
                sortList()
                // Modify UI
            }
        }

        /**
        * Heavy operation that cannot be done in the Main Thread
        */
        suspend fun sortList() = withContext(Dispatchers.Default) {
            // Heavy work
        }
    }
    

LiveData は、liveData ブロックでもコルーチンを利用します。

liveData {
        // runs in its own LiveData-specific scope
    }
    

コルーチンのサポートが組み込まれたアーキテクチャ コンポーネントの詳細については、アーキテクチャ コンポーネントで Kotlin コルーチンを使用するをご覧ください。

参照情報

コルーチンについて詳しくは、以下のリンク先をご覧ください。