В нашем Telegram чате иногда проскакивает следующий вопрос: Как правильно обновлять данные в списке?
Спрашивающий обычно подразумевает два варианта ответа:
1) Передавать новые данные в адаптер и вызывать метод notifyDataSetChanged, чтобы рефрешнуть RecyclerView
2) Создавать новый адаптер, давать ему новые данные и передавать этот адаптер в RecyclerView.setAdapter()
Оба этих варианта не являются правильными, хотя технически они вполне рабочие.
Проблема в том, что в обоих случаях весь список будет перерисован. Вернее, для каждой видимой строки будет вызван метод onBindViewHolder. И если у строки тяжелый layout, используется какая-либо анимация и данные адаптера обновляются достаточно часто, то на слабых девайсах вы вполне можете увидеть проблемы в скорости работы вашего списка.
Давайте на простом примере рассмотрим более оптимальный способ обновления данных в списке.
Пусть у нас есть RecyclerView, который отображает простой список товаров (Product).
Товар имеет поле id и два отображаемых поля: название (name) и цена (price).
По нажатию на кнопку Update мы будем обновлять данные в списке.
Первоначальное наполнение списка может выглядеть так:
@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); // ... List<Product> productList = new LinkedList<>(); productList.add(new Product(1, "Name1", 100)); productList.add(new Product(2, "Name2", 200)); productList.add(new Product(3, "Name3", 300)); productList.add(new Product(4, "Name4", 400)); productList.add(new Product(5, "Name5", 500)); adapter.setData(productList); adapter.notifyDataSetChanged(); }
В методе setData мы просто передаем данные в адаптер без вызова каких-либо notify методов.
Затем вызываем метод notifyDataSetChanged, чтобы список перерисовался.
Для упрощения весь код по работе с данными находится в Activity, но в реальных примерах лучше выносить его в презентер.
В адаптере в метод onBindViewHolder я добавил вывод в лог позиции обновляемой строки:
@Override public void onBindViewHolder(ProductHolder holder, int position) { Log.d(TAG, "bind, position = " + position); holder.bind(data.get(position)); }
Тем самым мы будем видеть, для каких строк списка был выполнен биндинг при обновлении данных.
Для начала убедимся, что при использовании метода notifyDataSetChanged для всех строк будет выполнен биндинг. По нажатию на кнопку Update будем обновлять данные в списке:
void onUpdateClick() { List<Product> productList = new LinkedList<>(); productList.add(new Product(1, "Name1", 100)); productList.add(new Product(2, "Name21", 200)); productList.add(new Product(3, "Name3", 300)); productList.add(new Product(4, "Name4", 400)); productList.add(new Product(5, "Name5", 501)); adapter.setData(productList); adapter.notifyDataSetChanged(); }
Для упрощения примера, мы сами формируем новый список, но на практике он мог прийти к нам от сервера или из БД. Данные почти те же самые, что и раньше, но у второго товара немного изменилось наименование, а у пятого - цена. Эти новые данные передаем в адаптер и вызываем notifyDataSetChanged
Жмем Update
Смотрим лог после нажатия на кнопку Update.
bind, position = 0
bind, position = 1
bind, position = 2
bind, position = 3
bind, position = 4
Биндинг сработал для всех 5-ти строк, хотя данные были обновлены только в двух. Не очень оптимальный вариант обновления.
Даже если по нажатию на Update данные придут те же самые, что и были, то биндинг все равно сработает для всех строк. Так происходит потому, что адаптер не знает, что поменялось, а что нет. Поэтому он обновляет все строки списка.
Чтобы решить эту проблему, мы вместо notifyDataSetChanged можем использовать более точечное обновление - метод notifyItemChanged.
void onUpdateClick() { List<Product> productList = new LinkedList<>(); productList.add(new Product(1, "Name1", 100)); productList.add(new Product(2, "Name21", 200)); productList.add(new Product(3, "Name3", 300)); productList.add(new Product(4, "Name4", 400)); productList.add(new Product(5, "Name5", 501)); adapter.setData(productList); adapter.notifyItemChanged(1); adapter.notifyItemChanged(4); }
Мы передаем адаптеру данные и говорим ему, что надо будет перерисовать только строки с позициями 1 и 4. (позиции в адаптере начинаются с нуля).
Запускаем приложение, жмем Update и смотрим лог:
bind, position = 1
bind, position = 4
Теперь биндинг сработал только для тех строк, которые мы обновили и явно указали адаптеру.
Кроме метода notifyItemChanged, который обновит измененную строку, есть еще несколько notify методов, которые помогут вам обновить список при добавлении, удалении или перемещении строк.
Эти точечные notify методы удобны, когда мы точно знаем, какие строки были изменены. Но если мы просто получаем новые данные извне, то будет достаточно сложно вручную все сравнивать и определять, что поменялось, а что нет. Эту работу за нас может выполнить DiffUtil.
Он сравнит два набора данных: старый и новый, выяснит, какие произошли изменения, и с помощью notify методов оптимально обновит адаптер.
От нас требуется только наследовать класс DiffUtil.Callback и реализовать несколько его абстрактных методов.
public class ProductDiffUtilCallback extends DiffUtil.Callback { private final List<Product> oldList; private final List<Product> newList; public ProductDiffUtilCallback(List<Product> oldList, List<Product> newList) { this.oldList = oldList; this.newList = newList; } @Override public int getOldListSize() { return oldList.size(); } @Override public int getNewListSize() { return newList.size(); } @Override public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) { Product oldProduct = oldList.get(oldItemPosition); Product newProduct = newList.get(newItemPosition); return oldProduct.getId() == newProduct.getId(); } @Override public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) { Product oldProduct = oldList.get(oldItemPosition); Product newProduct = newList.get(newItemPosition); return oldProduct.getName().equals(newProduct.getName()) && oldProduct.getPrice() == newProduct.getPrice(); } }
В конструктор передаем старые данные и новые данные. Они понадобятся для сравнения.
В методах 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
void onUpdateClick() { List<Product> productList = new LinkedList<>(); productList.add(new Product(1, "Name1", 100)); productList.add(new Product(2, "Name21", 200)); productList.add(new Product(3, "Name3", 300)); productList.add(new Product(4, "Name4", 400)); productList.add(new Product(5, "Name5", 501)); ProductDiffUtilCallback productDiffUtilCallback = new ProductDiffUtilCallback(adapter.getData(), productList); DiffUtil.DiffResult productDiffResult = DiffUtil.calculateDiff(productDiffUtilCallback); adapter.setData(productList); productDiffResult.dispatchUpdatesTo(adapter); }
Создаем ProductDiffUtilCallback и передаем в него старый список и новый. Передав productDiffUtilCallback в метод DiffUtil.calculateDiff, выполняем сравнение двух списков. Результат сравнения получаем в DiffResult.
Далее передаем в адаптер новые данные и просим productDiffResult обновить RecyclerView с учетом изменений. Т.е. это будет не просто бездумное notifyDataSetChanged, а именно использование notify методов, чтобы обновить список максимально эффективно.
Лог будет выглядеть так:
bind, position = 1
bind, position = 4
DiffUtil верно определил, что надо обновить только строки с позициями 1 и 4.
Давайте чуть усложним пример. В новых данных уберем первый товар и добавим шестой.
void onUpdateClick() { List<Product> productList = new LinkedList<>(); productList.add(new Product(2, "Name21", 200)); productList.add(new Product(3, "Name3", 300)); productList.add(new Product(4, "Name4", 400)); productList.add(new Product(5, "Name5", 501)); productList.add(new Product(6, "Name6", 600)); ProductDiffUtilCallback productDiffUtilCallback = new ProductDiffUtilCallback(adapter.getData(), productList); DiffUtil.DiffResult productDiffResult = DiffUtil.calculateDiff(productDiffUtilCallback); adapter.setData(productList); productDiffResult.dispatchUpdatesTo(adapter); }
Результат
Лог
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, Compose, Kotlin, RxJava, Dagger, Тестирование, Performance
- ну и если просто хочется поговорить с коллегами по разработке, то есть чат Флудильня