Знакомьтесь: библиотека TiRecycler

от автора

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

Уникальность библиотеки и отличия от адаптер-делегатов

Способ написания экранов со списками при помощи адаптер-делегатов очень многословен и заставляет писать много бойлерплейт-кода. Все дело в самом устройстве: его архитектура не подталкивает к написанию меньшего количества кода и большему переиспользованию.

Это я (слева) и Ханнес Дорфман на конференции Mobius в 2019 году
Это я (слева) и Ханнес Дорфман на конференции Mobius в 2019 году

Многие компании предпочитают библиотеку 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

схема работы TiRecycler
схема работы TiRecycler

Ловкость рук, и никакого дублирования.

Как работаем с адаптером 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 🙂


ссылка на оригинал статьи https://habr.com/ru/company/tinkoff/blog/665930/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *