Пагинация списків в Android з RxJava. Частина II

Всім добрий день!
Приблизно місяць тому я писав статті про організацію пагинации списків (RecyclerView) з допомогою RxJava. Що є пагинация по-простому? Це автоматичне завантаження даних до списку при його перегляді.
Рішення, яке я представив в тій статті було цілком робочий, стійке до помилок у відповідях на запити з підвантаження даних і стійке до переорієнтації екрану (коректне збереження стану).
Але завдяки коментарям хабровчан, їх зауважень і пропозицій, я зрозумів, що рішення має ряд недоліків, які цілком під силу усунути.
Величезне спасибі Матвію Mal'kovu за докладні коментарі та чудові ідеї. Без нього рефакторинг минулого рішення не відбувся б.
Всіх зацікавлених прошу під кат.

І так, які недоліки були у першого варіанту:
  1. Поява кастомних
    AutoLoadingRecyclerView
    та
    AutoLoadingRecyclerViewAdapter
    . Тобто просто так от дане рішення не вставиш у вже написаний код. Доведеться трохи попрацювати. І це, звичайно ж, кілька зв'язує руки в подальшому.
  2. При ініціалізації
    AutoLoadingRecyclerView
    треба явно викликати методи
    setLimit
    ,
    setLoadingObservable
    ,
    startLoading
    . І це крім стандартних для
    RecyclerView
    методів, типу
    setAdapter
    ,
    setLayoutManager
    та інших. Також потрібно тримати в голові, що метод
    startLoading
    обов'язково треба викликати останнім. Так, всі ці методи позначені коментарями, як і в якому порядку їх треба викликати, але це дуже не інтуїтивно, і можна легко заплутатися.
  3. Механізм пагинации був реалізований
    AutoLoadingRecyclerView
    . Коротка суть його в наступному:
    • PublishSubject
      , прив'язаний до
      RecyclerView.OnScrollListener
      , і який відповідно «эмитит» певні елементи при настанні події (коли користувач докрутив до певної позиції).
    • Subscriber
      , який прослуховує вищеназваний
      PublishSubject
      , і коли до нього надходить елемент
      PublishSubject
      , він відписується від нього і викликає спеціальний
      Вами
      , відповідальний за підвантаження нових елементів.
    • Вами
      , довантажувати нові елементи, оновлюючий список, а потім знову підключає
      Subscriber
      на
      PublishSubject
      для прослуховування скролінгу списку.
    Найбільший недолік даного алгоритму — це використання
    PublishSubject
    , який взагалі рекомендують використовувати у виняткових ситуаціях і який кілька ламає всю концепцію RxJava. В результаті отримуємо кілька «костыльную реактивщину».
Рефакторинг
А тепер, використовуючи перераховані вище недоліки, спробуємо розробити більш зручне і красиве рішення.

Першим справою позбудемося
PublishSubject
, а за місце нього створимо
Вами
, який буде «эмитить» при настанні заданого умови, тобто коли користувач доскроллит до певної позиції.
Метод отримання такого
Вами
(для спрощення будемо його називати —
scrollObservable
) буде наступним:
private static Вами<Integer> getScrollObservable(RecyclerView recyclerView, int limit, int emptyListCount) {
return Вами.create(subscriber -> {
final RecyclerView.OnScrollListener sl = new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(RecyclerView recyclerView, int dx int dy) {
if (!subscriber.isUnsubscribed()) {
int position = getLastVisibleItemPosition(recyclerView);
int updatePosition = recyclerView.getAdapter().getItemCount() - 1 - (limit / 2);
if (position >= updatePosition) {
subscriber.onNext(recyclerView.getAdapter().getItemCount());
}
}
}
};
recyclerView.addOnScrollListener(sl);
subscriber.add(Subscriptions.create(() -> recyclerView.removeOnScrollListener(sl)));
if (recyclerView.getAdapter().getItemCount() == emptyListCount) {
subscriber.onNext(recyclerView.getAdapter().getItemCount());
}
});
}

Пройдемося по параметрам:
  1. RecyclerView recyclerView
    — наш шуканий список :)
  2. int limit
    — кількість підвантажуваних елементів за раз. Я додав цей параметр сюди для зручності визначення «позиції X», після якої
    Вами
    починає «эмитить». Визначається позиція ось цим виразом:
    int updatePosition = recyclerView.getAdapter().getItemCount() - 1 - (limit / 2);
    

    Як я говорив в минулій статті, виявлено воно було чисто емпіричним шляхом, і ви вже самі можете поміняти його в залежності від розв'язуваної вами завдання.
  3. int emptyListCount
    — вже більш цікавий параметр. Пам'ятаєте, я говорив, що в минулій версії, після ініціалізації самим останнім потрібно викликати метод
    startLoading
    для первинного завантаження. Так от зараз, якщо список порожній і його не проскроллить, то
    scrollObservable
    автоматично «эмитит» перший елемент, який і служить відправною точкою старту пагинации:
    if (recyclerView.getAdapter().getItemCount() == 0) {
    subscriber.onNext(recyclerView.getAdapter().getItemCount());
    }
    

    Але, що якщо в списку вже є якісь елементи «по дефолту» (наприклад, один елемент). А пагінацію треба якось починати. В цьому як раз і допомагає параметр
    emptyListCount
    .
    int emptyListCount = 1;
    if (recyclerView.getAdapter().getItemCount() == emptyListCount) {
    subscriber.onNext(recyclerView.getAdapter().getItemCount());
    }
    

Отриманий
scrollObservable
«эмитит» число, що дорівнює кількості елементів у списку. Це ж число є і зрушення (або «offset»).
subscriber.onNext(recyclerView.getAdapter().getItemCount());

При скролінгу після досягнення певної позиції
scrollObservable
починає масово «эмитить» елементи. Нам же потрібен тільки один «эмит» з нових «offset». Тому додаємо оператор
distinctUntilChanged()
, відтинає всі повторювані елементи.
Код:
getScrollObservable(recyclerView, limit, emptyListCount)
.distinctUntilChanged();

Також необхідно пам'ятати, що працюємо ми з UI елементом і відстежуємо зміни його стану. Тому вся робота по «прослушці» скролінгу списку повинна відбуватися в UI потоці:
getScrollObservable(recyclerView, limit, emptyListCount)
.subscribeOn(AndroidSchedulers.mainThread())
.distinctUntilChanged();


Тепер же необхідно коректно довантажити ці дані.
Для цього створимо інтерфейс
PagingListener
, имплементируя який, розробник задає
Вами
, що відповідає за завантаження даних:
public interface PagingListener<T> {
Вами<List<T>> onNextPage(int offset);
}

Переключення на «завантажує»
Вами
здійснимо за допомогою оператора
switchMap
. Також пам'ятаємо, що підвантаження даних бажано здійснювати не у UI потоці.
Увагу на код:
getScrollObservable(recyclerView, limit, emptyListCount)
.subscribeOn(AndroidSchedulers.mainThread())
.distinctUntilChanged()
.observeOn(Schedulers.from(BackgroundExecutor.getSafeBackgroundExecutor()))
.switchMap(pagingListener::onNextPage);

Підписуємося ми до цього
Вами
вже у фрагменті або актівіті, де і розробник вирішує, як вчинити з знову завантаженими даними. Або їх відразу в список, або відфільтрувати, а тільки потім список. Саме чудове, що ми можемо з легкістю доконструировать
Вами
так, як хочемо. В цьому, звичайно ж, RxJava чудова, а
Subject
, який був у попередній статті, — не помічник.

Обробка помилок
Але що, якщо при завантаженні даних сталася якась короткочасна помилка, типу «пропала мережа» і т. д? У нас повинна бути можливість здійснення повторної спроби запиту даних. Звичайно, напрошується оператор
retry(long count)
(оператор
retry()
я уникаю можливості зависання, якщо помилка виявиться не короткочасним). Тоді:
getScrollObservable(recyclerView, limit, emptyListCount)
.subscribeOn(AndroidSchedulers.mainThread())
.distinctUntilChanged()
.observeOn(Schedulers.from(BackgroundExecutor.getSafeBackgroundExecutor()))
.switchMap(pagingListener::onNextPage)
.retry(3);

Але ось у чому проблема. Якщо сталася помилка, і користувач долистал до кінця списку — нічого не станеться, повторний запит не відправиться. Вся справа в тому, що оператор
retry(long count)
у разі помилки заново підписує
Subscriber
на
Вами
, і ми знову «прослуховуємо» скролінг списку. А список дійшов до кінця, тому повторного запиту не відбувається. Лікується це тільки «посмикуванням» списку, щоб спрацював скролінг. Але це, звичайно ж, не правильно.

Тому довелося викручуватися так, щоб у разі помилки запит все одно повторно відправлявся в незалежності від скролінгу списку і не більшу кількість разів, що розробник задасть.
Рішення таке:
int startNumberOfRetryAttempt = 0;
getScrollObservable(recyclerView, limit, emptyListCount)
.subscribeOn(AndroidSchedulers.mainThread())
.distinctUntilChanged()
.observeOn(Schedulers.from(BackgroundExecutor.getSafeBackgroundExecutor()))
.switchMap(offset -> getPagingObservable(pagingListener, pagingListener.onNextPage(offset), startNumberOfRetryAttempt, offset, retryCount))

private static <T> Вами<List<T>> getPagingObservable(PagingListener<T> listener, Вами<List<T>> вами, int numberOfAttemptToRetry, int offset, int retryCount) {
return вами.onErrorResumeNext(throwable -> {
// retry to load new data portion if error occurred
if (numberOfAttemptToRetry < retryCount) {
int attemptToRetryInc = numberOfAttemptToRetry + 1;
return getPagingObservable(listener, listener.onNextPage(offset), attemptToRetryInc, offset, retryCount);
} else {
return Вами.empty();
}
});
}

Параметр
retryCount
визначає розробник. Це максимальна кількість повторних запитів у випадку помилки. Тобто це не максимальна кількість спроб для усіх запитів, а максимальне — тільки для конкретного запиту.
Як працює цей код, а точніше метод
getPagingObservable
?
До
Вами<List<T>> вами
застосовуємо оператор
onErrorResumeNext
, який у разі помилки підставляє інший
Вами
. Всередині даного оператора ми спочатку перевіряємо кількість вже здійснених спроб. Якщо їх ще менше
retryCount
:
if (numberOfAttemptToRetry < retryCount) {

, то ми інкрементуємо лічильник здійснених спроб:
int attemptToRetryInc = numberOfAttemptToRetry + 1;

і рекурсивно викликає цей метод з оновленим лічильником спроб, який знову здійснює той же запит через
listener.onNextPage(offset)
:
return getPagingObservable(listener, listener.onNextPage(offset), attemptToRetryInc, offset, retryCount);

Якщо кількість спроб перевищила максимально допустимий, то просто повертає порожній
Вами
:
return Вами.empty();


Приклад
А тепер вашій увазі повний приклад використання
PaginationTool
.
PaginationTool
/**
* @author e.matsyuk
*/
public class PaginationTool {

// for first start of items loading then on RecyclerView there are not items and no scrolling
private static final int EMPTY_LIST_ITEMS_COUNT = 0;
// default limit for requests
private static final int DEFAULT_LIMIT = 50;
// default max attempts to retry loading request
private static final int MAX_ATTEMPTS_TO_RETRY_LOADING = 3;

public static <T> Вами<List<T>> paging(RecyclerView recyclerView, PagingListener<T> pagingListener) {
return paging(recyclerView, pagingListener, DEFAULT_LIMIT, EMPTY_LIST_ITEMS_COUNT, MAX_ATTEMPTS_TO_RETRY_LOADING);
}

public static <T> Вами<List<T>> paging(RecyclerView recyclerView, PagingListener<T> pagingListener, int limit) {
return paging(recyclerView, pagingListener, limit, EMPTY_LIST_ITEMS_COUNT, MAX_ATTEMPTS_TO_RETRY_LOADING);
}

public static <T> Вами<List<T>> paging(RecyclerView recyclerView, PagingListener<T> pagingListener, int limit, int emptyListCount) {
return paging(recyclerView, pagingListener, limit, emptyListCount, MAX_ATTEMPTS_TO_RETRY_LOADING);
}

public static <T> Вами<List<T>> paging(RecyclerView recyclerView, PagingListener<T> pagingListener, int limit, int emptyListCount, int retryCount) {
if (recyclerView == null) {
throw new PagingException("null recyclerView");
}
if (recyclerView.getAdapter() == null) {
throw new PagingException("null recyclerView adapter");
}
if (limit <= 0) {
throw new PagingException("limit must be greater then 0");
}
if (emptyListCount < 0) {
throw new PagingException("emptyListCount must be not less then 0");
}
if (retryCount < 0) {
throw new PagingException("retryCount must be not less then 0");
}

int startNumberOfRetryAttempt = 0;
return getScrollObservable(recyclerView, limit, emptyListCount)
.subscribeOn(AndroidSchedulers.mainThread())
.distinctUntilChanged()
.observeOn(Schedulers.from(BackgroundExecutor.getSafeBackgroundExecutor()))
.switchMap(offset -> getPagingObservable(pagingListener, pagingListener.onNextPage(offset), startNumberOfRetryAttempt, offset, retryCount));
}

private static Вами<Integer> getScrollObservable(RecyclerView recyclerView, int limit, int emptyListCount) {
return Вами.create(subscriber -> {
final RecyclerView.OnScrollListener sl = new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(RecyclerView recyclerView, int dx int dy) {
if (!subscriber.isUnsubscribed()) {
int position = getLastVisibleItemPosition(recyclerView);
int updatePosition = recyclerView.getAdapter().getItemCount() - 1 - (limit / 2);
if (position >= updatePosition) {
subscriber.onNext(recyclerView.getAdapter().getItemCount());
}
}
}
};
recyclerView.addOnScrollListener(sl);
subscriber.add(Subscriptions.create(() -> recyclerView.removeOnScrollListener(sl)));
if (recyclerView.getAdapter().getItemCount() == emptyListCount) {
subscriber.onNext(recyclerView.getAdapter().getItemCount());
}
});
}

private static int getLastVisibleItemPosition(RecyclerView recyclerView) {
Class recyclerViewLMClass = recyclerView.getLayoutManager().getClass();
if (recyclerViewLMClass == LinearLayoutManager.class || LinearLayoutManager.class.isAssignableFrom(recyclerViewLMClass)) {
LinearLayoutManager linearLayoutManager = (LinearLayoutManager)recyclerView.getLayoutManager();
return linearLayoutManager.findLastVisibleItemPosition();
} else if (recyclerViewLMClass == StaggeredGridLayoutManager.class || StaggeredGridLayoutManager.class.isAssignableFrom(recyclerViewLMClass)) {
StaggeredGridLayoutManager staggeredGridLayoutManager = (StaggeredGridLayoutManager)recyclerView.getLayoutManager();
int[] into = staggeredGridLayoutManager.findLastVisibleItemPositions(null);
List<Integer> intoList = new ArrayList<>();
for (int i : into) {
intoList.add(i);
}
return Collections.max(intoList);
}
throw new PagingException("Unknown LayoutManager class: " + recyclerViewLMClass.toString());
}

private static <T> Вами<List<T>> getPagingObservable(PagingListener<T> listener, Вами<List<T>> вами, int numberOfAttemptToRetry, int offset, int retryCount){
return вами.onErrorResumeNext(throwable -> {
// retry to load new data portion if error occurred
if (numberOfAttemptToRetry < retryCount) {
int attemptToRetryInc = numberOfAttemptToRetry + 1;
return getPagingObservable(listener, listener.onNextPage(offset), attemptToRetryInc, offset, retryCount);
} else {
return Вами.empty();
}
});
}

}
PagingException
/**
* @author e.matsyuk
*/
public class PagingException extends RuntimeException {

public PagingException(String detailMessage) {
super(detailMessage);
}

}
PagingListener
/**
* @author e.matsyuk
*/
public interface PagingListener<T> {
Вами<List<T>> onNextPage(int offset);
}
PaginationFragment
/**
* A placeholder fragment containing a simple view.
*/
public class PaginationFragment extends Fragment {

private static final int LIMIT = 50;
private PagingRecyclerViewAdapter recyclerViewAdapter;
private Subscription pagingSubscription;

@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View rootView = inflater.inflate(R. layout.fmt_pagination, container, false);
setRetainInstance(true);
init(rootView, savedInstanceState);
return rootView;
}

@Override
public void onResume() {
super.onResume();
}

private void init(View view, Bundle savedInstanceState) {
RecyclerView recyclerView = (RecyclerView) view.findViewById(R. id.RecyclerView);
GridLayoutManager recyclerViewLayoutManager = new GridLayoutManager(getActivity(), 1);
recyclerViewLayoutManager.supportsPredictiveItemAnimations();
// init adapter for the first time
if (savedInstanceState == null) {
recyclerViewAdapter = new PagingRecyclerViewAdapter();
recyclerViewAdapter.setHasStableIds(true);
}

recyclerView.setLayoutManager(recyclerViewLayoutManager);
recyclerView.setAdapter(recyclerViewAdapter);
// if all items was loaded we don't need Pagination
if (recyclerViewAdapter.isAllItemsLoaded()) {
return;
}
// RecyclerView pagination
pagingSubscription = PaginationTool
.paging(recyclerView, offset -> EmulateResponseManager.getInstance().getEmulateResponse(offset, LIMIT), LIMIT)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(new Subscriber<List<Item>>() {
@Override
public void onCompleted() {
}

@Override
public void onError(Throwable e) {
}

@Override
public void onNext(List<Item> items) {
recyclerViewAdapter.addNewItems(items);
recyclerViewAdapter.notifyItemInserted(recyclerViewAdapter.getItemCount() - items.size());
}
});
}

@Override
public void onDestroyView() {
if (pagingSubscription != null && !pagingSubscription.isUnsubscribed()) {
pagingSubscription.unsubscribe();
}
super.onDestroyView();
}

}
PagingRecyclerViewAdapter
/**
* @author e.matsyuk
*/
public class PagingRecyclerViewAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {

private static final int MAIN_VIEW = 0;

private List<Item> listElements = new ArrayList<>();
// after reorientation test this member
// or one extra request will be sent after each reorientation
private boolean allItemsLoaded;

static class MainViewHolder extends RecyclerView.ViewHolder {

TextView textView;

public MainViewHolder(View itemView) {
super(itemView);
textView = (TextView) itemView.findViewById(R. id.text);
}
}

public void addNewItems(List<Item> items) {
if (items.size() == 0) {
allItemsLoaded = true;
return;
}
listElements.addAll(items);
}

public boolean isAllItemsLoaded() {
return allItemsLoaded;
}

@Override
public long getItemId(int position) {
return getItem(position).getId();
}

public Item getItem(int position) {
return listElements.get(position);
}

@Override
public int getItemCount() {
return listElements.size();
}

@Override
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
if (viewType == MAIN_VIEW) {
View v = LayoutInflater.from(parent.getContext()).inflate(R. layout.recycler_item, parent, false);
return new MainViewHolder(v);
}
return null;
}

@Override
public int getItemViewType(int position) {
return MAIN_VIEW;
}

@Override
public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
switch (getItemViewType(position)) {
case MAIN_VIEW:
onBindTextHolder(holder, position);
break;
}
}

private void onBindTextHolder(RecyclerView.ViewHolder holder, int position) {
MainViewHolder mainHolder = (MainViewHolder) holder;
mainHolder.textView.setText(getItem(position).getItemStr());
}

}

Також даний приклад і приклад з попередньої статті доступні на GitHub.

Спасибі за увагу! Буду радий зауважень, пропозицій і, звичайно ж, подяками.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.