Kotlinのcoroutine入門:coroutineのメリットとは?

最近業務で久々にAndroid開発に携わることになり、Kotlinやcoroutineについて勉強したので書き残しておく。

参考:Kotlin Coroutines DEEP DIVE

なぜcoroutineが必要なのか?

何かをUIで表示するときによく必要になるような、データを取得、加工、表示するような処理を素直にコードにしてみると以下のようになる。

fun onCreate() {
  val news = getNewsFromApi()
  val sortedNews = news.sortedByDescending { it.publishedAt }
  view.showNews(sortedNews)
}

しかし、実際にはそう簡単にはいかない。

例えばAndroidなどはViewを更新できるのはアプリケーションごとに1スレッドであるので、このスレッドを通信待ちなどでブロックするわけにはいかない。かといって、そのような制約があるため、単純に別スレッドを立てるわけにもいかない。

スレッドを切り替える方式

fun onCreate() {
  thread {
    val news = getNewsFromApi()
    val sortedNews = news.sortedByDescending { it.publishedAt }
    runOnUiThread {
      view.showNews(sortedNews)
    }
  }
}

こうすることもできるが、これでも問題がある。

  • スレッド処理をキャンセルできないため、メモリリークの可能性
  • スレッド作成は重い
  • 頻繁にスレッドを切り替えたりすると混乱しやすく管理が難しい
  • コードが必要以上に大きく複雑になりやすい

コールバック方式

スレッド以外のもう一つのパターンはコールバックを使う方法である。

関数をノンブロッキングにし、その代わり処理が完了したあとに実行したいコールバックを渡すようにする。

fun onCreate() {
  getNewsFromApi { news ->
    val sortedNews = news.sortedByDescending { it.publishedAt }
    view.showNews(sortedNews)
  }
}

しかしキャンセルできないことに注意。コールバックでキャンセルできるようにすることもできるが、簡単ではないし、キャンセルできるようにするためにオブジェクトを保持しておく必要も生じる。

fun onCreate() {
  startedCallbacks += getNewsFromApi { news ->
    val sortedNews = news.sortedByDescending { it.publishedAt }
    view.showNews(sortedNews)
  }
}

これで問題は解決するとはいえ、多くの欠点がある。

それを確認するため、より複雑な例として、データを3つのエンドポイントから取得する場合を考えてみる。

fun showNews() {
  getConfigFromApi { config ->
    getNewsFromApi(config) { news ->
      getUserFromApi { user ->
        view.showNews(sortedNews)
      }
    }
  }
}
  • newsとuserデータは並列化できそうだが、今のコールバック形式で対応するのは難しい
  • キャンセルするための仕組みを追加するとなると、さらに面倒
  • ネストが深くなり読みづらい(通称コールバック地獄)
  • コールバックを使うと、ある処理の次の処理を管理するといったことが難しい

このようにコールバック方式は理想には程遠い。

では、次にRxJavaなどのリアクティブストリーム方式を考えてみる。

Rxjavaなどのリアクティブなストリーム方式

Javaでよくあるもう一つのやり方がリアクティブなストリームを用いる方式で、この方式だと開始、加工、監視などのあらゆる処理をストリームとして書くことができる。さらにスレッドの切り替えや並列化なども対応できるようになっている。

RxJavaで書くと以下のようになる。

fun onCreate() {
  disposables += getNewsFromApi()
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    .map { news -> 
      news.sortedByDescending { it.publishedAt }
    }
    .subscribe { sortedNews ->
      view.showNews(sortedNews)
    }
}

これでコールバック方式よりだいぶ良くなった。

  • メモリリークしない
  • キャンセル可能
  • スレッドの使い分け

しかし、唯一の問題は、最初の理想的なコードから比べるとだいぶ複雑であること。

subscribeOn, observeOn, map, subscribeなど、使いこなすために覚えることが多いし、キャンセル処理は明示する必要がある、各関数はObservableやSingleクラスでwrapする必要もあるなど。

さらにもう一つ問題がある。これで3つのエンドポイントからデータ取得しようとすると、さらに非常に複雑になる。

Kotlinのコルーチン

Kotlinのコルーチンの中核となるのは、コルーチンの処理を中断・再開(suspend, resume)できるようにしたことである。

これのおかげで処理をメインスレッドで動かし、データ取得するときには中断することができる。中断しているときはスレッドをブロックしているわけではないので、他の処理をすることができる。

コルーチンを使うと以下のように書くことができる。

fun onCreate() {
  viewmodelScope.launch {
    val news = getNewsFromApi()
    val sortedNews = news.sortedByDescending { it.publishedAt }
    view.showNews(sortedNews)
  }
}

これは冒頭で示した理想的なコードにかなり近くなっている。

この方式だとメインスレッドで動作しながら、スレッドをブロックすることがない。

では、3つのエンドポイントから取得する場合はどうなるだろうか。

fun showNews() {
  viewmodelScope.launch {
    val config = getConfigFromApi()
    val news = getNewsFromApi(config)
    val user = getUserFromApi()
    view.showNews(user, news)
  }
}

だいぶ良いが、これだと各APIの呼び出しは直列になっている。

そこで、asyncとawaitを使って非同期にするとこのようになる。

fun showNews() {
  viewmodelScope.launch {
    val config = async { getConfigFromApi() }
    val news = async { getNewsFromApi(config.await()) }
    val user = async { getUserFromApi() }
    view.showNews(user.await(), news.await())
  }
}

これでもまだシンプルで読みやすいし、効率的でメモリリークも起きない。

このようにコルーチンを使うことで良い感じにシンプルに書くことができる。

コルーチンのもう一つのメリット

最後にもう一つ、コルーチンを利用するのが良い大きな理由がある。それは、スレッドは重いということ。生成し、管理しメモリ確保する必要がある。

この問題は以下のコードで確認するとわかりやすい。

例として、10万ユーザがリクエストしてくるバックエンドサービスの処理を想定したコード。実際に動かしてみると、前者はすべてのドットが表示されるまでに時間がかかるか、メモリ不足に陥るだろう。これがスレッドを使うことのコストである。

fun main() {
  repeat(100000) {
    thread {
      Thread.sleep(1000L)
      print(".")
    }
  }
}

一方で、後者はコルーチンを使っている。実行すると数秒ですべてのドットが表示されるはずである。

fun main() = runBlocking {
  repeat(100000) {
    launch {
      delay(1000L)
      print(".")
    }
  }
}

コメント

タイトルとURLをコピーしました