Прошло полгода, как я с паскаля перекатился на kotlin и влюбился в android-разработку, и вот уже разрешаю себе публично лезть со своими идеями в чужой монастырь. Но причина на то есть. Понаблюдав в профильных чатах за тем, какие чаще всего возникают вопросы у android-разработчиков, и не только у новичков, я понял, что в большинстве случаев, когда человек сталкивается с ошибкой, которую не может понять, как не может понять объяснение коллег из чата или их наводящие вопросы, причиной является бездумное использование готовых кусков кода или библиотек. Однако, полагаясь на готовые примеры кода, которые у них не работают (а в этой сфере код, написанный больше года назад, по умолчанию требует обновления или вообще переработки, и это касается кода со stack overflow, библиотечных гайдов и даже гайдов от самого Google), они не понимают причин возникающих ошибок или же отличающегося поведения, поскольку полагаются на библиотеку как китайскую комнату, не пытаясь разобраться в её архитектуре и принципах работы.

Поскольку очень часто всплывают вопросы, связанные с recycler view, я бы хотел немного разобраться в том, как самостоятельно сделать расширяемый и чистый код для отображения мульти-элементного списка в приложении.


Изучая архитектурные шаблоны android-разработки, я приучил себя в первую очередь искать ответы на сервере Google developer guides. Но иногда там, особенно в обучающих codelabs, приводятся примеры кода больше упрощенного, чем рассчитанного на универсальность, чистоту и расширяемость.

В данном случае у меня возникла потребность использовать модный recycler view для отображения списка элементов с разной внутренней разметкой и логикой. На такой идее строятся все современные приложения - от мессенджеров и лент социальных сетей до банковских приложений. К тому же комбинирование на лету с использованием реактивного подхода разных визуальных элементов списка recycler view вместо ручной верстки разметки является мостиком в мир декларативно-функционального ui, который нам предлагают в Jetpack Compose, и на который рано или поздно Google мягко предложит переходить.

Codelab, посвященный включению в список recycler view элемента с другой разметкой, строится на оборачивании элемента списка внутрь sealed класса. Но это не главный недостаток. Главный, на мой взгляд,- помещение всего кода, обрабатывающего разные элементы списка, внутрь класса самого адаптера. Это заставит в будущем расти код адаптера как снежный ком, нарушая как принцип открытости/закрытости, так и принцип единственной ответственности (при желании, можно найти нарушения каждой буквы из акронима SOLID, но поэтому их и объединили).

Другим существенным минусом является то, что Google предложил вынести свойство id из data-классов в качестве дискриминатора двух типов элементов: для заголовка id будет равен Long.MIN_VALUE, а для данных id будет транзитом переходить из data-класса. И здесь полностью закрыта щелочка для дальнейшего расширения: у вас или data-класс, который для адаптера будет всегда одинаков, или заголовок. Вся архитектура могучего recycler view мгновенно сжалась до всего лишь двух вариантов.

Решением проблемы можно считать использование готовых библиотек. Я из самых актуальных и наиболее распространенных нашел adapter delegates, groupie и epoxy. По ним по всем написаны многие статьи как здесь, так и там. Самый базовый подход, который я собираюсь сейчас изложить, наиболее близко воплощен в первой библиотеке. Группи и эпокси гораздо мощнее, универсальнее, но при этом сложнее внутри и станут сложнее снаружи, если вдруг разработчику захочется использовать всю их мощь.

Любая библиотека всегда таит в себе две беды:

  • вам бывает лень разбираться в ее устройстве, в итоге вы не осознаете, что используете библиотеку всего лишь на 10%, прямо как мозг у некоторых млекопитающих;

  • вы зависите от библиотечных классов: вам или надо наследовать ваши данные от них, или еще как-то ломать свои представления об идеальных data-классах и их движении.

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

Но также не беда, если вам захочется понять принцип, на котором все перечисленные выше библиотеки основаны, а поняв, попробовать реализовать свой собственный способ работы с recycler view с именно той функциональностью, которая нужна вам в вашем конкретном приложении. Иногда пара своих дополнительных классов с десятком строк кода в каждом вместо зависимости от библиотеки делает то же самое, при этом не перегружая код и оставляя вам гораздо больше свободы.

Так вот, в самом широком смысле в этих библиотеках применяется паттерн Посетитель с менеджером.

Адаптер recycler view, то есть обычный ListAdapter, уже имеет все необходимые методы, которые подталкивают к использованию Посетителя:

  • getItemType - функция, которая должна возвращать тип элемента (поскольку тип в данном случае всего лишь целое число, Google рекомендует использовать более понятные константы);

  • onCreateViewHolder - функция, которая возвращает ViewHolder такого класса, который реализован для требуемого типа элемента (тип передается в функцию параметром с помощью предыдущей функции);

  • onBindViewHolder - функция, которая осуществляет привязку конкретного элемента в списке (передается по номеру) и конкретного ViewHolder, который создан и возвращен предыдущей функцией.

С учетом специфики recycler view всегда следует помнить, что после пролистывания экрана для следующих элементов recycler view использует уже созданные места, поэтому если не хочется определять соответствие типа каждый раз, нужно внимательно следить за тем, что и в какие классы передается, а также ответственно отнестись к реализации DiffUtil-коллбэка.

Если вы реализуете стандартный шаблон DiffCallback,
class BaseDiffCallback : DiffUtil.ItemCallback<HasStringId>() {
    override fun areItemsTheSame(oldItem: HasStringId, newItem: HasStringId): Boolean = oldItem.id == newItem.id
    override fun areContentsTheSame(oldItem: HasStringId, newItem: HasStringId): Boolean = oldItem == newItem
}

то всегда помните, что areContentsTheSame вызывается только тогда, когда areItemsTheSame возвращает true. В моем примере классы реализуют интерфейс HasStringId, в котором есть id типа String и метод equals, что позволяет использовать data-классы как для моделей данных, так и для моделей слоя view. Data-классы с данными из сети всегда имеют уникальный id, поэтому их отличие DiffUtil определяет максимально быстро, а для вспомогательных ui-классов с одинаковыми id вызываются оба метода.

Чтобы управлять набором посетителей, введем такое понятие, как менеджер. Это будет класс, реализующий следующий интерфейс:

interface ViewHoldersManager {
    fun registerViewHolder(itemType: Int, viewHolder: ViewHolderVisitor)
    fun getItemType(item: Any): Int
    fun getViewHolder(itemType: Int): ViewHolderVisitor
}

И определим для целей тестового примера набор типов для recycler view:

object ItemTypes {
    const val UNKNOWN = -1
    const val HEADER = 0
    const val TWO_STRINGS = 1
    const val ONE_LINE_STRINGS = 2
    const val CARD = 3
}

Собственно менеджер и будет тем "делегатом" в терминологии adapter delegates, которому адаптер делегирует функции по определению типа для отрисовки текущего элемента. Для этого в менеджере должны быть зарегистрированы все необходимые классы вью холдеров.

Я буду использовать hilt и data binding, поскольку с их помощью очень легко решить второстепенные задачи: внедрить менеджера вью холдеров и упростить отображение данных на ui. Бонусом будет точное знание того места, где именно и в какой момент инициализируется менеджер вью холдеров, а также какие вью холдеры регистрируются в нем:

@Module
@InstallIn(FragmentComponent::class)
object DiModule {

    @Provides
    @FragmentScoped
    fun provideAdaptersManager(): ViewHoldersManager = ViewHoldersManagerImpl().apply {
        registerViewHolder(ItemTypes.HEADER, HeaderViewHolder())
        registerViewHolder(ItemTypes.ONE_LINE_STRINGS, OneLine2ViewHolder())
        registerViewHolder(ItemTypes.TWO_STRINGS, TwoStringsViewHolder())
        registerViewHolder(ItemTypes.CARD, CardViewHolder())
    }
}

Добавим верстку для всех элементов:

Сard item
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <data>
        <variable name="card" type="ru.alexmaryin.recycleronvisitor.data.ui_models.CardItem" />
    </data>

    <androidx.cardview.widget.CardView xmlns:card_view="http://schemas.android.com/apk/res-auto"
        android:id="@+id/card_view"
        android:layout_width="match_parent"
        android:layout_height="200dp"
        android:layout_margin="8dp"
        card_view:cardBackgroundColor="@color/cardview_shadow_end_color"
        card_view:cardCornerRadius="15dp">

        <ImageView
            android:id="@+id/card_background_image"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_gravity="center"
            android:scaleType="centerCrop"
            tools:ignore="ContentDescription"
            tools:src="@android:mipmap/sym_def_app_icon" />

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_gravity="bottom"
            android:background="@android:drawable/screen_background_dark_transparent"
            android:orientation="vertical"
            android:padding="16dp">

            <TextView
                android:id="@+id/card_title"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:ellipsize="end"
                android:maxLines="1"
                android:paddingTop="8dp"
                android:paddingBottom="8dp"
                android:textAllCaps="true"
                android:textColor="#FFFFFF"
                android:textStyle="bold"
                tools:text="Cart title"
                android:text="@{card.title}"/>

            <TextView
                android:id="@+id/txt_discription"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:ellipsize="end"
                android:maxLines="2"
                android:textColor="#FFFFFF"
                tools:text="this is a simple discription with losts of text lorem ipsum dolor sit amet,
            consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
                android:text="@{card.description}"/>

        </LinearLayout>
    </androidx.cardview.widget.CardView>
</layout>
One line item
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>
        <variable name="model" type="ru.alexmaryin.recycleronvisitor.data.ui_models.OneLineItem2" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <TextView
            android:id="@+id/text1"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:paddingStart="8dp"
            android:text="@{model.left}"
            android:textAlignment="textEnd"
            android:textAppearance="?attr/textAppearanceListItem"
            android:textColor="@color/cardview_dark_background"
            app:layout_constraintEnd_toStartOf="@+id/divider"
            app:layout_constraintHorizontal_bias="0.5"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            tools:ignore="RtlSymmetry,TextContrastCheck"
            tools:text="Left text" />

        <ImageView
            android:id="@+id/divider"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:alpha="0.6"
            android:padding="5dp"
            android:scaleType="center"
            android:scaleX="0.5"
            android:scaleY="0.9"
            android:src="@drawable/ic_outline_waves_24"
            android:visibility="visible"
            app:layout_constraintBottom_toBottomOf="@+id/text1"
            app:layout_constraintEnd_toStartOf="@+id/text2"
            app:layout_constraintHorizontal_bias="0.5"
            app:layout_constraintStart_toEndOf="@+id/text1"
            app:layout_constraintTop_toTopOf="@+id/text1"
            app:srcCompat="@drawable/ic_outline_waves_24"
            tools:ignore="ContentDescription"
            tools:visibility="visible" />

        <TextView
            android:id="@id/text2"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:paddingEnd="8dp"
            android:text="@{model.right}"
            android:textAppearance="?attr/textAppearanceListItem"
            app:layout_constraintBottom_toBottomOf="@+id/divider"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.5"
            app:layout_constraintStart_toEndOf="@+id/divider"
            app:layout_constraintTop_toTopOf="@+id/divider"
            tools:ignore="RtlSymmetry"
            tools:text="Right text" />

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>
Two line item
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>
        <variable name="model" type="ru.alexmaryin.recycleronvisitor.data.ui_models.TwoStringsItem" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:minHeight="?attr/listPreferredItemHeight"
        android:mode="twoLine"
        android:paddingStart="?attr/listPreferredItemPaddingStart"
        android:paddingEnd="?attr/listPreferredItemPaddingEnd">

        <TextView
            android:id="@+id/text1"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginTop="8dp"
            android:text="@{model.caption}"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            android:textAppearance="?attr/textAppearanceListItem" />

        <TextView
            android:id="@id/text2"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="@{model.details}"
            app:layout_constraintTop_toBottomOf="@id/text1"
            app:layout_constraintStart_toStartOf="parent"
            android:textAppearance="?attr/textAppearanceListItemSecondary" />

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>
Header item
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">

    <data>
        <variable
            name="headerItem"
            type="ru.alexmaryin.recycleronvisitor.data.ui_models.RecyclerHeader" />
    </data>

    <TextView
        style="@style/regularText"
        android:id="@+id/header"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="#591976D2"
        android:textAlignment="center"
        android:textStyle="italic"
        android:text="@{headerItem.text}"/>
</layout>

Вью холдер будет классом, реализующим простой интерфейс Посетителя:

interface ViewHolderVisitor {
    val layout: Int
    fun acceptBinding(item: Any): Boolean
    fun bind(binding: ViewDataBinding, item: Any, clickListener: AdapterClickListenerById)
}

Здесь два стандартных для Посетителя метода (классически они называются acceptVisitor и execute, однако мы же пишем не абстрактного посетителя для реализации паттерна в вакууме, а весьма конкретное его приложение) - acceptBinding и bind, а также свойство layout, в которое конкретные вью холдеры будут записывать ссылку на ресурс своей разметки.

Роль функции accept заключается в следующем: когда адаптер просит (или требует) от менеджера выдать ему соответствие типа элемента тому объекту, который должен быть отрисован, менеджер пробегается по всем зарегистрировавшимся вью холдерам, вызывая их метод accept, и выдает тип первого, который вернет true. Таким образом, ни адаптер, ни менеджер не знают ничего о внутреннем устройстве и даже классе самих элементов, что позволяет бесконечно увеличивать их количество и изменять как угодно. Единственное требование - зарегистрировавшийся вью холдер должен честно признаваться (accept = true), что готов забиндить какой-то элемент, тип которого он указал при регистрации.

Реализация как менеджера, так и конкретных вью холдеров будет настолько короткой, насколько это возможно. Практически везде однострочные функции:

class ViewHoldersManagerImpl : ViewHoldersManager {

    private val holdersMap = emptyMap<Int, ViewHolderVisitor>().toMutableMap()

    override fun registerViewHolder(itemType: Int, viewHolder: ViewHolderVisitor) {
        holdersMap += itemType to viewHolder
    }

    override fun getItemType(item: Any): Int {
        holdersMap.forEach { (itemType, holder) -> 
            if(holder.acceptBinding(item)) return itemType
        }
        return ItemTypes.UNKNOWN
    }

    override fun getViewHolder(itemType: Int) = holdersMap[itemType] ?: throw TypeCastException("Unknown recycler item type!")
}

И для примера вью холдер карточки (другие реализации очевидны и практически аналогичны):

class CardViewHolder : ViewHolderVisitor {
  
    override val layout: Int = R.layout.card_item

    override fun acceptBinding(item: Any): Boolean = item is CardItem

    override fun bind(binding: ViewDataBinding, item: Any, clickListener: AdapterClickListenerById) {
        with(binding as CardItemBinding) {
            card = item as CardItem
            Picasso.get().load(item.image).into(cardBackgroundImage)
        }
    }
}

Не стоит бояться операторов явного приведения типов as в коде. Во-первых, реализуя интерфейс, вы заключаете определенный контракт: если функция accept согласна с тем, что Посетитель работает с элементами класса CardItem, в метод bind совершенно точно будет передан объект только этого класса и никакого другого. Это же касается и разметки: если вы однозначно определяете имя ресурса разметки в свойстве layout, именно для этой разметки в свойство binding будет передаваться сгенерированный data binding класс. Ну и во-вторых, если бы это не было безопасно, разве линтер idea или android studio не разразились ли громкими воплями на всю сборку?

Что касается самого главного, адаптера для recycler view,- он единственный и универсальный, поскольку все функции по различению разных элементов делегирует менеджеру, а тот в свою очередь выбирает одного единственного холдера, согласного посетить и прибиндить намертво элемент к экрану смартфона:

class BaseListAdapter(
    private val clickListener: AdapterClickListenerById,
    private val viewHoldersManager: ViewHoldersManager
) : ListAdapter<HasStringId, BaseListAdapter.DataViewHolder>(BaseDiffCallback()) {

    inner class DataViewHolder(
        private val binding: ViewDataBinding,
        private val holder: ViewHolderVisitor
    ) : RecyclerView.ViewHolder(binding.root) {
        fun bind(item: HasStringId, clickListener: AdapterClickListenerById) =
            holder.bind(binding, item, clickListener)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DataViewHolder =
        LayoutInflater.from(parent.context).run {
            val holder = viewHoldersManager.getViewHolder(viewType)
            DataViewHolder(DataBindingUtil.inflate(this, holder.layout, parent, false), holder)
        }

    override fun onBindViewHolder(holder: DataViewHolder, position: Int) = holder.bind(getItem(position), clickListener)

    override fun getItemViewType(position: Int): Int = viewHoldersManager.getItemType(getItem(position))
}

Сам адаптер создается и наполняется элементами уже в слое view, в данном случае во фрагменте таким нехитрым способом:

// где-то выше во фрагменте:
// private val viewModel: MainViewModel by viewModels()
// private lateinit var recycler: RecyclerView
// @Inject lateinit var viewHoldersManager: ViewHoldersManager
// private val items = mutableListOf<HasStringId>()

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        recycler = requireActivity().findViewById(R.id.recycller)
        val itemsAdapter = BaseListAdapter(AdapterClickListenerById {}, viewHoldersManager)
        itemsAdapter.submitList(items)
        recycler.apply {
            layoutManager = LinearLayoutManager(requireContext())
            addItemDecoration(DividerItemDecoration(requireContext(), (layoutManager as LinearLayoutManager).orientation))
            adapter = itemsAdapter
        }
        populateRecycler()
    }

private fun populateRecycler() {
     lifecycleScope.launch {
        viewModel.getItems().flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED)
           .collect { items.add(it) }
     }
   }

Благодаря одному "лишнему" классу менеджера вью холдеров и паре интерфейсов мы получили простой, но очень функциональный recycler view с возможностью безболезненного расширения. К тому же в противовес библиотекам у нас остались следующие преимущества:

  • нет необходимости наследовать классы данных от библиотечных супер-классов;

  • нет необходимости оборачивать разные элементы в sealed класс;

  • один и тот же data-класс можно использовать для сериализации/десериализации данных из интернета, сохранения в локальной базе и в качестве модели для view или data биндинга;

  • изменения классов данных и вью-холдеров не влекут изменений кода адаптера;

  • все внутренности элементов инкапсулированы от адаптера, законы SOLID выполняются;

  • отсутствует избыточная функциональность, неизбежно приносимая библиотеками (YAGNI).

Разумеется, моя реализация еще имеет пути для улучшения и расширения. Можно, как в groupie добавить группировку элементов и их визуальное сворачивание. Можно отказаться от data binding или дополнить адаптер вариантами для view binding или обычного инфлейта разметки со всеми любимыми findViewById во вью холдерах. И тогда код превратится в ту же самую библиотеку, которых уже вон сколько и так. Для моих же конкретных целей на тот момент, когда возникла необходимость, варианта с простым Посетителем более, чем достаточно:

Прошу не судить строго, поскольку в мире android это мое первое появление на свет. Полноценный код примера из текста статьи будет доступен в репозитории github.