visible true

技術的なメモを書く

FlowのflowViaChannelを使ってRxBindingを置き換える

※こちらは古いので FlowのchannelFlowを使ってRxBindingを置き換える - visible true を参照ください。

Kotlin Coroutine 1.2.xでFlowというコールドストリームをサポートするクラスや関数群が登場しました。

Flow - kotlinx-coroutines-core

次のような感じでめっちゃRxJavaっぽい雰囲気ですが動作の仕組みはコルーチンでやってる感じです。

val f = flowOf(1, 2, 3) // Flowを固定値で作る
  .map {
    it * 2
  }
  .flowOn(Dispatchers.IO) // 実行コンテキストを設定できる

runBlocking {
  f.collect { // この呼出しで初めて値が送出され始める
    println(it)
  }
}

Channelはホットストリームなので取扱いが難しい的な話

本題と逸れるのであんまり語らないですが、こんな話があってFlowが登場しました。

Cold flows, hot channels

produceとかChannelだとObservable的な使い方できなかったり宣言時点で動き出すので管理大変やで〜みたいな感じですね。

例えばRxTextView#textChangesと置き換えてみる

FlowでRxTextView#textChangesのような機能を実装すると次のようになります。

fun TextView.textChangeAsFlow() =
  flowViaChannel<String?> { channel ->
    channel.offer(text.toString())
    val textWatcher = addTextChangedListener {
      channel.offer(it?.toString())
    }
    channel.invokeOnClose {
      removeTextChangedListener(textWatcher)
    }
  }

flowViaChannel関数を使ってChannelを使ったFlowが作れます。

次のように使います。

lifecycleScope.launchWhenCreated {
  binding.editNickName.textChangeAsFlow()
    .map { it?.isNotEmpty() ?: false } 
    .collect {
      binding.submitButton.isEnabled = it
    }
}

collect関数を呼び出した時に、呼び出し元のスコープでFlowが動作し始めます*1。 なので、呼び出し元のスコープがキャンセルされると、自動的にcollect関数も終了します。 例ではlifecycleScopeを使っているので、onStopの時にキャンセルされます。 もしFragmentで使う場合はviewLifecycleOwner.lifecycleScopeで呼び出すなどの工夫が必要そうです。

Flowを1 : nにしたい時はどうするといいかな?

TextViewにaddTextChangedListenerするケースだと、複数回呼び出しても問題ありませんが、 setOnClickListenerなどリスナーが上書きされる性質のものを使う場合は、1 : n の関係でFlowを作りたいですね。

broadcastIn関数を使ってBroadcastChannelを作ると 1 : n のFlowが作れます。

lifecycleScope.launchWhenCreated {
  val textChange = binding.editNickName
    .textChangeAsFlow()
    .broadcastIn(this)
    .asFlow()
  // ... 省略
}

broadcastIn関数は引数にCoroutineScopeを必要とします。BroadcastChannelを作った時点でhot streamになるので、スコープに所属させていないとリークしてしまうからです。broadcastIn関数を呼び出したあとasFlow関数で改めてFlowに変換しています。これでbroadcastIn関数の手前部分がhotになり、それ以降はcoldになります。

使い方は次のようになります。

lifecycleScope.launchWhenCreated {
  val textChange = editNickName
    .textChangeAsFlow()
    .broadcastIn(this)
    .asFlow()

  launch {
    textChange
      .map { it?.isNotEmpty() ?: false }
      .collect {
        sendButton.isEnabled = it
      }
  }
  launch {
    textChange
      .map { 140 - (it?.length ?: 0) }
      .collect { remain ->
        editNickNameLimit.text = "remain: $remain"
      }
  }
}

よさそう。

おわりに

Flow便利そうですね。RxBindingの機能は多岐に渡るので、今回のお試しだけで全部置き換えられるかっていうとわからないですが、概ねいけるんじゃないかなーと思います。

ところでFlowはまだpreviewなのでプロダクトへの投入は推奨されていません。 とはいえ大体うまく動くのでまぁちょっとくらいならいいんじゃないかなと思ったりしますが、 Structured Concurrencyの時のようにめちゃくちゃ変わる可能性もあるんでほどほどにしましょう。