Всем привет! Меня зовут Александр Гузенко, и в Тинькофф я занимаюсь всякими техническими вещами вроде 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 :)