Kotlin Coroutines Flow
Kotlin Coroutines 1.2.0からFlow
というCold Streamを実現する仕組みが導入されました。これまではChannel
というHot Streamの仕組みがあったのでRxにだいぶ近づいてきた感じがあります。
この記事では、FlowとLiveData、双方向データバインディングを使っていい感じにUIのイベントを処理してみます。
LiveData as Flow
まずは、LiveDataにセットされた値をFlowとして扱うための拡張関数を作ります。Channel
を作るときはchannel
やproduce
といったChannel Builderで作っていましたが、Flow
ではflow
やflowViaChannel
というFlow Builderが用意されています。
flow
ではFlow
に値を流す処理emit
がsuspend関数になっており、Observer
のonChanged
から直接実行できないためflowViaChannel
のoffer
を使っています。
fun <T> LiveData<T>.asFlow() = flowViaChannel<T?> {
it.offer(value)
val observer = Observer<T> { t -> it.offer(t) }
observeForever(observer)
it.invokeOnClose {
removeObserver(observer)
}
}
LiveData + Two-way Data Binding
これは従来のものとなにも変わりません。LiveDataを双方向データバインディングでビューと結びつけます。今回は2つのEditTextのテキストに設定しています。
class MainViewModel : ViewModel() {
val org = MutableLiveData<String>()
val repository = MutableLiveData<String>()
}
<?xml version="1.0" encoding="utf-8"?>
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
>
<data>
<variable
name="viewModel"
type="com.chibatching.flowreactiveuisample.MainViewModel"
/>
</data>
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity"
>
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
>
<EditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="org"
android:text="@={viewModel.org}"
/>
<EditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="repository"
android:text="@={viewModel.repository}"
/>
</LinearLayout>
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
tools:listitem="@layout/list_item_result"
/>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>
Flow + LiveData = Reactive UI
今回は2つのEditTextのうち1つにUser/Organization、もう1つにリポジトリ名を入力してGitHubのリポジトリをインクリメンタルに検索するものを作ります。
検索結果はRecyclerView
でEditTextの下に表示します。
class MainActivity : AppCompatActivity() {
private val viewModel: MainViewModel by lazy {
ViewModelProviders.of(this).get(MainViewModel::class.java)
}
private val binding by lazy {
DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main)
}
private val adapter = RepoListAdapter()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding.viewModel = viewModel
binding.lifecycleOwner = this
binding.recyclerView.adapter = adapter
viewModel.repos.observe(this, Observer { repos ->
repos?.let { adapter.repos = it }
adapter.notifyDataSetChanged()
})
}
}
reposという名前のLiveData
をobserve
して変更が来たらRecyclerView
を更新するシンプルな作りです。
先程のMainViewModel
にreposを実装します。
class MainViewModel : ViewModel() {
val org = MutableLiveData<String>()
val repository = MutableLiveData<String>()
val repos = object : MutableLiveData<List<Repo>>() {
override fun onActive() {
value?.let { return }
viewModelScope.launch {
var job: Deferred<Unit>? = null
org.asFlow()
.combineLatest(repository.asFlow()) { org, repo ->
Pair(org, repo) // 2つのEditTextの最新の値を合成する
}
.collect { // ここから値が流れてきたときの処理
job?.cancel() // 検索中に値が入力されたときは検索をキャンセルする
job = async(Dispatchers.Main) {
value = searchRepository(it.first, it.second)
}
}
}
}
}
private suspend fun searchRepository(org: String?, repo: String?): List<Repo> {
// 省略
}
}
これだけでEditTextの値が編集される度に検索を行い、結果をリストに表示することができました。
もうちょっといい感じに
1文字入力される度に毎回検索が走るのは、変換途中だったりするためあまり使い勝手が良くない&通信が激増するのでRxJavaでよくやるような一定時間ウェイトを入れて入力が無ければ処理を実行する(≒debounce)を実装してみます。
Flow
にはすでにいくつかのオペーレーターが実装されているのですが、残念ながらこのdebounceに相当するようなオペーレーターはまだありません。
ただ、自分自身でオペーレーターを実装することもKotlin Coroutinesの世界でできるため難しくありません。debounceの処理はこんな感じに実装できました。
追記: Kotlin Coroutines 1.2.1でdebounce
オペレーターが実装され自分で作る必要がなくなりました。
fun <T> Flow<T>.debounce(waitMillis: Long) = flow {
coroutineScope {
val context = coroutineContext
var delayPost: Deferred<Unit>? = null
collect {
delayPost?.cancel()
delayPost = async(Dispatchers.Default) {
delay(waitMillis)
withContext(context) {
// emitはContextの変更を許していないので元のContextで実行されるようにする
emit(it)
}
}
}
}
}
これをMainViewModel
に追加します。
class MainViewModel : ViewModel() {
val org = MutableLiveData<String>()
val repository = MutableLiveData<String>()
val repos = object : MutableLiveData<List<Repo>>() {
override fun onActive() {
value?.let { return }
viewModelScope.launch {
var job: Deferred<Unit>? = null
org.asFlow()
.combineLatest(repository.asFlow()) { org, repo ->
Pair(org, repo) // 2つのEditTextの最新の値を合成する
}
.debounce(500) // 入力を500ms待つ
.distinctUntilChanged() // 待った結果、値に変更がないときは無視する(標準で用意されているオペーレーター)
.collect { // ここから値が流れてきたときの処理
job?.cancel() // 検索中に値が入力されたときは検索をキャンセルする
job = async(Dispatchers.Main) {
value = searchRepository(it.first, it.second)
}
}
}
}
}
private suspend fun searchRepository(org: String?, repo: String?): List<Repo> {
// 省略
}
}
おわり
ということで、Flow
とLiveData
, DataBinding
を使って簡単にEditTextの変更を扱うことができました。
RxJavaを使えばできるけどKotlin Coroutinesだと大変だなーというところがまた一つ減った感じですね〜。
全体のコードはこちらです↓
chibatching/FlowReactiveUiSample