10
0

More than 1 year has passed since last update.

Groupieより生産性高い(はず?)なRecylcerViewのススメ

Last updated at Posted at 2021-12-17

この記事はUzabase Advent Calendar 2021の18日目の記事です。


はじめに

Androidで一覧系を作るとき面倒ですよね。RecylcerViewなんですがそれがめんどくさい。
なので、2019年ぐらいから流行り出したGroupieEpoxyを使う人が多いのではないでしょうか?

だがしかし! 同僚が「@ko2icが作った仕組みの方がgroupieよりも簡単」という発言を信じて、仕組みを公開しようと思いますw
(読んでみて違うと思った方、先に謝っておきます。すいません。)

実は、Groupieが流行る前に作った仕組みなのですが、NewsPicksというデザインの凝ったアプリで洗練させていった感じです。
大体のアプリではこの仕組みを使うことで生産性は上がる気がしています。

まずは、簡単なこんなUIを作ります。(赤字は対応するViewModelを書いているだけで、実際のアプリにあるわけではありません)

Groupieでは

レイアウトでRecyclerViewを定義し、

<androidx.recyclerview.widget.RecyclerView
    android:id="@+id/recyclerView"

それにGroupieAdapterのインスタンスを設定します

val adapter = GroupieAdapter()
binding.recyclerView.adapter = adapter

リストのアイテムになるViewHolderとViewModelを作って

class HeaderViewHolder(private val viewModel: HeaderViewModel) : BindableItem<ListItemMainHeaderBinding>() {
    override fun getLayout(): Int = R.layout.list_item_main_header
    override fun bind(viewBinding: ListItemMainHeaderBinding, position: Int) {
       viewBinding.viewModel = viewModel
   }
}

class ContentViewHolder(private val viewModel: CommentItemViewModel) : BindableItem<ListItemMainContentBinding>() {
    override fun getLayout(): Int = R.layout.list_item_main_content
    override fun bind(viewBinding: ListItemMainContentBinding, position: Int) {
        viewBinding.viewModel = viewModel
    }
}

object HeaderViewModel {
    val title = "Sample Title"
}

class CommentItemViewModel(dto: CommentItemDto) {
    val email: String = dto.email
    val body: String = dto.body
}

一覧に表示するアイテムを設定していく感じです。ViewModelのLiveDataのitemsに非同期で取得した値が設定されて、Fragmentでobserveしている箇所でGroupieのオブジェクトに追加していく感じです。

viewModel.items.observe(viewLifecycleOwner, Observer {
       val commentItemViewModels = it.map { ContentViewHolder(CommentItemViewModel(it)) }
       val section = Section()
       section.setHeader(HeaderViewHolder(HeaderViewModel))
       section.addAll(commentItemViewModels)
       adapter.add(section)
})

参考までにViewModelはこんな感じです。

MainViewModel.kt
    private val _items = MutableLiveData<List<CommentItemDto>>()
    val items: LiveData<List<CommentItemDto>> get() = _items

    private fun fetchList() {
        CommentRepository().fetchComments().onEach {
            _items.value = it
        }.catch { cause ->
            // TODO エラー処理
            throw cause
        }.onCompletion {
            isLoadingForRefresh.set(false)
        }.launchIn(viewModelScope)
    }

微妙だと思っている点

上記のような追加処理をFragmentで書くところが微妙かなと。BindingはViewModelで完結させたいんです。GroupieだとFragmentにロジックが入るんです。
上記の例は単純なので記述量は少ないですが、複雑な一覧になればなるほど、ロジックが膨れます。
Fragmentでは、ViewModelの値をそのまま渡すだけで終わらせたい。
なぜなら、ViewModelに処理を寄せれば、多くのデグレをViewModelだけの単体テストで検知できるからです。
もちろん、GroupieのようにViewに依存してる処理はViewModelに書きたくないですよね。
(上記の実装は、かなり端折ってますが、自作の場合と比べるとViewHolderを作るところやovserveしている部分が自作のより記述量が多いです。)

ということで、以下を満たす仕組みを作ってました。

  • ViewModelで非同期処理が完結できる
  • ViewModelで表示したい値をすべて設定できる(Fragmentに余計な処理をなるべくしたくない)
  • ViewModelがViewに依存しない(当たり前だけど)
  • ViewModelの単体テストで多くのデグレ検知ができる

さらにいうとこの仕組みを作ると以下も簡単に余計な依存もなく対応できます。

  • Recycler In Recycler (RecylcerViewのネスト)をしたい
  • Pull to Refreshの仕組みに入れたい
  • ページングの仕組みに入れたい

ということで、自作が有利になるような表を書いときますw

Groupie 自作
Adapter実装 不要 一つだけ必要
ライブラリ化すれば不要
ViewHolder不要 必要 不要
Fragmentでの記述量
Recyler in Recyler作成時の記述量
ネストの方もViewHolderが必要とかで
Fragmentの記述量が多い
ページングなどの機能拡張 面倒 簡単

仕組み

ライブラリ化してないので、クラスを書いていきます。ライブラリ化したら記述不要になります。

まずは、CollectionItemViewModelというリストのアイテムひとつひとつを表すインターフェイスを定義します。

CollectionItemViewModel.kt
interface CollectionItemViewModel

次にItemViewTypeProviderというレイアウトxmlとViewModelをマッピングするインターフェイスを作ります。

ItemViewTypeProvider.kt
interface ItemViewTypeProvider {
    fun getLayoutRes(modelCollectionItem: CollectionItemViewModel): Int
    fun getBindingVariableId(modelCollectionItem: CollectionItemViewModel) = BR.viewModel
}

あとは、自作のRecyclerViewAdapterクラスを作ります。

RecyclerViewAdapter.kt
open class RecyclerViewAdapter<T>(
    private val list: ObservableArrayList<T>,
    private val viewTypeProvider: ItemViewTypeProvider,
    private val onPostBindViewListener: ((T, ViewGroup) -> Unit)? = null
) : RecyclerView.Adapter<RecyclerViewAdapter.ItemViewHolder>()
    where T : CollectionItemViewModel {

    private val loadingLayoutResId = R.layout.list_item_loading

    init {
        list.addOnListChangedCallback(object :
            ObservableList.OnListChangedCallback<ObservableList<T>>() {
            override fun onChanged(viewModels: ObservableList<T>) {
                notifyDataSetChanged()
            }

            override fun onItemRangeChanged(
                viewModels: ObservableList<T>,
                positionStart: Int,
                itemCount: Int
            ) {
                notifyItemRangeChanged(positionStart, itemCount)
            }

            override fun onItemRangeInserted(
                viewModels: ObservableList<T>,
                positionStart: Int,
                itemCount: Int
            ) {
                notifyItemRangeInserted(positionStart, itemCount)
            }

            override fun onItemRangeMoved(
                viewModels: ObservableList<T>,
                fromPosition: Int,
                toPosition: Int,
                itemCount: Int
            ) {
                notifyItemMoved(fromPosition, toPosition)
            }

            override fun onItemRangeRemoved(
                viewModels: ObservableList<T>,
                positionStart: Int,
                itemCount: Int
            ) {
                notifyItemRangeRemoved(positionStart, itemCount)
            }
        })
    }

    private var inflater: LayoutInflater? = null

    fun getItem(position: Int) = list.elementAtOrNull(position)

    override fun getItemCount() = list.count()
    override fun getItemViewType(position: Int): Int {
        val item = list[position]
        if (item is LoadingItemViewModel) {
            return loadingLayoutResId
        }
        return viewTypeProvider.getLayoutRes(item)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder {
        val inflater = this.inflater ?: from(parent.context)

        if (viewType == loadingLayoutResId) {
            val binding =
                DataBindingUtil.inflate<ListItemLoadingBinding>(inflater, viewType, parent, false)
            return ItemViewHolder(binding)
        }

        val binding = DataBindingUtil.inflate<ViewDataBinding>(inflater, viewType, parent, false)
        return ItemViewHolder(binding)
    }

    override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {
        val item = list[position]
        holder.binding.setVariable(viewTypeProvider.getBindingVariableId(item), item)
        holder.binding.executePendingBindings()
        val onPostBindViewListener = this.onPostBindViewListener
        if (onPostBindViewListener != null) {
            onPostBindViewListener(item, holder.itemView as ViewGroup)
        }
    }

    class ItemViewHolder(val binding: ViewDataBinding) : RecyclerView.ViewHolder(binding.root) {
    }
}

使い方

一覧のアイテムになるViewModelにCollectionItemViewModel を実装します。

object HeaderViewModel : CollectionItemViewModel {
    val title = "Sample Title"
}
class CommentItemViewModel(dto: CommentItemDto): CollectionItemViewModel {
    val email: String = dto.email
    val body: String = dto.body
}

ViewModelに対応するレイアウトを作ります。ViewModelごとに違うデザインのレイアウトがあるという状態です。

list_item_main_header.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="ko2ic.sample.ui.HeaderViewModel"/>
    </data>
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:textSize="24sp"
            android:text="@{viewModel.title}"
            android:layout_margin="8dp"
            tools:text="タイトル"
            />
    </LinearLayout>
</layout>
list_item_main_content.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="ko2ic.sample.ui.CommentItemViewModel"/>
    </data>

        <androidx.cardview.widget.CardView
            android:layout_gravity="center"
            android:layout_width="match_parent"
            android:layout_height="100dp"
            android:layout_margin="8dp"
            app:cardCornerRadius="4dp">

            <androidx.constraintlayout.widget.ConstraintLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:padding="8dp">

                <TextView
                    android:id="@+id/email"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:text="@{viewModel.email}"
                    android:textSize="16sp"
                    app:layout_constraintLeft_toLeftOf="parent"
                    app:layout_constraintTop_toTopOf="parent"
                    tools:text="aaaaaaa"
                    />

                <TextView
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:text="@{viewModel.body}"
                    app:layout_constraintTop_toBottomOf="@id/email"
                    android:layout_margin="8dp"
                    tools:text="body"
                    />

            </androidx.constraintlayout.widget.ConstraintLayout>
        </androidx.cardview.widget.CardView>
</layout>

Fragmentで ItemViewTypeProvider の実装をし、adapterに自作のRecyclerViewAdapterのインスタンスを設定します。

MainFragment.kt
class MainFragment : Fragment(R.layout.main_fragment) {
    companion object {
        fun newInstance() = MainFragment()
    }
    private lateinit var viewModel: MainViewModel

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        val binding = MainFragmentBinding.bind(view)
        viewModel = ViewModelProvider(this)[MainViewModel::class.java]
        binding.viewModel = viewModel
        (activity as? AppCompatActivity)?.supportActionBar?.title = "RecyclerView Sample"

        val itemViewTypeProvider = object : ItemViewTypeProvider {
            override fun getLayoutRes(modelCollectionItem: CollectionItemViewModel): Int {
                return when (modelCollectionItem) {
                    is HeaderViewModel -> R.layout.list_item_main_header
                    is CommentItemViewModel -> R.layout.list_item_main_content
                    else -> throw IllegalArgumentException("Unexpected layout")
                }
            }
        }
        binding.recyclerView.adapter = RecyclerViewAdapter(viewModel.viewModels, itemViewTypeProvider)
    }
}

ポイントは、以下のようにViewModelに対応したXmlレイアウトファイルを指定するところです。

is HeaderViewModel -> R.layout.list_item_main_header

で、Fragmentで直接定義しているViewModel(ここではMainViewModel)で非同期処理を実行し、val viewModels = ObservableArrayList<CollectionItemViewModel>() にそれぞれのデザインごとのViewModelに変換して格納します。
ここのプロパティviewModelsRecyclerViewAdapterの引数に渡しているものになります。

Groupieのときと比べるとこのクラスで一覧のアイテムを全て設定していて、GroupieなどのViewに関連する余計なクラスにも依存していないことがわかると思います。

MainViewModel.kt
class MainViewModel : ViewModel() {

    val viewModels = ObservableArrayList<CollectionItemViewModel>()

    init {
        fetchList()
    }

    private fun fetchList() {
        CommentRepository().fetchComments().onEach {
            val commentItemViewModels = it.map { CommentItemViewModel(it) }
            viewModels.add(HeaderViewModel)
            viewModels.addAll(commentItemViewModels)
        }.catch { cause ->
            // TODO エラー処理
            throw cause
        }.launchIn(viewModelScope)
    }
}

これで、以下が実現できているのがなんとなくわかると思います。
FragmentではViewModelとレイアウトファイルのマッピングをしているだけで、ほとんどの処理はViewModelにあるので以下が実現できています。

  • ViewModelで非同期処理が完結できる
  • ViewModelで表示したい値をすべて設定できる
  • ViewModelがViewに依存しない(当たり前だけど)
  • ViewModelの単体テストで多くのデグレ検知ができる

RecylerViewのネスト方法(RecylerView In RecylerView)

ネストするのも簡単です。どういう場合にネストしたくなるかとういうのは様々ですが、縦スクロールの中に一つのアイテムだけが横スクロールできるデザインのときが多いのではないでしょうか。

こんなUIです。赤字で囲んでいる箇所は横スクロールできる部分です。

仕組み

念の為、どこでその仕組みがあるかというと自作のRecyclerViewAdapterのコンストラクタの3番目の引数で実現しています。

RecyclerViewAdapter.kt
open class RecyclerViewAdapter<T>(
    private val list: ObservableArrayList<T>,
    private val viewTypeProvider: ItemViewTypeProvider,
    private val onPostBindViewListener: ((T, ViewGroup) -> Unit)? = null  <- ここ

これは外側のRecyclerViewAdapterのBindが終わったあと(外側のRecyclerView)に、何かしらの処理したい場合に動作するリスナーで以下の箇所で動作します。

RecyclerViewAdapter.kt
    override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {
        val item = list[position]
        holder.binding.setVariable(viewTypeProvider.getBindingVariableId(item), item)
        holder.binding.executePendingBindings()
        val onPostBindViewListener = this.onPostBindViewListener
        if (onPostBindViewListener != null) {
            onPostBindViewListener(item, holder.itemView as ViewGroup) <- ここ
        }
    }

実装方法

まずは、縦のRecylerViewに追加するViewModelを追加します。このクラスは、横スクロールのアイテムが格納されるval viewModels = ObservableArrayList<ImageItemViewModel>()を持ちます。

ImageMainViewModel.kt
class ImageMainViewModel(private val scope: CoroutineScope) : CollectionItemViewModel {

    val viewModels = ObservableArrayList<ImageItemViewModel>()

    init {
        fetchList()
    }

    private fun fetchList() {
        ImageRepository().fetchImages().onEach {
            val imageItemViewModels = it.map { ImageItemViewModel(it.url) }
            viewModels.addAll(imageItemViewModels)
        }.catch { cause ->
            // TODO エラー処理
            print(cause)
        }.launchIn(scope)
    }
}

横スクロールのアイテムのViewModelも作成します。

ImageItemViewModel.kt
class ImageItemViewModel(val url: String) : CollectionItemViewModel

対応するレイアウトを作成します。

list_item_image_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="ko2ic.sample.ui.ImageMainViewModel" />
    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recyclerView"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal"
            app:layoutManager="LinearLayoutManager"
            tools:listitem="@layout/list_item_image" />

    </LinearLayout>
</layout>
list_item_image.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">

    <data>
        <variable
            name="viewModel"
            type="ko2ic.sample.ui.ImageItemViewModel" />
    </data>

    <FrameLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="16dp">

        <ImageView
            android:layout_width="52dp"
            android:layout_height="52dp"
            android:scaleType="centerCrop"
            app:imageUrl="@{viewModel.url}" />
    </FrameLayout>

</layout>

ItemViewTypeProviderでレイアウトXMLとViewModelをマッピングします。

        val itemViewTypeProvider = object : ItemViewTypeProvider {
             override fun getLayoutRes(modelCollectionItem: CollectionItemViewModel): Int {
                 return when (modelCollectionItem) {
                     is HeaderViewModel -> R.layout.list_item_main_header
                     is CommentItemViewModel -> R.layout.list_item_main_content                   
+                    is ImageMainViewModel ->  R.layout.list_item_image_main
+                    is ImageItemViewModel -> R.layout.list_item_image
                     else -> throw IllegalArgumentException("Unexpected layout")
                 }
             }

RecyclerViewAdapterコンストラクタの第三引数で、横スクロールのRecyclerViewに設定する処理を記述します。

MainFragment.kt
        binding.recyclerView.adapter = RecyclerViewAdapter(viewModel.viewModels, itemViewTypeProvider)
        { modelCollectionItem, view ->
            when (modelCollectionItem) {
                is ImageMainViewModel -> {
                    val imageMainBinding =
                        DataBindingUtil.findBinding<ListItemImageMainBinding>(view)
                    imageMainBinding?.recyclerView?.adapter = RecyclerViewAdapter(
                        modelCollectionItem.viewModels,
                        itemViewTypeProvider
                    )
                }
            }
        }

これで完成です。余計なViewHolderなどを作らずに実装できました。

Pull To Refreshを入れる

これは、この仕組みというよりBindingAdapterを利用します。ただ、自作のRecyclerViewAdapterでは、ほとんどの処理をActivityやFragmentからViewModelの持っていきたいという考え方があり、それを後押しする仕組みになると思います。

isLoadingForRefreshonRefresh() メソッドを追加します。isLoadingForRefreshは、onCompletionでfalseになるようにします。

MainViewModel.kt

    val isLoadingForRefresh = ObservableField(false)
    fun onRefresh() {
        viewModels.clear()
        isLoadingForRefresh.set(true)
        fetchList()
    }

    private fun fetchList() {
        CommentRepository().fetchComments().onEach {
・・・
        }.catch { cause ->
・・・
        }.onCompletion {
            isLoadingForRefresh.set(false) // <-ここ追加
        }.launchIn(viewModelScope)
    }

@BindingAdapterでリフレッシュ処理が動くようにします。

@BindingAdapter(value = ["onRefresh", "refreshColors"])
fun SwipeRefreshLayout.onRefresh(listener: SwipeRefreshLayout.OnRefreshListener, @ColorInt vararg colors: Int) {
    setColorSchemeColors(*colors)
    setOnRefreshListener(listener)
}

レイアウトでRecyclerViewをSwipeRefreshLayoutで囲みます。app:refreshingapp:refreshColorsが、BindingAdapterのそれぞれのメソッド引数に渡り動作します。

main_fragment.xml
        <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:onRefresh="@{viewModel::onRefresh}"
            app:refreshColors="@{@intArray/refresh_color}"
            app:refreshing="@{viewModel.isLoadingForRefresh}">

            <androidx.recyclerview.widget.RecyclerView
・・・

これだけでPull To Refreshは完成です。

ページング(すいません、最後まで書いてません)

ページングについても同じような仕組みを使えば実現できます。
さらにいうと、自作のRecyclerViewAdapterは、CollectionItemViewModelのリストを要求するので、ページングの処理をより抽象的に記述できるので、より便利に実装できます。

RecyclerViewAdapterの以下の箇所で実現しています。

RecyclerViewAdapter.kt
    override fun getItemViewType(position: Int): Int {
        val item = list[position]
        if (item is LoadingItemViewModel) { <- ここ
            return loadingLayoutResId
        }
        return viewTypeProvider.getLayoutRes(item)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder {
        val inflater = this.inflater ?: from(parent.context)

        if (viewType == loadingLayoutResId) {  <- ここ
            val binding =
                DataBindingUtil.inflate<ListItemLoadingBinding>(inflater, viewType, parent, false)
            return ItemViewHolder(binding)
        }

これをViewModelとDataBindingを使って簡単に実装できるように仕組み化しています。

これ以上書くとさらにすごい分量になり時間も費やすことになるので、要望がたくさんあったらライブラリ化しようと思います。

このブログに書いたコードはgithubに置いてあるので気になる方は確認をしてください。
https://github.com/ko2ic/RecyclerView4DataBindingSample

終わりに

知ってる人は知っていると思いますが、僕はFlutterが最高と思っています。Android開発はFlutterに駆逐されるのではないかと思っています。
そうでなかった場合、宣言的UIなJetpack Composeで実装していくのが普通になっていくのかもしれません。
ただ、SwiftUIもそうですが、正直まだまだな印象が僕にはあります。(Flutterは洗練されてはいますが、現場によっては採用できないケースも多いかなと)

そんなときにいままでの実装の中でも生産性を上げる実装を作ることはまだまだできると思い投稿してみました。
いまさら、Androidで、しかもComposeじゃないのかよ。と思った人もいるかも。というかそういう人はこんな記事は読まない?

ただ、古いコードにも改善の余地は色々ある気がしています。同じように生産性をさらに上げるためのAndroidの投稿が増えるといいなと思っています。

NewsPicksでは、新しいコードでも古いコードでもガンガン生産性を上げる仕組みを作りたい、もしくは使いたい人を募集しています!
連絡お待ちしております !

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