Edited at
AndroidDay 23

RecyclerViewによるレイアウトを楽にするGroupieを1年使ってみて


Groupie

1年間Groupieをたくさん使ったので、その知見を書いておこうと思います。

Groupieは簡単に複雑なRecyclerViewによる画面レイアウトを行うことができます。

どう簡単なのかというと以下のようなところがあります。


  • ただGroupieのアイテムのリストを作って、RecyclerViewのAdapterのGroupAdapter#updateに渡すだけで作れる

  • 基本的にViewTypeや、ViewHolderなどを考える必要がない

  • 変更時や追加時などのアニメーションもされる

Groupieは去年のDroidKaigiのアプリで採用を決めたのをきっかけに使い始めました。

1年使ってみて、さまざまな問題があり、さまざまな解決策を考え、対応していきました。

もし、何か改善できそうな部分とか、ツッコミなどあれば教えてください🙇


Groupieの紹介: Groupieでシンプルなリストを表示するには

GroupieをDataBindingと一緒に使うのがおすすめです。

こういう形でbuild.gradleに書くことができます。


build.gradle

android {

...
dataBinding.enabled = true
}

dependencies {
...
implementation 'com.xwray:groupie:2.3.0'
implementation 'com.xwray:groupie-databinding:2.3.0'
...


こういうヘッダーとテキストがあるようなのを表示したいとします。



item_header.xmlはヘッダーっぽいレイアウトを作っておき、item_text.xmlもほぼ同じ形でレイアウトを作っておきます。


item_header.xml

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

<layout xmlns:android="http://schemas.android.com/apk/res/android">

<TextView
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="64dp"
android:layout_margin="16dp"
android:textAppearance="@style/TextAppearance.AppCompat.Headline"
/>
</layout>


通常のRecyclerViewだとViewTypeがどうとか、ViewHolderがどうとか考える必要がありますが、Groupieではこれだけで実装できます。何も考える必要はありません。

通常、非同期で取得したものを表示すると思いますが、その場合はGroupAdapterをメンバ変数に持っておき、updateを呼ぶだけです。


MainActivity.kt

class MainActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val groupAdapter = GroupAdapter<ViewHolder<*>>()
findViewById<RecyclerView>(R.id.recyclerView).adapter = groupAdapter
val items = listOf(
HeaderItem("Header1"),
TextItem("test1"),
TextItem("test2"),
TextItem("test3"),
HeaderItem("Header2"),
TextItem("test4"),
TextItem("test5")
)
groupAdapter.update(items)
}
}

class HeaderItem(val text: String) : BindableItem<ItemHeaderBinding>() {
override fun getLayout() = R.layout.item_header

override fun bind(viewBinding: ItemHeaderBinding, position: Int) {
viewBinding.textView.text = text
}
}

class TextItem(val text: String) : BindableItem<ItemTextBinding>() {
override fun getLayout() = R.layout.item_text

override fun bind(viewBinding: ItemTextBinding, position: Int) {
viewBinding.textView.text = text
}
}



起こった問題とその対策


変更アニメーション問題: アイテムを変更した時にアニメーションしてしまう

Groupieで例えばアイテムが、お気に入りボタンなどを押して表示が変わったり、1秒ごとに更新をかけたりしている場合、以下のようになります。そうすると以下のコードだけでは全てのアイテムが点滅していまいます。これはGroupieが内部的にRecyclerViewのDiffUtilを使っていることによるものです。

        val handler = Handler()

// 一秒ごとにポストする
handler.postDelayed(object : Runnable {
override fun run() {
val items = listOf( // 更新したリストを作る
HeaderItem("Header1"),
TextItem("test1"),
TextItem("test2"),
TextItem("test3"),
HeaderItem("Header2"),
TextItem("test4"),
TextItem("test5")
)
groupAdapter.update(items)
handler.postDelayed(this, 1000)
}
}, 1000)
}

Groupie内のGroupAdapter.DiffCallbackの実装では以下のように比較していて、isSameAs()equals()もfalseなので、アニメーションしてしまいます。


Groupie内のGroupAdapter.DiffCallbackの実装

        @Override

public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) {
Item oldItem = getItem(oldGroups, oldItemPosition);
Item newItem = getItem(newGroups, newItemPosition);
return newItem.isSameAs(oldItem); // Itemのid or isSameAs()を実装した結果で比較する
}

@Override
public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) {
Item oldItem = getItem(oldGroups, oldItemPosition);
Item newItem = getItem(newGroups, newItemPosition);
return newItem.equals(oldItem); // equals()で比較する
}



変更アニメーション問題: data classによる解決策

以下のようにBindableItemのコンストラクタにIDとKotlinのdata classによりequals()を実装してあげればそれを回避することができます。

以下のようにすることでさまざまな変更の時に自動的にアニメーションしてくれるようになります。

:o: :ok:

// data classに変更してequals()を実装する

data class HeaderItem(val text: String) : BindableItem<ItemHeaderBinding>(
// 識別するIDを渡す(これがisSameAs()で使われる)
text.hashCode().toLong()
) {
override fun getLayout() = R.layout.item_header

override fun bind(viewBinding: ItemHeaderBinding, position: Int) {
viewBinding.textView.text = text
}
}

// data classに変更してequals()を実装する
data class TextItem(val text: String) : BindableItem<ItemTextBinding>(
// 識別するIDを渡す(これがisSameAs()で使われる)
text.hashCode().toLong()
) {
override fun getLayout() = R.layout.item_text

override fun bind(viewBinding: ItemTextBinding, position: Int) {
viewBinding.textView.text = text
}
}


変更アニメーション問題: data classでも不要に変更アニメーションが走る

実際のプロダクトでの話に入っていきます。

実際のプロダクトではさまざまな要素がitemクラスのインスタンス渡され、計測用のログのオブジェクトを作ったり、クリックリスナを渡したり、さまざまなことをします。また ArticleNoticeSession、などなどさまざまなモデルが渡されることになりますが、そのモデルがdata classとは限らないため、equalsがfalseになってしまい、点滅のアニメーションをしてしまいます。

次の例では毎回新しいクリックリスナが渡されることにより、data classにより自動生成される、equals()がfalseになってしまうため、アニメーションしてしまいます

:x: :ng:

        val handler = Handler()

handler.postDelayed(object : Runnable {
override fun run() {
val clickListener: (View) -> Unit = { // 毎回**新しい**クリックリスナを作る
Log.d("Groupie!", "click:$it")
}
val items = listOf(
HeaderItem("Header1"),
TextItem("test1", clickListener), // クリックリスナを渡す
TextItem("test2", clickListener),
TextItem("test3", clickListener),
HeaderItem("Header2"),
TextItem("test4", clickListener),
TextItem("test5", clickListener)
)
groupAdapter.update(items)
handler.postDelayed(this, 1000)
}
}, 1000)
}
}
...
data class TextItem(
val text: String,
// 毎回作られるクリックリスナがdata classのequals()で比較されてfalseが返る!!!
val clickListener: (View) -> Unit
) : BindableItem<ItemTextBinding>(
text.hashCode().toLong()
) {
override fun getLayout() = R.layout.item_text

override fun bind(viewBinding: ItemTextBinding, position: Int) {
viewBinding.textView.text = text
viewBinding.textView.setOnClickListener(clickListener)
}
}

そのため、次のようにすることで回避しました。


変更アニメーション問題: equals() / hashCode() を無理なく実装する

Groupieから使われるequals() / hashCode()はIntelliJの自動生成で生成できますが、増えたり減ったりしたときのメンテナンスやレビューが面倒だったりします。

そこで、EqualableContentProviderというインターフェースを定義して、楽をするようにしました。

:o: :ok:

class TextItem(

val text: String,
val clickListener: (View) -> Unit
) : BindableItem<ItemTextBinding>(
text.hashCode().toLong()
), EqualableContentsProvider { // EqualableContentProviderを実装する

override fun getLayout() = R.layout.item_text

override fun bind(viewBinding: ItemTextBinding, position: Int) {
viewBinding.textView.text = text
viewBinding.textView.setOnClickListener(clickListener)
}
// **比較に使いたいものを渡す**
override fun providerEqualableContents(): Array<*> = arrayOf(text)

override fun equals(other: Any?): Boolean {
return isSameContents(other) // EqualableContentProviderが実装してくれる
}

override fun hashCode(): Int {
return contentsHash() // EqualableContentProviderが計算してくれる
}
}


EqualableContentsProvider.kt

import java.util.Arrays

interface EqualableContentsProvider {
fun providerEqualableContents(): Array<*>

override fun equals(other: Any?): Boolean // equals()とhashCode()の実装を強制する

override fun hashCode(): Int

fun isSameContents(other: Any?): Boolean {
other ?: return false
if (other !is EqualableContentsProvider) return false
if (other::class != this::class) return false
return other.providerEqualableContents().contentDeepEquals(this.providerEqualableContents())
}

fun contentsHash(): Int {
return Arrays.deepHashCode(arrayOf(this::class, this.providerEqualableContents()))
}
}



bind()で毎回やりたくない操作をアイテム毎に1回だけ行う

例えばdimens.xmlから取得したサイズや、LayoutInflaterなどはbind()のたびに取得するとスクロールするたびに取得することになるので、多少ですが無駄に計算する必要が出てきます。(そもそもレイアウトが使い回されるため、bind()であまりLayoutInflaterを使うべきではないですが。)

通常ならKotlinのlazyで済むんですが、Itemのインスタンスからは取得できないContextが必要になったりして、毎回nullチェックして代入したりといったことが必要になってしまっていました。

そこで、lazyWithParamを定義して、引数ありでlazyが出来るように実装しました。

    val layoutInflater by lazyWithParam { context: Context ->

LayoutInflater.from(context)
}

override fun bind(viewBinding: ItemSessionBinding, position: Int) {
val inflater = layoutInflater.get(viewBinding.root.context)

fun <P, T> lazyWithParam(function: (P) -> T): Lazy<LazyInstanceHolder<P, T>> {

return lazy { LazyInstanceHolder(function) }
}

class LazyInstanceHolder<in P, out T>(val function: (P) -> T) {
private var instance: T? = null
fun get(param: P): T {
return instance ?: function(param).also { instance = it }
}
}


アイテムのどれがどれか分からない対策にデバッグ用のItemDecorationを作る

プロダクションでは無数のアイテムを使うことになります。さまざまなアイテムがあり、どれがどれなのか分からなくなってきます。

未公開ですが、ItemDecorationを使って表示上でどの要素がどのアイテムなのか視覚的に分かるようにするItemDecorationをHyperion-Androidで動的にRecyclerViewにつけるプラグインを作り、分かるようにしています。


bind()でexecutePendingBindings()は呼ぶ必要がない

executePendingBindings()はGroupieのデフォルトで呼び出されるので、呼ぶ必要がないです。

https://github.com/lisawray/groupie/blob/6e7a296bda49f14955de1701ef8204b24dfa9c43/library-databinding/src/main/java/com/xwray/groupie/databinding/BindableItem.java#L56


アイテムで変更を検知する場合のリーク防止

基本的に普通に何かの値の変更を監視しておいて、変更のタイミングでnotifyChanged()を呼ぶことで、変更を検知してbind()させることができます。

RecyclerViewのあるActivity/FragmentのライフサイクルのonDestory()Item#unregisterGroupDataObserver()で監視を外すようにすることでうまく動くようです。

https://github.com/lisawray/groupie/pull/234 これがいい感じで入ればもっと楽になるかも :thinking:


Architecture ComponentのPaging対応

以下のようにありがたいサンプルを公開していただいていて、だいたいうまく動くようです。

https://medium.com/@chibatching/how-to-connect-paging-library-with-groupie-f8b4102d59b0

自分で呼んだnotifyItemChanged()がうまく動かないという問題などが多少あるので、以下をPagedGroupに追加したりしました。

  @CallSuper

fun notifyItemChanged(position: Int) {
parentObserver?.onItemChanged(this, position)
}


アイテムをAssistedInjectで作成する

これはプロダクトではやっていないんですが、square/AssistedInjectを使うといい感じに変更部分以外だけInjectできるので、アイテムで保持する要素が増えても、楽に作れて良さそうです。

class ArticleItem @AssistedInject constructor(

@Assisted val article: Article, // これは渡す
navController: NavController // ここはDaggerのInjectに頼る
) : BindableItem<ItemArticleBinding>(
article.id.toLong()
) {
@AssistedInject.Factory
interface Factory {
fun create(
article: Article,
): ArticleItem
}

    @Inject lateinit var articleItemFactory: ArticleItem.Factory

...
articleItemFactory.create(article)


Groupieを使った感想

Groupieを使うことでアイテムを使い回すのがとても楽になります。同じようなレイアウトがたくさん出てくるアプリではとてもおすすめできます。使い回す時は既存のItemをnewするだけなので、工数を削減でき、かなり得した気分になります。

少しハマりどころを紹介しましたが、まだ言語化できていないハマりどころがあり、RecyclerViewをネストしたり、Impressionのログを実装したり、リアルタイムで更新されるPagingなどなど複雑なパターンでは結局はRecyclerView特有のViewHolderなどを忘れることはできず、意識して実装を行う必要があり、つらいパターンもいくつかでてきます。

しかし上記のようなパターンはそこまで多くはなく、基本的には実装がかなりシンプルになると感じており、簡単にRecyclerViewを扱えるようになるため、オススメできます。