Kotlin+Androidでasync/await

  • 99
    いいね
  • 0
    コメント

はじめに

皆が待ち望んでいたKotlin 1.1が3月1日にリリースされました。
様々なアップデートが含まれていますが、やはり目玉となるのはコルーチンでしょう。
この記事ではコルーチンのサポートによって実現されたasync/awaitをAndroidで使用する方法について説明します。

コルーチンとは

ものすごくざっくりと言ってしまえば、コルーチンは中断・再開可能な関数です。
通常の関数は呼び出されると戻り値を返すまでそのまま進んでいきますが、コルーチンは任意地点で実行を中断して関数を抜けることができます。
また、抜けたところから抜けた時点の状態で再開することも可能です。

async/awaitとは

コルーチンは色々と使い手のある機能ですが、async/awaitもコルーチンで実現されるパターンの1つです。
async/awaitパターンは非同期処理を同期的に記述することを可能にします。

まずは以下のコードをご覧ください。

fun onClick() {
    button.isEnabled = false
    heavyFunc() // 時間がかかる
    button.isEnabled = true
}

あるボタンが押された際に押されたボタンを非活性化して重い処理を行い、完了後にボタンを活性化するイメージです。
今、heavyFuncは時間がかかる関数であるため、アプリはフリーズしてしまっています。
これを避けるためにはheavyFuncを別スレッドで実行する必要があります。

fun onClick() {
    button.isEnabled = false
    heavyFuncAsync() // 非同期処理
    button.isEnabled = true
}

しかし、上記のコードは狙いどおりの動作をしません。
heavyFuncAsyncが非同期実行されるため呼び出しが即座に完了し、ボタンがすぐに活性化してしまうからです。
これに対する代表的な対策はheavyFuncAsync関数に完了後実行されるコールバックを渡せるようにすることです。

fun onClick() {
    button.isEnabled = false
    heavyFuncAsyncWithCallback {
        uiThread { // UIスレッドでViewを触る
            button.isEnabled = true
        }
    }
}

悪くないですね。それでは非同期タスクA, B, C, Dを順番に実行したい場合はどうなるでしょうか?

fun onClick() {
    button.isEnabled = false
    taskAAsync {
        taskBAsync {
            taskCAsync {
                taskDAsync {
                    uiThread {
                        button.isEnabled = true
                    }
                }
            }
        }
    }
}

いわゆる「コールバック地獄」という非常にネストが深いコードが誕生します。
とてもではないですが書きたくないコードですね。
この「コールバック地獄」に対する解の1つはRx(Promise)です。

fun onClick() {
    button.isEnabled = false
    completableA().andThen(completableB())
        .andThen(completableC())
        .andThen(completableD())
        .observeOn(AndroidSchedulers.mainThread())
        .subscribe {
            button.isEnabled = true
        }
    }
}

メソッドチェーンを使って「コールバック地獄」を一掃することができました。

さて前置きが長くなりましたがいよいよ本題です。
async/awaitを使った場合はどうなるでしょうか?

fun onClick() = launch(UI) {
    button.isEnabled = false
    taskAAsync().await()
    taskBAsync().await()
    taskCAsync().await()
    taskDAsync().await()
    button.isEnabled = true
}

launch(UI)await()といった記述が見えるものの、構造としては同期的な書き方とほぼ同一です。
なぜこのような書き方ができるのかというと、

  1. onClickが呼ばれるとlaunch(UI)がUIスレッドで動くコルーチンを作成する
  2. taskAAsyncは非同期関数なので、呼び出しは即座に完了する
  3. taskAAsyncawaitが呼ばれるとonClickコルーチンが中断される(この時点でonClickを呼んだ関数に制御が移る)
  4. taskAAsyncの処理が完了するとそこからonClickコルーチンが再開される
  5. taskBAsyncawaitが呼ばれると...

というように処理されるからです。
このようにasync/awaitは非同期処理を簡潔・明瞭に記述することができます。

使ってみる

async/awaitがどのようなものかわかったところで、実際に使っていきましょう。
以下に説明していくコードはサンプルプロジェクトにすべて含まれているので、よろしければ活用ください。

gradle設定

Kotlin EAP 1.1リポジトリを追加します。これはコルーチンがまだExperimentalなためです。
Kotlin 1.2になる際にAPIに破壊的変更がある可能性が示唆されています。

app/build.gradle
repositories {
    maven { url "http://dl.bintray.com/kotlin/kotlin-eap-1.1" }
}

また、gradle.propertiesにコルーチンを使用する旨を指定します。

gradle.properties
kotlin.coroutines=enable // 追加

コルーチンライブラリの追加

coreがその名の通りコア機能を集めたライブラリで、androidはUIスレッドでコルーチンを起動するために必要なライブラリです。

app.gradle
dependencies {
    def coroutines_version = '0.13'
    compile "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
    compile "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
}

async関数

まずは非同期実行される関数を定義します。

object AsyncModel {
    fun returnTenAsync() = async(CommonPool) {
        delay(1000)
        return@async 10
    }

    fun returnTwentyAsync() = async(CommonPool) {
        delay(2000)
        return@async 20
    }
}

async(context)は値を返すコルーチンを生成する関数です。
ここでいうcontextはAndroidのContextではなく、コルーチンを実行するスレッドのようなものだと理解してください。
ここではCommonPoolというスレッドプール上でコルーチンを実行させています。

delay(ms)Thread.sleepのNon-Blocking版です。
呼び出されると直ちにコルーチンを中断し、指定された時間が経った後にコルーチンを再開します。

上記の2関数はasync内でIntを返していますが、戻り値はIntではなくDeferred<Int>です。
Deferred<T>awaitすることで値が返るまでコルーチンを中断し、返ってきた後に再開することができます。

awaitしてみる

async関数から結果を取得し、トーストを出してみます。

fun onSerialButtonClick() = launch(UI) {
    setButtonsEnabled(false)

    val ten = AsyncModel.returnTenAsync().await()
    val twenty = AsyncModel.returnTwentyAsync().await()
    val result = ten * twenty

    showToast("result = $result") // 200
    setButtonsEnabled(true)
}

launch(context)は結果を返さない(Unitを返す)コルーチンを生成する関数です(戻り値はDeferred<Unit>ではなくJobです)。
UIはコルーチンコンテキストで、このコルーチンをAndroidのmainスレッドで実行するための指定です。

async関数の戻り値であるDeferred<Int>awaitするとIntが取れるので、それを使って計算することができます。
tenawaitしてからtwentyawaitしているため、このコルーチンは約3秒で完了します。

しかしながら、async関数はそれぞれ独立しているため、並列で計算させても大丈夫なはずです。

fun onParallelButtonClick() = launch(UI) {
    setButtonsEnabled(false)

    val ten = AsyncModel.returnTenAsync()
    val twenty = AsyncModel.returnTwentyAsync()
    val result = ten.await() * twenty.await()

    showToast("result = $result") // 200
    setButtonsEnabled(true)
}

上記の書き方の場合、このコルーチンは約2秒で完了します。
async関数を呼び出した時点で計算がスタートしているからです。
すなわち、結果が必要になった時に逐次awaitしていけば勝手に並列に処理を行ってくれるわけです。

コルーチンスコープ

これまで出てきたdelayawaitはどこでも使えるわけではなく、コルーチンの内部(コルーチンスコープ内)でしか使えません。
(正確にいえばsuspend関数の中でも使えますが、本記事では触れません)
別の場所で使うとコンパイルエラーになるので注意してください。

Rxとの連携

コルーチンはRxと連携することもできます。
まずは依存を追加しましょう。

app.gradle
dependencies {
    def coroutines_version = '0.13'
    compile "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
    compile "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
    compile "org.jetbrains.kotlinx:kotlinx-coroutines-rx2:$coroutines_version" // 追加
}

async関数をRx連携バージョンで書いてみます。

object RxModel {
    fun returnTenAsync() = rxSingle(CommonPool) {
        delay(1000)
        return@rxSingle 10
    }

    fun returnTwentyAsync() = rxSingle(CommonPool) {
        delay(2000)
        return@rxSingle 20
    }
}

rxSingleは戻り値としてSingle<T>を返すコルーチンを生成します。
コルーチンスコープ内なのでdelayも使えます。
もちろんいつものように書いても大丈夫です。

object RxModel {
    fun returnTenAsync() = Single.create<Int> {
        Thread.sleep(1000)
        it.onSuccess(10)
    }.subscribeOn(Schedulers.io())

    fun returnTwentyAsync() = Single.create<Int> {
        Thread.sleep(2000)
        it.onSuccess(20)
    }.subscribeOn(Schedulers.io())
}

さてこれを使ってみましょう。以下の処理は完了までに何秒かかるでしょうか?

fun onRxButtonClick() = launch(UI) {
    setButtonsEnabled(false)

    val ten = RxModel.returnTenAsync()
    val twenty = RxModel.returnTwentyAsync()
    val result = ten.await() * twenty.await()

    showToast("result = $result") // 200
    setButtonsEnabled(true)
}

答えは 約3秒 です。
rxSingleは単にcold Singleを作って返しているだけです。
awaitが呼ばれて初めてsubscribeされるため、この書き方であっても直列に実行されます。

それでは並列に呼ぶためにはどうすればいいのでしょうか?
今のところSingleDeferredに変換する関数が提供されていないため、以下の書き方になると思います。

val result = ten.zipWith(twenty) { t1, t2 -> t1 * t2 }.await() // Rxの流儀で待ち合わせ

もしもっとスマートな書き方をご存じの方がいらしたら是非コメントください。

追記
単にasyncでラップすればいいと気付きました。

fun onRxButtonClick() = launch(UI) {
    setButtonsEnabled(false)

    val ten = async(CommonPool) { RxModel.returnTenAsync().await() }
    val twenty = async(CommonPool) { RxModel.returnTwentyAsync().await() }
    val result = ten.await() * twenty.await()

    showToast("result = $result") // 200
    setButtonsEnabled(true)
}

これだとちゃんと2秒で完了します。
Rx-wayでSingleを作成している場合は、subscribeOnを指定せずasync内でawaitすれぼOKです(async内がそもそも非同期なので)。

その他の機能

ここで説明できた内容はほんの少しに過ぎません。
もっと詳しく知りたい方は公式ドキュメントをご覧ください。

まとめ

非同期処理といえばRxという今日この頃ですが、複雑なストリーム処理などを行っていない場合はasync/awaitに置き換えることが可能です。
Rxと連携することもできるので、まずはRxのsubscribeを置き換えてみるなどカジュアルにチャレンジしてみてください。