Привет, Хабр! Меня зовут Михаил Богатиков, я Android-разработчик в ПСБ. В своей работе я часто сталкиваюсь с задачей отображения сложных и динамических списков.

Казалось бы, RecyclerView — давно знакомый инструмент. Но когда данные становятся разнородными, а списки — большими, простой вызов notifyDataSetChanged() превращается в узкое место, вызывающее фризы и дергающуюся анимацию. Как перейти от лобового обновления к точечным изменениям? Как объединить несколько типов элементов в одном адаптере?

В этой статье я пройду путь эволюции работы с RecyclerView.Adapter:

  • Разберу, как работает система уведомлений под капотом.

  • Вспомню, как DiffUtil и ListAdapter спасли нас от ручных вычислений.

  • Расскажу про AsyncListDiffer, ConcatAdapter и нюансы восстановления позиции.

  • И наконец, покажу, как спроектировать универсальный адаптер с поддержкой разных ViewHolder, частичным обновлением (Payloads) и ViewBinding — с полным примером кода.

Поехали!

Как работает система уведомлений в Recyclerview

На схеме мы видим, что, RecyclerView содержит Adapter, AdapterHelper и RecyclerViewDataObserver, а Adapter содержит AdapterDataObservable. Adapter содержит список элементов и при любых изменениях списка надо вызвать методы notify*, которые соответствуют изменениям списка у AdapterDataObservable. Все слушатели, включая RecyclerView, получают callback и реагируют на него при помощи AdapterDataObserver, который используется как слушатель и регистрируется в Adapter. После получения callback RecyclerViewDataObserver добавляет операцию по изменению списка в AdapterHelper. Если в списке операций только эта операция, то RecyclerViewDataObserver вызывает свою функцию triggerUpdateProcessor для отрисовки изменений.

** AdapterDataObserver можно также использовать в Fragment/Activity чтобы реагировать на изменения списка.

Передача уведомлений об изменении списка от адаптера к RecyclerView возможна разными способами. Рассмотрим, как управлять этими уведомлениями: от самого первого варианта до самых продвинутых способов в порядке появления инструментов.

Оптимизация обработки изменения списка

Ручное управление уведомлениями

С первым появлением RecyclerView были добавлены методы: notifyItemChanged(int), notifyItemInserted(int), notifyItemRemoved(int), notifyItemRangeChanged(int, int), notifyItemRangeInserted(int, int), notifyItemRangeRemoved(int, int), но не все их использовали, т.к. сложно было рассчитать все случаи, если список обновлялся из нескольких источников.

     Самым популярным (и самым вредным) методом был notifyDataSetChanged(), который полностью перерисовывает весь список. Этот подход перекочевал из ListView и стал своего рода костылём, который многие использовали, чтобы не заморачиваться с правильной обработкой изменений. По сути, это было заведомо неоптимальное решение, которое работало по принципу "и так сойдёт" — пока список не вырастал и не начинал тормозить.    

Чтобы достичь итогового состояния надо было вызывать нужные notify* методы по очереди, это приводило к лишним операциям и заставляло RecyclerView перерисовывать ViewHolder чаще, а также приходилось следить за индексами, чтобы не ошибиться в правильности данных, подаваемых в адаптер.

DiffUtil

В 2016 году добавили DiffUtil, который позволял сделать эту работу автоматически и сократить количество notify* и переложить вызовы на систему, т.к. вызовов в результате вычислений выдавал итоговый список как результат и было достаточно вызвать один notify* метод, результат которого шёл обратно и вносил изменения в отрисовку RecyclerView без лишних шагов. Это было большим прорывом, т. к. достаточно было подать на вход классу DiffUtil внутри адаптера 2 списка (старый и новый).

val diffResult = DiffUtil.calculateDiff(MyDiffCallback(oldList, newList)) 

diffResult.dispatchUpdatesTo(adapter)

Тут внутри функции dispatchUpdatesTo создаётся AdapterListUpdateCallback, который на вход принимает adapter и при обработке команды отдаёт оптимизированные команды при помощи BatchingListUpdateCallback, в котором соединяются одинаковые операции по диапазону и передаются обратно через adapter в AdapterDataObserver, как если бы мы сами вызывали notify* у адаптера.

Вскоре стали требоваться новые оптимизации.  DiffUtil производил вычисления на UI-потоке, что при больших списках стало приводить к фризам. Прежде чем проблему устранили, была выпущена удобная обёртка для работы с DiffUtil - ListAdapter.

Методы DiffUtil.Callback для переопределения:

1)    areItemsTheSame(T oldItem, T newItem): Boolean

 Сравнивает 2 элемента списка по одному ключу, чаще всего это Id и возвращает boolean.

2)    areContentsTheSame(T oldItem, T newItem)

Сравнивает именно содержимое двух элементов, чаще всего сравниваемые объекты являются Data class, поэтому не надо писать дополнительную логику.

3)    getChangePayload(T oldItem, T newItem)                                 

До добавления DiffUtil была ещё одна проблема: нередко надо было обновить только часть ячейки, но не было возможности сделать это напрямую, т. к. RecyclerView можно только полностью перерисовать ячейку для обновления состояния, поэтому добавили Payload. Если areItemsTheSame == true, а areContentsTheSame == false и определён этот метод, то вызывается метод onBindViewHolder(holder:RecyclerView.ViewHolder, position:Int, payloads:MutableList<Any>), который мы должны переопределить и указать на основании поступающих данных как обновить ячейку. Можно сделать для этой цели отдельный метод, обновляющий статус like. Тогда наш код будет выглядеть так:

override fun getChangePayload (T oldItem, T newItem){
  val diff = mutableMapOf<String, Any>()
if (oldItem.isFavorite != newItem.isFavorite){
  diff["liked "] = newItem.isFavorite }
}
override fun onBindViewHolder(holder:RecyclerView.ViewHolder,
position:Int, payloads:MutableList<Any>){
if (payloads.isEmpty()) {
// Если payload пустой, делаем полное обновление
onBindViewHolder(holder, position)
} else {
// Обрабатываем payload — обновляем только нужные поля
for (payload in payloads) {
 if (payload is Map<*, *>) {
 (payload["liked "] as? Boolean)?.let { isLiked ->
 holder.updateFavoriteState(isLiked)}
// Обновляем только поле like
}}}}

ListAdapter

В марте 2018 года выпустили ListAdapter, в котором можно было не писать шаблонный код для работы с DiffUtil, а сразу вызвать у адаптера submitList, который также вызывал под капотом DiffUtil и отдавал изменения адаптеру.

Из-за проблем с DiffUtil на UI-потоке позже выпустят AsyncListDiffer, который переведёт работу в фоновый поток, и переведут на него ListAdapter.

При добавлении данных вызывается:

public void submitList(@Nullable List<T> list) {
 	mDiffer.submitList(list);
}

Актуальный список для отображения также хранится в mDiffer.getCurrentList()

Также важно, что в ListAdapter есть плавные анимации из коробки для обновления списка.

ConcatAdapter

     Для более удобной работы с ViewHolder можно использовать ConcatAdapter, он позволяет добавить несколько типов адаптеров в один.

Показываются данные из адаптеров по порядку. Так можно смастерить целый экран, для этого можно добавить свой адаптер, показывающий загрузку и адаптер с данными.

Через ConcatAdapter.Config.StableIdMode можно настроить режимы работы ConcatAdapter:

1) NO_STABLE_IDS  – режим по умолчанию. ConcatAdapter игнорирует стабильные идентификаторы, предоставляемые дочерними адаптерами. Если добавить адаптер, использующий стабильные идентификаторы в этом режиме, будет выдано предупреждение о возможной некорректной работе этого адаптера.

2) ISOLATED_STABLE_IDS – в этом режиме метод hasStableIds() возвращает true и требуется, чтобы все адаптеры имели стабильные идентификаторы. Чтобы не было конфликтов Id ConcatAdapter изолирует пул идентификаторов адаптеров друг от друга.

3) SHARED_STABLE_IDS – в этом режиме метод hasStableIds() возвращает true и требуется, чтобы все адаптеры не возвращали одинаковые идентификаторы и знали друг о друге.

О StableIds:

До появления DiffUtil была попытка оптимизировать изменения в адаптере. Для этого надо было вызвать adapter.hasStableIds(true), также надо было переопределить внутри адаптера getItemId(position: Int) для элемента ViewHolder, это позволяло адаптеру присваивать своим элементам id и сравнивать их, работать похожим образом с DiffUtils добавляя анимацию изменений и делать работу более стабильной.

Если элементы ConcatAdapter не должны меняться между адаптерами, то лучше применять режим по умолчанию, не используя StableIds.

Как работать с ConcatAdapter:

1) val adapter = ConcatAdapter(firstAdapter, secondAdapter) Т.е. указываем адаптеры для соединения в порядке необходимым для показа

2) Методы addAdapter, removeAdapter. Что интересно, для добавления адаптера можно добавить индекс для указания того, куда мы хотим добавить адаптер. А сами адаптеры хранятся в виде List<NestedAdapterWrapper>, а класс NestedAdapterWrapper немного переопределяет поведение адаптеров под общую логику concatAdapter, чтобы всё работало как обычный адаптер.

StateRestorationPolicy

 Нередки ситуации, когда при возврате к экрану у RecyclerView сбивается позиция скролла, по этой причине для каждого адаптера добавили возможность восстанавливать позицию прокрутки и даже руководить этим.

Варианты восстановления:

1) ALLOW – Восстанавливать независимо от количества элементов в адаптере немедленно. Это состояние используется по умолчанию и не подходит для асинхронной обработки изменений адаптера.

2) PREVENT_WHEN_EMPTY – Восстанавливать только когда в адаптере минимум 1 item.

3) PREVENT – Не восстанавливать до тех пор пока не изменится на другой тип StateRestorationPolicy.

Примечание:

ConcatAdapter ждёт готовности всех адаптеров, прежде чем восстанавливать их состояние.

AsyncListDiffer

     Помогает вынести вычисления DiffUtil в фоновый поток. С ним можно создать полноценную замену ListAdapter и использовать лучшие практики, но что важно, помимо стандартного способа создания с DiffUtil.ItemCallback<T>, можно создать его с AsyncDifferConfig, в котором можно дополнительно задать Executors для mainThreadExecutor и mBackgroundThreadExecutor, например увеличить количество thread если есть необходимость увеличить производительность, т. к. изначально стоит 2 потока в mBackgroundThreadExecutor.

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

Как решить проблему с разными viewholder и viewTypes:

1) Библиотека Groupie. Надо реализовать ячейку, например, BindableItem, и создать универсальный адаптер через GroupieAdapter<GroupieViewHolder>, и просто добавить ячейку в адаптер. Под капотом библиотека для каждой ячейки генерит свой ViewHolder и понимает как отрисовать её в RecyclerView. Для стандартных случаев эта библиотека покрывает достаточно кейсов.

2) Библиотека DelegatesAdapter в отличие от Groupie предлагает нам создавать список адаптеров, применяя подход builder и регистрируя, где необходимо список адаптеров, которые мы должны поддерживать, надо реализовать адаптер KDelegateAdapter<IViewModel>, который будет отвечать за прикрепление данных к view. Все адаптеры крепятся к CompositeDelegateAdapter.Builder<IViewModel>()

3) Библиотека Epoxy. Надо реализовать EpoxyModel(для binding) и EpoxyController (определяет, какие модели использовать, и как всё отображать, отдаёт adapter для RecyclerView). Позволяет реализовать очень сложные кейсы.

4) Реализовать самостоятельно.

Пример самостоятельной реализации универсального адаптера с универсальным ViewHolder

Для начала реализуем базовый Item:

abstract class Item {
 	abstract val id: Int
 	abstract fun getLayoutId(): Int
 	abstract fun createVH(
     	inflater: LayoutInflater,
     	parent: ViewGroup,
     	attachToParent: Boolean
 	): BaseViewHolder
 	abstract fun compare(item: Item): Boolean
 	open fun getPayload(newItem: Item): Any? = null
 }

id – реализуем для возможности быстрого сравнения Item, независимо от типа.

getLayoutId – в нашей реализации используется для уникальной ячейки, но если вёрстка будет переиспользоваться, то можно использовать, например, id.

createVH – для создания любого подходящего ViewHolder.

compare – для сравнения одинаковых Item прописываем логику сравнения в самом Item для DiffUtil.ItemCallback.

getPayload – надо реализовать, если хотим поддерживать частичное обновление ячейки.

Реализуем Item, поддерживающий ViewBinding:

abstract class ItemVB<VB : ViewBinding> : Item() {
 	abstract fun onBind(binding: VB)
	open fun onBindPayloads(binding: VB, bundle: Bundle){}
 }

Тут через VB надо будет реализовать конкретный ViewBinding и onBindPayloads, если есть необходимость в частичном обновлении ячейки.

Реализованный класс будет выглядеть так:

class TextItem(
 	val data: String,
    val callback: (String) -> (Unit)
 ) : ItemWithBinding<TextItemBinding>() {
private companion object{
 	const val TEXT = "TEXT"
 }

 	override fun getLayoutId(): Int = R.layout.text_item

 	override fun createVH(
     	inflater: LayoutInflater,
     	parent: ViewGroup,
     	attachToParent: Boolean
 	): BindingViewHolder< TextItemBinding > =
    BindingViewHolder(TextItemBinding.inflate(inflater, parent, attachToParent))

 	override val id: Int = 1

 	override fun onBind(binding: TextItemBinding) {
     	binding.textView.text = data

     	// Можно добавить обработку кликов
     	binding.root.setOnClickListener {
         	// обработка клика
        callback.invoke(data)	
        }
 	}
override fun compare(item: Item): Boolean =
     	(item as? TextItem)?.data == data
override fun getPayload(newItem: Item): Any? {
 	if (newItem !is TextItem) {
     	return null
 	}
 	val bundle = Bundle()
 	if (newItem.data != data) {
     	bundle.putString(TEXT, newItem.data)
 	}
 	return bundle.takeIf { !it.isEmpty }
 }

 override fun onBindPayloads(binding: DayItemBinding, bundle: Bundle) {
    val text = bundle.getString(TEXT)
 	if (!text.isNullOrEmpty()) {
     	binding. textView.text = data.temp.toString()
 	}
 }
 }

Теперь надо реализовать базовый ViewHolder, принимающий любой Item:

abstract class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
 	abstract fun bind(item: Item)
 	abstract fun bindPayloads(item: Item, bundle: Bundle)
 }

bind для обычного связывания и bindPayloads, если надо передать частичную отрисовку через Bundle.

Реализуем ViewHolder, работающий с ViewBinding:

 class BindingViewHolder<VB : ViewBinding>(
 	private val binding: VB
 ) : BaseViewHolder(binding.root) {

 	override fun bind(item: Item) {
     	(item as? ItemWithBinding<VB>)?.onBind(binding)
 	}

 	override fun bindPayloads(item: Item, bundle: Bundle) {
     	(item as? ItemWithBinding<VB>)?.onBindPayloads(binding, bundle)
 	}
 }

Т. к. тип VB заранее не известен (затирается Java, erased types), чтобы избежать использования рефлексии мы делаем Unchecked cast для (item as? ItemWithBinding<VB>), но с проверкой на ноль. Задача ViewHolder инициализировать ViewBinding для Item.

Собираем адаптер:

class ItemListAdapter : ListAdapter<Item, BaseViewHolder>(
object : DiffUtil.ItemCallback<Item>() {
 	override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean =
     	oldItem.id == newItem.id

 	override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean =
     	oldItem.compare(newItem)

 	override fun getChangePayload(oldItem: Item, newItem: Item): Any? =
     	oldItem.getPayload(newItem)
 }) {

 	override fun getItemViewType(position: Int): Int {
     	return currentList[position].getLayoutId()
 	}

 	override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder {
     	// Создаем ViewHolder для ItemWithBinding
     	val item = currentList.first {
         	it.getLayoutId() == viewType
     	}
     	return item.createVH(LayoutInflater.from(parent.context), parent, false)
 	}

 	override fun onBindViewHolder(holder: BaseViewHolder, position: Int) {
     	holder.bind(currentList[position])
 	}

 	override fun onBindViewHolder(
     	holder: BaseViewHolder,
     	position: Int,
     	payloads: MutableList<Any>
 	) {
     	if (payloads.isEmpty()) {
         	onBindViewHolder(holder, position)
         	return
     	}
     	payloads.forEach { data ->
         	val payload = (data as Bundle)
         	holder.bindPayloads(currentList[position], payload)
     	}
 	}
 }

Элементы адаптера, использующие ресурсы:

     Элементы списка, использующие R, стоит создавать без контекста Android в ViewModel/Presenter, т. к. если получать ресурсы там, то при смене локали язык не поменяется, и будет старое значение, т. к. viewModel/presenter могут пережить смерть своего родителя и жить дольше.

     Пример кода:

class MyViewModel{
private val _myItems = MutableStateFlow(listOf(MyItem(textRes = R.id.mytext)))
val myItems = _myList.asStateFlow()
}

После получения списка MyItem в fragment производим mapping в список TextItem.

В Item метод bind содержащий ссылку на view, которому надо присвоить значение:

class TextItem(val textRes:Int): Item<MyTextViewBinding>{
override bind(binding: MyTextViewBinding){
binding.text = context.getString(textRes) }
}

На этом всё. Мы рассмотрели лучшие практики работы с адаптером RecyclerView, оптимизировали систему уведомлений, предложили свою реализацию универсального адаптера. Делитесь в комментариях своим мнением и опытом, задавайте вопросы – постараюсь на всё ответить.

Комментарии (0)