0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Firebase + MVVM + ListAdapterでリアルタイムにリストを更新する

Posted at
1 / 2

MVVMとは

Modelはデータの管理や保存、外部との入出力、内部的な処理を担い、Viewは利用者に対する画面表示や入力・操作の受け付けを担当する。両者の間を仲介して互いの状態変更を通知、反映させる役割をViewModelが担う。

アクティビティやフラグメントの中でUIの更新とデータの用意を行うと複雑になってしまう。そこで、データ類の管理を行う処理を「ViewModel」に切り離すことで、アクティビティやフラグメントは画面の表示と操作に専念できる。

1.準備

viewBindingを有効にする

buildFeatures {
        viewBinding = true
}

2.実装

データを管理するModelの作成

Item.kt
//コンストラクタではデフォルト値を設定しておく
data class Item(
    var id: String = "",
    val name: String = "",
    val price: Int = 0,
)

1行分のレイアウトを作成する

item_layout.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_margin="8dp"
    android:background="#BeE5FC">

    <TextView
        android:id="@+id/tv_id"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:textAppearance="@style/TextAppearance.AppCompat.Medium"
        android:textStyle="bold"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:text="Id" />


    <TextView
        android:id="@+id/tv_name"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:textAppearance="@style/TextAppearance.AppCompat.Medium"
        android:textStyle="bold"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/tv_id"
        tools:text="Name" />

    <TextView
        android:id="@+id/tv_price"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:textAppearance="@style/TextAppearance.AppCompat.Medium"
        android:textStyle="bold"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/tv_name"
        tools:text="Price" />


</androidx.constraintlayout.widget.ConstraintLayout>

ListAdapterを継承したアダプタークラスを作成

import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.example.firebasetestapp.databinding.ItemLayoutBinding

class ItemAdapter() : ListAdapter<Item, ItemAdapter.ItemViewHolder>(DIFF_CALLBACK) {

    class ItemViewHolder(private val binding: ItemLayoutBinding) : RecyclerView.ViewHolder(binding.root) {
        //ViewHolderにモデルとViewのデータを紐付けるための関数を作成
        @SuppressLint("SetTextI18n")
        fun bindTo(item: Item) {
            binding.apply {
                tvId.text = "id=${item.id}"
                tvName.text = "name=${item.name}"
                tvPrice.text = "price=${item.price}"
            }
        }
    }

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

    override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {
        holder.bindTo(getItem(position))
    }

    //※ListAdapter側で submitListをオーバーライドする (受け取ったリストがnullでなければ、リストを元に新しいArrayListのインスタンスを生成して返す仕様に変更)
    //注:submitList(null)が実行されてしまうと以降正しくリストが表示されなくなってしまう。
    override fun submitList(list: List<Item>?) {
        super.submitList(list?.let { ArrayList(it) })
    }

    //DiffUtil.Callbackを実装したオブジェクトを作成し、ListAdapterに渡す
    companion object {
        val DIFF_CALLBACK = object : DiffUtil.ItemCallback<Item>() {
            override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean {
                return oldItem.id == newItem.id
            }

            override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
                return oldItem == newItem
            }

        }
    }

}

DiffUtil.Callbackとは

2つのリストの差分を計算するときにDiffUtilによって使用されるコールバッククラス
diff = difference(違い)の省略
変更があった際に差分のみ更新される。効率よくリスト表示することができる。

areContentsTheSame(oldItemPosition: Int, newItemPosition: Int)
2つの項目が同じデータを持っているかどうかを確認する場合に、DiffUtilによって呼び出される

areItemsTheSame(oldItemPosition: Int, newItemPosition: Int)
2つのオブジェクトが同じアイテムを表しているかどうかを判断するために、DiffUtilによって呼び出される

★差分があった場合にはAdapterに変更が通知される

ChildEventListenerからDataSnapshotを受け取るLiveDataクラスの作成

FirebaseQueryLiveData.kt
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.lifecycle.LiveData
import com.example.firebasetestapp.source.mvvc.Constants.Companion.LOG_TAG
import com.google.firebase.database.*

class FirebaseQueryLiveData(private val query: Query) : LiveData<DataSnapshot>() {
    constructor(ref: DatabaseReference) : this(query = ref)

    private var listenerRemovePending: Boolean = false
    private val handler: Handler = Handler(Looper.getMainLooper())
    private val listener: MyChildEventListener = MyChildEventListener()
    private val removeListener: Runnable = Runnable {
        query.removeEventListener(listener)
        listenerRemovePending = false
    }

    override fun onActive() {
        if (listenerRemovePending) {
            //2秒経過する前に再度アクティブになった場合は、スケジューリングされた作業を削除し、リスナーがリッスンを継続できるようにする。
            handler.removeCallbacks(removeListener)
        } else {
            query.addChildEventListener(listener)
        }
        listenerRemovePending = false
    }
    //不要なクエリを避けるためLiveDataが非アクティブになった後、Handlerを利用してデータベースリスナーの削除を2秒後にスケジューリングする。
    override fun onInactive() {
        handler.postDelayed(removeListener, 2000)
        listenerRemovePending = true
    }

    //内部にcom.google.firebase.database.ChildEventListenerを継承したクラスを作成
    inner class MyChildEventListener : ChildEventListener {
        override fun onChildAdded(snapshot: DataSnapshot, previousChildName: String?) {
            value = snapshot
        }

        override fun onChildChanged(snapshot: DataSnapshot, previousChildName: String?) {
            //LiveDataに値をセットする
            value = snapshot
        }

        override fun onChildRemoved(snapshot: DataSnapshot) {
            value = snapshot
        }

        override fun onChildMoved(snapshot: DataSnapshot, previousChildName: String?) {
            value = snapshot
        }

        override fun onCancelled(error: DatabaseError) {
            Log.e(LOG_TAG, "Can't listen to query $query", error.toException())
        }

    }

}

LiveDataを保持しておくViewModelクラスの作成

ItemViewModel.kt
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import com.google.firebase.database.DatabaseReference
import com.google.firebase.database.ktx.database
import com.google.firebase.ktx.Firebase

class ItemViewModel : ViewModel() {
    //取得したい値が格納されているデータベースの参照をDatabaseReferenceオブジェクトで用意
    companion object {
        val ITEM_REF: DatabaseReference = Firebase.database.reference.child("items")
    }

//ViewModel側では受け取りたいデータ型でLiveDataを作成する
//Transformations.map(LiveData<X> source, Function<X, Y> func)で変換した値を格納する
val liveData: LiveData<Item> = Transformations.map(FirebaseQueryLiveData(ITEM_REF)) {
        it.getValue(Item::class.java)?.apply { id = it.key.toString() }
    }
    val itemList: MutableLiveData<MutableList<Item>> by lazy { MutableLiveData<MutableList<Item>>(mutableListOf()) }
}

FragmentやActivityでLiveDataを監視する処理を実装する

SampleFragment.kt
class SampleFragment : Fragment() {

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

    private var _binding: FragmentSampleBinding? = null
    private val binding get() = _binding!!
    private val viewModel: ItemViewModel by viewModels()
    private val listAdapter: ItemAdapter = ItemAdapter()

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?,
    ): View? {
        _binding = FragmentSampleBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        binding.apply {
            rvMain.apply {
                layoutManager = LinearLayoutManager(context)
                adapter = listAdapter
            }
        }

        viewModel.liveData.observe(viewLifecycleOwner) {
            viewModel.itemList.value?.add(it)
            viewModel.itemList.notifyObserver()
        }

        //viewModelのitemList(MutableLiveData<MutableList<Item>>)が変更されるとListAdapterにsubmitListで値が渡される
        viewModel.itemList.observe(viewLifecycleOwner) {
            listAdapter.submitList(it)
        }

    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
    
    //Observerに通知するために実装
    private fun <T> MutableLiveData<T>.notifyObserver() {
        this.value = this.value
    }

}

3.実行

Screenshot_20221214-105015.png

Realtime Database

スクリーンショット 2022-12-14 10.48.42.png

まとめ

FirebaseDatabaseにデータが追加されたり、変更があったりするとviewModelのliveData:LiveDataが更新され、FragmentやActivityに通知されます。

それぞれの役割に分かれているので、コードの見通しが良くなりました。
改善の余地がまだまだあると思います。お気づきの点があれば、どんどん教えていただければ幸いです。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?