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

от автора

Всем добрый день!
Приблизительно месяц назад я писал статью об организации пагинации списков (RecyclerView) с помощью RxJava. Что есть пагинация по-простому? Это автоматическая подгрузка данных к списку при его прокрутке.
Решение, которое я представил в той статье было вполне рабочее, устойчивое к ошибкам в ответах на запросы по подгрузке данных и устойчивое к переориентации экрана (корректное сохранение состояния).
Но благодаря комментариям хабровчан, их замечаниям и предложениям, я понял, что решение имеет ряд недостатков, которые вполне по силам устранить.
Огромное спасибо Матвею Малькову за подробные комментарии и отличные идеи. Без него рефакторинг прошлого решения не состоялся бы.
Всех заинтересовавшихся прошу под кат 🙂

И так, какие недостатки были у первого варианта:

  1. Появление кастомных AutoLoadingRecyclerView и AutoLoadingRecyclerViewAdapter. То есть просто так вот данное решение не вставишь в уже написанный код. Придется немного потрудиться. И это, конечно же, несколько связывает руки в дальнейшем.
  2. При инициализации AutoLoadingRecyclerView надо явно вызывать методы setLimit, setLoadingObservable, startLoading. И это помимо стандартных для RecyclerView методов, типа setAdapter, setLayoutManager и других. Также в голове нужно держать, что метод startLoading обязательно надо вызывать последним. Да, все эти методы помечены комментариями, как и в каком порядке их надо вызывать, но это весьма не интуитивно, и можно легко запутаться.
  3. Механизм пагинации был реализован в AutoLoadingRecyclerView. Краткая суть его в следующем:
    • Есть PublishSubject, привязанный к RecyclerView.OnScrollListener, и который соответственно «эмитит» определенные элементы при наступлении события (когда пользователь докрутил до определенной позиции).
    • Есть Subscriber, который прослушивает вышеназванный PublishSubject, и когда к нему поступает элемент с PublishSubject, он отписывается от него и вызывает специальный Observable, ответственный за подгрузку новых элементов.
    • И есть Observable, подгружающий новые элементы, обновляющий список, а затем снова подключающий Subscriber к PublishSubject для прослушки скроллинга списка.

    Самый большой недостаток данного алгоритма — это использование PublishSubject, который вообще рекомендуют использовать в исключительных ситуациях и который несколько ломает всю концепцию RxJava. В результате получаем несколько «костыльную реактивщину» 🙂

Рефакторинг
А теперь, используя вышеперечисленные недостатки, попробуем разработать более удобное и красивое решение.

Первым делом избавимся от PublishSubject, а за место него создадим Observable, который будет «эмитить» при наступлении заданного условия, то есть когда пользователь доскроллит до определенной позиции.
Метод получения такого Observable (для упрощения будем его называть — scrollObservable) будет следующим:

private static Observable<Integer> getScrollObservable(RecyclerView recyclerView, int limit, int emptyListCount) {         return Observable.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», после которой Observable начинает «эмитить». Определяется позиция вот этим выражением:
    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, имплементируя который, разработчик задает Observable, отвечающий за загрузку данных:

public interface PagingListener<T> {     Observable<List<T>> onNextPage(int offset); } 

Переключение на «загружающий» Observable осуществим с помощью оператора switchMap. Также помним, что подгрузку данных желательно осуществлять не в UI потоке.
Внимание на код:

getScrollObservable(recyclerView, limit, emptyListCount)                 .subscribeOn(AndroidSchedulers.mainThread())                 .distinctUntilChanged()                 .observeOn(Schedulers.from(BackgroundExecutor.getSafeBackgroundExecutor()))                 .switchMap(pagingListener::onNextPage); 

Подписываемся мы к данному Observable уже во фрагменте или активити, где и разработчик решает, как поступать с вновь загруженными данными. Или их сразу в список, или отфильтровать, а только потом список. Самое замечательное, что мы можем с легкостью доконструировать Observable так, как хотим. В этом, конечно же, 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 к Observable, и мы снова «прослушиваем» скроллинг списка. А список-то дошел до конца, поэтому повторного запроса не происходит. Лечится это только «подергиванием» списка, чтобы сработал скроллинг. Но это, конечно же, не правильно.

Поэтому пришлось изворачиваться так, чтобы в случае ошибки запрос все равно повторно отправлялся в независимости от скроллинга списка и не большее количество раз, что разработчик задаст.
Решение такое:

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> Observable<List<T>> getPagingObservable(PagingListener<T> listener, Observable<List<T>> observable, int numberOfAttemptToRetry, int offset, int retryCount) {     return observable.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 Observable.empty();         }     }); } 

Параметр retryCount задает разработчик. Это максимальное количество повторных запросов в случае ошибки. То есть это не максимальное количество попыток для всех запросов, а максимальное — только для конкретного запроса.
Как работает данный код, а точнее метод getPagingObservable?
К Observable<List<T>> observable применяем оператор onErrorResumeNext, который в случае ошибки подставляет другой Observable. Внутри данного оператора мы сначала проверяем количество уже совершенных попыток. Если их еще меньше retryCount:

if (numberOfAttemptToRetry < retryCount) { 

, то мы инкрементируем счетчик совершенных попыток:

int attemptToRetryInc = numberOfAttemptToRetry + 1; 

, и рекурсивно вызываем этот же метод с обновленным счетчиком попыток, который снова осуществляет тот же запрос через listener.onNextPage(offset):

return getPagingObservable(listener, listener.onNextPage(offset), attemptToRetryInc, offset, retryCount); 

Если количество попыток превысило максимально допустимое, то просто возвращает пустой Observable:

return Observable.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> Observable<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> Observable<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> Observable<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> Observable<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 Observable<Integer> getScrollObservable(RecyclerView recyclerView, int limit, int emptyListCount) {         return Observable.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> Observable<List<T>> getPagingObservable(PagingListener<T> listener, Observable<List<T>> observable, int numberOfAttemptToRetry, int offset, int retryCount) {         return observable.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 Observable.empty();             }         });     }  } 

PagingException

/**  * @author e.matsyuk  */ public class PagingException extends RuntimeException {      public PagingException(String detailMessage) {         super(detailMessage);     }  } 

PagingListener

/**  * @author e.matsyuk  */ public interface PagingListener<T> {     Observable<List<T>> onNextPage(int offset); } 

PaginationFragment

/**  * A placeholder fragment containing a simple view.  */ public class PaginationFragment extends Fragment {      private final static 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.
Спасибо за внимание! Буду рад замечаниям, предложениям и, конечно же, благодарностям 🙂

ссылка на оригинал статьи http://habrahabr.ru/post/271875/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *