Пагинация списков в 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 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 {
    Observable> 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  Observable> getPagingObservable(PagingListener listener, Observable> 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> 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  Observable> paging(RecyclerView recyclerView, PagingListener pagingListener) {
        return paging(recyclerView, pagingListener, DEFAULT_LIMIT, EMPTY_LIST_ITEMS_COUNT, MAX_ATTEMPTS_TO_RETRY_LOADING);
    }

    public static  Observable> paging(RecyclerView recyclerView, PagingListener pagingListener, int limit) {
        return paging(recyclerView, pagingListener, limit, EMPTY_LIST_ITEMS_COUNT, MAX_ATTEMPTS_TO_RETRY_LOADING);
    }

    public static  Observable> paging(RecyclerView recyclerView, PagingListener pagingListener, int limit, int emptyListCount) {
        return paging(recyclerView, pagingListener, limit, emptyListCount, MAX_ATTEMPTS_TO_RETRY_LOADING);
    }

    public static  Observable> paging(RecyclerView recyclerView, PagingListener 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 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 intoList = new ArrayList<>();
            for (int i : into) {
                intoList.add(i);
            }
            return Collections.max(intoList);
        }
        throw new PagingException("Unknown LayoutManager class: " + recyclerViewLMClass.toString());
    }

    private static  Observable> getPagingObservable(PagingListener listener, Observable> 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 {
    Observable> 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>() {
                    @Override
                    public void onCompleted() {
                    }

                    @Override
                    public void onError(Throwable e) {
                    }

                    @Override
                    public void onNext(List 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 {

    private static final int MAIN_VIEW = 0;

    private List 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 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.

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

© Habrahabr.ru