Привет, я Карим Насыбуллин, Android-разработчик в Яндекс Go. Около года занимаюсь развитием сервиса BUY&SELL. Недавно моей команде пришлось решить проблему использования довольно сложного BDUI-списка айтемов с пагинацией. В этой статье расскажу, как Concat Adapter помог нам с этой задачей.
Контекст
BUY&SELL — p2p-маркетплейс для торговли товарами между пользователями Яндекс Go. Продукт создавался в сжатые сроки и в условиях, близких к хакатону. Поэтому со временем потребовал развития — кроме статичных секций в него нужно было добавить более сложные. Например, кнопки с состояниями и пагинационный список. Первым шагом мы внедрили Backend Driven User Interface (далее BDUI) решение для некоторых экранов, однако логика сильно усложнилась и перед командой стояла задача найти способ упростить её.
В нашем решении BDUI представляет собой контейнер со списком различных секций, которые приходят с бекенда. Каждый контейнер с его логикой можно вставить в любой другой контейнер и прокинуть в него список секций, которые нужно отрисовать. Например, часть экрана деталей товара собирается на бекенде и выглядит так:

Кроме статических элементов (например, картинок, сепараторов и текстов), внутри BDUI мы поддерживаем более сложные секции — кнопки с состояниями и табы.
Экран, который приходит с бэкэнда может выглядеть так:
items: [
{
type: text,
},
{
type: button,
},
...
sectionN
]
В секции хранится определённая информация для отрисовки страницы, а сама она может выглядеть так:
{
type: "text",
text: "some text",
style: "style for text like font, size and etc",
}
Но есть одно исключение — секция табов. Среди секций может прийти, например, такой объект:
{
type: tabs,
type: tabs,
tabs: [
{
title: string,
items: [
{
type: offer,
},
{
type: offer,
},
...
]
},
{
title: TabTitle2,
items: [
{
type: text,
},
{
type: slot,
},
...
]
}
]
}

Отрисовка происходит в RecyclerView через BDUI-адаптер, который поддерживает несколько секций:
- text — текст с настраиваемыми стилями и размерами с бэкенда, также поддерживающий иконки
- button — кнопка с состояниями, умеет перерисовывать себя при нажатии
- divider — разделитель с размером
- image — картинка
- tabs — табы с контентом
Логика страниц
Раньше на BDUI-страницах мы использовали один адаптер и управляли логиков в зависимости от вида экрана:
- для экрана без табов все просто — достаточно отрисовать список секций и управлять их поведением, например:
- для экрана с табами отрисовывали все секции и запоминали позицию, для которой нужно управлять логикой переключения таба. Затем переключались между страницами и обновляли список на экране:

Основной недостаток решения с одним адаптером — сложность в контроле списка при пагинации: нужно точно знать позицию, на которую добавятся элементы при загрузке следующей страницы. Если добавить к этому логику табов, писать код в презентере и поддерживать логику становится очень сложно.
Мы исследовали различные решения для Recycler View, которые помогли бы нам избежать сложной логики и обратили внимание на технологию Concat Adapter.
Concat Adapter
Concat Adapter — это специальный адаптер в Android, который используется для объединения нескольких адаптеров в линейный поток элементов. Концепция позволяет легко комбинировать несколько различных источников данных в едином RecyclerView, управляя каждым из них отдельными адаптерами.
Мы решили разделить по разным адаптерам секции BDUI и пагинацию для товаров. Выбрали два экрана, которые охватывают большинство возможных вариаций списка секций, а главное — все экраны, важные для продукта.
Экран без табов
В секции Offer Items Section (указывает на начало пагинации в этом месте) разделяем все элементы по три адаптерам по такой логике:
- Header adapter — все секции до элемента Offer Items Section
- Middle adapter — секции из списка с пагинацией Offer Items Section
- Footer adapter — все секции после элемента Offer Items Section
Теперь при получении новых элементов в пагинационном списке легко отрисовать элементы — нужно просто добавить их в Middle adapter.
[
1 - {
type: header
},
2 - {
type: section_separator
},
3 - {
type: button
},
4 - {
type: offer_items - секция указывающая на пагинационный список
}
]
Первые три секции ушли в Header Adapter, а секции, пришедшие из списка с пагинацией, добавились в Middle Adapter.
Экран с табами
В секции Tabs Section (указывает на табы) разделяем все элементы по пяти адаптерам по следующей логике:
- Header adapter— всё, что идёт до секции Tabs Section, и сам Tabs Section.
Далее смотрим на текущий активный таб и в зависимости от его элементов добавляем:
- Tab Header adapter — все секции до элемента Offer Items Section в этом табе
- MIDDLE adapter — секции из списка с пагинацией Offer Items Section в этом табе
- Tab Footer adapter — все секции после элемента Offer Items Section в этом табе
- Footer adapter — всё, что идет после элемента, указывающего на Tabs Section

Представим, что нам пришла следующая конфигурация:
[
1 - {
type: header
},
2 - {
type: button
},
3 - {
type: section_separator
},
4 - {
type: button
},
5 - {
type: tabs,
tabs: [
{
title: string,
items: [
5.1 - {
type: offer_items - пагинационный список
}
]
},
{
title: string,
items: [
5.2 - {
type: button
},
5.3 - {
type: text
}
]
}
]
}
]
Первые пять секций уходят в Header Adapter, а Tab Header Adapter и Tab Footer Adapter остаются пустыми. Offer Items Section добавился в Middle Adapter с пагинацией, а секции Button Section и Text Section пока не добавлены ни в один адаптер, поскольку не находятся в активном табе.
При переходе на новый таб перерисовываем контент табов, но при этом элементы Header adapter и Footer adapter не трогаем. Итог выглядит так:
Чтобы вынести логику адаптеров, мы добавили ConcatAdapterHandler. Он предоставляет ConcatAdapter, который мы уже подключаем к RecyclerView.