Android での Kotlin コルーチン

コルーチンとは、Android で使用できる並行実行のデザイン パターンです。これを使用すると、非同期実行するコードを簡略化できます。コルーチンはバージョン 1.3 で Kotlin に追加されており、他の言語で確立されたコンセプトに基づいています。

Android では、メインスレッドをブロックしてアプリの応答を止める可能性のある長時間実行タスクの管理に役立ちます。コルーチンを使用するプロのデベロッパーの 50% 以上が、生産性が向上したと報告しました。このトピックでは、Kotlin コルーチンを使用してこれらの問題に対処する方法を説明し、より簡潔で無駄のないアプリコードを記述できるようにします。

機能

コルーチンは、Android での非同期プログラミングに推奨するソリューションです。主な機能は次のとおりです。

  • 軽量: 中断がサポートされているため、1 つのスレッドで多数のコルーチンを実行できます。これにより、コルーチンを実行しているスレッドがブロックされません。中断により、多数の同時実行処理をサポートしつつ、ブロックさせる場合よりメモリを節約できます。
  • メモリリークの低減: 構造化同時実行を使用して、スコープ内でオペレーションを実行します。
  • 組み込みのキャンセル サポート: キャンセルは、実行中のコルーチン階層を通じて自動的に伝播されます。
  • Jetpack の統合: Jetpack ライブラリの多くには、コルーチンを全面的にサポートする拡張機能が用意されています。一部のライブラリでは、構造化された同時実行に使用できる独自のコルーチン スコープも用意されています。

例の概要

このトピックのサンプルは、アプリ アーキテクチャ ガイドをベースにしており、ネットワーク リクエストを行って、結果をメインスレッドに返し、それをアプリでユーザーに表示するものになっています。

具体的には、ViewModel アーキテクチャ コンポーネントがメインスレッドのリポジトリ レイヤを呼び出してネットワーク リクエストをトリガーします。このガイドでは、コルーチンを使用してメインスレッドのブロックを避けるソリューションについて順を追って説明します。

ViewModel には、コルーチンを直接扱う KTX 拡張機能であるlifecycle-viewmodel-ktx ライブラリが用意されており、このガイドでも使用されています。

依存関係情報

Android プロジェクトでコルーチンを使用するには、アプリの build.gradle ファイルに次の依存関係を追加します。

Groovy

dependencies {
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
}

Kotlin

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9")
}

バックグラウンド スレッドでの実行

メインスレッドでネットワーク リクエストを行うと、レスポンスを受信するまでの間、待機したり、ブロックされたりすることになります。スレッドがブロックされるため、OS は onDraw() を呼び出せず、アプリがフリーズして、アプリケーション応答なし(ANR)のダイアログが表示される可能性があります。ユーザー エクスペリエンスを向上させるために、この処理をバックグラウンド スレッドで実行するようにしましょう。

まず、Repository クラスを見て、ネットワーク リクエストがどのように行われているかを確認します。

sealed class Result<out R> {
    data class Success<out T>(val data: T) : Result<T>()
    data class Error(val exception: Exception) : Result<Nothing>()
}

class LoginRepository(private val responseParser: LoginResponseParser) {
    private const val loginUrl = "https://example.com/login"

    // Function that makes the network request, blocking the current thread
    fun makeLoginRequest(
        jsonBody: String
    ): Result<LoginResponse> {
        val url = URL(loginUrl)
        (url.openConnection() as? HttpURLConnection)?.run {
            requestMethod = "POST"
            setRequestProperty("Content-Type", "application/json; utf-8")
            setRequestProperty("Accept", "application/json")
            doOutput = true
            outputStream.write(jsonBody.toByteArray())
            return Result.Success(responseParser.parse(inputStream))
        }
        return Result.Error(Exception("Cannot open HttpURLConnection"))
    }
}

makeLoginRequest は同期しており、呼び出し元のスレッドをブロックします。ネットワーク リクエストのレスポンスをモデル化するために、独自の Result クラスが用意されています。

ViewModel は、ボタンなどがクリックされたときにネットワーク リクエストをトリガーします。

class LoginViewModel(
    private val loginRepository: LoginRepository
): ViewModel() {

    fun login(username: String, token: String) {
        val jsonBody = "{ username: \"$username\", token: \"$token\"}"
        loginRepository.makeLoginRequest(jsonBody)
    }
}

前のコードでは、ネットワーク リクエストを行うときに、LoginViewModel が UI スレッドをブロックしています。この実行をメインスレッドから移動させるには、新しいコルーチンを作成して、I/O スレッドでネットワーク リクエストを実行するのが最も簡単です。

class LoginViewModel(
    private val loginRepository: LoginRepository
): ViewModel() {

    fun login(username: String, token: String) {
        // Create a new coroutine to move the execution off the UI thread
        viewModelScope.launch(Dispatchers.IO) {
            val jsonBody = "{ username: \"$username\", token: \"$token\"}"
            loginRepository.makeLoginRequest(jsonBody)
        }
    }
}

login 関数のコルーチン コードを詳しく見てみましょう。

  • viewModelScope は定義済みの CoroutineScope で、ViewModel KTX 拡張機能に含まれています。すべてのコルーチンはスコープ内で実行する必要があります。CoroutineScope により、1 つ以上の関連するコルーチンを管理します。
  • launch は、コルーチンを作成し、この関数自体の実行内容を対応するディスパッチャーにディスパッチする関数です。
  • Dispatchers.IO は、このコルーチンが I/O 処理用に予約されたスレッドで実行されることを示します。

login 関数は次のように実行されます。

  • アプリが、メインスレッドで View レイヤから login 関数を呼び出します。
  • launch が新しいコルーチンを作成し、I/O 処理用に予約されたスレッドで独自にネットワーク リクエストが行われます。
  • このコルーチンの実行中も、login 関数は実行を継続し、場合によってはネットワーク リクエストが完了する前に戻ります。簡単にするため、ここではネットワーク レスポンスを無視します。

このコルーチンは viewModelScope で開始されるため、ViewModel のスコープで実行されます。ユーザーが画面から移動して ViewModel が破棄された場合、viewModelScope は自動的にキャンセルされ、実行中のすべてのコルーチンもキャンセルされます。

前のサンプルには、makeLoginRequest を呼び出すときに、実行を明示的にメインスレッドから移動させる必要があるという問題があります。Repository を変更して、この問題を解決する方法を見てみましょう。

コルーチンでメインセーフティにする

ある関数がメインスレッドでの UI の更新をブロックしないとき、その関数はメインセーフティであると言います。メインスレッドから makeLoginRequest 関数を呼び出すと UI がブロックされるため、makeLoginRequest はメインセーフティではありません。コルーチンの実行を別のスレッドに移動するために、次のようにコルーチン ライブラリの withContext() 関数を使用します。

class LoginRepository(...) {
    ...
    suspend fun makeLoginRequest(
        jsonBody: String
    ): Result<LoginResponse> {

        // Move the execution of the coroutine to the I/O dispatcher
        return withContext(Dispatchers.IO) {
            // Blocking network request code
        }
    }
}

withContext(Dispatchers.IO) は、コルーチンの実行を I/O スレッドに移動し、呼び出し元の関数をメインセーフティにして、必要なときに UI が更新されるようにします。

makeLoginRequest には suspend キーワードも付いています。このキーワードは、Kotlin 独自のもので、関数がコルーチン内から呼び出されるようにするものです。

次のサンプルでは、LoginViewModel 内でコルーチンが作成されます。makeLoginRequest の実行がメインスレッドの外に移り、login 関数のコルーチンがメインスレッドで実行されるようになります。

class LoginViewModel(
    private val loginRepository: LoginRepository
): ViewModel() {

    fun login(username: String, token: String) {

        // Create a new coroutine on the UI thread
        viewModelScope.launch {
            val jsonBody = "{ username: \"$username\", token: \"$token\"}"

            // Make the network call and suspend execution until it finishes
            val result = loginRepository.makeLoginRequest(jsonBody)

            // Display result of the network request to the user
            when (result) {
                is Result.Success<LoginResponse> -> // Happy path
                else -> // Show error in UI
            }
        }
    }
}

makeLoginRequestsuspend 関数であり、すべての suspend 関数はコルーチンで実行する必要があるため、ここでは引き続きコルーチンが必要です。

このコードには、前の login のサンプルとは異なる点があります。

  • launchDispatchers.IO パラメータがない。launchDispatcher を渡さない場合、viewModelScope から起動されたコルーチンがすべてメインスレッドで実行されます。
  • ネットワーク リクエストの結果が処理され、成功または失敗の UI が表示される。

login 関数は次のように実行されます。

  • アプリが、メインスレッドで View レイヤから login() 関数を呼び出します。
  • launch がメインスレッドで新しいコルーチンを作成し、そのコルーチンの実行が開始されます。
  • コルーチン内では、loginRepository.makeLoginRequest() を呼び出すと、makeLoginRequest() 内の withContext ブロックの実行が終了するまでコルーチンの実行を中断するようになりました。
  • withContext ブロックが終了すると、login() のコルーチンはメインスレッドで実行を再開し、ネットワーク リクエストの結果を返します。

例外の処理

Repository レイヤがスローする例外を処理するには、Kotlin の例外に対する組み込みサポートを使用します。次のサンプルでは、try-catch ブロックを使用しています。

class LoginViewModel(
    private val loginRepository: LoginRepository
): ViewModel() {

    fun login(username: String, token: String) {
        viewModelScope.launch {
            val jsonBody = "{ username: \"$username\", token: \"$token\"}"
            val result = try {
                loginRepository.makeLoginRequest(jsonBody)
            } catch(e: Exception) {
                Result.Error(Exception("Network request failed"))
            }
            when (result) {
                is Result.Success<LoginResponse> -> // Happy path
                else -> // Show error in UI
            }
        }
    }
}

このサンプルでは、makeLoginRequest() の呼び出しでスローされた予期しない例外は、UI でエラーとして処理されます。

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

Android でのコルーチンの詳細については、Kotlin コルーチンでアプリのパフォーマンスを改善するをご覧ください。

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