August 28, 2019

Советы для профессионального использования RecyclerView

Я решил написать эту статью, т.к. заметил, что многие разработчики допускают ошибки при использовании RecyclerView, даже несмотря на то, что Google уже довольно давно его выпустила.

Описанные здесь пункты упоминались в различных докладах и материалах на Google Devs.

В этой статье я буду кратко описывать ключевые пункты из этого видео, и вам не обязательно смотреть его прямо сейчас. Но рекомендую посмотреть его целиком после прочтения статьи.

1. Атрибут setHasFixedSize

Установите атрибут recyclerView.setHasFixedSize(true), когда recyclerView не планирует изменять размеры своих дочерних элементов динамически.

В результате recyclerView не будет перерисовываться каждый раз, когда в элементе списка обновятся данные, – этот элемент перерисуется самостоятельно.

2. Click listener

Устанавливайте обработчик кликов в onCreateViewHolder(...).

Всякий раз когда пользователь кликает по элементу списка, viewHolder сообщает позицию адаптера, где этот клик произошёл (vh.getAdapterPosition()). Это важно, потому что элементы могут перемещаться внутри адаптера и связанные с ними view-компоненты не будут пересоздаваться.

В результате, к тому времени когда view-компонент будет создан, может произойти следующее: элемент списка будет находиться, допустим, на позиции 2, но когда пользователь кликнет по нему, элемент будет уже на позиции 5. Таким образом, использование метода vh.getAdapterPosition() гарантирует получение корректного индекса списка.

3. Использование различных типов view-компонентов

Возвращайте напрямую layout в случае использования различных типов view-компонентов (например, R.layout.view_one).

Если ваш адаптер поддерживает различные типы view-компонентов, то метод getItemViewType и onCreateViewHolder будут выглядеть приблизительно так, как изображено ниже. Вам необходимо написать оператор switch внутри метода onCreateViewHolder для реализации нужной логики для соответствующих типов view-компонентов.

Но вместо этих типов вы можете вернуть сразу layout. Это избавит вас от шаблонного кода в onCreateViewHolder:

Этот приём нельзя использовать постоянно, т.к. иногда вам может потребоваться более сложная логика внутри каждого выбранного layout для разных случаев. Но если это не ваш случай, то возвращаемые layout — это верный путь для работы с различными типами view-компонентов.

4. DiffUtil

Используйте DiffUtil для добавления новых данных в RecyclerView.

Всякий раз когда данные в recyclerView меняются, большинство разработчиков вызывают метод notifyDataSetChanged() для отображения обновлённых данных на UI. Они просто не знают, что данный метод ресурсозатратен и что именно здесь DiffUtil справляется куда эффективнее.

DiffUtil — это класс-утилита, который может вычислять разницу между двумя списками в виде списка обновлений, который затем конвертирует первый список во второй. Его можно использовать для вычисления обновлений в адаптере recyclerView. Чтобы использовать DiffUtil, вы должны реализовать DiffUtil.Callback, в котором есть несколько обязательных методов, необходимых для реализации логики DiffUtil:

Самое большое преимущество DiffUtil заключается в том, что в RecyclerView вы можете обновить конкретный текст в TextView определённого элемента, вместо того чтобы перерисовывать весь список. Для этого вам необходимо реализовать метод onChangePayload в DiffUtil.Callback. Есть очень хорошая статья на эту тему.

Далее рассмотрим классы ItemDecoration и ItemAnimator и принцип их работы в RecyclerView на примере простого приложения, которое доступно на Github.

5. ItemDecoration

ItemDecoration используется для декорирования элементов списка в RecyclerView.

С помощью ItemDecoration вы сможете добавлять разделители между view-компонентам, выравнивать их или разбивать равными промежутками. Чтобы добавить простой разделитель между view-компонентами, воспользуйтесь классом DividerItemDecoration, который можно найти в библиотеке поддержки версии 25.1.0 и выше. Следующий фрагмент кода демонстрирует его реализацию:

mDividerItemDecoration = DividerItemDecoration(
        recyclerView.getContext(), mLayoutManager.getOrientation())
recyclerView.addItemDecoration(mDividerItemDecoration)

Лучший способ создания собственного разделителя — расширение класса RecyclerView.ItemDecoration. В примере приложения я использовал GridLayoutManager и применил CharacterItemDecoration к RecyclerView:

recyclerView.addItemDecoration(CharacterItemDecoration(50))

Здесь CharacterItemDecoration устанавливает смещение (англ. offset) на 50 пикселей в своём конструкторе и переопределяет getItemOffsets(...). Внутри метода getItemOffsets() каждое поле outRects определяет количество пикселей, которые необходимо установить для каждого view-компонента, подобно внутренним и внешним отступам. Поскольку я использовал GridLayoutManager и хотел настроить равные расстояния между элементами сетки, я установил отступ справа в 25 пикселей (т.е. offset/2) для каждого чётного элемента и отступ слева в 25 пикселей для каждого нечётного элемента, сохраняя при этом верхний отступ одинаковым для всех элементов.

6. ItemAnimator

ItemAnimator используется для анимации элементов или view-компонентов внутри RecyclerView.

Давайте сделаем наше приложение «Инстаграмоподобным», расширив DefaultItemAnimator и переопределив несколько методов.

fun canReuseUpdatedViewHolder(viewHolder: RecyclerView.ViewHolder): Boolean {
    return true
}

Метод canReuseUpdatedViewHolder(...) определяет, будет ли один и тот же ViewHolder использоваться для анимации, если данные этого элемента изменятся. Если он возвращает false, то оба ViewHolders — старый и обновленный — передаются в метод animateChange(...).

fun recordPreLayoutInformation(
        state: RecyclerView.State,
        viewHolder: RecyclerView.ViewHolder,
        changeFlags: Int,
        payloads: List<Object>
): ItemHolderInfo {

    if (changeFlags == FLAG_CHANGED) {
        for (payload in payloads) {
            if (payload is String) {
                return CharacterItemHolderInfo(payload)
            }
        }
    }
    return super.recordPreLayoutInformation(state, viewHolder, changeFlags, payloads)
}

class CharacterItemHolderInfo(val updateAction: String) : ItemHolderInfo {
}

RecyclerView вызывает метод recordPreLayoutInformation(...) до начала отрисовки layout. ItemAnimator должен записывать необходимую информацию о view-компоненте до того, как он будет перезаписан, перемещен или удалён. Данные, возвращаемые этим методом, будут переданы соответствующему методу анимации (в нашем случае это animateChange(...)).

override fun animateChange(
    oldHolder: RecyclerView.ViewHolder,
    newHolder: RecyclerView.ViewHolder,
    preInfo: ItemHolderInfo,
    postInfo: ItemHolderInfo
): Boolean {

    if (preInfo is CharacterItemHolderInfo) {
        val holder = newHolder as CharacterRVAdapter.CharacterViewHolder
        if (preInfo.updateAction == CharacterRVAdapter.ACTION_LIKE_IMAGE_DOUBLE_CLICKED) {
            animatePhotoLike(holder)
        }
    }
    return false
}

private fun animatePhotoLike(holder: CharacterRVAdapter.CharacterViewHolder) {
    holder.likeIV.setVisibility(View.VISIBLE)
    holder.likeIV.setScaleY(0.0f)
    holder.likeIV.setScaleX(0.0f)

    val animatorSet = AnimatorSet()
    val scaleLikeIcon = ObjectAnimator.ofPropertyValuesHolder(
        holder.likeIV,
        PropertyValuesHolder.ofFloat("scaleX", 0.0f, 2.0f),
        PropertyValuesHolder.ofFloat("scaleY", 0.0f, 2.0f),
        PropertyValuesHolder.ofFloat("alpha", 0.0f, 1.0f, 0.0f)
    )

    scaleLikeIcon.setInterpolator(DECELERATE_INTERPOLATOR)
    scaleLikeIcon.setDuration(1000)

    val scaleLikeBackground = ObjectAnimator.ofPropertyValuesHolder(
        holder.characterCV,
        PropertyValuesHolder.ofFloat("scaleX", 1.0f, 0.95f, 1.0f),
        PropertyValuesHolder.ofFloat("scaleY", 1.0f, 0.95f, 1.0f)
    )
    scaleLikeBackground.setInterpolator(DECELERATE_INTERPOLATOR);
    scaleLikeBackground.setDuration(600)
    animatorSet.playTogether(scaleLikeIcon, scaleLikeBackground)
    animatorSet.start()
}

RecyclerView вызывает метод animateChange(...), когда элемент адаптера присутствует одновременно до и после отрисовки после вызова метода notifyItemChanged(int). Этот метод также можно использовать при вызове notifyDataSetChanged(), если при этом в адаптере используются стабильные идентификаторы. Это необходимо для того, чтобы RecyclerView мог переиспользовать view-компоненты в тех же ViewHolders. Обратите внимание на то, что этот метод принимает в качестве аргументов: (ViewHolder oldHolder, ViewHolder newHolder, ItemHolderInfo preInfo, ItemHolderInfo postInfo). Поскольку мы повторно используем ViewHolder, оба — oldHolder и newHolder — одинаковы.

Всякий раз когда пользователь дважды кликает на любой элемент, вызывается следующий метод:

notifyItemChanged(position, ACTION_LIKE_IMAGE_DOUBLE_CLICKED);

Это запускает всю цепочку вызовов: canReuseUpdatedViewHolder(...), recordPreLayoutInformation(...) и, в конечном итоге, animateChange(...) в ItemAnimator, который, в свою очередь, анимирует элемент списка и иконку сердечка в этом элементе (пример на гифке выше).

Несколько хороших статей на тему RecyclerView:


Источники:

Советы для профессионального использования RecyclerView. Часть 1

Советы для профессионального использования RecyclerView. Часть 2