駆け出しエンジニアぱかぱかの成長記録

引くほど忘れっぽい新卒2年目駆け出しSEぱかぱかの備忘録です。

【Android】RecyclerViewとViewbinding/DataBinding

お久しぶりです!ぱかぱかです!
また前回記事から時間が空いてしまいました…

今日は現在開発を進めている飲食店記録アプリ「グルメノート」で、Viewbindingと
DataBindingを使ってRecyclerViewに紐づける実装についてまとめておきます。
本当は店登録画面からお店の画像をアップロードしAWSのS3に保存するところを実装したかったのですが、AWSの認証関係がうまくいかず挫折しました…
またいつかリベンジします…

前回の記事

radish-se.hatenablog.com

Roomを使って店登録画面から店の情報をrestaurantsテーブルとvisit_recordsテーブルに登録できるようになった!

ViewBinding

今回はViewBindingとDataBindingが登場しますが、まずはViewBindingについてまとめていきます。
ViewBindingとはモデルオブジェクトとレイアウトを結びつける仕組みです。
findViewByIdしてコード側でView情報を取得せずとも、バインディングオブジェクトを通じて画面部品へアクセスできるようになります。

ちなみに今私がいるプロジェクトでは使われておらず、findViewByIdしてせっせと紐づけて実装しています。
findViewById()を使うとコードが煩雑になりますし、id指定誤りによるNullPointerExceptionや、データ型誤りによるClassCastExceptioinの危険性を孕んでいます。
これが原因でバグが発生した事例もありViewBindingに移行したほうがいいと思うのですが、なかなかな修正量になるので難しいところですね。

build.gradleでViewBindingオプションを設定

ViewBindingを利用するためにbuild.gradle(Module: app)にビルドオプションを設定します。
Android公式ページの記述に従いbuildFeaturesのviewBindingをtrueにします。
(公式で日本語のViewBindingのページを見ると定義の仕方が少し古くなってます。ちゃんとメンテされていないんですかね…)

View binding  |  Android Developers

android {
    ...
    buildFeatures {
        viewBinding true
    }
}

バインディングオブジェクトの利用

なんと、build.gradleに先ほどの数行を追加しただけで、もうバインディングオブジェクトが自動で生成されるようになります。
オブジェクト名はレイアウトxmlファイル名のキャメル記法 + "Binding"です。
例えば今回はRecyclerViewを配置している「fragment_visited_restaurant.xml」を使おうと思いますが、バインディングオブジェクト名は以下のように自動生成されています。

fragment_visited_restaurant.xml → FragmentVisitedRestaurantBinding

参考までに「fragment_visited_restaurant.xml」の中身です。
RecyclerViewがあるだけですね。

<?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"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_height="wrap_content"
        android:layout_width="0dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

今回はこれを行った店リストを表示するVisitedRestaurantFragmentから利用したいと思います。
バインディングオブジェクトを取得する際はinflate()を使用します。
(FragmentVisitedRestaurantBindingにstaticメソッドとして自動生成されるようです)

Fragmentから利用する場合は引数が3個のinflate()を使用し、引数を以下のように指定します。

第1引数:onCreateView()メソッドの引数inflater
第2引数:onCreateView()メソッドの引数container
第3引数:false

レイアウトxmlファイルを元にinflateされた画面全体のインスタンス(contentView)はバインディングオブジェクトのrootで取得できるので、それを戻り値としてreturnします。
これで、ViewBindingで生成したRecyclerViewのレイアウトが表示されるようになります。

class VisitedRestaurantFragment: Fragment()  {
    lateinit var binding: FragmentVisitedRestaurantBinding

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = FragmentVisitedRestaurantBinding.inflate(inflater, container, false)

        return binding.root
    }
}

DataBinding

次はDataBindingについてまとめていきます。
ViewBindingだけではViewで表示する情報をコード側で設定する必要がありますが、ここにDataBindingを組み合わせるとレイアウト側がモデルから情報を受け取りViewに反映するところまで実現できます。
SpringでいうThymeleafみたいのものでしょうか。

build.gradleでDataBindingオプションを設定

DataBindingを利用するためにこちらもViewBindingと同様にbuild.gradle(Module: app)にビルドオプションを設定します。
Android公式ページの記述に従いbuildFeaturesのdataBindingをtrueにします。

Databinding  |  Android デベロッパー  |  Android Developers

android {
    ...
    buildFeatures {
        viewBinding true
        dataBinding true ←追加
    }
}

レイアウトファイルの修正

DataBindingでは、画面部品に反映させるデータをレイアウトファイルに設定する必要があります。
今度は行った店リストの各セルを表すレイアウトファイル「visited_restaurant_list_item.xml」を以下のように修正していきます。

①レイアウト全体をタグで囲む。
②先頭にタグを追加しメンバーを定義する。
ウィジェットの中でビューに反映したいデータを指定する。

<?xml version="1.0" encoding="utf-8"?>
<layout> ←①

    <data> ←②
        <variable
            name="visitedRestaurantItem"
            type="com.example.gourmetnote.VisitedRestaurantListItem"/>
    </data>

    <androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/list_item_card"
    ... >

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

        <TextView
            android:id="@+id/restaurant_name"
            ...
            android:text="@{visitedRestaurantItem.restaurantName}" ←③
            ... />

...
</layout>

①のようにlayoutタグでラップするのはお決まりの記述になります。
②のdataタグの中でレイアウトで使用したいオブジェクトとそれを表す変数を定義します。
その変数を使って③のようにレイアウト内でデータにアクセスすることで値を表示することができます。

visited_restaurant_list_item.xmlの外観は以下のような感じで、店名・場所・ジャンル・登録日・メモなどにデータを表示したいので、それぞれ③のようにプレースホルダで設定していきます。


ViewModelとの紐付け

今度はAdapterでRecyclerViewとViewModelとを紐づける実装を修正します。

ViewModel

ViewModelは前回の記事でも登場しましたが、アクティビティに必要なデータを保持するクラスです。
この中でDBのデータをRepository経由で取り出してリストに詰めて持っておきたいと思うのですが、今回は記事が長くなりそうなので一旦固定値のvisitedRestaurantListを持たておきます。

class VisitedRestaurantViewModel(application: Application): AndroidViewModel(application) {
    val visitedRestaurantList = listOf(
        VisitedRestaurantListItem("吉野家",Evaluation.THREE_STAR,"東京","和食","2023/11/01","新メニューが美味しかった",
            ContextCompat.getDrawable(application, R.drawable.gyudon),true),
        VisitedRestaurantListItem("サイゼリヤ",Evaluation.FIVE_STAR,"名古屋","イタリアン","2023/11/01","安すぎ",ContextCompat.getDrawable(application, R.drawable.doria),true),
        VisitedRestaurantListItem("牛角",Evaluation.FOUR_STAR,"大阪","焼肉","2023/11/01","牛タンうまい",ContextCompat.getDrawable(application, R.drawable.yakiniku),false),
        VisitedRestaurantListItem("CoCo壱",Evaluation.THREE_STAR,"京都","カレー","2023/11/01","辛かった",ContextCompat.getDrawable(application, R.drawable.curry),true),
        VisitedRestaurantListItem("天下一品",Evaluation.ONE_STAR,"神戸","ラーメン","2023/11/01","味が濃い",ContextCompat.getDrawable(application, R.drawable.ramen),false),
        VisitedRestaurantListItem("コメダ珈琲",Evaluation.TWO_STAR,"広島","カフェ","2023/11/01","量多い",ContextCompat.getDrawable(application, R.drawable.komeda),false),
        VisitedRestaurantListItem("スシロー",Evaluation.THREE_STAR,"沖縄","寿司","2023/11/01","サーモン3皿食べた",ContextCompat.getDrawable(application, R.drawable.sushiro),true),
        VisitedRestaurantListItem("餃子の王将",Evaluation.FIVE_STAR,"札幌","中華","2023/11/01","餃子テイクアウトした",ContextCompat.getDrawable(application, R.drawable.gyoza),false),
        VisitedRestaurantListItem("やよい軒",Evaluation.THREE_STAR,"仙台","和食","2023/11/01","健康的",ContextCompat.getDrawable(application, R.drawable.yayoi),true),
        VisitedRestaurantListItem("マクドナルド",Evaluation.FOUR_STAR,"青森","ファストフード","2023/11/01","ポテト高くなってた",ContextCompat.getDrawable(application, R.drawable.mac),false)
    )
}

Adapter

Adapterの実装も修正します。
以前RecyclerViewを実装した時はRecyclerView.Adapterを継承していたのですが、今回はListAdapterを採用してみます。
ListAdapter は RecyclerView.Adapterを継承したクラスです。
RecyclerView.Adapterと異なりoverrideする関数が少なくリスト変数を保持する必要がないListAdapterを継承するのが一般的なようです。

VisitedRestaurantListItemBindingでvisited_restaurant_list_item.xmlに持たせたvisitedRestaurantItem変数に情報を渡すbind()を作り、onBindViewHolderの中で実行しています。

class VisitedRestaurantListAdapter(private val viewModel: VisitedRestaurantViewModel): ListAdapter<VisitedRestaurantListItem, VisitedRestaurantListAdapter.VisitedRestaurantCardViewHolder>(DiffCallBack) {
        class VisitedRestaurantCardViewHolder(private val binding: VisitedRestaurantListItemBinding): RecyclerView.ViewHolder(binding.root) {
            fun bind(item: VisitedRestaurantListItem) {
                binding.visitedRestaurantItem = item
            }
        }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VisitedRestaurantCardViewHolder {
        val layoutInflater = LayoutInflater.from(parent.context)
        return VisitedRestaurantCardViewHolder(VisitedRestaurantListItemBinding.inflate(layoutInflater,parent,false))
    }

    override fun getItemCount(): Int {
        return viewModel.visitedRestaurantList.count()
    }

    override fun onBindViewHolder(holder: VisitedRestaurantCardViewHolder, position: Int) {
        holder.bind(viewModel.visitedRestaurantList[position])
    }
}

ListAdapterではDiffUtil.ItemCallback型の変数をコンストラクタに渡す必要があるので、Adapterクラス内で定義しておきます。
これは2つの要素を比較するユーティリティクラスで、ListAdapterでは要素の追加・変更・削除を検知するのに使われます。
これで判定基準が適切かはさておき、ひとまず必要なので作成しておきました。

    private object DiffCallBack: DiffUtil.ItemCallback<VisitedRestaurantListItem>() {
        override fun areContentsTheSame(
            oldItem: VisitedRestaurantListItem,
            newItem: VisitedRestaurantListItem
        ): Boolean {
            return oldItem == newItem
        }

        override fun areItemsTheSame(
            oldItem: VisitedRestaurantListItem,
            newItem: VisitedRestaurantListItem
        ): Boolean {
            return oldItem.restaurantName == newItem.restaurantName
        }
    }

Fragmentの修正

RecyclerViewにlayoutManagerとadapterを設定してあげます。

class VisitedRestaurantFragment: Fragment()  {
    lateinit var binding: FragmentVisitedRestaurantBinding
    private val viewModel: VisitedRestaurantViewModel by viewModels()

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = FragmentVisitedRestaurantBinding.inflate(inflater, container, false)
        binding.recyclerView.layoutManager = LinearLayoutManager(context) ←追加
        binding.recyclerView.adapter = VisitedRestaurantListAdapter(viewModel) ←追加

        return binding.root
    }
}

完成

中身は色々いじったけど、完成品は何も変わらないんですよね〜笑
でも、少しずつあるべき姿に近づいていますよ!

次回はコードにベタ打ちしたリストではなくDBから取り出したデータを元に表示できるようにします。
あとはデータソースの更新に連動して画面表示も自動的に変化するLiveDataも取り入れていきたいと思います。

ではまた!