Всем добрый день!
Приблизительно месяц назад я писал статью об организации пагинации списков (RecyclerView) с помощью RxJava. Что есть пагинация по-простому? Это автоматическая подгрузка данных к списку при его прокрутке.
Решение, которое я представил в той статье было вполне рабочее, устойчивое к ошибкам в ответах на запросы по подгрузке данных и устойчивое к переориентации экрана (корректное сохранение состояния).
Но благодаря комментариям хабровчан, их замечаниям и предложениям, я понял, что решение имеет ряд недостатков, которые вполне по силам устранить.
Огромное спасибо Матвею Малькову за подробные комментарии и отличные идеи. Без него рефакторинг прошлого решения не состоялся бы.
Всех заинтересовавшихся прошу под кат 🙂
И так, какие недостатки были у первого варианта:
- Появление кастомных
AutoLoadingRecyclerView
иAutoLoadingRecyclerViewAdapter
. То есть просто так вот данное решение не вставишь в уже написанный код. Придется немного потрудиться. И это, конечно же, несколько связывает руки в дальнейшем. - При инициализации
AutoLoadingRecyclerView
надо явно вызывать методыsetLimit
,setLoadingObservable
,startLoading
. И это помимо стандартных дляRecyclerView
методов, типаsetAdapter
,setLayoutManager
и других. Также в голове нужно держать, что методstartLoading
обязательно надо вызывать последним. Да, все эти методы помечены комментариями, как и в каком порядке их надо вызывать, но это весьма не интуитивно, и можно легко запутаться. - Механизм пагинации был реализован в
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()); } }); }
Пройдемся по параметрам:
RecyclerView recyclerView
— наш искомый список 🙂int limit
— количество подгружаемых элементов за раз. Я добавил этот параметр сюда для удобства определения «позиции X», после которойObservable
начинает «эмитить». Определяется позиция вот этим выражением:
int updatePosition = recyclerView.getAdapter().getItemCount() - 1 - (limit / 2);
Как я говорил в прошлой статье, выявлено оно было чисто эмпирическим путем, и вы уже можете сами поменять его в зависимости от решаемой вами задачи.
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
.
/** * @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(); } }); } }
/** * @author e.matsyuk */ public class PagingException extends RuntimeException { public PagingException(String detailMessage) { super(detailMessage); } }
/** * @author e.matsyuk */ public interface PagingListener<T> { Observable<List<T>> onNextPage(int offset); }
/** * 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(); } }
/** * @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/
Добавить комментарий