Туториалы
09 сентября 2021
Туториалы
09 сентября 2021
Ускорьте Android RecyclerView с помощью DiffUtil

Узнайте, как обновить данные в Android RecyclerView с помощью DiffUtil (и улучшить тем самым производительность), а также как добавить анимацию в RecyclerView.

Android RecyclerViews, отображающий списки, являются частью почти каждого приложения Android. В списках содержится большой объем информации, поэтому важно обеспечить удобство работы как при прокрутке списка, так и при обновлении его содержимого. Для этих целей разработали служебный класс DiffUtil. Android RecyclerView, использующий DiffUtil, предоставляет данную возможность.

В этом туториале вы создадите приложение для создания списка покупок. Оно будет включать в себя использование DiffUtil, чтобы избежать перерисовки всех ячеек в списке, когда изменяется только подмножество его данных. В ходе этого процесса вы узнаете:

  • Как реализовать DiffUtil с ListAdapter;
  • Как преобразовать ваш ListAdapter в любой класс, расширяющий RecyclerView.Adapter;
  • Как использовать полезные данные (payloads);
  • Как DiffUtil добавляет анимацию в RecyclerView.

Начинаем работу

Загрузите стартовый проект, нажав на кнопку «Загрузить материалы».

Внутри ZIP-файла вы найдете два проекта. У Starter есть скелет приложения, которое вы будете создавать, а в Final находится конечный вариант программы, с которой вы будете сравнивать ваш код.

На изображении выше вы видите список покупок. Возможно, вы относитесь к тем, кто, приходя из магазина домой, осознает, что забыл купить все, что нужно. Лучше всего составить список покупок заранее, но его легко забыть дома. Однако с огромной долей вероятности ваш телефон всегда будет с вами.

Итак, вы собираетесь создать BringCookies, приложение, содержащее список продуктов, которое поможет вам купить все, что вам нужно, во время следующего похода в магазин. И в нем есть бонусная функция! Пункт Cookies всегда есть в списке, потому что всегда есть место для других файлов cookies. :]

Но сначала потратим немного времени, чтобы разобраться в структуре проекта.

Объяснение структуры проекта

Откройте проект Starter в Android Studio и дождитесь его синхронизации.

Вы увидите набор подпапок и других важных файлов:

  • adapters: содержит адаптер RecyclerView и набор утилит, используемых для выбора одного или нескольких элементов из списка.
  • model: содержит объект данных Item для представления элемента. Item состоит из id, его value, timeStamp, отвечающий за то, когда он был создан, и логического флага done, который указывает, был элемент отмечен или нет.
  • MainActivity: отдельное действие приложения. 
  • MainFragment: отвечает за отображение списка покупок для пользователя и предоставление набора механизмов, с которыми пользователь может взаимодействовать.
  • Utils.kt: этот файл содержит набор служебных методов, которые вы будете использовать на протяжении всего проекта. А именно, вы будете использовать их для сохранения и загрузки списка покупок в общие настройки и из них, а также для форматирования времени в легко читаемую дату.

Заметка

В этой статье вы будете использовать RecyclerView для отображения списка покупок. Чтобы узнать больше о том, как использовать это вью, ознакомьтесь с учебником Android RecyclerView с Kotlin или учебным пособием по промежуточному RecyclerView с Kotlin .

Двигаемся дальше, пора узнать больше о DiffUtil.

Знакомство с DiffUtil

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

Чтобы алгоритм, используемый DiffUtil, работал, списки должны быть неизменяемыми. В противном случае при изменении содержимого результат может отличаться от ожидаемого. Следовательно, чтобы обновить элемент в списке, создайте и установите копию этого элемента.

Понимание алгоритма DiffUtil

Чтобы показать разницу между двумя списками (в случае для RecyclerView) - тем, который вы уже показываете, и тем, который вы хотите показать (при изменении любого из элементов в списке), DiffUtil использует разностный алгоритм Юджина В. Майерса. Он позволяет вычислять разницу между двумя наборами элементов.

Алгоритм Майерса не обрабатывает перемещаемые элементы, поэтому DiffUtil выполняет второй проход для результата, который определяет, какие элементы перемещены.

 

На изображении выше есть два списка слов, распределенных по сетке: ANDROID по горизонтали и DIORDNA по вертикали.

Алгоритм рассчитывает кратчайший путь от одного списка до другого. Шаги по диагонали не учитываются в количестве необходимых итераций.

 

Начиная с (0, 0), соответствующему символу «A», алгоритм Майерса перебирает каждую точку в матрице, ища кратчайший путь для преобразования одного списка в другой.

С этой начальной точки он может опуститься до (0, 1) или прямо до (1, 0):

  • Из (0, 1) он может перейти в (0, 2) или (1, 1).
  • Из (1, 0) можно перейти к (1, 2) или (2, 0).

Из последней координаты (2, 0) можно напрямую перейти к (3, 1) через диагональ, тем самым сэкономив пару шагов.

Следуя по этому пути, вы можете перейти по пути (0, 0) → (0, 1) → (1, 0) → (3, 1), пропуская (2, 0) и (3, 0).

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

Заметка

Дополнительную информацию об алгоритме Майерса можно найти в этом сообщении блога Джеймса Коглана.

Создание RecyclerView с помощью ListAdapter

ListAdapter – это расширение RecyclerView.Adapter, которое требует инициализации DiffUtil.ItemCallback или AsyncDifferConfig. Он предоставляет набор методов, которые позволяют легко получить доступ к данным адаптера, но вы также можете использовать любой доступный адаптер.

Итак, сначала откройте MainAdapter.kt.

Этот класс расширяет реализацию по умолчанию для RecyclerView.Adapter. Измените его, чтобы использовать ListAdapter:

class MainAdapter(val action: (items: MutableList, changed: Item, checked: Boolean) -> Unit) : ListAdapter()

 Android Studio предложит два варианта для импорта:

  • ListAdapter из androidx.recyclerview.widget.
  • ListAdapter из android.widget.

Импортируйте первый androidx.recyclerview.widget, чтобы успешно получить доступ к адаптеру.

Добавление DiffUtil в ListAdapter

Теперь, когда вы расширяете ListAdapter, появляется ошибка, т.к. требуется класс, который реализует DiffUtil.ItemCallback.

Добавьте следующий код над внутренним классом ItemViewHolder и импортируйте androidx.recyclerview.widget.DiffUtil:

//1
private class DiffCallback : DiffUtil.ItemCallback() {
 
  //2
  override fun areItemsTheSame(oldItem: Item, newItem: Item) =
    oldItem.id == newItem.id

  //3
  override fun areContentsTheSame(oldItem: Item, newItem: Item) =
    oldItem == newItem
}

Вот пошаговое объяснение этой логики:

  1. DiffUtil.ItemCallback- это собственный класс, ответственный за вычисление разницы между двумя списками. Поскольку ОС не знает, какие поля следует редактировать, приложение обязано переопределить areItemsTheSame и areContentsTheSame для предоставления данной информации.
  2. Item состоит из id, его value, timeStamp и информации о том, проверен done или нет. Сам id является уникальным и неизменным, но вы можете редактировать все остальные поля. Таким образом, вы можете считать два элемента из разных списков одинаковыми, если они имеют одно и то же id.
  3. Чтобы избежать пересмотра всего списка при изменении, будут обновлены только те элементы, которые имеют разные значения в обоих списках.

Создав обратный вызов, добавьте его в объявление класса:

class MainAdapter(val action: (items: MutableList, changed: Item, checked: Boolean) -> Unit) : 
    ListAdapter(DiffCallback())

DiffCallback теперь является аргументом ListAdapter, и он отвечает за сравнение существующего списка с новым для определения измененных ячеек, которые необходимо нарисовать.

Обновление ссылок на данные ListAdapter

ListAdapter содержит данные списка во внутреннем поле с именем currentList. Чтобы обновить данные, вызовите submitList.

Сейчас уже нет необходимости обрабатывать существующую логику MainAdapter, поэтому удалите var items: List = emptyList().

Это вызовет несколько ошибок в проекте, поэтому вам необходимо обновить все ссылки в этом списке.

Перейдите к onBindViewHolder и замените:

val item = getItem(pos)

на:

val item = currentList[pos]

getItem(pos) возвращает объект в указанной позиции и его эквиваленте вызова currentList[pos].

Следующий способ изменения - getItemCount. Замените:

return items.size

на: 

return currentList.size

currentList содержит все элементы.

Удалите setListItems, поскольку эта логика теперь обрабатывается при помощи DiffUtil.

Наконец, есть еще три ссылки на items. Внутри bind замените два вхождения items.toMutableList() на currentList.toMutableList().

Это соответствует пользовательскому action, которое обрабатывается при помощи MainFragment.

Последнее использование items находится внутри getSelectionKey, в конце класса адаптера.

Замените items вызовом getItem, как показано ниже:

override fun getSelectionKey(): Long = getItem(adapterPosition).timeStamp

Вот и все! Вы обновили MainAdapter.kt и он готов к использованию DiffUtil.

Соберите и запустите проект.

Вы увидите, что при компиляции MainFragment.kt возникает несколько ошибок. Приложение пытается получить доступ элементов items к MainAdapter.kt, который больше не существует. Поэтому следует использовать ListAdapter.currentList, о котором вы узнаете дальше.

Доступ к данным ListAdapter

Откройте MainFragment.kt и перейдите к onOptionsItemSelected.

Действие перемешивания меняет порядок продуктов в списке. Изменив порядок, вы можете увидеть интеграцию DiffUtilс ItemAnimator, что приводит к плавной анимации, которая перемешивает все элементы.

Кроме того, пользователю предлагается отслеживать все, что необходимо купить. :]

Перейдите к onOptionsItemSelected и замените:

val items = adapter.items.toMutableList()

на:

val items = adapter.currentList.toMutableList()

currentList получает все предметы из адаптера.

Также замените:

adapter.setListItems(items)

на:

adapter.submitList(items)

submitList устанавливает перетасованную версию списка.

Одно замечание: файлы cookie нельзя перемешивать. Он всегда остается в верхней части списка. :]

Перейдите к setupUiComponents и обновите первую строку внутри setOnClickListener из ivAddToCart:

val list = mainAdapter.currentList.toMutableList()

Щелчок по этому вью создаст новый элемент и добавит его в список.

В конце этого метода есть вызов setListItems, которого больше не существует. Замените на:

mainAdapter.submitList(getGroceriesList(requireContext()))

Это позволяет обращаться к ListAdapter напрямую.

Следующее обновление проводится аналогично. Перейдите к updateAndSave и обновите вызов setListItems на submitList:

(binding.rvGroceries.adapter as MainAdapter).submitList(list)

Каждый раз, когда вы добавляете или удаляете новый элемент, он отправляет новый список в MainAdapter.kt, который сохраняется в общих настройках.

Наконец, перейдите к onActionItemClicked и измените обе ссылки items на currentList, чтобы изменение выглядело, как показано ниже:

var selected = mainAdapter.currentList.filter {
  tracker.selection.contains(it.timeStamp)
}

val groceries = mainAdapter.currentList.toMutableList()

Это позволяет получить прямой доступ ко всем элементам на currentList.

Вы почти закончили, за исключением ошибки в одном классе: ItemsKeyProvider, который вы используете для действий длительного нажатия.

Откройте этот файл и измените обе ссылки items на currentList:

override fun getKey(position: Int): Long =
  adapter.currentList[position].timeStamp

override fun getPosition(key: Long): Int =
  adapter.currentList.indexOfFirst { it.timeStamp == key }

Соберите и запустите проект. Затем добавьте несколько продуктов. :]

Заметка

1. В примере приложения используется библиотека RecyclerView Selection, которая позволяет выбирать один или несколько элементов из списка. timeStamp из Item - это тип ключа выбора. ItemKeyProvider - это KeyProvider. ItemDetailsLookup- это класс, который предоставляет библиотеке выбора информацию об элементах, связанных с выбором пользователя, на основе MotionEvent с помощью getItem, созданного в ViewHolder.

SelectionTracker, который объявлен и инициализирован в MainFragment, позволяет библиотеке выбора прослеживать выбор пользователя, чтобы проверить, выбран ли конкретный элемент или нет. Дополнительные сведения о библиотеке выбора см. в документации Android .

2. Вы можете удалить выбранные элементы с помощью Delete, который был создан с помощью ActionMode. Дополнительные сведения об Action Mode см. В документации Android. Опять же, удалить файлы cookie из списка невозможно. :]

Сравнение ссылок и содержания

setupUiComponents на MainFragment.kt определяет обновление элемента:

element = if (index == 0) {
  Snackbar.make(binding.clContainer, R.string.item_more_cookies, Snackbar.LENGTH_SHORT).show()
  element.copy(done = false)

} else {
  element.copy(done = isChecked)
}

Когда пользователь отмечает элемент как done, он создает новую копию этого объекта с изменением этого поля на isChecked, что соответствует значению true. Если он отменит выбор, поле примет значение false.

В качестве упражнения, вместо создания нового объекта, обновите его значение напрямую и посмотрите, как себя ведет приложение.

>if (index == 0) {
  Snackbar.make(binding.clContainer, R.string.item_more_cookies, Snackbar.LENGTH_SHORT).show()
  element.done = false

} else {
  element.done = isChecked
}

Запустите приложение, выберите необходимые элементы из списка. Вы должны увидеть что-то вроде этого:

 

Такая логика работа обусловлена тем, что список, к которому вы обращаетесь, совпадает со списком в ListAdapter. Вы меняете сам элемент, поэтому при вызове submitList оба списка будут одинаковыми и ничего не произойдет. oldItem из DiffCallback будет таким же, как newItem.

Отмените это изменение.

Запустите приложение. Выберите и отметьте один из пунктов, чтобы убедиться, что все работает должным образом.

Использование DiffUtil в фоновом потоке

Разница между DiffUtil и AsyncListDiffer заключается в том, что последний работает в фоновом потоке. Это делает его идеальным для продолжительных операций или использования вместе с LiveData.

Чтобы реализовать AsyncListDiffer с ListAdapter, откройте MainAdapter.kt и измените объявление класса на:

class MainAdapter(val action: (items: MutableList, changed: Item, checked: Boolean) -> Unit) :
    ListAdapter(AsyncDifferConfig.Builder(DiffCallback()).build())

Импортируйте androidx.recyclerview.widget.AsyncDifferConfig.

Вместо вызова DiffCallback напрямую, AsyncDifferConfig.Builder создает асинхронный объект, который использует DiffUtil, созданный ранее.

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

 

Использование DiffUtil в любом адаптере RecyclerView

Хотя ListAdapter рекомендован RecyclerView.Adapter для использования с DiffUtil, его можно использовать с любым адаптером. Разница в том, что необходимо объявить переменную, которая содержит DiffCallback и соответствующие currentList и submitList для доступа и редактирования списка, которой не существует в других адаптерах.

В качестве упражнения откройте MainAdapter.kt, измените объявление класса, чтобы расширить RecyclerView.Adapter и реализовать AsyncListDiffer.

Для начала измените ListAdapter на RecyclerView.Adapter:

class MainAdapter(val action: (items: MutableList, changed: Item, checked: Boolean) -> Unit) :
    RecyclerView.Adapter()

 Импортируйте androidx.recyclerview.widget.AsyncListDiffer.

После данного изменения DiffCallback больше не устанавливается, и, поскольку currentList - это свойство ListAdapter, оно больше не доступно.

Теперь объявите AsyncListDiffer вместе с созданным ранее DiffCallback:

private val differ: AsyncListDiffer = AsyncListDiffer(this, DiffCallback())

Это поле содержит список адаптеров. Для доступа к нему посредством onBindViewHolder, вызовите:

differ.currentList[pos]

В getItem, в конце класса адаптера, измените getItem(adapterPosition).timeStamp на:

differ.currentList[adapterPosition].timeStamp

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

fun submitList(list: List) {
  differ.submitList(list)
}

fun currentList(): List {
  return differ.currentList
}

Имена этих методов аналогичны тем, которые вы вызывали ранее, поэтому изменения будут минимальными.

Теперь обновите вызовы для currentList:

currentList()

Это изменение является обязательным, поскольку теперь оно относится к методу, а не к полю.

Соберите и запустите проект. Добавьте и удалите пару элементов из списка.

Использование Payloads

Вы можете использовать полезные данные (payloads), когда ячейки списка содержат несколько вью и когда обновление одного элемента не требует изменения всего вью. Они особенно полезны, когда вы хотите избежать получения одного и того же изображения или выполнения сложных вычислений.

Сначала откройте MainAdapter.kt. Перед объявлением класса добавьте:

private const val ARG_DONE = "arg.done"

Вы можете использовать эту функцию для определения изменения done из Item и соответствующего изменения и обновление списка.

Перейдите к DiffCallback и переопределите getChangePayload:

override fun getChangePayload(oldItem: Item, newItem: Item): Any? {
  if (oldItem.id == newItem.id) {
    return if (oldItem.done == newItem.done) {
      super.getChangePayload(oldItem, newItem)
    } else {
      val diff = Bundle()
      diff.putBoolean(ARG_DONE, newItem.done)
      diff
    }
  }

  return super.getChangePayload(oldItem, newItem)
}

Импортируйте android.os.Bundle.

Обратите внимание, что getChangePayload - это не абстрактный метод. Этот метод вызывается, когда areItemsTheSame возвращает true и areContentsTheSame возвращает false. Это указывает на то, что некоторые поля Item изменились. Тем не менее, рекомендуется сравнивать id элементов, чтобы гарантировать, что это один и тот же элемент.

В этом случае изменения полы относятся к done, поэтому, если его состояние отлично, Bundle возвращается с измененной информацией.

Перейдите к ItemViewHolder и добавьте update:

fun update(bundle: Bundle) {
  if (bundle.containsKey(ARG_DONE)) {
    val checked = bundle.getBoolean(ARG_DONE)
    itemBinding.cbItem.isChecked = checked
    setItemTextStyle(checked)
  }
}

Обновлены только те вью, которые используют done. Это позволяет избежать траты ресурсов на обновление полей, которые не изменились.

В этом примере MainAdapter расширяет ListAdapter. Если вы используете RecyclerView.Adapter, внесите соответствующие изменения.

Обратитесь к onBindViewHolder, добавьте onBindViewHolder, чтобы получить payload в качестве аргумента и обновить существующий:

//1
override fun onBindViewHolder(holder: ItemViewHolder, pos: Int) {
  onBindViewHolder(holder, pos, emptyList())
}
 
//2
override fun onBindViewHolder(viewHolder: ItemViewHolder, pos: Int, payload: List) {
  val item = getItem(pos)
 
  if (payload.isEmpty() || payload[0] !is Bundle) {
    //3
    viewHolder.bind(item)
   } else {
    //4
    val bundle = payload[0] as Bundle
    viewHolder.update(bundle)
  }
}

Вот пошаговое объяснение этой логики:

  1. onBindViewHolder должен быть переопределен. Вот почему вам нужно добавить второй, который содержится в payload в качестве аргумента. Первый метод вызывает второй с аргументом полезной нагрузки, заданным как emptyList().
  2. Вызовите этот метод, если нет изменений с 1 или если есть разница в DiffCallback, рассчитанном в getChangePayload.
  3. Если список payload пуст, значит, объект новый и вью необходимо отрисовать.
  4. Если payload содержит какие-то данные, это означает, что произошло обновление этого объекта. Вы можете повторно использовать некоторые из его вью.

Соберите и запустите проект. Добавьте полезную нагрузку, перейдите к списку покупок и отметьте некоторые предметы.

А вот и рецепт печенья! :]

 

Анимируем RecyclerView с помощью DiffUtil

Еще одно преимущество DiffUtil заключается в том, что каждое обновление в вашем списке приводит к плавной красивой анимации. Контент не просто переключается - вместо этого он плавно адаптируется к новым данным.

У вас может быть два разных типа анимации, которые уже встроены в эту реализацию DiffUtil внутри RecyclerView:

  1. Обновление количества элементов или их порядка

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

      2. Изменение существующего элемента

Обновляет только те элементы, которые видны и были изменены. Плавный переход от одного состояния к другому, который уведомляет пользователя об измененном объекте.

 

Это возможно благодаря встроенной интеграции DiffUtil и ItemAnimator в RecyclerView. Изменение ItemAnimator автоматически отразится на любом обновлении, внесенном в список.

В качестве альтернативы, настройка binding.rvGroceries.itemAnimator = null удалит все анимации.

Значением по умолчанию для itemAnimator является DefaultItemAnimator. Здесь уже определены анимации для добавления, удаления и перемещения элементов.

 

 

DiffUtil в Jetpack Compose

Jetpack Compose - это новый набор библиотек, которые позволяют декларативно разрабатывать пользовательский интерфейс. Вам больше не нужно полагаться на XML и findViewById для установки и обновления вью. Вы можете делать все программно, используя концепции состояния и перекомпоновки.

С помощью Compose вы можете создать список, используя LazyColumn:

@Composable
fun Groceries() {
  val groceries = remember { mutableStateOf(getGroceriesList(context)) }
  LazyColumn {
    items(groceries.value) {
      AddGrocery(it)
    }
  }
}
 
@Composable
fun AddGrocery(item: Item) {
  Column {
    Text(
      text = item.content
    )
  }
}

В качестве альтернативы, если вы хотите создать горизонтальный список, вы можете использовать LazyRow.

Jetpack Compose работает путем перекомпоновки. Редизайн происходит только для функций с измененным содержанием. Это напрямую улучшает производительность, так как перерисовывает только изменившиеся вью. Из-за этого в настоящее время нет реализации на LazyColumn и LazyRow, которые работают с DiffUtil напрямую.

Еще одно преимущество DiffUtil заключается в том, что каждый раз, когда в списке появляется обновление, оно использует ItemAnimator для создания плавной анимации без необходимости писать дополнительный код.

Хотя это не поддерживается напрямую в Compose, то же самое возможно при использовании AnimatedVisibility и анимации всех вью.

Хотите узнать, как это реализовать? Прочтите главу книги: Анимация свойств с помощью Compose, чтобы узнать больше.

Поздравляем! Вы узнали, как улучшить работу со списками и как создать плавную анимацию обновления при обновлении его содержимого.

Есть несколько сценариев, в которых списки могут иметь низкую производительность. Вы найдете набор примеров в разделе «Slow Rendering» документации Android. Еще одно решение этой проблемы - Paging library, которая загружает только ту информацию, которая необходима для отображения на экране.

Оригинал статьи


Оцените статью
0
0
0
0
0

Чтобы добавить комментарий, авторизуйтесь
Войти
Акулов Иван Борисович
Пишет и переводит статьи для SwiftBook