
Всем привет! Меня зовут Александр Гузенко, и в Тинькофф я занимаюсь всякими техническими вещами вроде CI/CD, gradle и внедрением новых подходов. Хочу рассказать вам про библиотеку, которую мы создали в команде Тинькофф Бизнеса, когда столкнулись с многословными адаптер-делегатами.
Уникальность библиотеки и отличия от адаптер-делегатов
Способ написания экранов со списками при помощи адаптер-делегатов очень многословен и заставляет писать много бойлерплейт-кода. Все дело в самом устройстве: его архитектура не подталкивает к написанию меньшего количества кода и большему переиспользованию.

Многие компании предпочитают библиотеку AdapterDelegates: она упрощает работу со списками в Android. Автор Ханнес Дорфман для своего времени написал отличную библиотеку, которую до сих пор используют в некоторых проектах и у нас в компании. Но разработка не стоит на месте, в ИТ все устаревает еще до того, как попадает в прод, поэтому мы решили написать что-то своe.
Не нужно писать весь адаптер целиком, чтобы отобразить на экране новый элемент. За это отвечает ViewHolder, а адаптер просто передает ему данные и вызывает его методы. Эти две задачи достаточно высокоуровневые, чтобы от них абстрагироваться. Я думаю, некоторые, даже используя адаптер-делегаты, выносят ViewHolder в отдельный класс. Так можно переиспользовать их в разных адаптерах. Предлагаю рассмотреть класс адаптер-делегата и придумать, что там можно «вынести за скобки»:
/** * @param <T> the type of adapters data source i.e. List<Accessory> */ public interface AdapterDelegate<T> { /** * Called to determine whether this AdapterDelegate is the responsible for the given data * element. * * @param items The data source of the Adapter * @param position The position in the datasource * @return true, if this item is responsible, otherwise false */ public boolean isForViewType(@NonNull T items, int position); /** * Creates the {@link RecyclerView.ViewHolder} for the given data source item * * @param parent The ViewGroup parent of the given datasource * @return The new instantiated {@link RecyclerView.ViewHolder} */ @NonNull public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent); /** * Called to bind the {@link RecyclerView.ViewHolder} to the item of the datas source set * * @param items The data source * @param position The position in the datasource * @param holder The {@link RecyclerView.ViewHolder} to bind */ public void onBindViewHolder(@NonNull T items, int position, @NonNull RecyclerView.ViewHolder holder); }
Шаг 1: выносим isForViewType в модель
В этом методе для определения ViewType мы передаем наш айтем и позицию.
А можно ли с ним что-то сделать, чтобы было удобнее? Да, можно, если наш дженерик будет не простым T, а T extends ViewTyped. ViewTyped — это наш интерфейс, в котором определим viewType для вьюхолдера. Это позволит вынести его за рамки каждого адаптер-делегата, но все еще иметь к нему доступ.
Предлагаю еще одно коренное изменение. Что вы обычно используете для определения ViewType? У нас в компании, да и во многих статьях по ресайлеру я видел повсеместные instance of/is. Но что насчет лейаута? Обычно одной конкретной модельке соответствует один конкретный лейаут.
Если нужно будет отобразить какую-нибудь модельку вида:
data class AccountDetailUi( val icon: Int, val detailTitle: String, val moneyAmount: MoneyAmount, val date: Date )
То ей может соответствовать такой лейаут:

Предлагаю добавить в модельку наследование от интерфейса ViewTyped и знание о лейауте. Модель будет выглядеть так:
data class AccountDetailUi( val icon: Int, val detailTitle: String, val moneyAmount: MoneyAmount, val date: Date, override val viewType: Int = R.layout.item_account_details ) : ViewTyped
Получается, на уровне Adapter, который не делегат, а обычный, можно брать данные о том, что инфлэйтить и чем наполнять. Значит, от этого метода в делегатах мы избавились и перенесли его на уровень UI-модели.
Шаг 2: превращаем onCreateViewHolder в фабрику
Иду дальше, встречаю onCreateViewHolder. Тут почти в 100% случаев все вызывают inflate и передают view в конструктор ViewHolder'а. Так давайте вспомним, чему нас учит SOLID, и вынесем это в отдельный класс. Назовем его HolderFactory:
abstract class HolderFactory: (ViewGroup, Int) -> BaseViewHolder<ViewTyped> { abstract fun createViewHolder(view: View, viewType: Int): BaseViewHolder<*>? final override fun invoke(viewGroup: ViewGroup, viewType: Int): BaseViewHolder<ViewTyped> { val view: View = viewGroup.inflate(viewType) return when (viewType) { // тут у нас сразу будет создаваться пачка базовых ViewHolder, например R.layout.item_progress -> BaseViewHolder<ProgressItem>(view) R.layout.item_error -> ErrorViewHolder(view) R.layout.item_empty_content -> EmptyContentViewHolder(view) //и так далее в зависимости от готовности вашего проекта к шаблонным лейаутам else -> checkNotNull(createViewHolder(view, viewType)) { "unknown viewType=" + viewGroup.resources.getResourceName(viewType) } } as BaseViewHolder<ViewTyped> } }
Хочу обратить внимание на строчку:
R.layout.item_progress -> BaseViewHolder<ProgressItem>(view)
Мы «не плодим сущности сверх необходимого»: для ProgressItem нам не нужно создавать отдельный ViewHolder, потому что его задача — просто отрисовать xml-вьюшку и ничего более.
Шаг 3: выносим onBindViewHolder в отдельный класс
В предыдущем примере у нас мелькал класс BaseViewHolder, его-то мы сейчас и разберем:
open class BaseViewHolder<T : ViewTyped>( override val containerView: View ) : RecyclerView.ViewHolder(containerView), LayoutContainer { open fun bind(item: T) = Unit open fun bind(item: T, payload: List<Any>) = Unit //при необходимости сюда можно добавить и другие колбэки из разряда //onViewRecycled и вот это все, но у нас пока не было надобности, //а как говорил Оккама, «не плоди сущности сверх необходимого» }
Простой и маленький класс, в котором только самое необходимое. Метод bind, в который передается дженерик, — наследник ViewTyped и его перегрузка. В нее можно передать payload, чтобы обновить одну или несколько частей ViewHolder, не обновляя его полностью.
Шаг 4: Адаптер. Собираем все воедино
Мы разделили методы интерфейса AdapterDelegate по своим обязанностям и теперь можем связать их в классе Adapter. У нас получилось два адаптера — это связано с тем, что где-то у нас есть DiffUtils, а где-то они не нужны. Поэтому рассмотрим сначала базовую реализацию, а конкретные реализации разберем дальше:
abstract class BaseAdapter<T : ViewTyped>(internal val holderFactory: HolderFactory) : RecyclerView.Adapter<BaseViewHolder<ViewTyped>>() { abstract var items: List<T> override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder<ViewTyped> = holderFactory(parent, viewType) override fun getItemCount(): Int = items.size override fun onBindViewHolder(holder: BaseViewHolder<ViewTyped>, position: Int) = holder.bind(items[position]) override fun onBindViewHolder(holder: BaseViewHolder<ViewTyped>, position: Int, payloads: MutableList<Any>) { if (payloads.isNotEmpty()) { holder.bind(items[position], payloads) } else { super.onBindViewHolder(holder, position, payloads) } } override fun getItemViewType(position: Int): Int { return items[position].viewType } }
Дальше делегируем работу нашим помощникам.
Для определения viewType — нашему интерфейсу ViewTyped, для создания ViewHolder'а — holderFactory, для наполнения ViewHolder данными — нашему BaseViewHolder

Ловкость рук, и никакого дублирования.
Как работаем с адаптером DiffUtils
Для работы с DiffUtils добавим в наш интерфейс еще одну проперти:
interface ViewTyped { val viewType: Int val uid: String get() = error("provide uid for viewType $this") }
Хочу подсветить, что теоретически каждый элемент может начать использоваться с DiffUtils, но пока эта функциональность не будет нужна, мы не обязываем переопределять uid.
А если мы решим перевести экран на использование DiffUtils и где-то не укажем uid для наших элементов, свалимся с ошибкой. Еще на этапе разработки мы увидим проблемный класс и сможем быстро его поправить. Чтобы наконец все заработало, нам нужны еще две детали. Первая — ViewTypedDiffCallback:
open class ViewTypedDiffCallback<T : ViewTyped>() : DiffUtil.ItemCallback<T>() { override fun areItemsTheSame(oldItem: T, newItem: T): Boolean { return oldItem.uid == newItem.uid } override fun areContentsTheSame(oldItem: T, newItem: T): Boolean { return oldItem.equals(newItem) } }
Вторая — AsyncAdapter:
class AsyncAdapter<T : ViewTyped>( holderFactory: HolderFactory, diffItemCallback: DiffUtil.ItemCallback<T> ) : BaseAdapter<T>(holderFactory) { private val asyncListDiffer = AsyncListDiffer(this, diffItemCallback) override var items: List<T> get() = asyncListDiffer.currentList set(newItems) = asyncListDiffer.submitList(newItems) }
Когда используем AsyncListDiffer, который предоставляет библиотека ресайклера, мы создаем асинхронный адаптер. Такой адаптер может использовать DiffUtils.
Как это выглядит в TiRecycler
Сначала покажу пример того, как мы в итоге все это используем, а потом объясню по порядку:
interface TiRecycler<T : ViewTyped> { fun setItems(items: List<T>) val adapter: BaseAdapter<T> companion object { @JvmOverloads operator fun <T : ViewTyped> invoke( recyclerView: RecyclerView, holderFactory: HolderFactory, diffCallback: DiffUtil.ItemCallback<T>? = null, init: TiRecyclerBuilder<T>.() -> Unit = {} ): TiRecycler<T> { return TiRecyclerBuilderImpl( holderFactory = holderFactory, diffCallback = diffCallback ) .apply(init) .build(recyclerView) } }
val tiRecycler = TiRecycler(recyclerView, CoreRecyclerHolderFactory()) { itemDismissCallbacks += ItemDismissTouchHelperCallback(this@CoreRecyclerDemoActivity, R.layout.item_text) } recycler.setItems(getStubItems())
Мы используем возможности Kotlin красиво написать объявление вызова, как конструктора интерфейса — просто красивый сахар. Снова делегируем всю работу в отдельный класс — TiRecyclerBuilderImpl, который конструирует нужный объект — наследник интерфейса TiRecycler.
В зависимости от значения — null или diffCallback — мы подставляем нужную реализацию адаптера и удобно добавляем dismiss-колбэки, тач-хелперы, декораторы и дефолтный LinearLayoutManager, если забыли объявить его в XML.
Как обрабатываем клики: Rx и MVI
Этот подход лучше всего работает для архитектур UDF like, потому что нужна реактивная связка для кликов. Но можно попробовать подружить его и с MVP-подходом. У нас есть кастомный Observable, который имплементирует работы View.OnClickListener:
data class ItemClick(val viewType: Int, val position: Int, val view: View) class TiRecyclerItemClicksObservable : Observable<ItemClick>(), TiRecyclerHolderClickListener { private val source: PublishRelay<ItemClick> = PublishRelay.create() override fun accept(viewHolder: BaseViewHolder<*>, onClick: () -> Unit) { viewHolder.itemView.run { setOnClickListener(Listener(source, viewHolder, this, onClick)) } } override fun accept(view: View, viewHolder: BaseViewHolder<*>, onClick: () -> Unit) { view.setOnClickListener(Listener(source, viewHolder, view, onClick)) } override fun subscribeActual(observer: Observer<in ItemClick>) { source.subscribe(observer) } class Listener( private val source: Consumer<ItemClick>, private val viewHolder: BaseViewHolder<*>, private val clickedView: View, private val onClick: () -> Unit ) : View.OnClickListener { override fun onClick(v: View) { if (viewHolder.bindingAdapterPosition != RecyclerView.NO_POSITION) { onClick() source.accept(ItemClick(viewHolder.itemViewType, viewHolder.bindingAdapterPosition, clickedView)) } } } }
В HolderFactory создаем экземпляр класса и пишем интересующие нас фильтры:
protected val clicks = TiRecyclerItemClicksObservable() fun clickPosition(vararg viewType: Int): Observable<Int> { return clicks.filter { it.viewType in viewType }.map(ItemClick::position) } fun clickPosition(viewType: Int, viewId: Int): Observable<Int> { return clicks.filter { it.viewType == viewType && it.view.id == viewId }.map(ItemClick::position) }
Аналогично сделано для лонг-кликов и свайпов. Следующим шагом нужно добавить дополнительный конструктор в BaseViewHolder, чтобы можно было ловить клики:
constructor(containerView: View, clicks: TiRecyclerHolderClickListener) : this(containerView) { clicks.accept(this) // так мы ловим клик на весь itemView //а клик на конкретную вью можно поймать по ее id, например так: //clicks.accept(binding.btnRepeat, this@ErrorViewHolder) }
Финальный этап — предоставить метод со стороны интерфейса Recycler для подписки в Activity/Fragment/View:
override fun <R : ViewTyped> clickedItem(vararg viewType: Int): Observable<R> { return adapter.holderFactory.clickPosition(*viewType).map { adapter.items[it] as R } } override fun <R : ViewTyped> clickedViewId(viewType: Int, viewId: Int): Observable<R> { return adapter.holderFactory.clickPosition(viewType, viewId).map { adapter.items[it] as R } }
На объекте recycler вызываем эти методы и передаем туда параметры:
tiRecycler.clickedItem(R.layout.onboarding_cell_item) //или tiRecycler.clickedItem(R.layout.onboarding_cell_item, R.id.someItem)
Но чтобы не плодить тонну подписок на каждый клик, у нас есть класс *UiEvents, который принимает Observable<ViewTyped>. Этот класс складывает клики в mergeArray и передает на единый вход store/presenter — это MVI-я прослойка с единым input-стримом, который дальше фильтруется нужным обработчиком.
Почему сделали так
Я знаю, что есть FastAdapter, в котором реализована примерно та же мысль, и сам AdapterDelegate выглядит лучше с котлин DSL. Нашему подходу уже около четырех лет, а я только нашел время, чтобы рассказать о нем. Возможно, «сейчас придет компоуз и всех вас уничтожит», но пока списки там работают не идеально, ждем. А пока ждем — улучшаем ситуацию с помощью нашего подхода TiRecycler :)
