Help us understand the problem. What is going on with this article?

AndroidでリアクティブなRecyclerViewを作る

More than 1 year has passed since last update.

リアクティブなRecyclerView

reactive_sample.gif

(リアクティブ感を出すために、FireStoreに直接インサートしたデモです)

本記事のポイント

  • FireStoreのデータをRxKotlinを使ってViewに反映
  • ViewModelからViewへの単方向DataBinding

さらっと出てくるけど説明しないもの

  • Dagger
  • RecyclerViewの基本的な使い方

Utility

RxkotlinとLiveDataの接続を容易にするためにextentionを定義します。

import androidx.lifecycle.LiveData
import androidx.lifecycle.LiveDataReactiveStreams
import org.reactivestreams.Publisher

fun <T> Publisher<T>.toLiveData() = LiveDataReactiveStreams.fromPublisher(this) as LiveData<T>

画面のレイアウト

<?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"
    tools:context=".presentation.main.TodoListFragment">

    <data>

        <variable
            name="fragment"
            type="sample.presentation.main.TodoListFragment" />

        <variable
            name="viewModel"
            type="sample.presentation.main.TodoListViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">


        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/todo_list"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:background="#ffcccccc"
            android:clipToPadding="true"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <com.google.android.material.floatingactionbutton.FloatingActionButton
            android:id="@+id/addButton"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginEnd="32dp"
            android:layout_marginBottom="32dp"
            android:clickable="true"
            android:focusable="true"
            android:tint="@android:color/holo_red_dark"
            android:onClick="@{() -> fragment.addButtonClicked()}"
            app:backgroundTint="@android:color/holo_red_light"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:srcCompat="@android:drawable/ic_input_add" />

    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>

dataBindingで状態の変化に強いviewを作る

viewModelで公開しているLiveDataをxmlからdatabindingすることで、LiveDataのデータが変わると自動的にレイアウトも変わるようになる。
RxKotlin -> LiveData -> layoutとstreamが繋がり幸せ度が高い。

ボタンのクリックイベントはdatabindingでbinding先のメソッドを呼び出すようにしている。かつて、クリックイベントを直接メソッドに紐づける場合は、butterknife等でやられていた、今は公式のフレームワークで可能。

Activityのlayoutは何も書いていないので省略。

activity

import android.os.Bundle
import androidx.databinding.DataBindingUtil
import sample.R
import sample.databinding.ActivityMainBinding
import sample.presentation.NavigationController
import dagger.android.support.DaggerAppCompatActivity
import javax.inject.Inject


class MainActivity : DaggerAppCompatActivity() {

    @Inject
    lateinit var navigationController: NavigationController

    private val binding: ActivityMainBinding by lazy {
        DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding
        navigationController.navigateToTodoList()
    }
}

databindingを利用する場合はDataBindingUtilを利用する。ただ、bindingオブジェクトはclassの至る所で触るのでlazyで定義すると使いやすい。

fragment

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.ViewModelProviders
import androidx.recyclerview.widget.LinearLayoutManager
import sample.databinding.FragmentTodoListBinding
import sample.di.ViewModelFactory
import sample.presentation.NavigationController
import dagger.android.support.DaggerFragment
import javax.inject.Inject


class TodoListFragment : DaggerFragment() {

    companion object {
        fun newInstance() = TodoListFragment()
    }

    @Inject lateinit var factory: ViewModelFactory
    @Inject lateinit var navigationController: NavigationController

    private val binding: FragmentTodoListBinding by lazy {
        FragmentTodoListBinding.inflate(requireActivity().layoutInflater).apply {
            viewModel = fragmentViewModel
            fragment = this@TodoListFragment
        }
    }
    private val fragmentViewModel: TodoListViewModel by lazy {
        ViewModelProviders.of(this, this.factory).get(TodoListViewModel::class.java)
    }

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
                              savedInstanceState: Bundle?): View? {
        return this.binding.root
    }

    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        this.binding.todoList.setHasFixedSize(true)
        this.binding.todoList.adapter = TodoRecyclerViewAdapter(this.viewLifecycleOwner, this.fragmentViewModel)
        this.binding.todoList.layoutManager = LinearLayoutManager(context)
    }

    /**
     * フロートボタンのイベントハンドラ。
     * layoutのxmlからイベントバインディングしている。
     */
    fun addButtonClicked() {
        val fragment = InputDialogFragment.newInstance()
        fragment.show(requireFragmentManager(), "tag")
    }
}

floatingActionButtonのイベントハンドラ先については、今回は関係ないので省略。

viewModel

import androidx.lifecycle.ViewModel
import sample.repository.TodoRepository
import sample.util.ext.toLiveData
import io.github.droidkaigi.confsched2018.util.rx.SchedulerProvider
import javax.inject.Inject

class TodoListViewModel @Inject constructor(
        private val todoRepository: TodoRepository,
        schedulerProvider: SchedulerProvider
): ViewModel() {

    val todoData = this.todoRepository.getTodoList().subscribeOn(schedulerProvider.io()).toLiveData()

}

RepositoryのTodoListから受け取ったものをLiveDataに変換して外部に公開している。
これをlayoutでdatabindingしている。

Repository

import sample.dataStore.TodoDataStore
import sample.model.Todo
import io.reactivex.Flowable
import io.reactivex.Single
import javax.inject.Inject

class TodoRepositoryImpl @Inject constructor(private val todoDataStore: TodoDataStore): TodoRepository {
    override fun getTodoList(): Flowable<List<Todo>> {
        return this.todoDataStore.getTodoDataList().map { it.sortedBy { it.registerDate } }
    }

    override fun addTodo(todo: Todo): Single<Unit> {
        return this.todoDataStore.addTodoData(todo)
    }
}

dataStoreから上がってきたものをソートして上に返しているだけ。

dataStore

import com.google.firebase.Timestamp
import com.google.firebase.firestore.FirebaseFirestore
import sample.model.Todo
import io.reactivex.BackpressureStrategy
import io.reactivex.Flowable
import io.reactivex.Observable
import io.reactivex.Single
import timber.log.Timber
import java.time.Instant
import java.time.OffsetDateTime
import java.time.ZoneId

class TodoDataStoreImpl: TodoDataStore {
    private val collectionPath = "todo"
    data class TodoForFireStore(val title: String,
                                val message: String,
                                val done: Boolean,
                                val registerDate: Timestamp)

    override fun addTodoData(todo: Todo): Single<Unit> {
        return Single.create<Unit> { observer ->
            val todoForFireStore = TodoForFireStore(
                    title = todo.title,
                    message = todo.message,
                    done = todo.done,
                    registerDate = Timestamp(todo.registerDate.toEpochSecond(), todo.registerDate.nano)
            )
            val db = FirebaseFirestore.getInstance()
            db.collection(this.collectionPath).add(todoForFireStore).addOnSuccessListener {reference ->
                Timber.d(reference.toString())
                observer.onSuccess(Unit)
            }.addOnFailureListener {
                Timber.d(it.toString())
                observer.onError(it)
            }
        }
    }

    override fun getTodoDataList(): Flowable<List<Todo>> {
        return Observable.create<List<Todo>> { observer ->
            val db = FirebaseFirestore.getInstance()
            db.collection(this.collectionPath).addSnapshotListener { snapshot, exception ->
                // もしエラーが起きてたら
                if (exception != null) {
                    Timber.d(exception.toString())
                    observer.onError(exception)
                    return@addSnapshotListener
                }

                if (snapshot == null || snapshot.isEmpty) {
                    Timber.d("data is empty")
                    observer.onNext(listOf())
                    return@addSnapshotListener
                }

                observer.onNext(snapshot.map{
                    val title = it.get("title") as String
                    val message = it.get("message") as String
                    val done = it.get("done") as Boolean
                    val registeredTimestamp = it.get("registerDate") as Timestamp
                    val registerDate = OffsetDateTime.ofInstant(
                            Instant.ofEpochMilli(registeredTimestamp.seconds * 1000),
                            ZoneId.systemDefault()
                    )
                    Todo(
                            title = title,
                            message = message,
                            done = done,
                            registerDate = registerDate
                    )
                })
            }
            return@create
        }.toFlowable(BackpressureStrategy.ERROR)
    }
}

FireStoreを扱うdataStore。裏側の実装がfireStoreであることは、ここで完全に隠蔽し、ここから上のレイヤ(RepositoryやviewModel)には、裏側の実装を意識させない。

recyclerViewAdapter

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import sample.databinding.ItemBinding
import sample.model.Todo

class TodoRecyclerViewAdapter(lifeCycleOwner: LifecycleOwner, private val viewModel: TodoListViewModel):
        RecyclerView.Adapter<TodoRecyclerViewAdapter.BindingHolder>() {

    // Adapterが知っている一番最後のlist
    private var oldList: List<Todo> = listOf()

    // 初期化時にviewModelを購読して、変更があった場合、RecyclerViewに通知する
    init {
        this.viewModel.todoData.observe(lifeCycleOwner, Observer {
            this.updateList(it)
        })
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TodoRecyclerViewAdapter.BindingHolder {
        val binding = ItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return BindingHolder(binding)
    }

    override fun getItemCount(): Int = this.viewModel.todoData.value?.size ?: 0

    override fun onBindViewHolder(holder: BindingHolder, position: Int) {
        holder.binding.todo = this.oldList[position]
    }

    private fun updateList(newList: List<Todo>) {
        val diffResult = DiffUtil.calculateDiff(TodoRecyclerViewAdapter.TodoDiffUtilImpl(this.oldList, newList))
        diffResult.dispatchUpdatesTo(this)
        this.oldList = newList
    }

    data class BindingHolder(val binding: ItemBinding) : RecyclerView.ViewHolder(binding.root)

    class TodoDiffUtilImpl(private val oldList: List<Todo>, private val newList: List<Todo>): DiffUtil.Callback() {
        override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean
                = oldList[oldItemPosition].registerDate == newList[newItemPosition].registerDate

        override fun getOldListSize(): Int = oldList.size

        override fun getNewListSize(): Int = newList.size

        override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? {
            return super.getChangePayload(oldItemPosition, newItemPosition)
        }

        override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean
                = oldList[oldItemPosition] == newList[newItemPosition]
    }
}

AdapterではtodoListに更新があった場合、変更を検知してリストを更新しなければならない。そのために、DiffUtilなるものが公式から提供されている。
二つのリストに違いがあった場合に、RecyclerView用の変更イベントを投げてくれる。
DiffUtilを継承したクラスで、リストのDiffとは何かを定義する。

最後に

RecyclerViewAdapterの構成を非常に悩んだ。
今でもoldListをAdapterで保持するのが正解かどうかわからない・・・
viewModelに持たせた方が良いような・・・。
でもoldListが必要なのはAdapterが原因な訳で、viewModelが他のクラスに影響されるのもどうなのかなーとか。

(ほとんど処理が無いアプリなので当たり前ですが)かなりスッキリかけたので、とりあえずこれで良いかな・・・

参考

Urotea
モバイル、Webフロント、サーバサイドなんでも興味持っているマンです(できるとは言っていない)。 雑多な話はnoteの方に書いております。
https://note.com/urotea
nssol
お堅いと評判のユーザ系SIerです。※各記事の内容は個人の見解であり、所属する組織の公式見解ではありません。
https://www.nssol.nipponsteel.com
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away