Пример использования Android DiffUtil
В нашем Telegram чате иногда проскакивает следующий вопрос: Как правильно обновлять данные в списке?
Спрашивающий обычно подразумевает два варианта ответа:
1) Передавать новые данные в адаптер и вызывать метод notifyDataSetChanged, чтобы рефрешнуть RecyclerView
2) Создавать новый адаптер, давать ему новые данные и передавать этот адаптер в RecyclerView.setAdapter()
Оба этих варианта не являются правильными, хотя технически они вполне рабочие.
Проблема в том, что в обоих случаях весь список будет перерисован. Вернее, для каждой видимой строки будет вызван метод onBindViewHolder. И если у строки тяжелый layout, используется какая-либо анимация и данные адаптера обновляются достаточно часто, то на слабых девайсах вы вполне можете увидеть проблемы в скорости работы вашего списка.
Давайте на простом примере рассмотрим более оптимальный способ обновления данных в списке.
Пусть у нас есть RecyclerView, который отображает простой список товаров (Product).
Товар имеет поле id и два отображаемых поля: название (name) и цена (price).
По нажатию на кнопку Update мы будем обновлять данные в списке.
Первоначальное наполнение списка может выглядеть так:
В методе setData мы просто передаем данные в адаптер без вызова каких-либо notify методов.
Затем вызываем метод notifyDataSetChanged, чтобы список перерисовался.
Для упрощения весь код по работе с данными находится в Activity, но в реальных примерах лучше выносить его в презентер.
В адаптере в метод onBindViewHolder я добавил вывод в лог позиции обновляемой строки:
Тем самым мы будем видеть, для каких строк списка был выполнен биндинг при обновлении данных.
Для начала убедимся, что при использовании метода notifyDataSetChanged для всех строк будет выполнен биндинг. По нажатию на кнопку Update будем обновлять данные в списке:
Для упрощения примера, мы сами формируем новый список, но на практике он мог прийти к нам от сервера или из БД. Данные почти те же самые, что и раньше, но у второго товара немного изменилось наименование, а у пятого — цена. Эти новые данные передаем в адаптер и вызываем notifyDataSetChanged
Смотрим лог после нажатия на кнопку Update.
bind, position = 0
bind, position = 1
bind, position = 2
bind, position = 3
bind, position = 4
Биндинг сработал для всех 5-ти строк, хотя данные были обновлены только в двух. Не очень оптимальный вариант обновления.
Даже если по нажатию на Update данные придут те же самые, что и были, то биндинг все равно сработает для всех строк. Так происходит потому, что адаптер не знает, что поменялось, а что нет. Поэтому он обновляет все строки списка.
Чтобы решить эту проблему, мы вместо notifyDataSetChanged можем использовать более точечное обновление — метод notifyItemChanged.
Мы передаем адаптеру данные и говорим ему, что надо будет перерисовать только строки с позициями 1 и 4. (позиции в адаптере начинаются с нуля).
Запускаем приложение, жмем Update и смотрим лог:
bind, position = 1
bind, position = 4
Теперь биндинг сработал только для тех строк, которые мы обновили и явно указали адаптеру.
Кроме метода notifyItemChanged, который обновит измененную строку, есть еще несколько notify методов, которые помогут вам обновить список при добавлении, удалении или перемещении строк.
Эти точечные notify методы удобны, когда мы точно знаем, какие строки были изменены. Но если мы просто получаем новые данные извне, то будет достаточно сложно вручную все сравнивать и определять, что поменялось, а что нет. Эту работу за нас может выполнить DiffUtil.
Он сравнит два набора данных: старый и новый, выяснит, какие произошли изменения, и с помощью notify методов оптимально обновит адаптер.
От нас требуется только наследовать класс DiffUtil.Callback и реализовать несколько его абстрактных методов.
В конструктор передаем старые данные и новые данные. Они понадобятся для сравнения.
В методах getOldListSize и getNewListSize просто возвращаем количество записей в старом списке и в новом.
А в методах areItemsTheSame и areContentsTheSame нам дают две позиции: одну из старого списка (oldItemPosition) и одну из нового (newItemPosition). Соответственно, мы из списка oldList берем Product с позицией oldItemPosition, а из newList — Product с позицией newItemPosition, и сравниваем их.
В чем ключевая разница между areItemsTheSame и areContentsTheSame?
Рассмотрим на примере товаров. У Product есть три поля: id, name и price.
Для каждой пары сравниваемых товаров DiffUtil сначала вызовет метод areItemsTheSame, чтобы определить, надо ли в принципе сравнивать эти товары. Т.е. cначала достаточно сравнить их по id. Если id не равны, значит это разные товары и сравнивать их цены и наименование нет смысла — скорее всего они также будут отличаться.
А вот если id равны, значит товар из старого списка и товар из нового списка — это один и тот же товар и надо определить изменился ли он. В этом случае DiffUtil вызывает метод areContentsTheSame, чтобы определить, есть ли отличия между старым товаром и новым. В этом методе мы сравниваем товары по цене и наименованию. Если они одинаковы, значит товары по указанным позициям в старом и новом списке одинаковы. И биндинг для строки, отображающей этот товар, вызывать не надо, потому что не будет там никаких изменений. А если цена или наименование у нового товара отличается от старого, значит товар изменился и для строки, отображающей этот товар надо будет вызвать биндинг.
Т.е. в areItemsTheSame вы сравниваете поля, чтобы в принципе определить, разные ли это объекты. А в areContentsTheSame уже сравниваете детали, чтобы определить, поменялось ли что-то из того, что вы отображаете на экране.
Давайте представим, что в Product есть еще одно поле, например — дата поставки. Но в списке отображать это поле не нужно. Учитывать ли это поле в areContentsTheSame?
Если вы будете его учитывать, то при изменении только этого поля, строка списка с товаром будет перерисована, но при этом визуально никаких изменений не будет. Это будет лишняя работа. Поэтому в areContentsTheSame имеет смысл использовать только те поля объекта, изменение которых приведет к видимым изменениям строки в списке.
Используем наш созданный ProductDiffUtilCallback
Создаем ProductDiffUtilCallback и передаем в него старый список и новый. Передав productDiffUtilCallback в метод DiffUtil.calculateDiff, выполняем сравнение двух списков. Результат сравнения получаем в DiffResult.
Далее передаем в адаптер новые данные и просим productDiffResult обновить RecyclerView с учетом изменений. Т.е. это будет не просто бездумное notifyDataSetChanged, а именно использование notify методов, чтобы обновить список максимально эффективно.
Лог будет выглядеть так:
bind, position = 1
bind, position = 4
DiffUtil верно определил, что надо обновить только строки с позициями 1 и 4.
Давайте чуть усложним пример. В новых данных уберем первый товар и добавим шестой.
Результат
Лог
bind, position = 0
bind, position = 3
bind, position = 4
Товары сместились на один вверх, но DiffUtil все равно корректно определил, что биндинг надо вызвать только для трех строк, которые отображают второй, пятый и шестой товары. Третий и четвертый товары хоть и поменяли позиции из-за удаления первого, но, данные в них не поменялись, и похоже, что для них были использованы те же самые холдеры, поэтому в выполнении биндинга для них не было необходимости.
У DiffUtil.Callback есть еще один метод — getChangePayload. О нем расскажу в отдельной статье.
При использовании DiffUtil учитывайте, что выполнение метода DiffUtil.calculateDiff может занимать долгое время. Поэтому, если ожидаете, что количество записей будет измеряться сотнями и изменения списка будут значительные, то имеет смысл вызывать этот метод асинхронно.
У методa calculateDiff есть еще один вариант вызова
DiffUtil.DiffResult calculateDiff (DiffUtil.Callback cb, boolean detectMoves)
Что означает флаг detectMoves? По умолчанию, этот флаг = true. В этом случае DiffUtil попытается найти перемещения строк, которые произошли в новом списке по сравнению со старым. И если он найдет такие перемещения, то он вызовет соответствующие notify методы, и вы получите красивую анимацию
Но это будет в ущерб скорости работы calculateDiff.
Если же вам не нужна такая анимация, то можно указывать detectMoves = false
В этом случае, при изменении порядка записей анимация будет выглядеть так:
Зато вы получите прирост в скорости работы calculateDiff
Присоединяйтесь к нам в Telegram:
— в канале StartAndroid публикуются ссылки на новые статьи с сайта startandroid.ru и интересные материалы с хабра, medium.com и т.п.
— в чатах решаем возникающие вопросы и проблемы по различным темам: Android, Kotlin, RxJava, Dagger, Тестирование
— ну и если просто хочется поговорить с коллегами по разработке, то есть чат Флудильня
— новый чат Performance для обсуждения проблем производительности и для ваших пожеланий по содержанию курса по этой теме
Источник
Using DiffUtil in Android RecyclerView
DiffUtil is a utility class that can calculate the difference between two lists and output a list of update operations that converts the first list into the second one.
Most of the developers call NotifyDataSetChanged every time the data set is changed, which is a costly task to do. DiffUtil can be used to calculate updates for a RecyclerView Adapter.
DiffUtil uses Eugene W. Myers’s difference algorithm to calculate the minimal number of updates to convert one list into another. Myers’s algorithm does not handle items that are moved so DiffUtil runs a second pass on the result to detect items that were moved.
Let’s take a short example to understand the same —
Lets take a model data class postingDetails.java
Now let me introduce you with the Android DiffUtil Abstract Class
class contains 4 abstract methods and 1 non Abstract method:
Returns the size of the new list.
Returns the size of the old list.
3. areContentsTheSame(int oldItemPosition, int newItemPosition)
Called by the DiffUtil when it wants to check whether two items have the same data. (contents inside your data model)
4. areItemsTheSame(int oldItemPosition, int newItemPosition)Called by the DiffUtil to decide whether two objects represent the same Item. (define its body if the data types of your model are variable and list items can be different) Usually, we return true in this case
5.)getChangePayload(int oldItemPosition, int newItemPosition)
When areItemsTheSame(int, int) returns true for two items and areContentsTheSame(int, int) returns false for them, DiffUtil calls this method to get a payload about the change.
note :getChangePayload( int oldItemPosition, int newItemPosition) called when items are same but the data has changed
This is very important method and we will be focusing on its use case in this tutorial. Now let’s implement these methods.
Now the method where we will be getting the object returned by getChangePayload() method is in the RecylerView Adapter
IMPORTANT: all the objects returned will be fetched in this overridden method onBindViewHolder in your adapter.
Now Instead of calling NotifyDataSetChanged in Adapter ,I created a method updatePostingDetails() in Adapter ,which contains
Dispatches the update events to the given adapter.
— mpostingDetails is the list defined in adapter containing an old list
— newpostingDetails is the new list given as a parameter
passing them to postingDetailsDiffCallback constructor
Источник
Разоблачаем магию 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 . Более подробно я расскажу об этом в следующий раз.
Источник