Paging Library. Основи
У цьому уроці почнемо знайомство з Paging Library. Розглянемо загальну схему роботи зв'язки PagedList і DataSource.
Paging Library містить інструменти для посторінкового підвантаження даних. Тобто коли дані довантажуються не всі відразу, а в міру прокручування списку. Давайте спочатку розглянемо в загальних рисах, чим цей спосіб відрізняється від звичайного, а потім виконаємо кілька прикладів.
Для підключення до проєкту додайте в dependencies
implementation "android.arch.paging:runtime:1.0.0"
Отже, ми хочемо відобразити дані в списку. Дані можуть бути звідки завгодно: база даних, сервер, файл із рядками тощо. Тобто будь-яке джерело, яке може надати нам дані для відображення їх у списку. Для зручності давайте називати його загальним словом Storage.
Зазвичай ми отримуємо дані зі Storage і поміщаємо їх у List в адаптер. Далі RecyclerView буде в адаптера просити View, а адаптер проситиме дані в List.
Виходить така схема:
RecyclerView >> Adapter >> List
де List одразу містить усі необхідні дані і нічого не треба більше довантажувати.
З Paging Library схема буде трохи складнішою:
RecyclerView >> PagedListAdapter >> PagedList > DataSource > Storage
Тобто звичайний Adapter ми міняємо на PagedListAdapter. А замість List у нас буде зв'язка PagedList + DataSource, яка вміє в міру необхідності підтягувати дані зі Storage.
Розглянемо докладніше ці компоненти.
PagedListAdapter
PagedListAdapter - це RecyclerView.Adapter, заточений під читання даних із PagedList.
Приклад:
class EmployeeAdapter extends PagedListAdapter<Employee, EmployeeViewHolder> {
protected EmployeeAdapter(DiffUtil.ItemCallback<Employee> diffUtilCallback) {
super(diffUtilCallback);
}
@NonNull
@Override
public EmployeeViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.employee, parent, false);
EmployeeViewHolder holder = new EmployeeViewHolder(view);
return holder;
}
@Override
public void onBindViewHolder(@NonNull EmployeeViewHolder holder, int position) {
holder.bind(getItem(position));
}
}
Як бачите, він дуже схожий на RecyclerView.Adapter. Від нього також вимагається біндити дані в Holder.
Відмінності такі:
- Йому відразу треба надати
DiffUtil.Callback. - Немає ніякого сховища даних (
Listабо т.п.) - Немає методу
getItemCount
Пункти 2 і 3 обумовлені тим, що адаптер усередині себе використовує PagedList як джерело даних, і він сам займатиметься зберіганням даних і визначенням їхньої кількості.
Щоб передати адаптеру PagedList, ми будемо використовувати метод адаптера submitList.
Приклад використання Android DiffUtil
Як правильно оновлювати дані в списку?
Запитувач зазвичай має на увазі два варіанти відповіді:
- Передавати нові дані в адаптер і викликати метод
notifyDataSetChanged, щоб рефрешнутиRecyclerView - Створювати новий адаптер, давати йому нові дані і передавати цей адаптер у
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, щоб визначити, чи треба в принципі порівнювати ці товари. Тобто спочатку достатньо порівняти їх за 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 може займати довгий час. Тому, якщо очікуєте, що кількість записів вимірюватиметься сотнями і зміни списку будуть значні, то має сенс викликати цей метод асинхронно.
У методу calculateDiff є ще один варіант виклику
DiffUtil.DiffResult calculateDiff (DiffUtil.Callback cb, boolean detectMoves)
Що означає прапор detectMoves? За замовчуванням, цей прапор = true. У цьому випадку DiffUtil спробує знайти переміщення рядків, які відбулися в новому списку порівняно зі старим. І якщо він знайде такі переміщення, то він викличе відповідні notify методи, і ви отримаєте красиву анімацію
Але це буде на шкоду швидкості роботи calculateDiff.
Якщо ж вам не потрібна така анімація, то можна вказувати detectMoves = false
У цьому разі, у разі зміни порядку записів анімація матиме такий вигляд:
Зате ви отримаєте приріст у швидкості роботи calculateDiff
PagedList
Якщо не сильно вдаватися в деталі, то PagedList - це обгортка над List. Він теж містить дані і вміє віддавати їх методом get(position). Але при цьому він перевіряє, наскільки запитуваний елемент близький до кінця наявних у нього даних, і за необхідності довантажує собі нові дані за допомогою DataSource.
Тобто у PagedList у списку вже є, наприклад, 40 елементів. Адаптер просить у нього елемент із позицією 31. PagedList дає йому цей елемент і при цьому розуміє, що адаптер просив елемент, близький до кінця його даних. А отже, є ймовірність, що скоро адаптер прийде за елементами з позицією 40 і далі. Тому PagedList звертається до DataSource за новою порцією даних, наприклад, від 41 до 50.
Створюється PagedList за допомогою білдера:
PagedList<Employee> pagedList = new PagedList.Builder<>(dataSource, config)
.setBackgroundThreadExecutor(Executors.newSingleThreadExecutor())
.setMainThreadExecutor(new MainThreadExecutor())
.build();
Від нас вимагається надати пару Executor-ів. Один для виконання запиту даних в окремому потоці, а другий для повернення результатів в UI потік.
На вхід конструктору білдера необхідно надати DataSource і PagedList.Config. Про DataSource ми поговоримо трохи пізніше, а PagedList.Config - це конфіг PagedList. У ньому ми можемо задати різні параметри, наприклад, розмір сторінки.
Створення PagedList.Config може виглядати так:
PagedList.Config config = new PagedList.Config.Builder()
.setEnablePlaceholders(false)
.setPageSize(10)
.build();
Детально всі його параметри ми розглянемо пізніше.
Варіант реалізації MainThreadExecutor:
class MainThreadExecutor implements Executor {
private final Handler mHandler = new Handler(Looper.getMainLooper());
@Override
public void execute(Runnable command) {
mHandler.post(command);
}
}
DataSource
DataSource - це посередник між PagedList і Storage. Виникає запитання: навіщо потрібен цей посередник? Чому PagedList не може безпосередньо попросити чергову порцію даних у Storage? Тому що у Storage можуть бути різні вимоги до способу запиту даних.
Наприклад, базі даних ми можемо дати позицію і бажану кількість записів, і у відповідь отримаємо порцію даних, починаючи із зазначеної позиції. А ось сервер може працювати зовсім по-іншому. Наприклад, він віддає дані посторінково і чекатиме від нас номер наступної сторінки, щоб віддати нову порцію даних. Також у сервера буває схема, коли з черговою порцією даних він надсилає нам токен. Цей токен необхідно використовувати для отримання наступної порції даних.
Paging Library надає три різних DataSource, які мають нам допомогти зв'язати між собою PagedList і Storage. Це PositionalDataSource, PageKeyedDataSource і ItemKeyedDataSource. В окремому уроці ми ще детально розглянемо, у чому різниця між ними. А поки що працюватимемо з PositionalDataSource, тому що він простіший і зрозуміліший за інші.
Практика
Давайте перейдемо до практичного прикладу і все стане зрозуміліше. Як DataSource будемо використовувати PositionalDataSource.
Отже, щоб уся схема запрацювала, нам треба створити DataSource, PagedList і адаптер:
// DataSource
MyPositionalDataSource dataSource = new MyPositionalDataSource(new EmployeeStorage())
// PagedList
PagedList.Config config = new PagedList.Config.Builder()
.setEnablePlaceholders(false)
.setPageSize(10)
.build()
PagedList<Employee> pagedList = new PagedList.Builder<>(dataSource, config)
.setMainThreadExecutor(new MainThreadExecutor())
.setBackgroundThreadExecutor(Executors.newSingleThreadExecutor())
.build()
// Adapter
adapter = new EmployeeAdapter(diffUtilCallback);
adapter.submitList(pagedList)
// RecyclerView
recyclerView.setAdapter(adapter);
DataSource передаємо в PagedList. PagedList передаємо в адаптер. Адаптер передаємо в RecyclerView.
Код MyPositionalDataSource:
class MyPositionalDataSource extends PositionalDataSource<Employee> {
private final EmployeeStorage employeeStorage;
public MyPositionalDataSource(EmployeeStorage employeeStorage) {
this.employeeStorage = employeeStorage;
}
@Override
public void loadInitial(@NonNull LoadInitialParams params, @NonNull LoadInitialCallback<Employee> callback) {
Log.d(TAG, "loadInitial, requestedStartPosition = " + params.requestedStartPosition +
", requestedLoadSize = " + params.requestedLoadSize);
List<Employee> result = employeeStorage.getData(params.requestedStartPosition, params.requestedLoadSize);
callback.onResult(result, 0);
}
@Override
public void loadRange(@NonNull LoadRangeParams params, @NonNull LoadRangeCallback<Employee> callback) {
Log.d(TAG, "loadRange, startPosition = " + params.startPosition + ", loadSize = " + params.loadSize);
List<Employee> result = employeeStorage.getData(params.startPosition, params.loadSize);
callback.onResult(result);
}
}
EmployeeStorage - це створений мною клас, який емулює Storage і містить 100 Employee записів. Не наводжу тут реалізацію цього класу, тому що вона не має значення. У реальному прикладі замість нього буде база даних (Room) або сервер (Retrofit), до яких ми звертаємося за даними.
MyPositionalDataSource наслідує PositionalDataSource і має реалізувати пару методів:
loadInitial- початкове завантаження даних.
Коли ми створюємо PagedList, він відразу запитує порцію даних у DataSource. Робить він це методом loadInitial. Як параметри він передає нам:
requestedStartPosition- з якої позиції довантажуватиrequestedLoadSize- розмір порції
Використовуючи ці параметри, ми запитуємо дані у Storage. Отриманий результат передаємо в callback.onResult
loadRange- підвантаження нової порції даних
Коли ми прокручуємо список, PagedList довантажує нові дані. Для цього він викликає метод loadRange. Як параметри він передає нам позицію, з якої треба довантажувати дані, і розмір порції.
Використовуючи ці параметри, ми запитуємо дані у Storage. Отриманий результат передаємо в callback.onResult
Я додав логів у ці методи, щоб було видно, що відбувається.
Про те, що означає другий параметр у callback.onResult, поговоримо в другій частині. А потоки, в яких буде виконуватися цей код, обговоримо в третій частині.
Запускаємо застосунок.
Для наочності я зробив гіфку, в якій ви можете бачити, які логи з'являються в міру прокручування списку.

Розбираємося, що відбувається.
Відразу після запуску в логах бачимо рядок:
loadInitial, requestedStartPosition = 0, requestedLoadSize = 30
PagedList запросив початкову порцію даних розміром 30 елементів (requestedLoadSize), починаючи з нульового (requestedStartPosition). DataSource передає ці параметри в Storage, отримує дані і повертає їх у PagedList. У підсумку адаптер відображає ці записи.
Звідки взялося число 30? За замовчуванням розмір початкового завантаження дорівнює розмір сторінки * 3. Розмір сторінки ми встановили рівним 10 (у PagedList.Config методом setPageSize), тому requestedLoadSize дорівнює 30.
Тепер починаємо скролити список вниз. Коли список показав запис із позицією 20, PagedList запросив наступну порцію даних:
loadRange, startPosition 30, loadSize = 10
Чому він зробив це саме після досягнення запису з позицією 20? За це відповідає параметр prefetchDistance. За замовчуванням він дорівнює pageSize, тобто 10. Відповідно, коли до кінця списку залишається 10 записів, PagedList довантажує наступну порцію.
У міру прокручування списку, довантажуються такі порції даних
loadRange, startPosition = 40, loadSize = 10
loadRange, startPosition = 50, loadSize = 10
loadRange, startPosition = 60, loadSize = 10
loadRange, startPosition = 70, loadSize = 10
loadRange, startPosition = 80, loadSize = 10
loadRange, startPosition = 90, loadSize = 10
loadRange, startPosition = 100, loadSize = 10
Після сотого запису список не прокручується. Так відбувається тому, що мій EmployeeStorage містить лише 100 записів. При спробі отримати у нього 10 записів, починаючи з позиції 100, він просто поверне порожній список. Коли DataSource передасть цей порожній список у callback.onResult, це буде сигналом для PagedList, що дані закінчилися. Після цього PagedList більше не намагатиметься довантажувати дані і список не буде скролитися.