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

fb01339f42f51a410bceaeb6a59d3d3e.png

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

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

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

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

Многие компании предпочитают библиотеку AdapterDelegates: она упрощает работу со списками в Android. Автор Ханнес Дорфман для своего времени написал отличную библиотеку, которую до сих пор используют в некоторых проектах и у нас в компании. Но разработка не стоит на месте, в ИТ все устаревает еще до того, как попадает в прод, поэтому мы решили написать что-то своe.

Не нужно писать весь адаптер целиком, чтобы отобразить на экране новый элемент. За это отвечает ViewHolder, а адаптер просто передает ему данные и вызывает его методы. Эти две задачи достаточно высокоуровневые, чтобы от них абстрагироваться. Я думаю, некоторые, даже используя адаптер-делегаты, выносят ViewHolder в отдельный класс. Так можно переиспользовать их в разных адаптерах. Предлагаю рассмотреть класс адаптер-делегата и придумать, что там можно «вынести за скобки»:  

/**
 * @param  the type of adapters data source i.e. List
 */
public interface AdapterDelegate {

  /**
   * 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
)

То ей может соответствовать такой лейаут:

ed2924a1f47ee4f0c7ccb5c4d62d87cf.png

Предлагаю добавить в модельку наследование от интерфейса 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 {


	abstract fun createViewHolder(view: View, viewType: Int): BaseViewHolder<*>?


	final override fun invoke(viewGroup: ViewGroup, viewType: Int): BaseViewHolder {
    	val view: View = viewGroup.inflate(viewType)
    	return when (viewType) {
			// тут у нас сразу будет создаваться пачка базовых ViewHolder, например
			R.layout.item_progress -> BaseViewHolder(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
	}
}

Хочу обратить внимание на строчку:

R.layout.item_progress -> BaseViewHolder(view)

Мы «не плодим сущности сверх необходимого»: для ProgressItem нам не нужно создавать отдельный ViewHolder, потому что его задача — просто отрисовать xml-вьюшку и ничего более. 

Шаг 3: выносим onBindViewHolder в отдельный класс

В предыдущем примере у нас мелькал класс BaseViewHolder, его-то мы сейчас и разберем:

open class BaseViewHolder(
    override val containerView: View
) : RecyclerView.ViewHolder(containerView), LayoutContainer {

    open fun bind(item: T) = Unit

    open fun bind(item: T, payload: List) = Unit
	
	//при необходимости сюда можно добавить и другие колбэки из разряда 
  //onViewRecycled и вот это все, но у нас пока не было надобности,
	//а как говорил Оккама, «не плоди сущности сверх необходимого»
}

Простой и маленький класс, в котором только самое необходимое. Метод bind, в который передается дженерик, — наследник ViewTyped и его перегрузка. В нее можно передать payload, чтобы обновить одну или несколько частей ViewHolder, не обновляя его полностью. 

Шаг 4: Адаптер. Собираем все воедино

Мы разделили методы интерфейса AdapterDelegate по своим обязанностям и теперь можем связать их в классе Adapter. У нас получилось два адаптера — это связано с тем, что где-то у нас есть DiffUtils, а где-то они не нужны. Поэтому рассмотрим сначала базовую реализацию, а конкретные реализации разберем дальше:

abstract class BaseAdapter(internal val holderFactory: HolderFactory) :
    RecyclerView.Adapter>() {

    abstract var items: List

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder = holderFactory(parent, viewType)

    override fun getItemCount(): Int = items.size

    override fun onBindViewHolder(holder: BaseViewHolder, position: Int) =
        holder.bind(items[position])

    override fun onBindViewHolder(holder: BaseViewHolder, position: Int, payloads: MutableList) {
        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() : DiffUtil.ItemCallback() {

    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(
    holderFactory: HolderFactory,
    diffItemCallback: DiffUtil.ItemCallback
) : BaseAdapter(holderFactory) {

    private val asyncListDiffer = AsyncListDiffer(this, diffItemCallback)

    override var items: List
        get() = asyncListDiffer.currentList
        set(newItems) = asyncListDiffer.submitList(newItems)
}

Когда используем AsyncListDiffer, который предоставляет библиотека ресайклера, мы создаем асинхронный адаптер. Такой адаптер может использовать DiffUtils. 

Как это выглядит в TiRecycler

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

interface TiRecycler {
    fun setItems(items: List)

    val adapter: BaseAdapter

    companion object {

        @JvmOverloads
        operator fun  invoke(
            recyclerView: RecyclerView,
            holderFactory: HolderFactory,
            diffCallback: DiffUtil.ItemCallback? = null,
            init: TiRecyclerBuilder.() -> Unit = {}
        ): TiRecycler {
            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(), TiRecyclerHolderClickListener {

    private val source: PublishRelay = 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) {
        source.subscribe(observer)
    }

    class Listener(
        private val source: Consumer,
        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 {
    return clicks.filter { it.viewType in viewType }.map(ItemClick::position)
}
fun clickPosition(viewType: Int, viewId: Int): Observable {
    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  clickedItem(vararg viewType: Int): Observable {
    return adapter.holderFactory.clickPosition(*viewType).map { adapter.items[it] as R }
}

override fun  clickedViewId(viewType: Int, viewId: Int): Observable {
    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. Этот класс складывает клики в mergeArray и передает на единый вход store/presenter — это MVI-я прослойка с единым input-стримом, который дальше фильтруется нужным обработчиком. 

Почему сделали так

Я знаю, что есть FastAdapter, в котором реализована примерно та же мысль, и сам AdapterDelegate выглядит лучше с котлин DSL. Нашему подходу уже около четырех лет, а я только нашел время, чтобы рассказать о нем. Возможно, «сейчас придет компоуз и всех вас уничтожит», но пока списки там работают не идеально, ждем. А пока ждем —  улучшаем ситуацию с помощью нашего подхода TiRecycler:)

© Habrahabr.ru