💨

もう迷わないCoroutines(記事版〜余談編〜)

2022/12/17に公開

はじめに

この記事はMoney Forward Engineering 1 Advent Calendar 2022 17日目の投稿です。
16日目はHidemasa ShimazuさんでDependabot 運用を自動化したいでした。
本日は掲題について書いていきたいと思います。

本稿について

先日のKotlin Fest 2022で「もう迷わないCoroutines 〜suspend funとChannelとFlow〜」というタイトルでお話をさせていただきました。
本記事はそのときの余談、記事版として位置づけ、補足・解説にフォーカスした内容になっています。

目次

  1. あらすじ
  2. 補足の話その1:Case.1:Callbackで値を受け取りたいとき/Case.2:Listenerで値を受け取りたいとき
  3. 補足の話その2:Case.3:通信処理の置き換え
  4. 特徴まとめ
  5. FAQ
  6. まとめ

あらすじ

セッションでは、Kotlin Coroutines(以降コルーチン)の基本を実用面に寄った範囲でおさらいし、suspend関数、Flow、Channelの特徴を整理しました。それらの特徴を踏まえて、実際にあり得るケースを想定し、コルーチンの各機能をどう使い分けるのがいいか?を解説・提案させていただいた、という内容になっています。
詳しくは動画も既に公開されておりますので、お時間のある際にご覧いただけますと幸いです。

https://youtu.be/3-JrmrOV9e4

補足の話その1:Case.1:Callbackで値を受け取りたいとき/Case.2:Listenerで値を受け取りたいとき

Case.1ではsuspendCancellableCoroutineを用いた例、Case.2ではcallbackFlowを用いた例をご紹介しましたが、それぞれ実現方法は他にも色々あると思っています。
例えばCase.1ではワンショットでの値取得でしたが、呼び出し元に対してFlowで揃えたい、というケースも少なくないと思います。

例えば、ワンショットでもFlowしたい
fun getGyro(): Flow<Gyro> = callbackFlow {
  val listener = object : Callback {
    override fun onNextValue(value: Gyro) {
      trySend(value)
      cancel()
    }

    override fun onError(cause: Throwable) {
      cancel(CancellationException("Error", cause))
    }
  }
  sensorManager.addListener(listener)
  sensorManager.start()

  awaitClose {
    sensorManager.stop()
    sensorManager.removeListener()
  }
}

このくらいの処理であれば、suspend関数ですでに実装されている処理をFlowに置き換えたい、といった場合でもそこまでのコストではなさそうに思いますが、より変更コストを低く抑えたい意思が強ければsuspend関数に対してasFlowを使用するという選択肢も検討できるかもしれません。

例えば、suspend関数をasFlowする
suspend fun getGyro(): Gyro
fun gyroFlow(): Flow<Gyro> = ::getGyro.asFlow()

launch {
  gyroFlow.collect {
  }
}

ちなみに、callbackFlowの理解にはflowWithLifecycleのソースコードが個人的には非常に参考になりましたので、ここに共有します。

FlowExtKt.class
@OptIn(ExperimentalCoroutinesApi::class)
public fun <T> Flow<T>.flowWithLifecycle(
    lifecycle: Lifecycle,
    minActiveState: Lifecycle.State = Lifecycle.State.STARTED
): Flow<T> = callbackFlow {
    lifecycle.repeatOnLifecycle(minActiveState) {
        this@flowWithLifecycle.collect {
            send(it)
        }
    }
    close()
}

補足の話その2:Case.3:通信処理の置き換え

Rxを使った通信処理を実装している環境におけるコルーチンへの置き換えを想定してお話をしました。
ここではkotlinx.coroutinesのreactive modulesについても触れました。以前はObservableに対してopenSubscriptionを使うことでChannelとして扱うこともできたのですが、現在はasFlowを使ってFlowに変換するというAPIに変わっており、openSubscriptionは使えなくなっています。以前に記事を書いた時点でObsolete APIとして扱われていたので、当時から利用を控えていたチームも少なくなかったかもしれないですね。

また、reactive modulesを使わない選択をした場合でも、引数(Request Bodyなど)が不要な通信リクエストであればasFlowで簡単にFlowにすることができますし、引数が必要な場合でもflowビルダーを用いて値を下流に流すのは簡単なので、エンドポイントひとつひとつに対しての変換だけで言うとそう難しくはなさそうかなと思います。

特徴まとめ

suspend関数、Flow、Channelそれぞれの特徴をまとめた表がこちらです。

表からも見て取れますが、継続的に値を購読したいケースにおいては、新規に実装する場合は基本的にはFlowを採用するで問題ないかなと思っています。
これにはいくつか理由がありますが、Flowに比べてChannelは実装・設計として複雑といった話ですとか、Channelは異なるプロセスのコルーチンにデータを送る必要があるといったケースなどで使用されますが、同時に存在するコルーチン間でデータを扱うためには同期をとる必要がありどうしてもコストが掛かる、といった点が挙げられます。

セッションではあくまで各機能での比較としているため、例えば、こういった場合はSharedFlowで作成しておいてtakeオペレータを挟んでFlowとして公開するのが良いよね、ワンショットな取得処理だけどFlowを使ってStreamとして扱いたいな、といった具体的な実際のユースケースまでは踏み込んでいませんでした。
より実践的で具体的なケースのお話をするのは話の構成として拡がりすぎてしまいそうと考え、セッションでは敢えて削っています。今後もし機会があれば、そういった具体的なケースの紹介・提案に絞ったお話をするのも面白そうかなと思っています。

例えば、初期値をインスタンス化時には設定できなくて遅延設定したいケース
private val mutableStateFlow = MutableStateFlow<State?>(null)
val stateFlow = mutableStateFlow.asStateFlow().filterNotNull()

また、フローチャートに整理されているとより分かりやすいかなとも思っていますので、こちらは作成したら本記事に追記する形で公開しようかと思っています。

FAQ

セッション内でいただいた質問について、加筆修正しつつで改めて回答を掲載したいと思います。

Q1. callbackFlowだとキャンセルできない?

できます。callbackFlowのsuspendブロック内ではProducerScopeにアクセスすることができるため、cancelisActiveも呼び出すことができます。

Q2. ワンショットでリスナーから値を取得してcloseするという使い方はあまり良くない?

問題ないと考えています。呼び出し元に対してFlowで揃えたい、という判断もあるかと思っていますし、そういった場合はcallbackFlowを使ったほうが実装に無駄がないケースもあると思っています。

Q3. Rx+Retrofitの置き換えのベストプラクティスを知りたい

同じく私もとても興味があります。セッションでご紹介した例ではRx→Flow/Channelの変換でボトルネックになるケースがあったことを判断材料に、リスク回避としてreactive modulesの利用には頼らないという実装を挙げました。
もちろん既存の状態に依る話ではありますが、どういった戦略で臨むのが良いか?という話は色んな事例を聴いてみたいところがあります。

Q4. blockingが必要な場面、敢えてコルーチンを使わないというケースはあるか?

SharedPreferencesにアクセスする際に泣く泣くblockingしたことがあります。
SharedPreferencesは色んなところで呼び出されがちというのと、コルーチン以外の非同期・スレッド処理が既に多く使われていたりすると、行儀よい形に修正するために掛かるコストや影響範囲などを考慮すると止むなく...
DataStoreへの置き換えでも同様の問題がでてくると思っていますので、戦略を練っていきたいなというお気持ちです。

Q5. Cold Flowではメモリ使用量とかは気にならない?

現状あまり気になったケースはなかったかなと思います。
とはいえCold Flowはcollectのたびにそれぞれで起動されることにはなるので、ビルダー関数の中でインスタンス化するものが過度に増殖しないようにする、というのはおさえておきたいところと思ってます。

Q6. 現時点でRxからsuspendに置き換わっている状態でさらにFlowに置き換える意味はあるか?

意味はあると思います。Flow自体はsuspendじゃない世界でも扱えるため、suspend関数を呼び出すよりも取り回しがしやすいという側面はあるかなと思います。また、FlowにしてStreamで扱えたほうが勝手がよい、という判断もあるかと思っているので、置き換えることでのメリットも考えられるかなと思っています。

まとめ

Kotlin Fest 2022登壇時には話しきれなかった話のご紹介などをさせていただきました。
FAQに関してはまだ拾い切れていないものや気になることなどあればご教示いただけますと幸いです。

また、コルーチン以外にもKotlinには便利な機能が多くあります。
そんなKotlinの次バージョン、1.8.0のRCが先日リリースされ、その足音が近づいてきています。
引き続きキャッチアップしていき、コルーチンを、そしてKotlinを愛でていきたいなと思っています。
皆さんも是非これからもKotlinを愛でていきましょう。

Discussion