Diffutils android kotlin пример

Пагинация и DiffUtils в RecyclerView c BRVAH

Это продолжение цикла статей про упрощение разработки адаптеров для RecyclerView.

В этой части рассмотрю следующие реализации потребностей отображения списков:

  • Загрузка изображения из сети, с использованием Glide
  • Пагинация (подгрузка списка)
  • Удаление элемента
  • Удаление и использование встроенного diffUtils

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

Доработал проект, для демонстрации этой задачи. Создал новые activity, dataClass, layout и адаптер. Все по аналогии с прошлым примером, покажу изменения.

data class NotificationWithImageDTO( val date: String, val text: String, var isRead: Boolean = false, val imageUrl: String ) 

Поле imageUrl хранит в себе ссылку на изображение.

Добавил ImageView, для вывода изображения

class NotificationWithImageAdapter(data: MutableList) : BaseQuickAdapter(R.layout.item_notification_with_image, data) < init < addChildClickViewIds(R.id.ivState) >override fun convert(holder: BaseViewHolder, item: NotificationWithImageDTO) < holder.setGone(R.id.view, holder.layoutPosition == 0) .setText(R.id.tvDateTime, item.date) .setText(R.id.tvDsc, item.text) .setImageResource( R.id.ivState, if (item.isRead) R.drawable.ic_delete else R.drawable.ic_read ) val imageView = holder.getView(R.id.imageView) val context = holder.itemView.context Glide.with(context) .load(item.imageUrl) .circleCrop() .into(imageView) > > 

К функционалу прошлого адаптера добавил получение конкретной view и context, для загрузи изображения через Glide. Для получения конкретной View использую метод холдера getView и типизирую его нужным мне типом. Для получения context, получаю ItemView, это из базовой реализации RecyclerView и из него получаю context. Работа Glide – полностью стоковая.

Часто списки, возвращаемые бэком огромны, так что нельзя получить все одним запросом. Для этого используется подгрузка списка или другими словами – пагинация. В этой библиотеке – реализация максимально простая.

Реализовал интерфейс LoadMoreModule в адаптере, методов для переопределения нет.

class NotificationWithImageAdapter(data: MutableList) : BaseQuickAdapter(R.layout.item_notification_with_image, data), LoadMoreModule

При подгрузке списка возможно несколько состояний:

  • Идет подгрузка
  • Подгружено успешно
  • Ошибка подгрузки
  • Подгружены все данные

Для отображения этих состояний в библиотеке предусмотрен абтрактный класс BaseLoadMoreView. Создал свой LoadMoreView являющийся его наследником:

class LoadMoreView : BaseLoadMoreView() < override fun getRootView(parent: ViewGroup): View = LayoutInflater.from(parent.context) .inflate(R.layout.view_load_more, parent, false) override fun getLoadingView(holder: BaseViewHolder): View = holder.getView(R.id.load_more_loading_view) override fun getLoadComplete(holder: BaseViewHolder): View = holder.getView(R.id.load_more_load_end_view) override fun getLoadEndView(holder: BaseViewHolder): View = holder.getView(R.id.load_more_load_end_view) override fun getLoadFailView(holder: BaseViewHolder): View = holder.getView(R.id.load_more_load_fail_view) 

В нем описываются layout-файл и методы получения view для каждого состояния

Доработал инициализацию адаптера для возможности подгрузки списка:

private val customLoadMoreView = LoadMoreView() … private fun initAdapter() < rv.adapter = adapter adapter.loadMoreModule.loadMoreView = customLoadMoreView adapter.loadMoreModule.setOnLoadMoreListener < loadMore() >adapter.loadMoreModule.isAutoLoadMore = true adapter.setOnItemChildClickListener < _, view, position ->if (view.id == R.id.ivState) < val item = adapter.getItem(position) if (!item.isRead) < item.isRead = true adapter.notifyItemChanged(position) >else < Toast.makeText( this, "Элемент будет удален, реализация в следующей части", Toast.LENGTH_SHORT ).show() >> > val data = repository.nextPage() adapter.setNewInstance(data) > private fun loadMore() < val data = repository.nextPage() adapter.addData(data) adapter.loadMoreModule.isEnableLoadMore = true adapter.loadMoreModule.loadMoreComplete() if (repository.isEnd()) < adapter.loadMoreModule.loadMoreEnd() >>

При инициализации добавилась настройка loadMoreModule.

  • Метод loadMoreView устанавливает view описанную выше
  • isAutoLoadMore – определяет можно ли автоматически подгружать список, если нельзя – то подргузку необходимо будет запускать руками методом loadMoreToLoading()
  • setLoadMoreListener – устанавливает метод вызываемый на событие подгрузки, в моем случае это мой метод loadMore()

В методе loadMore() происходит запрос данных, для следующей страницы. После их получения, добавляю эти данные в адаптер методом addData. После добавления данных, разрешаю подгрузку данных снова, и выставляю статус loadMoreComplete. Этот статус скрывает view загрузки. Далее запрашиваю у репозитория была ли эта страница последней, если это была последняя – то выставляю статус loadMoreEnd, этот статус отображает view окончания загрузки.

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

Иногда при подгрузке данных может возникнуть ошибка. Смоделирую ситуацию, чтоб метод репозитория случайным образом мог вкидывать exception. Получение данных оберну в блок try catch. В случае exception укажу, что произошла ошибка при подгрузке, методом adapter.loadMoreModule.loadMoreFail(). В этом случае отобразится errorView в нижней части RecyclerView. При клике на нее запустится метод подгрузки данных.

private fun loadMore() < try < val data = repository.nextPage() adapter.addData(data) adapter.loadMoreModule.isEnableLoadMore = true adapter.loadMoreModule.loadMoreComplete() if (repository.isEnd()) < adapter.loadMoreModule.loadMoreEnd() >> catch (e: Exception) < adapter.loadMoreModule.loadMoreFail() >>

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

Реализую локальное удаление, для этого доработаю инициализацию адаптера:

private fun initAdapter() < rv.adapter = adapter adapter.loadMoreModule.loadMoreView = customLoadMoreView adapter.loadMoreModule.setOnLoadMoreListener < loadMore() >adapter.loadMoreModule.isAutoLoadMore = true adapter.setOnItemChildClickListener < _, view, position ->if (view.id == R.id.ivState) < val item = adapter.getItem(position) if (!item.isRead) < item.isRead = true adapter.notifyItemChanged(position) >else < deleteLocalItem(position) >> > val data = repository.firstPage() adapter.setNewInstance(data) > private fun deleteLocalItem(position: Int)

Написал метод для локального удаления элемента. Для этого получаю элемент по его позиции в адаптере, и вызываю метод remove(item) у адаптера.

В завершение рассмотрю вариант удаления через бэкенд, когда возвращается новый список. Какие подводные камни тут есть? Вижу, как минимум один, но прямо очень серьезный. Если пользователь проскроллил список вниз, удалил элемент, то при установке нового списка в RecyclerView позиция собьётся, а скролл перенесется в начало списка. Как один из вариантов решения этой проблемы – это использование DiffUtils. В BRVAH он уже интегрирован.

Описание diffCallback отношения к библиотеке не имеет, поэтому показывать его не буду. Дальше этот callback установлю для адаптера, с помощью метода adapter.setDiffCallback(NotificationDiffCallback()). Далее при установке данных в адаптер необходимо использовать метод adapter.setDiffNewData(data).

private fun initAdapter() < rv.adapter = adapter adapter.loadMoreModule.loadMoreView = customLoadMoreView adapter.loadMoreModule.setOnLoadMoreListener < loadMore() >adapter.loadMoreModule.isAutoLoadMore = true adapter.setDiffCallback(NotificationDiffCallback()) adapter.setOnItemChildClickListener < _, view, position ->if (view.id == R.id.ivState) < val item = adapter.getItem(position) if (!item.isRead) < item.isRead = true adapter.notifyItemChanged(position) >else < // deleteLocalItem(position) deleteRemoteItem(position) >> > val data = repository.nextPage() adapter.setNewInstance(data) > private fun deleteRemoteItem(position: Int)

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

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

  • Анимацию появления элементов
  • Отображение загрузки списка и ошибки загрузки списка
  • Обработку «долгих» нажатий
  • Удаление элемента «свайпом»
  • Перемещение элементов
  • Использование нескольких layout в одном списке

Источник

Разоблачаем магию DiffUtil

Каждый Android-разработчик использовал RecyclerView для отображения списков и каждый сталкивался с проблемой обновления данных в списке, пока в 2016 году не появился магический класс DiffUtil . Я на пальцах объясню, как на самом деле он работает, и постараюсь рассеять его магию.

Немного истории

Одним из самых распространённых элементов в мобильных приложениях является список, в нашем случае RecyclerView . Это могут быть списки чего угодно: адреса офисов, списки друзей в соц. сетях, история заказов в приложениях такси и т.д. Все эти кейсы объединяет необходимость постоянно менять данные в списке на новые, когда, например, пользователь сделал Swipe to refresh, отфильтровал список или каким-либо другим способом получил пачку новых данных с бека.

Для реализации такого поведения предок современного Android-разработчика вручную выбирал, какие данные и каким образом изменились, и вызывал соответствующие методы у RecyclerView . Однако всё изменилось, когда Google выпустил Support Library версии 25.1.0, добавив туда DiffUtil , который позволял волшебным образом преобразовывать старый список в новый без полной пересборки RecyclerView . В этой статье я развею волшебство DiffUtil и объясню, как именно он работает.

Как работать с DiffUtil?

Для работы с DiffUtil необходимо реализовать DiffUtil.Callback , вызвать метод calculateDiff(@NonNull Callback cb) и применить к RecyclerView полученный DiffResult методом dispatchUpdatesTo() . Что же происходит при вызове метода calucalteDiff(@NonNull Callback cd) ? Данный метод возвращает DiffResult , который содержит набор операций для преобразования изначального списка в новый. Обновления применяются вызовами методов notifyItemRangeInserted , notifyItemRangeRemoved , notifyItemMoved и notifyItemRangeChanged . Первые три метода меняют структуру списка, а именно позиции у элементов, при этом не меняя сами элементы и не вызывая у них onBindViewHolder() (за исключением добавляемого элемента). Последний меняет сам элемент и вызывает onBindViewHolder() для изменения вьюхи элемента.

DiffUtil проверяет два списка на различия, используя алгоритм Майерса, который определяет только наличие/отсутствие изменений, но не умеет находить перемещения элементов. Для этого DiffUtil проходится по созданным алгоритмом Майерса змейкам (об этом дальше), а затем ищет перемещения. DiffResult формируется за если алгоритм не проверяет перемещения элементов и , где P – количество добавленных и удалённых элементов.

Алгоритм Майерса

Далее будет рассмотрено объяснение алгоритма Майерса на пальцах, ссылки на математические объяснения алгоритма (а также другие крутые статьи по теме) будут в конце статьи. Рассмотрим две последовательности: BACAAC и CBCBAB. Необходимо написать последовательность преобразований над первой последовательностью, после которых получим вторую. Выпишем последовательности в таблицу следующим образом: старый список будет обозначать первые элементы столбцов, а новый список первые элементы строк.

Перечеркнём ячейки, в которых пересекаются одинаковые элементы обоих последовательностей:

Дальнейшая задача состоит в том, чтобы дойти из левого верхнего угла матрицы в правый нижний угол за наименьшее количество шагов. Двигаться можно по горизонтальным и вертикальным граням. Если попали в точку, откуда начинается диагональная линия, то обязаны двигаться по ней, однако стоимость такого шага – 0. Соответственно стоимость шага по граням – 1.

Из точки (0;0) можем двигаться вправо и вниз. При движении вниз необходимо дополнительно пройти по диагонали. Движение, совершаемое за один шаг называется змейкой, в данном случае получили 2 змейки: (0; 0) -> (0; 1) и (0; 0) -> (1; 2). Стрелкой обозначается конец змейки, т.е. если после шага повертикали/горизонтали идёт обязательный шаг по диагонали, то стрелка будет на шаге по диагонали. Ниже показано полное построение змеек из начальной точки в конечную. Некоторые пути на видео были опущены, т.к. были заведомо не самыми короткими.

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

Как прохождение матрицы из крайнего левого угла в крайний правый поможет определить последовательность действий (скрипт) для преобразования одной последовательности в другую? Что значат шаги по горизонтали, вертикали и диагонали? Шаг по матрице в одном из возможных направлений – это действия над старой строкой:

  • Шаг по горизонтали – удаление из старой строки
  • Шаг по вертикали – добавление в старую строку
  • Шаг по диагонали – без изменений

На примере второго пути сопоставим путь и получаемый скрипт. Первый шаг – по вертикали, значит добавляем на 0 позицию в старую строку символ «С».

Однако это ещё не вся змейка. Далее мы обязаны двигаться по диагонали. При движении по диагонали элемент B остаётся неизменным. В итоге змейка состоит из движения по вертикали + движение по диагонали.

Далее змейка по горизонтали – убираем из старой строки элемент A.

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

Результатом работы алгоритма Майерса является скрипт с набором минимального количества действий, которые надо сделать для преобразования одной последовательности в другую. В DiffUtil алгоритм Майерса используется для поиска разных элементов, которые определяются методом areItemsTheSame() . Помимо формирования списка змеек, при прохождении по спискам алгоритмом Майерса создаются списки статусов элементов старого и нового списков. Все эти данные, а также флаг detectMoves и реализованный пользователем callback передаются в конструктор DiffResult(Callback callback, List snakes, int[] oldItemStatuses, int[] newItemStatuses, boolean detectMoves) .

Пока я писал эту статью, удалось раскопать что именно происходит в DiffResult : алгоритм проходится по змейкам и выставляет элементам флаги(в списки статусов), по которым определяется что именно произошло с элементом. По этим флагам во время применения изменений к RecyclerView определяется каким методом применять обновления: notifyItemRangeInserted, notifyItemRangeRemoved, notifyItemMoved и notifyItemRangeChanged . Более подробно я расскажу об этом в следующий раз.

Ссылки

  • The Myers Diff Algorithm and Kotlin Observable Properties — здесь помимо ознакомления с алгоритмом Майерса приводятся интересные фичи Kotlin для упрощения работы с DiffUtil .
  • The Myers Diff Algorithm part 1 – цикл статей, который начинает объяснение алгоритма Майерса на пальцах и постепенно переводит его на математический язык.
  • An O(ND) Difference Algorithm and Its Variations – официальная научная статья алгоритма Майерса.

Источник

Читайте также:  Javascript convert string to time
Оцените статью