Paging Library. PagedList і DataSource. Placeholders.
У цьому уроці розглянемо, які параметри ми можемо задати для PagedList. Детально розберемо, які значення необхідно передавати в callback.onResult у DataSource. Навчимося використовувати режим Placeholders.
У минулому уроці ми розглянули приклад із використанням інструментів Paging Library. Нагадаю основні моменти коду:
Адаптер:
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));
}
}
DataSource:
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 - це просто емуляція якогось зовнішнього джерела даних, яке містить 100 записів
Код у MainActivity, де ми все це збираємо разом:
// 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);
Де, EmployeeStorage - це просто емуляція якогось зовнішнього джерела даних, яке містить 100 записів
Код у MainActivity, де ми все це збираємо разом:
Initial Key
У нашому прикладі PagedList запитує початкову порцію даних з позиції 0. Але цілком може бути ситуація, коли необхідно запросити дані не з самого початку.
Наприклад, у застосунку відкрито список і вже прокручено до якоїсь позиції. Користувач згортає додаток і займається іншими справами. Системі раптом не вистачає пам'яті, і вона вбиває додаток. Користувач вирішує повернутися, відкриває застосунок і ось тут нам треба показати список на тому самому місці, де він був. Перед тим як додаток було вбито, ми в onSaveInstanceState зберегли поточну позицію списку і тепер хочемо показати дані в ньому з цієї ж позиції. PagedList дає нам таку можливість.
У білдері PagedList є параметр initialKey. Для прикладу задамо йому значення 50.
PagedList<Employee> pagedList = new PagedList.Builder<>(dataSource, config)
.setMainThreadExecutor(new MainThreadExecutor())
.setBackgroundThreadExecutor(Executors.newSingleThreadExecutor())
.setInitialKey(50)
.build();
А в методі loadInitial у MyPositionalDataSource треба буде трохи підправити параметри виклику callback.onResult.
@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, params.requestedStartPosition);
}
Під час запиту даних параметр params.requestedStartPosition дорівнюватиме 50, оскільки ми задали це в setInitialKey.
У callback.onResult ми повертаємо не тільки дані з Storage, а й позицію початкового елемента цих даних. Тобто ми повідомляємо PagedList, що дані, які ми йому передали, починаються з позиції params.requestedStartPosition, тобто 50.
Розумію, що це виглядає як мінімум дивно. PagedList ніби як сам попросив нас надати йому дані з позиції 50. Тобто він знає про це. Навіщо ми йому про це додатково повідомляємо в callback.onResult? Тому що він чекає від нас це значення. Навіть якщо воно дорівнюватиме тому значенню, яке він нам сам і надіслав у params.requestedStartPosition. У подальшій роботі він буде використовувати саме те значення, які ми повернули йому.
У найпростіших випадках два цих значення будуть рівні. І код у прикладі вище працюватиме. Але в складних випадках ці значення можуть відрізнятися. Тобто PagedList просив нас дані з позиції 50, а ми змогли надати тільки з позиції 40. Давайте розглянемо такий нетривіальний сценарій.
Згадуємо вищеописаний випадок, коли застосунок вбивається системою, поки він висить у фоні. Припустимо, що перед тим, як бути вбитим, він довантажив усі 100 записів. Список був промотаний до кінця і показував останні 10 записів: з 90 по 100. Отже, під час відновлення програми PagedList попросить у нас записи, починаючи з позиції 90.
Невеликий відступ. PagedList попросить 30 записів, починаючи з 90. Чому 30, адже всього було 100 записів, і він повинен попросити 10? Тому що він нічого не знає про те, скільки в нас даних у Storage. А 30 - це розмір його порції початкових даних за замовчуванням. І якщо в Storage є тільки 10 записів, починаючи з 90, то ми просто їх і повертаємо в PagedList. Початкові порції даних, які запитуються та отримуються, необов'язково повинні збігатися за розміром.
Давайте уявимо, що, поки додаток було вбито, кількість даних у Storage зменшилася до 70. А PagedList просить з 90. Наш Storage повинен вміти обробляти такі ситуації. Тобто коли ми в нього в первісному завантаженні попросимо записи, починаючи з 90, він повинен зрозуміти, що таких записів більше немає, і повернути найближчі доступні записи.
Наприклад, якщо в Storage всього 70 записів, і ми запитуємо 30 записів, починаючи з 90, то Storage може повернути нам 30 останніх записів, тобто 30 записів з позиції 40. Ми передаємо ці дані в callback.onResult, і обов'язково вказуємо, що їхня позиція починається з 40, а не з 90, як розраховував PagedList.
У підсумку виходить, що нас просили про дані з позиції 90 (params.requestedStartPosition), а в callback.onResult ми передаємо список і повідомляємо, що вийшло добути дані тільки з позиції 40. Таким чином ми скорегували початкову позицію PagedList. Він знає, що у нього є записи з позиції 40 по позицію 70. І це дасть йому змогу запитувати у DataSource інші дані, використовуючи правильні позиції.
Якщо ж ми в callback.onResult просто передамо params.requestedStartPosition (тобто 90), то PagedList думатиме, що він отримав початкові дані з позиції 90 по позицію 120. І далі він буде запитувати дані з 120 по 130, з 80 по 90 і т.д. Тобто вийде розсинхрон зі Storage. Тому нам необхідно повернути в PagedList коректне значення початкової позиції отриманих даних. А щоб ми могли це зробити, нам треба отримати це значення від Storage.
У підсумку виходить, що нас просили про дані з позиції 90 (params.requestedStartPosition), а в callback.onResult ми передаємо список і повідомляємо, що вийшло добути дані тільки з позиції 40. Таким чином ми скорегували початкову позицію PagedList. Він знає, що у нього є записи з позиції 40 по позицію 70. І це дасть йому змогу запитувати у DataSource інші дані, використовуючи правильні позиції.
Якщо ж ми в callback.onResult просто передамо params.requestedStartPosition (тобто 90), то PagedList думатиме, що він отримав початкові дані з позиції 90 по позицію 120. І далі він буде запитувати дані з 120 по 130, з 80 по 90 і т.д. Тобто вийде розсинхрон зі Storage. Тому нам необхідно повернути в PagedList коректне значення початкової позиції отриманих даних. А щоб ми могли це зробити, нам треба отримати це значення від Storage.
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);
EmployeeData result = employeeStorage.getInitialData(params.requestedStartPosition, params.requestedLoadSize);
callback.onResult(result.data, result.position);
}
@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);
}
}
У методі loadInitial я для отримання даних використовую employeeStorage.getInitialData. Цей метод поверне дані і позицію, з якої ці дані починаються. Якщо раптом запитуваних даних немає, то він зможе повернути найближчі дані. Це важливо, тому що ми не можемо передати порожній список у callback.onResult у методі loadInitial. Якщо ми в callback.onResult повернемо порожній список і позицію 90, тим самим повідомляючи, що нам не вдалося отримати ці дані, то PagedList повідомить нам, що це наша проблема:
Initial result cannot be empty if items are present in data set
Тому нам треба обов'язково отримати від Storage якісь початкові дані. У нашому прикладі я від employeeStorage.getInitialData отримаю 30 записів і позицію 40 і передам це в PagedList.
Зрозуміло, може виникнути ситуація, що даних у Storage немає зовсім. Навіть для початкового завантаження. Тоді ми передаємо в callback.onResult порожній список і позицію 0. Тільки з позицією 0 PagedList прийме від нас порожній список у методі loadInitial і зрозуміє, що даних узагалі ніяких немає.
У методі loadRange ми для отримання даних продовжуємо використовувати метод employeeStorage.getData. Він не повинен нічого враховувати і визначати. Від нього вимагається просто повернути дані, якщо вони є. А якщо їх немає, то це буде сигналом для PagedList, що дані закінчилися.
Давайте подивимося, який вигляд це має в роботі

PagedList просить 30 записів, починаючи з 90.
loadInitial, requestedStartPosition = 90, requestedLoadSize = 30
DataSource повертає йому 30 записів, починаючи з 40, і список їх відображає.
Відразу після першого завантаження PagedList просить у DataSource нову порцію даних: 10 штук, починаючи з позиції 30.
loadRange, startPosition = 30, loadSize = 10
Тобто він зрозумів, що на початку списку мають бути ще дані і довантажив одну порцію. Причому, якби ми не скоригували його позицію на 40 (у callback.onResult), то він вважав би, що відображає записи з 90 по 120, і запросив би 10 записів з позиції 80.
Подальші скроли вгору, будуть також довантажувати попередні записи, поки не дійдемо до нуля.
loadRange, startPosition = 20, loadSize = 10
loadRange, startPosition = 10, loadSize = 10
loadRange, startPosition = 0, loadSize = 10
При скролі в кінець списку, PagedList спробує довантажити наступні записи після 70.
loadRange, startPosition = 70, loadSize = 10
Але в Storage більше нічого немає. PagedList отримає порожній список (у callback у методі loadRange) і заспокоїться.
pageSize
Параметр pageSize дає змогу задати розмір сторінки
PagedList.Config config = new PagedList.Config.Builder()
.setEnablePlaceholders(false)
.setPageSize(15)
.build();
Якщо ми задамо йому значення 15, то PagedList проситиме у DataSource по 15 записів.
loadInitial, requestedStartPosition = 0, requestedLoadSize = 45
loadRange, startPosition = 45, loadSize = 15
loadRange, startPosition = 60, loadSize = 15
loadRange, startPosition = 75, loadSize = 15
loadRange, startPosition = 90, loadSize = 15
loadRange, startPosition = 100, loadSize = 15
Параметр requestedLoadSize також змінився, він дорівнює розмір сторінки * 3, тобто 45
initialLoadSizeHint
Цей параметр відповідає за кількість даних, яку запитуватиме PagedList під час початкового завантаження. За замовчуванням він дорівнює pageSize * 3, але ми можемо задати своє значення.
PagedList.Config config = configBuilder
.setEnablePlaceholders(false)
.setPageSize(10)
.setInitialLoadSizeHint(50)
.build();
Розмір сторінки задаємо 10, а initialLoadSizeHint у 50.
Логи:
loadInitial, requestedStartPosition = 0, requestedLoadSize = 50
loadRange, startPosition = 50, loadSize = 10
loadRange, startPosition = 60, loadSize = 10
loadRange, startPosition = 70, loadSize = 10
…
При першому завантаженні PagedList отримав 50 записів. І далі довантажує порціями по 10.
prefetchDistance
PagedList використовує цей параметр, щоб визначити, коли треба довантажувати наступну порцію даних. За замовчуванням цей параметр дорівнює pageSize. Можна задати своє значення.
PagedList.Config config = configBuilder
.setEnablePlaceholders(false)
.setPageSize(10)
.setPrefetchDistance(20)
.build();
Тепер PagedList буде довантажувати нові дані, коли при прокручуванні залишається 20 записів до кінця списку.
Placeholders
Презентація з Google IO.
PagedList зазвичай не знає скільки всього буде даних. Він у міру прокрутки списку довантажує дані і додає їх у список. Він буде так робити, поки в Storage не закінчаться дані.
Але є й інший режим. Коли PagedList виконує первісне завантаження даних, ми можемо відразу повідомити йому, що у нас очікується, наприклад, 100 записів. PagedList повідомить про це адаптеру, і список відразу покаже 100 записів. Реальними з них будуть тільки ті, які були отримані під час початкового завантаження. Решта будуть фейковими, замість реальних даних там будуть null-заглушки.
Тобто для таких записів метод адаптера getItem(position) повертатиме null. Відповідно, нам треба буде навчити Holder адекватно реагувати на null-дані, які ми йому передаємо, і відображати якусь візуальну заглушку, що показує користувачеві, що дані поки що не доступні.
Далі, у міру прокручування списку, PagedList буде довантажувати реальні дані і відображати їх замість заглушок.
Я зробив приклад із розміром сторінки = 5

Список містить 100 рядків. Саме це значення я передав у PagedList під час завантаження початкового завантаження даних. Видно, що весь список, крім початкових даних, заповнений заглушками. У міру прокручування списку виконується підвантаження даних і заглушки замінюються отриманими реальними даними. Нових записів у список додаватися вже не буде.
Реалізувати це нескладно.
У конфігурації PagedList треба ввімкнути placeholders.
PagedList.Config config = configBuilder
.setEnablePlaceholders(true)
.setPageSize(5)
.build();
Код методу loadInitial треба буде трохи переписати.
@Override
public void loadInitial(@NonNull LoadInitialParams params, @NonNull LoadInitialCallback<Employee> callback) {
Log.d(TAG, "loadInitial, requestedStartPosition = " + params.requestedStartPosition +
", requestedLoadSize = " + params.requestedLoadSize);
EmployeeData result = employeeStorage.getInitialData(params.requestedStartPosition, params.requestedLoadSize);
if (params.placeholdersEnabled) {
callback.onResult(result.data, result.position, result.count);
} else {
callback.onResult(result.data, result.position);
}
}
EmployeeStorage повинен вміти повертати нам кількість записів, які він містить. Він може повертати це значення разом із даними в методі getInitialData. Або можна отримувати це значення викликом окремого методу. Тут усе залежить від реалізації Storage.
Отримане значення нам слід передати як третій параметр у callback.onResult. У такий спосіб PagedList знатиме, скільки записів треба відобразити в списку.
Зверніть увагу, в коді я роблю перевірку параметра placeholdersEnabled. Якщо він увімкнений, то я передаю кількість записів у колбек, інакше - не передаю. Якщо у вас буде увімкнено параметр placeholdersEnabled і ви не передасте кількість записів, то буде помилка:
Placeholders requested, but totalCount not provided. Будь ласка, викличте трипараметричний метод onResult, або вимкніть placeholders у PagedList.Config
PagedList не зможе заповнити список заглушками, тому що він не знає, скільки записів у ньому буде.
Ну і зміни в холдері, щоб забезпечити підтримку заглушок
public void bind(Employee employee) {
if (employee == null) {
textViewName.setText(R.string.wait);
textViewSalary.setText(R.string.wait);
} else {
textViewName.setText(employee.name);
textViewSalary.setText(employee.salary);
}
}
Якщо адаптер замість реальних даних дає null, значить це заглушка і треба відобразити якийсь тимчасовий текст.