Пагинация списков в 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> 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.

    Спасибо за внимание! Буду рад замечаниям, предложениям и, конечно же, благодарностям.
    Метки:
    • +11
    • 15,2k
    • 2
    Поделиться публикацией
    Комментарии 2
    • –1
      Большое спасибо! Очень полезно было для меня!

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.