Головная боль от RecyclerView.Adapter — выход есть

Привет, Хабр! Сегодня в нашем блоге Макс Туев, архитектор Surf, одной из наших сертифицированных студий. Ребята занимаются заказной разработкой, поэтому сроки важны не меньше, чем качество кода. Подходы и технологии, которые тормозят разработку, здесь не подходят. Хороший пример такого — RecyclerView.Adapter. Под катом Макс расскажет, как сэкономить время и нервы. Слово Максу.
wwe2wxlu0sjed-3aakcmpu5_ru0.jpeg

С простыми списками RecyclerView.Adapter справляется на ура. Но вот попытка реализовать адаптер для сложных случаев с несколькими типами ячеек иногда приводит к рождению монстров, которых разработчики стараются как можно быстрее забыть и больше к ним не прикасаться. Но бывает, что, новый апдейт приносит еще пару ячеек именно в этот едва живой адаптер. Если это звучит знакомо — добро пожаловать под кат. Расскажу как мы в студии решали эти проблемы. В частности, покажу, как научиться управлять списком используя всего 10 строк кода. Пример — на гифке ниже.

eigl4kgncxyd1xai3gubojxqsk4.gif

Откуда растут ноги монстров? Основные проблемы создают 2 особенности адаптера:

  1. Управление позицией элементов (getItemViewType, onBindViewHolder, …)
  2. Определение, для каких элементов вызывать методы notifyItem…

Начнем со второго пункта


Эту проблему можно решить, используя notifyDataSetChanged (). Но тогда не получится использовать ItemAnimator, что было для нас недопустимо.

Решить это помог DiffUtil, который не так давно появился в Support Library. Этот класс позволяет определить, что изменилось в списке и оповестить об изменениях адаптер. Для этого нужно передать в него старый и новый списки. Вот хороший пример.

Проблема с notify, казалось бы, решена. Но все не так просто. К примеру, если мы изменим одно поле у объекта из списка, то DiffUtil не отработает. А такой код встречается очень часто. Если присмотреться, можно заметить, что для работы ему не нужен весь объект целиком. Нужен лишь id элемента для метода areItemsTheSame() и хеш от данных элемента для метода areContentTheSame(). Эту особенность и использовали для нашего решения.

Из каждого блока данных каждой ячейки экстрагируется id и hash при каждом их изменении. Затем полученный список из этих «высушенных» объектов сравнивается c помощью DiffUtil с таким же списком, собранным из прежних данных адаптера. Снаружи выглядит это примерно так:

fun render(items: List) {
        adapter.setData(items)
}

Когда данные передаются в адаптер, он самостоятельно вызывает методы notify для частей списка, которые изменились. Кроме того, анимации сохраняются при любом изменении списка, будь то добавление нового элемента, удаление элемента или перемещение элемента. Добавить здесь больше нечего, на примере наглядно все видно. А к недостаткам подхода еще вернемся в конце статьи.

Управление позицией элементов


Самое сложное в реализации и особенно поддержке адаптера с множеством ячеек — методы getItemCount(), getItemViewType(), onBindViewHolder(), onCreateViewHolder(). Они связаны между собой и обладают весьма специфической логикой. Вот, как может выглядеть один из них для списка с опциональным футером и хедером:

    @Override
    public int getItemCount() {
        int numHeaders = userInfo == null ? 0 : 1;
        return data.size() == 0 ? numHeaders : data.size() + numHeaders + 1;
    }

А теперь представьте, что нужно быстро добавить еще три типа ячеек, причем наличие третьего зависит от наличия первый двух. Придется модифицировать и, самое неприятное, отлаживать все 4 метода.
Чтобы значительно упростить адаптер, можно использовать список расширяющих один базовый класс или интерфейс объектов, а логику расположения элементов вынести на уровень выше. Но для этого потребуются объекты-обертки для данных каждого типа ячейки. Это слишком громоздкое решение, когда требуется, например, добавить один header.

Мы взяли этот подход для своего решения, но использовали универсальные объекты-обертки. Так выглядит этот класс:

class Item(
        val itemController: BaseItemController,
        val data: T
)

ItemController здесь отвечает, грубо говоря, за все взаимодействие с ячейкой. К нему мы еще вернемся.

В итоге все получилось немного сложнее, но суть осталась та же.

Ответственность за создание списка Item передается Activity или Fragment. Теперь нет необходимости расширять RecyclerView.Adapter, т.к. для реализации этого решения был создан универсальный EasyAdapter. Для наглядности сразу приведу пример метода Activity, обновляющего адаптер:

    fun render(screenModel: MainScreenModel) {
        val itemList = ItemList.create()
                .addIf(screenModel.hasHeader(), headerController)
                .addAll(screenModel.carousels, carouselController)
                .addIf(!screenModel.isEmpty(), deliveryController)
                .addIf(screenModel.hasCommercial, commercialController)
                .addAll(screenModel.elements, elementController)
                .addIf(screenModel.hasBottomCarousel(), screenModel.bottomCarousel, carouselController)
                .addIf(screenModel.isEmpty(), emptyStateController)
        adapter.setItems(itemList)
    }

Списком в гифке выше, кстати, управляет именно этот метод.
Класс ItemList нужен, чтобы создавать списки было удобнее. Ряд его особенностей позволяет сильно упростить эту задачу и сделать код более декларативным:

  1. Цепочечный стиль заполнения.
  2. Нет явного создания объектов Item.
  3. Методы для добавления ячеек без данных.
  4. Методы для добавления ячеек с предикатом.
  5. Методы для добавления как единичных ячеек так и списка ячеек.

При вызове adapter.setItems(), будут, как говорилось ранее, вызваны необходимые методы notify.
В ItemList есть еще один важный метод — fun addAll (data: Collection, itemControllerProvider: ItemControllerProvider): ItemList. Он позволяет настраивать Adapter из списка объектов, расширяющих один базовый класс или интерфейс. Он пригодится, если логика конструирования списка нетривиальная и есть смысл перенести ее в Presenter.

Вернемся к ItemController и сразу посмотрим пример реализации:

class ElementController(
        val onClickListener: (element: Element) -> Unit
) : BindableItemController() {

    override fun createViewHolder(parent: ViewGroup): Holder = Holder(parent)

    override fun getItemId(data: Element): Long = data.id.hashCode().toLong()

    inner class Holder(parent: ViewGroup) : BindableViewHolder(parent, R.layout.element_item_layout) {
        private lateinit var data: Element
        private val nameTv: TextView
        private val coverView: ElementCoverView

        init {
            itemView.setOnClickListener { onClickListener.invoke(data) }
            nameTv = itemView.findViewById(R.id.name_tv)
            coverView = itemView.findViewById(R.id.cover_view)
        }

        override fun bind(data: Element) {
            this.data = data
            nameTv.text = data.name
            coverView.render(data.cover)
        }
    }
}

Первое что бросается в глаза — полная инкапсуляция всех взаимодействий с ячейкой. Это позволяет как быстро и безопасно вносить изменения в список, так и полностью переиспользовать всю логику взаимодействия с ячейкой на других экранах. Еще ItemController отвечает за экстрагирование из данных id и hash, необходимых для правильной работы автоматического вызова методов notify.

Такая структура позволяет упростить еще кое-что:

  1. Не нужно реализовывать методы onBindViewHolder, getItemHash.
  2. Нет необходимости пробрасывать Listener внутрь Holder.
  3. ItemController для ячейки без данных будет еще проще.
  4. Не нужно придумывать названия для Holder и Listener (если еще пользуетесь java).
  5. Можно использовать шаблон ItemController для быстрой реализации.

Этот способ позволяет в разы в разы упростить реализацию списков и унифицировать работу со всеми адаптерами в проекте. Можно реализовывать сложные экраны, которые раньше приходилось делать с помощью ScrollView. Это позволяет выиграть время на старте и уменьшить количество кода в Activity. Кроме того, достаточно легко перевести существующий адаптер на этот стиль. Мы обычно так делаем, когда нужно немного изменить существующий большой адаптер в старом проекте.

Есть, правда, и несколько недостатков. Например, getChangePayload в DiffUtil.Callback игнорируется, и для каждой ячейки при изменении списка создаются объекты Item и ItemInfo (что, в принципе, можно решить при необходимости). Если собираетесь использовать гигантские списки — замерьте производительность (о производительности DiffUtil можно почитать здесь). Но чаще всего проблем с этим подходом у вас не возникнет.

Надеюсь моя заметка кому-то из вас пригодится и позволит тратить меньше времени и нервов на работу с RecyclerView.

Пример реализации с примерами использования здесь. Там же шаблон ItemController для AndroidStudio и базовый адаптер для пагинации на основе описанных подходов.

Большое спасибо разработчикам Surf, особенно Федору Атякшину (@rereverse), за помощь в разработке.

© Habrahabr.ru