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

Тобто він зрозумів, що на початку списку мають бути ще дані і довантажив одну порцію. Причому, якби ми не скоригували його позицію на 40callback.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, значить це заглушка і треба відобразити якийсь тимчасовий текст.