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



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


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

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

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


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

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

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

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

fun render(items: List<Foo>) {
        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<T, H : RecyclerView.ViewHolder>(
        val itemController: BaseItemController<T, H>,
        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<Element, ElementController.Holder>() {

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

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

    inner class Holder(parent: ViewGroup) : BindableViewHolder<Element>(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), за помощь в разработке.

Комментарии (4)


  1. kenrube
    21.12.2017 08:11

    Итоговый интерфейс достаточно сильно напоминает Epoxy от Airbnb. Потенциально ваше решение можно использовать для тех же целей — создания длинных экранов (не обязательно списковых) целиком в одном RecyclerView.


  1. zagayevskiy
    21.12.2017 11:50

    Первый пункт решается паттерном AdapterDelegate. Можно самописный, а можно и от Ханнеса Дорфмана. Это давно решенная проблема, непонятно, зачем велосипедить.
    По второму пункту —


    К примеру, если мы изменим одно поле у объекта из списка, то DiffUtil не отработает

    Нужно всего лишь написать свой Callback, и всё отработает. Не понял вашей проблемы.


    Плюс пара замечаний по коду. Single-Expression functions в основном нужны для сокращения кода и в ваших случаях указание типа явно всю идею ломает.


    У вас неправильно написан холдер — в общем случае нельзя будет использовать общий RecycledViewPool для нескольких ресайклеров, что ухудшает переиспользование.


    Объявлять проперти как функцию, а потом вызывать её через invoke — это какое-то отдельное извращение.


    1. J_Able
      22.12.2017 15:37

      1. «Первый пункт решается паттерном AdapterDelegate». AdapterDelegate не сильно помогает решить проблему управления позицией элементов. У Ханнеса Дорфмана это не так заметно поскольку там используются однотипные списки, и я писал, почему это решение нам не совсем подошло.
      2. «Нужно всего лишь написать свой Callback». В этом келбеке придется определить методы areItemsTheSame и areContentsTheSame для нормальной работы которых нужны будут старый и новый списки. Если мы изменим одно поле у объекта списка, то не сможем передать в келбек старый список для сравнения. Поэтому DiffUtil не отработает.
      3. «общем случае нельзя будет использовать общий RecycledViewPool» — а это для данной статьи и не важно, основная ее задача показать как упростить работу со списком. Поэтому в примерах и представлены случаи с минимальным количеством кода.
      4. По котлину замечания верные.


  1. Lopar
    21.12.2017 20:17

    на правах оффтопа
    Не первый раз вижу фото, в котором компьютерно-ориентированные специалисты работают в синей подсветке. Причем не только фото — даже в видео попадается время от времени. Это просто чтобы сделать красиво, или с такой подсветкой действительно работается легче?