Как сделать самоподгружаемый RecyclerView с помощью RxJava
Часто при разработке клиента мы сталкиваемся с задачей отображения какой-либо информации с сервера, базы данных или еще чего-нибудь в виде списка. И при прокручивании списка данные должны автоматически подгружаться и вставляться в список незаметно для пользователя. У пользователя вообще должно сложиться впечатление, что он скроллит бесконечный список.
В данной статье я бы хотел рассказать вам о том, как сделать автоподгружаемый список простейшим в реализации для разработчика и максимально эффективным и быстрым для пользователя. А также о том, как нам в этом здорово поможет RxJava с ее главной догмой — «Everything is Stream!»
Как нам реализовать такое в Android?
Для начала определимся с исходными данными:
- За отображение списка отвечает компонент
RecyclerView
(надеюсь, проListView
уже успели все забыть:) ) и все необходимые для настройкиRecyclerView
классы. - Подгрузка данных будет осуществляться при помощи запросов (в сеть, в БД и т.д.) с классическими для такой задачи параметрами
offset
иlimit
Далее опишем приблизительный алгоритм работы автоподгужаемого списка:
- Загружаем первую порцию данных для списка. Отображаем эти данные.
- При скроллинге списка мы должны отслеживать, какие по номеру элементы отображаются на экране. А конкретно, порядковый номер первого или последнего видимого для пользователя элемента.
- При наступлении какого-либо события, например, последний видимый на экране элемент является и последним вообще в списке, мы должны подгружать новую порцию данных. Также необходимо не допустить отправки одинаковых запросов. То есть нужно как-то отписаться от «прослушивания» скроллинга списка.
- Новые данные отправить в список. Список необходимо обновить. Снова подписаться на «прослушку» скроллинга.
- Пункты 2, 3, 4 необходимо повторять до тех пор, пока уже все данные не будут загружены, ну или при наступлении другого необходимого нам события.
И при чем тут RxJava?
Помните, я в начале говорил про главную догму Rx — «Everything is Stream!». Если в ООП мы мыслим категориями объектов, то в Реактивном — категориями потоков.
Например, взглянем на второй пункт алгоритма. Первое, на чем мы здесь остановимся, это скроллинг и соответственно изменяющийся порядковый номер первого или последнего видимого на экране элемента (в рассматриваемом нами ниже примере — последнего). То есть список при скроллинге постоянно «излучает» номера последнего элемента на протяжении всей своей жизни. Ничего не напоминает? Конечно же, это классический «hot observable». А если быть более конкретным, это PublishSubject
. Второе, на роль «слушателя» отлично подойдет Subscriber
.
RxJava подобно огромному набору «конструкторов», из которых разработчик может создавать самые различные конструкции. Начало конструкции уже положено — список «выпускает» элементы, являющиеся номерами последней видимой строки списка. Далее можно вставить конструктора (они же по сути и потоки), отвечающие за обработку полученных значений, отправку запросов на подгрузку новых данных и встраивания их в список. Ну обо всем по порядку.
Даешь реактивный код!
А теперь к практической части.
Так как мы создаем новый автоподгружаемый список, то унаследуемся от RecyclerView
.
public class AutoLoadingRecyclerView<T> extends RecyclerView
Далее мы должны задать списку параметр limit
, отвечающий за размер порции подгружаемых данных за раз.
private int limit;
public int getLimit() {
if (limit <= 0) {
throw new AutoLoadingRecyclerViewExceptions("limit must be initialised! And limit must be more than zero!");
}
return limit;
}
/**
* required method
*/
public void setLimit(int limit) {
this.limit = limit;
}
Теперь AutoLoadingRecyclerView
должен «излучать» порядковый номер последнего видимого на экране элемента.
Однако «излучение» просто порядкового номера не очень удобно в дальнейшем. Ведь это значение нужно обрабатывать. Да и наш канал (он же «излучатель») будет изрядно флудить, что также накладывает проблему на backpressure. Тогда немного усовершенствуем «излучатель». Пусть на выходе мы будем получать сразу уже готовые значения offset
и limit
, объединенные в следующую модель:
public class OffsetAndLimit {
private int offset;
private int limit;
public OffsetAndLimit(int offset, int limit) {
this.offset = offset;
this.limit = limit;
}
public int getOffset() {
return offset;
}
public int getLimit() {
return limit;
}
}
Уже лучше. А теперь уменьшим «флуд» канала. Пусть канал «излучает» элементы только тогда, когда это необходимо, то есть когда нужно подгрузить новую порцию данных.
Взглянем на код.
private PublishSubject<OffsetAndLimit> scrollLoadingChannel = PublishSubject.create();
// старт работы канала
private void startScrollingChannel() {
addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
int position = getLastVisibleItemPosition();
int limit = getLimit();
int updatePosition = getAdapter().getItemCount() - 1 - (limit / 2);
if (position >= updatePosition) {
int offset = getAdapter().getItemCount() - 1;
OffsetAndLimit offsetAndLimit = new OffsetAndLimit(offset, limit);
scrollLoadingChannel.onNext(offsetAndLimit);
}
}
});
}
// получение порядкового номера последнего видимого на экране элемента списка
// в зависимости от конкретного LayoutManager
private int getLastVisibleItemPosition() {
Class recyclerViewLMClass = getLayoutManager().getClass();
if (recyclerViewLMClass == LinearLayoutManager.class || LinearLayoutManager.class.isAssignableFrom(recyclerViewLMClass)) {
LinearLayoutManager linearLayoutManager = (LinearLayoutManager)getLayoutManager();
return linearLayoutManager.findLastVisibleItemPosition();
} else if (recyclerViewLMClass == StaggeredGridLayoutManager.class || StaggeredGridLayoutManager.class.isAssignableFrom(recyclerViewLMClass)) {
StaggeredGridLayoutManager staggeredGridLayoutManager = (StaggeredGridLayoutManager)getLayoutManager();
int[] into = staggeredGridLayoutManager.findLastVisibleItemPositions(null);
List<Integer> intoList = new ArrayList<>();
for (int i : into) {
intoList.add(i);
}
return Collections.max(intoList);
}
throw new AutoLoadingRecyclerViewExceptions("Unknown LayoutManager class: " + recyclerViewLMClass.toString());
}
Вы, наверное, хотите спросить, откуда я взял вот это условие:
int updatePosition = getAdapter().getItemCount() - 1 - (limit / 2);
Выявлено оно было чисто эмпирическим путем при предположении, что среднее время запроса — 200-300мс. При данном условии «плавность скроллинга» никак не страдает от параллельной догрузки данных. Если у вас время запроса больше, то можно попробовать либо увеличить limit
, либо немного поменять данное условие, чтобы подгрузка данных происходила немного пораньше.
Но все равно полностью от флуда канала мы не избавились. Когда условие начала подгрузки выполняется и скроллинг продолжается, канал продолжает нас «заваливать» сообщениями. И мы имеем все возможности послать несколько раз одинаковые запросы на подгрузку данных, да и backpressure никто не отменял — сетевой клиент может сломаться. Поэтому, как только мы получаем первое сообщение от канала, мы сразу же отписываемся от него, запускаем подгрузку данных, обновляем адаптер и список, и потом снова подписываемся к каналу, который уже не будет «флудить», так как поменяется условие (количество элементов в списке увеличится):
int updatePosition = getAdapter().getItemCount() - 1 - (limit / 2);
И так по циклу. А теперь внимание на код:
// метод подписки к каналу
private void subscribeToLoadingChannel() {
Subscriber<OffsetAndLimit> toLoadingChannelSubscriber = new Subscriber<OffsetAndLimit>() {
@Override
public void onCompleted() {
}
@Override
public void onError(Throwable e) {
Log.e(TAG, "subscribeToLoadingChannel error", e);
}
@Override
public void onNext(OffsetAndLimit offsetAndLimit) {
// отписываемся от канала
unsubscribe();
// подгружаем новые данные
loadNewItems(offsetAndLimit);
}
};
// scrollLoadingChannel - это наш канал. смотри код выше
subscribeToLoadingChannelSubscription = scrollLoadingChannel
.subscribe(toLoadingChannelSubscriber);
}
// метод подгрузки данных
private void loadNewItems(OffsetAndLimit offsetAndLimit) {
Subscriber<List<T>> loadNewItemsSubscriber = new Subscriber<List<T>>() {
@Override
public void onCompleted() {
}
@Override
public void onError(Throwable e) {
Log.e(TAG, "loadNewItems error", e);
subscribeToLoadingChannel();
}
@Override
public void onNext(List<T> ts) {
// добавляем в адаптер подгруженные данные
// конечно же, в стандартном адаптере нет метода addNewItems. мы используем кастомный адаптер, о нем ниже
getAdapter().addNewItems(ts);
// обновляем список
getAdapter().notifyItemInserted(getAdapter().getItemCount() - ts.size());
// если в ответе на запрос не пришли данные, значит их уже нет на сервере(БД), а значит цикл подгрузки можно заканчивать.
// в противном случае начинается новая итерация цикла
if (ts.size() > 0) {
// обратно подписываемся к каналу
subscribeToLoadingChannel();
}
}
};
// getLoadingObservable().getLoadingObservable(offsetAndLimit) - подгрузка данных через переданный AutoLoadingRecyclerView Observable. о нем тоже ниже
loadNewItemsSubscription = getLoadingObservable().getLoadingObservable(offsetAndLimit)
// подгрузка происходит в не UI потоке
.subscribeOn(Schedulers.from(BackgroundExecutor.getSafeBackgroundExecutor()))
// обработка результата происходит в UI (это добавление данных к адаптеру и обновление списка)
// поэтому проблем с синхронизацией нет (доступ к списку элементов в адаптере с нескольких потоков исключен),
// и обновление View происходит в UI потоке
.observeOn(AndroidSchedulers.mainThread())
.subscribe(loadNewItemsSubscriber);
}
Самое сложное позади. Нам удалось организовать безопасный цикл обновления списка. И все это внутри нашего AutoLoadingRecyclerView
.
А для того, чтобы у нас сложилось целостное впечатление, внимание на полный код ниже:
/**
* Offset and limit for {@link AutoLoadingRecyclerView AutoLoadedRecyclerView channel}
*
* @author e.matsyuk
*/
public class OffsetAndLimit {
private int offset;
private int limit;
public OffsetAndLimit(int offset, int limit) {
this.offset = offset;
this.limit = limit;
}
public int getOffset() {
return offset;
}
public int getLimit() {
return limit;
}
@Override
public String toString() {
return "OffsetAndLimit{" +
"offset=" + offset +
", limit=" + limit +
'}';
}
}
/**
* @author e.matsyuk
*/
public class AutoLoadingRecyclerViewExceptions extends RuntimeException {
public AutoLoadingRecyclerViewExceptions() {
super("Exception in AutoLoadingRecyclerView");
}
public AutoLoadingRecyclerViewExceptions(String detailMessage) {
super(detailMessage);
}
}
/**
* @author e.matsyuk
*/
public interface ILoading<T> {
Observable<List<T>> getLoadingObservable(OffsetAndLimit offsetAndLimit);
}
/**
* Adapter for {@link AutoLoadingRecyclerView AutoLoadingRecyclerView}
*
* @author e.matsyuk
*/
public abstract class AutoLoadingRecyclerViewAdapter<T> extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
private List<T> listElements = new ArrayList<>();
public void addNewItems(List<T> items) {
listElements.addAll(items);
}
public List<T> getItems() {
return listElements;
}
public T getItem(int position) {
return listElements.get(position);
}
@Override
public int getItemCount() {
return listElements.size();
}
}
/**
* @author e.matsyuk
*/
public class LoadingRecyclerViewAdapter extends AutoLoadingRecyclerViewAdapter<Item> {
private static final int MAIN_VIEW = 0;
static class MainViewHolder extends RecyclerView.ViewHolder {
TextView textView;
public MainViewHolder(View itemView) {
super(itemView);
textView = (TextView) itemView.findViewById(R.id.text);
}
}
@Override
public long getItemId(int position) {
return getItem(position).getId();
}
@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());
}
}
/**
* @author e.matsyuk
*/
public class AutoLoadingRecyclerView<T> extends RecyclerView {
private static final String TAG = "AutoLoadingRecyclerView";
private static final int START_OFFSET = 0;
private PublishSubject<OffsetAndLimit> scrollLoadingChannel = PublishSubject.create();
private Subscription loadNewItemsSubscription;
private Subscription subscribeToLoadingChannelSubscription;
private int limit;
private ILoading<T> iLoading;
private AutoLoadingRecyclerViewAdapter<T> autoLoadingRecyclerViewAdapter;
public AutoLoadingRecyclerView(Context context) {
super(context);
init();
}
public AutoLoadingRecyclerView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public AutoLoadingRecyclerView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
init();
}
/**
* required method
* call after init all parameters in AutoLoadedRecyclerView
*/
public void startLoading() {
OffsetAndLimit offsetAndLimit = new OffsetAndLimit(START_OFFSET, getLimit());
loadNewItems(offsetAndLimit);
}
private void init() {
startScrollingChannel();
}
private void startScrollingChannel() {
addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
int position = getLastVisibleItemPosition();
int limit = getLimit();
int updatePosition = getAdapter().getItemCount() - 1 - (limit / 2);
if (position >= updatePosition) {
int offset = getAdapter().getItemCount() - 1;
OffsetAndLimit offsetAndLimit = new OffsetAndLimit(offset, limit);
scrollLoadingChannel.onNext(offsetAndLimit);
}
}
});
}
private int getLastVisibleItemPosition() {
Class recyclerViewLMClass = getLayoutManager().getClass();
if (recyclerViewLMClass == LinearLayoutManager.class || LinearLayoutManager.class.isAssignableFrom(recyclerViewLMClass)) {
LinearLayoutManager linearLayoutManager = (LinearLayoutManager)getLayoutManager();
return linearLayoutManager.findLastVisibleItemPosition();
} else if (recyclerViewLMClass == StaggeredGridLayoutManager.class || StaggeredGridLayoutManager.class.isAssignableFrom(recyclerViewLMClass)) {
StaggeredGridLayoutManager staggeredGridLayoutManager = (StaggeredGridLayoutManager)getLayoutManager();
int[] into = staggeredGridLayoutManager.findLastVisibleItemPositions(null);
List<Integer> intoList = new ArrayList<>();
for (int i : into) {
intoList.add(i);
}
return Collections.max(intoList);
}
throw new AutoLoadingRecyclerViewExceptions("Unknown LayoutManager class: " + recyclerViewLMClass.toString());
}
public int getLimit() {
if (limit <= 0) {
throw new AutoLoadingRecyclerViewExceptions("limit must be initialised! And limit must be more than zero!");
}
return limit;
}
/**
* required method
*/
public void setLimit(int limit) {
this.limit = limit;
}
@Deprecated
@Override
public void setAdapter(Adapter adapter) {
if (adapter instanceof AutoLoadingRecyclerViewAdapter) {
super.setAdapter(adapter);
} else {
throw new AutoLoadingRecyclerViewExceptions("Adapter must be implement IAutoLoadedAdapter");
}
}
/**
* required method
*/
public void setAdapter(AutoLoadingRecyclerViewAdapter<T> autoLoadingRecyclerViewAdapter) {
if (autoLoadingRecyclerViewAdapter == null) {
throw new AutoLoadingRecyclerViewExceptions("Null adapter. Please initialise adapter!");
}
this.autoLoadingRecyclerViewAdapter = autoLoadingRecyclerViewAdapter;
super.setAdapter(autoLoadingRecyclerViewAdapter);
}
public AutoLoadingRecyclerViewAdapter<T> getAdapter() {
if (autoLoadingRecyclerViewAdapter == null) {
throw new AutoLoadingRecyclerViewExceptions("Null adapter. Please initialise adapter!");
}
return autoLoadingRecyclerViewAdapter;
}
public void setLoadingObservable(ILoading<T> iLoading) {
this.iLoading = iLoading;
}
public ILoading<T> getLoadingObservable() {
if (iLoading == null) {
throw new AutoLoadingRecyclerViewExceptions("Null LoadingObservable. Please initialise LoadingObservable!");
}
return iLoading;
}
private void subscribeToLoadingChannel() {
Subscriber<OffsetAndLimit> toLoadingChannelSubscriber = new Subscriber<OffsetAndLimit>() {
@Override
public void onCompleted() {
}
@Override
public void onError(Throwable e) {
Log.e(TAG, "subscribeToLoadingChannel error", e);
}
@Override
public void onNext(OffsetAndLimit offsetAndLimit) {
unsubscribe();
loadNewItems(offsetAndLimit);
}
};
subscribeToLoadingChannelSubscription = scrollLoadingChannel
.subscribeOn(Schedulers.from(BackgroundExecutor.getSafeBackgroundExecutor()))
.observeOn(AndroidSchedulers.mainThread())
.subscribe(toLoadingChannelSubscriber);
}
private void loadNewItems(OffsetAndLimit offsetAndLimit) {
Subscriber<List<T>> loadNewItemsSubscriber = new Subscriber<List<T>>() {
@Override
public void onCompleted() {
}
@Override
public void onError(Throwable e) {
Log.e(TAG, "loadNewItems error", e);
subscribeToLoadingChannel();
}
@Override
public void onNext(List<T> ts) {
getAdapter().addNewItems(ts);
getAdapter().notifyItemInserted(getAdapter().getItemCount() - ts.size());
if (ts.size() > 0) {
subscribeToLoadingChannel();
}
}
};
loadNewItemsSubscription = getLoadingObservable().getLoadingObservable(offsetAndLimit)
.subscribeOn(Schedulers.from(BackgroundExecutor.getSafeBackgroundExecutor()))
.observeOn(AndroidSchedulers.mainThread())
.subscribe(loadNewItemsSubscriber);
}
/**
* required method
* call in OnDestroy(or in OnDestroyView) method of Activity or Fragment
*/
public void onDestroy() {
scrollLoadingChannel.onCompleted();
if (subscribeToLoadingChannelSubscription != null && !subscribeToLoadingChannelSubscription.isUnsubscribed()) {
subscribeToLoadingChannelSubscription.unsubscribe();
}
if (loadNewItemsSubscription != null && !loadNewItemsSubscription.isUnsubscribed()) {
loadNewItemsSubscription.unsubscribe();
}
}
}
По AutoLoadingRecyclerView
нужно еще отметить, что мы не должны забывать про жизненный цикл и возможные утечки памяти со стороны RxJava. Поэтому, когда мы «убиваем» наш список, мы должны не забыть и отписаться от всех Subscribers
.
А теперь взглянем на конкретное практическое применение нашего списка:
/**
* A placeholder fragment containing a simple view.
*/
public class MainActivityFragment extends Fragment {
private final static int LIMIT = 50;
private AutoLoadingRecyclerView<Item> recyclerView;
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View rootView = inflater.inflate(R.layout.fragment_main, container, false);
init(rootView);
return rootView;
}
@Override
public void onResume() {
super.onResume();
// старт подгрузки первой порции данных для отображения в списке
// после этого включается уже автоматический режим догрузки данных
recyclerView.startLoading();
}
private void init(View view) {
recyclerView = (AutoLoadingRecyclerView) view.findViewById(R.id.RecyclerView);
GridLayoutManager recyclerViewLayoutManager = new GridLayoutManager(getActivity(), 1);
recyclerViewLayoutManager.supportsPredictiveItemAnimations();
LoadingRecyclerViewAdapter recyclerViewAdapter = new LoadingRecyclerViewAdapter();
recyclerViewAdapter.setHasStableIds(true);
recyclerView.setLayoutManager(recyclerViewLayoutManager);
recyclerView.setLimit(LIMIT);
recyclerView.setAdapter(recyclerViewAdapter);
recyclerView.setLoadingObservable(offsetAndLimit -> EmulateResponseManager.getInstance().getEmulateResponse(offsetAndLimit.getOffset(), offsetAndLimit.getLimit()));
}
@Override
public void onDestroyView() {
recyclerView.onDestroy();
super.onDestroyView();
}
}
Отличие AutoLoadingRecyclerView
от стандартного RecyclerView
лишь в добавлении методов setLimit, setLoadingObservable, onDestroy и startLoading
. А в коробке у нас самоподгружаемый список. По-моему, это очень удобно, емко и красиво.
Исходный код с практическим примером вы можете посмотреть на GitHub. Пока что AutoLoadingRecyclerView
представляет собой больше практическую реализацию идеи, нежели класс, который без проблем настраивается под любые нужды разработчика. Поэтому я буду очень рад вашим комментариям, предложениям, своим видением AutoLoadingRecyclerView
и замечаниям.
Отдельную благодарность хотел бы выразить пользователю lNevermore за подготовку данного материала.