怠慢プログラマーの備忘録

怠慢でナマケモノなプログラマーの備忘録です。

【Android】Kotlin Coroutines

Coroutinesとは

プログラミングの構造の一種。サブルーチンがエントリーからリターンまでを一つの処理単位とするのに対し、コルーチンはいったん処理を中断した後、続きから処理を再開できる。接頭辞 co は協調を意味するが、複数のコルーチンが中断・継続により協調動作を行うことによる。 サブルーチンと異なり、状態管理を意識せずに行えるため、協調的処理、イテレータ、無限リスト、パイプなど、継続状況を持つプログラムが容易に記述できる。 by Wikidedia

ちなみにコルーチンという言葉が最初に出てきたのがコンウェイの法則で有名なあのメルヴィンさんの論文らしい...

Kotlin Coroutinesとは

Kotlin 1.2より実験的な機能として組み込まれていましたが1.3より正式にサポートされました。

kotlinlang.org

プロセス上のメモリを使用するため個別のメモリが必要なく、またスレッド切り替えのタイミングを指定できるためオーバーヘッドが発生しづらいという利点があります。

launch

Job(コルーチン自体)を返却するコルーチンビルダーのことです。launchでコルーチンを新規作成しその上で非同期処理を実装することになります。

CoroutineScope(Dispatchers.Main).launch {
    // 何かしらの処理 
}

coroutine の基本は「launch() でコルーチン作成・非同期処理を開始し、suspendで処理を中断する」のようなシンプルな認識です。

並列処理時に見落としがちなキャンセル処理も、CoroutineScope が適切にやってくれるようになっています。

CoroutineScope

Job(コルーチン自体)を実行させるには、どのスコープでJob(コルーチン自体)を実行させるかという指定が必要になります。

Scopeの中にはContextを持ち、そのContextの中にJob(コルーチン自体)が存在します。

  • CoroutineScope(Default)
  • CoroutineScope(Main)
  • GlobalScope

CoroutineScope はJob(コルーチン自体)の起動だけではなく起動したJob(コルーチン自体)を適切に終了する役割があります。

例えばKTXで提供されているviewModelScope は、画面を閉じた時にJob(コルーチン自体)を自動でキャンセルしてくれます。 ユーザー操作による処理を実行する場合はviewModelScopeから起動すると良いです。

一方で Kotlinの標準で用意されているGlobalScopeもありますがこちらは処理が自動でキャンセルされません。

Androidの一般的には自動でキャンセルを行ってくれるscopeを使った方が良いかと思います。 (手動でJob(コルーチン自体)をキャンセルするにはcancel()を呼びます)

またCoroutineScopeは複数種類ありその引数に実行するスレッドを指定することができます。

CoroutineScopeのスレッド

  • Dispatchers.Main
    Mainスレッド(UIスレッド)で実行。UIの操作はメインスレッドで行う必要があり、asyncを利用する場合await()で中断する必要がある。

  • Dispatchers.Default
    バックグラウンドの共有プールを利用する。

  • Dispatchers.IO
    追加でスレッドを立てる。I/Oの処理用に用いられる。

async/await

async はコルーチンを作成し、戻り値として Deferred 型のオブジェクトを返します。

コルーチン自体を非ブロッキングにした上で、コルーチン処理の戻り値を受け取ることができます。

val hoge1 = async { client.service.getArticle("param") }.await()

asyncの戻り値Deferred 型にはawait()メソッドが定義されていて、asyncで起動されたコルーチンが終了するまで現在のコルーチンを中断し、コルーチン終了後に戻り値を受け取ることができます。

Suspending Function

await()でCoroutineを中断させることのできる関数をSuspending Functionと言います。 コルーチンから呼び出す関数やメソッドは必ずsuspendを付与する必要があります。

suspend fun getArticle(@Query("param") param: String): List<SampleEntity>

withContext と async/await

これらの処理は「Articleの取得を待ってタイトルを設定する」という同じ意味合いを持ちます。

       CoroutineScope(Dispatchers.Main).launch {
            val client = SampleAPIClient()
            try {
                val hoge1 = withContext(Dispatchers.Default) {
                    client.service.getArticle("param")
                }
                title.text = hoge1.title.toString()
            } catch (e: Exception) {
                Log.d("[ERROR]", e.toString())
            }
        }

        CoroutineScope(Dispatchers.Main).launch {
            val client = SampleAPIClient()
            try {
                val hoge1 = async { client.service.getArticle("param") }.await()
                title.text = hoge1.title.toString()
            } catch (e: Exception) {
                Log.d("[ERROR]", e.toString())
            }
        }

withContextが現在のコルーチンのDispatchersに切り替えますが、asyncは新たにコルーチンを生成した上で待機します。
パフォーマンスに影響がさほど出るわけではありませんが、基本的にはwithContextでいい気もしてきます。

async/awaitでなければならないシーンはRxJavaのObservable.zipなような複数ストリームを扱いたいような時に有効です。

        CoroutineScope(Dispatchers.Main).launch {
            val client = SampleAPIClient()
            try {
                val user = async { client.service.getUser(userID) }
                val article = async { client.service.getArticle("param") }
                data = Data(user.await(), article.await())
            } catch (e: Exception) {
                Log.d("[ERROR]", e.toString())
            }
        }

[example] Coroutines + Retorfit + OkHttpを使ってAPI通信をしてみる

build.gradle(:app)

    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = "1.8"
    }

    implementation 'com.squareup.retrofit2:retrofit:2.6.2'
    implementation 'com.squareup.retrofit2:converter-gson:2.6.2'
    implementation "com.squareup.retrofit2:converter-moshi:2.6.2"
    implementation 'com.jakewharton.retrofit:retrofit2-kotlin-coroutines-adapter:0.9.2'

    implementation 'com.squareup.okhttp3:logging-interceptor:3.8.1'

    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.4"
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.4"
APIClient.kt
abstract class APIClient {
    abstract val baseUrl: String

    val retrofit: Retrofit by lazy {
        Retrofit.Builder()
            .baseUrl(baseUrl)
            .addConverterFactory(GsonConverterFactory.create())
            .addCallAdapterFactory(CoroutineCallAdapterFactory())
            .client(makeOkHttpClientBuilder(baseOkHttp).build())
            .build()
    }

    protected open fun makeOkHttpClientBuilder(base: OkHttpClient.Builder): OkHttpClient.Builder = base

    private val baseOkHttp = OkHttpClient().newBuilder()
        .addInterceptor(Interceptor { chain ->
            val original = chain.request()
            //header
            val request = original.newBuilder()
                .header("Accept", "application/json")
                .method(original.method(), original.body())
                .build()

            return@Interceptor chain.proceed(request)
        })
        .addInterceptor { it.proceed(addUserAgent(it.request())) }
        .connectTimeout(15, TimeUnit.SECONDS)
        .readTimeout(20, TimeUnit.SECONDS)

    private fun addUserAgent(req: Request): Request {
        val agent = "AppName/" + BuildConfig.VERSION_NAME + " Android/" + BuildConfig.VERSION_NAME
        return req.newBuilder().header("User-Agent", agent).build()
    }
}
SampleAPIClient.kt
class SampleAPIClient: APIClient() {
    override val baseUrl: String = "https://hoge.amazonaws.com/dev/"

    val service: SampleAPISearvice = retrofit.create(SampleAPISearvice::class.java)
}
SampleAPIService.kt
interface SampleAPISearvice {
    @GET("get-article")
    suspend fun getArticle(@Query("param") param: String): List<SampleEntity>
}
SampleEntitiy.kt
data class SampleEntity (
    val title: String,
    val url: String,
    val imageUrl: String,
    val subTitle: String
) : Serializable
MainActivity.kt
class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val title: TextView = this.findViewById(R.id.main_title)

        CoroutineScope(Dispatchers.Main).launch {
            val client = SampleAPIClient()
            try {
                val hoge1 = async { client.service.getArticle("param") }.await()
                // val hoge1 = withContext(Dispatchers.Default) {
                //     client.service.getArticle("param")
                // }
                title.text = hoge1.title.toString()
            } catch (e: Exception) {
                Log.d("[ERROR]", e.toString())
            }
        }
    }
}