Pull to refresh

Пагинация списков в Android с RxJava. Часть I

Website developmentJavaDevelopment of mobile applicationsDevelopment for Android
Часто при разработке клиента мы сталкиваемся с задачей отображения какой-либо информации с сервера, базы данных или еще чего-нибудь в виде списка. И при прокручивании списка данные должны автоматически подгружаться и вставляться в список незаметно для пользователя. У пользователя вообще должно сложиться впечатление, что он скроллит бесконечный список.

В данной статье я бы хотел рассказать вам о том, как сделать автоподгружаемый список простейшим в реализации для разработчика и максимально эффективным и быстрым для пользователя. А также о том, как нам в этом здорово поможет RxJava с ее главной догмой — «Everything is Stream!»

Как нам реализовать такое в Android?


Для начала определимся с исходными данными:
  1. За отображение списка отвечает компонент RecyclerView (надеюсь, про ListView уже успели все забыть:) ) и все необходимые для настройки RecyclerView классы.
  2. Подгрузка данных будет осуществляться при помощи запросов (в сеть, в БД и т.д.) с классическими для такой задачи параметрами offset и limit

Далее опишем приблизительный алгоритм работы автоподгужаемого списка:
  1. Загружаем первую порцию данных для списка. Отображаем эти данные.
  2. При скроллинге списка мы должны отслеживать, какие по номеру элементы отображаются на экране. А конкретно, порядковый номер первого или последнего видимого для пользователя элемента.
  3. При наступлении какого-либо события, например, последний видимый на экране элемент является и последним вообще в списке, мы должны подгружать новую порцию данных. Также необходимо не допустить отправки одинаковых запросов. То есть нужно как-то отписаться от «прослушивания» скроллинга списка.
  4. Новые данные отправить в список. Список необходимо обновить. Снова подписаться на «прослушку» скроллинга.
  5. Пункты 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.

А для того, чтобы у нас сложилось целостное впечатление, внимание на полный код ниже:

OffsetAndLimit
/**
 * 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 +
                '}';
    }
}


AutoLoadingRecyclerViewExceptions
/**
 * @author e.matsyuk
 */
public class AutoLoadingRecyclerViewExceptions extends RuntimeException {

    public AutoLoadingRecyclerViewExceptions() {
        super("Exception in AutoLoadingRecyclerView");
    }

    public AutoLoadingRecyclerViewExceptions(String detailMessage) {
        super(detailMessage);
    }
}


ILoading
/**
 * @author e.matsyuk
 */
public interface ILoading<T> {

    Observable<List<T>> getLoadingObservable(OffsetAndLimit offsetAndLimit);

}


AutoLoadingRecyclerViewAdapter
/**
 * 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();
    }
}


LoadingRecyclerViewAdapter
/**
 * @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());
    }

}


AutoLoadingRecyclerView
/**
 * @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 за подготовку данного материала.
Tags:androidrxjavamobile developmentmobilearchitecturerecyclerviewlayoutmanagerscrolling
Hubs: Website development Java Development of mobile applications Development for Android
Total votes 15: ↑14 and ↓1 +13
Views36.2K

Comments 8

Only those users with full accounts are able to leave comments. Log in, please.

Popular right now

QA engineer
from 120,000 ₽DialogМоскваRemote job
Middle+/Senior android developer
from 180,000 ₽Bright groupМоскваRemote job
Frontend Software Development Engineer
from 3,000 to 5,000 $EnnablRemote job
Android developer
from 60,000 to 150,000 ₽IceRock DevelopmentRemote job

Top of the last 24 hours