Пагинация списков в Android с RxJava. Часть II
Всем добрый день!
Приблизительно месяц назад я писал статью об организации пагинации списков (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 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 {
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
.
/**
* @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();
}
});
}
}
/**
* @author e.matsyuk
*/
public class PagingException extends RuntimeException {
public PagingException(String detailMessage) {
super(detailMessage);
}
}
/**
* @author e.matsyuk
*/
public interface PagingListener {
Observable> 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>() {
@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();
}
}
/**
* @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.
Спасибо за внимание! Буду рад замечаниям, предложениям и, конечно же, благодарностям.