Избавляемся от рутины RecyclerView.Adapter с помощью DataBinding


RecyclerView — основной UI элемент практически любого приложения. Написание адаптеров и ViewHolder'ов зачастую слишком рутинная работа и содержит достаточно boilerplate кода. В этой статье я хочу показать как с использованием DataBinding и паттерна MVVM можно написать абстрактный адаптер и напрочь забыть про ViewHolder'ы, inflate, ручной биндинг и прочую рутину.


ViewHolder


Мы все привыкли писать отдельный ViewHolder под каждый тип ячеек в таблице для хранения ссылок на отдельные вьюшки и связывания данных.


Можно сказать что DataBinding генерирует на лету тот код, что вы обычно пишите в ViewHolder'ах, поэтому надобность в них отпадает и мы легко можем использовать одну реализацию, хранящую в себе объект готового биндинга:


abstract class ViewModelAdapter : RecyclerView.Adapter<ViewModelAdapter.ViewHolder>() {
    class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        val binding = DataBindingUtil.bind<ViewDataBinding>(view)
    }
}

ViewDataBinding это базовый абстрактный класс для всех сгенерированных классов DataBinding'а и хоть мы и передаем его параметром шаблона для метода bind, DataBindingUtil сам поймет какой layout мы используем и какую реализацию в итоге использовать.


ViewModelAdapter


Разобравшись с ViewHolder'ом надо определиться чего мы хотим от нашего базового адаптера в итоге. Все, что мне требуется от адаптера в пределах MVVM архитектуры — отдать список объектов (ViewModel'ей), сказать какую разметку я хочу использовать для данных в этом списке классов и совершенно не беспокоиться о необходимой для этого логике.


Логику привязки данных на себя берет DataBinding, но это уже совершенно другая статья, коих в интернете уже достаточно.


Напишем логику для конфигурации нашего адаптера:


abstract class ViewModelAdapter : RecyclerView.Adapter<ViewModelAdapter.ViewHolder>() {

    data class CellInfo(val layoutId: Int, val bindingId: Int)

    protected val items = LinkedList<Any>()

    private val cellMap = Hashtable<Class<out Any>, CellInfo>()

    protected fun cell(clazz: Class<out Any>, @LayoutRes layoutId: Int, bindingId: Int) {
        cellMap[clazz] = CellInfo(layoutId, bindingId)
    }

    protected fun getCellInfo(viewModel: Any): CellInfo {
        cellMap.entries
                .filter { it.key == viewModel.javaClass }
                .first { return it.value }

        throw Exception("Cell info for class ${viewModel.javaClass.name} not found.")
    }

}

Для каждого класса объектов таблицы будем хранить пару layoutId и bindingId.


  • layoutId — как понятно из имени и аннотации @LayoutRes это соответствующая разметка ячейки.
  • bindingId — это сгенерированный идентификатор переменной, используемый в соответствующей разметке. Он нам понадобится для того, чтобы забиндить объект таблицы в написанный ранее ViewHolder, а точнее в ViewDataBinding.

Остается лишь реализовать абстрактные функции RecyclerView.Adapter:


abstract class ViewModelAdapter : RecyclerView.Adapter<ViewModelAdapter.ViewHolder>() {

    override fun getItemCount(): Int = items.size

    override fun getItemViewType(position: Int): Int {
        return getCellInfo(items[position]).layoutId
    }

    override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): ViewHolder {
        val inflater = LayoutInflater.from(parent?.context)
        val view = inflater.inflate(viewType, parent, false)
        return ViewHolder(view)
    }

    override fun onBindViewHolder(holder: ViewHolder?, position: Int) {
        if (holder != null) {
            val cellInfo = getCellInfo(items[position])
            if (cellInfo.bindingId != 0)
                holder.binding.setVariable(cellInfo.bindingId, items[position])
        }
    }

}

  • getItemViewType — так как layoutId уникален для разных ячеек мы с легкостью можем использовать его как viewType.
  • onCreateViewHolder — не забываем что viewType это наш layoutId.
  • onBindViewHolder — все что требуется для привязки данных объекта к разметке — сообщить DataBinding'у о том, что в данной ячейке теперь новый объект, всю остальную логику он возьмет на себя.

На этом вся основная логика ViewModelAdapter описана, однако остается одна проблема — обработка кликов по ячейкам. Обычно эту логику описывают в Activity, но я не любитель транслировать логику вверх по иерархии, если без этого ну никак не обойтись, поэтому реализую ее прямо в адаптере, но вы можете реализовывать ее там где вам удобно.


Для реализации обработки кликов добавим в ViewModelAdapter такое понятие как sharedObject, объект который будет биндится на все ячейки таблицы (не обязательно, если в разметке не найдет variable с данным bindingID ничего не упадет).


abstract class ViewModelAdapter : RecyclerView.Adapter<ViewModelAdapter.ViewHolder>() {

    private val sharedObjects = Hashtable<Int, Any>()

    protected fun sharedObject(sharedObject: Any, bindingId: Int) {
        sharedObjects[bindingId] = sharedObject
    }

    override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): ViewHolder {
        val inflater = LayoutInflater.from(parent?.context)
        val view = inflater.inflate(viewType, parent, false)
        val viewHolder = ViewHolder(view)

        sharedObjects.forEach { viewHolder.binding.setVariable(it.key, it.value) }

        return viewHolder
    }

}

Теперь рассмотрим как это все в итоге работает:
Как пример я реализовал адаптер для бокового меню (используйте NavigationView из стандартной библиотеки если у вас нет необходимости отойти от Material Design).


object NavigationAdapter : ViewModelAdapter() {

    init {
        cell(NavigationHeaderViewModel::class.java, R.layout.cell_navigation_header, BR.vm)
        cell(NavigationItemViewModel::class.java, R.layout.cell_navigation_item, BR.vm)
        cell(NavigationSubheaderViewModel::class.java, R.layout.cell_navigation_subheader, BR.vm)

        sharedObject(this, BR.adapter)
    }

    override fun reload(refreshLayout: SwipeRefreshLayout?) {
        items.clear()
        items.add(NavigationHeaderViewModel)
        items.add(NavigationItemViewModel(R.drawable.ic_inbox_black_24dp, "Inbox"))
        items.add(NavigationItemViewModel(R.drawable.ic_star_black_24dp, "Starred"))
        items.add(NavigationItemViewModel(R.drawable.ic_send_black_24dp, "Sent mail"))
        items.add(NavigationItemViewModel(R.drawable.ic_drafts_black_24dp, "Drafts"))
        items.add(NavigationSubheaderViewModel("Subheader"))
        items.add(NavigationItemViewModel(R.drawable.ic_mail_black_24dp, "All mail"))
        items.add(NavigationItemViewModel(R.drawable.ic_delete_black_24dp, "Trash"))
        items.add(NavigationItemViewModel(R.drawable.ic_report_black_24dp, "Spam"))
        notifyDataSetChanged()
    }

    fun itemSelected(view: View, model: NavigationItemViewModel) {
        Toast.makeText(view.context, "${model.title} selected!", Toast.LENGTH_SHORT).show()
    }

}

И как пример layout: cell_navigation_item.xml


<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

        <variable
            name="vm"
            type="com.github.akvast.mvvm.ui.vm.NavigationItemViewModel" />

        <variable
            name="adapter"
            type="com.github.akvast.mvvm.ui.adapter.NavigationAdapter" />
    </data>

    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="?attr/selectableItemBackground"
        android:onClick="@{v -> adapter.itemSelected(v, vm)}">

        <ImageView
            android:layout_width="24dp"
            android:layout_height="24dp"
            android:layout_gravity="center_vertical"
            android:layout_marginLeft="16dp"
            android:src="@{vm.icon}"
            android:tint="@{@color/grey_600}" />

        <TextView
            style="@style/TextAppearance.AppCompat.Body2"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:gravity="center_vertical"
            android:minHeight="48dp"
            android:paddingBottom="12dp"
            android:paddingLeft="72dp"
            android:paddingRight="16dp"
            android:paddingTop="12dp"
            android:text="@{vm.title}"
            tools:text="Item title" />

    </FrameLayout>

</layout>

Как видите все достаточно просто, нет никакой лишней логики. Мы можем объявлять сколько угодно типов ячеек вызовом 1 функции. Мы можем позабыть о ручном связывании данных для UI.


Данный адаптер успешно проходит боевые испытания на протяжении полугода в нескольких крупных проектах.


С удовольствием отвечу на ваши вопросы в комментариях.


Полезные ссылки


Полный код и example проект на GitHub
ViewModelAdapter, написанный на Java
Официальная документация по DataBinding
Настройка использования DataBinding и других библиотек в Kotlin

Поделиться публикацией
Похожие публикации
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама
Комментарии 4
  • 0
    private val cellMap = Hashtable<Class<out Any>, CellInfo>()

    Почему именно Hashtable? Замените его на HashMap/ConcurrentHashMap и можно будет заменить этот ужас с жадными преобразованиями и нелокальными ретёрнами:


    protected fun getCellInfo(viewModel: Any): CellInfo {
        cellMap.entries
                .filter { it.key == viewModel.javaClass }
                .first { return it.value }
    
        throw Exception("Cell info for class ${viewModel.javaClass.name} not found.")
    }

    на


    protected fun getCellInfo(viewModel: Any) = cellMap[viewModel.javaClass]
      ?: throw Exception(...)
    • 0

      Hashtable вполне можно заменить на HashMap/ConcurrentHashMap ради производительности.


      Но с функцией getCellInfo не все так просто, на самом деле она у меня немного больше, а для статьи решил сократить.


          protected fun getCellInfo(viewModel: Any): CellInfo {
              // Find info with simple class check:
              cellMap.entries
                      .filter { it.key == viewModel.javaClass }
                      .first { return it.value }
      
              // Find info with inheritance class check:
              cellMap.entries
                      .filter { it.key.isInstance(viewModel) }
                      .first {
                          cellMap[viewModel.javaClass] = it.value
                          return it.value
                      }
      
              throw Exception("Cell info for class ${viewModel.javaClass.name} not found.")
      }

      У нас есть Android/iOS проект в котором вся бизнес логика написана общим кодом на С++, нативным же только UI и некоторые специфичные вещи. Для генерации мостов между С++ и Kotlin/Objective-C мы используем Djinni. Классы объектов, которые мы передаем в адаптер, являются приватными и мы не можем напрямую их использовать.


      Использовать поиск только по isInstance мы тоже не можем, потому-что ViewModel классы могут наследовать друг друга (в другом нашем проекте) и в этом случае нужна точная проверка класса.

      • 0
        А вообще да, есть над чем задуматься. Спасибо!
        • 0

          О, понятно. Тогда пару жадный filter-first лучше заменить на find, работать будет так же, но без промежуточной коллекции.

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