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

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

More than 1 year has passed since last update.

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_update.gif

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を扱えるようになるため、オススメできます。

takahirom
Google Developer Expert for Android
cyberagent
サイバーエージェントは「21世紀を代表する会社を創る」をビジョンに掲げ、インターネットテレビ局「AbemaTV」の運営や国内トップシェアを誇るインターネット広告事業を展開しています。インターネット産業の変化に合わせ新規事業を生み出しながら事業拡大を続けています。
http://www.cyberagent.co.jp/
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
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  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
ユーザーは見つかりませんでした