LoginSignup
98
60

More than 3 years have passed since last update.

Kotlin Coroutines Flow + LiveData + Two-way Data Binding

Last updated at Posted at 2019-04-22

Kotlin Coroutines Flow

Kotlin Coroutines 1.2.0からFlowというCold Streamを実現する仕組みが導入されました。これまではChannelというHot Streamの仕組みがあったのでRxにだいぶ近づいてきた感じがあります。
この記事では、FlowとLiveData、双方向データバインディングを使っていい感じにUIのイベントを処理してみます。

LiveData as Flow

まずは、LiveDataにセットされた値をFlowとして扱うための拡張関数を作ります。Channelを作るときはchannelproduceといったChannel Builderで作っていましたが、FlowではflowflowViaChannelというFlow Builderが用意されています。
flowではFlowに値を流す処理emitがsuspend関数になっており、ObserveronChangedから直接実行できないためflowViaChannelofferを使っています。

LiveDataExtensions.kt
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のテキストに設定しています。

MainViewModel
class MainViewModel : ViewModel() {
    val org = MutableLiveData<String>()
    val repository = MutableLiveData<String>()
}
activity_main.xml
<?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の下に表示します。

MainActivity
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という名前のLiveDataobserveして変更が来たらRecyclerViewを更新するシンプルな作りです。
先程のMainViewModelにreposを実装します。

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の最新の値を合成する
                    }
                    .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オペレーターが実装され自分で作る必要がなくなりました。:tada:

FlowExtensions.kt
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に追加します。

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> {
        // 省略
    }
}

おわり

ということで、FlowLiveData, DataBindingを使って簡単にEditTextの変更を扱うことができました。
RxJavaを使えばできるけどKotlin Coroutinesだと大変だなーというところがまた一つ減った感じですね〜。

全体のコードはこちらです↓
chibatching/FlowReactiveUiSample

98
60
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
98
60