Пагинация списков в 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 ) будет следующим:
Пройдемся по параметрам:
- RecyclerView recyclerView — наш искомый список 🙂
- int limit — количество подгружаемых элементов за раз. Я добавил этот параметр сюда для удобства определения «позиции X», после которой Observable начинает «эмитить». Определяется позиция вот этим выражением:
Как я говорил в прошлой статье, выявлено оно было чисто эмпирическим путем, и вы уже можете сами поменять его в зависимости от решаемой вами задачи.
int emptyListCount — уже более интересный параметр. Помните, я говорил, что в прошлой версии, после инициализации самым последним нужно вызвать метод startLoading для первичной загрузки. Так вот сейчас, если список пуст и его не проскроллить, то scrollObservable автоматически «эмитит» первый элемент, который и служит отправной точкой старта пагинации:
Но, что если в списке уже есть какие-то элементы «по дефолту» (например, один элемент). А пагинацию надо как-то начинать. В этом как раз и помогает параметр emptyListCount .
Полученный scrollObservable «эмитит» число, равное количеству элементов в списке. Это же число есть и сдвиг (или «offset»).
При скроллинге после достижения определенной позиции scrollObservable начинает массово «эмитить» элементы. Нам же необходим только один «эмит» с изменившимся «offset». Поэтому добавляем оператор distinctUntilChanged() , отсекающий все повторяющиеся элементы.
Код:
Также необходимо помнить, что работаем мы с UI элементом и отслеживаем изменения его состояния. Поэтому вся работа по «прослушке» скроллинга списка должна происходить в UI потоке:
Теперь же необходимо корректно подгрузить эти данные.
Для этого создадим интерфейс PagingListener , имплементируя который, разработчик задает Observable , отвечающий за загрузку данных:
Переключение на «загружающий» Observable осуществим с помощью оператора switchMap . Также помним, что подгрузку данных желательно осуществлять не в UI потоке.
Внимание на код:
Подписываемся мы к данному Observable уже во фрагменте или активити, где и разработчик решает, как поступать с вновь загруженными данными. Или их сразу в список, или отфильтровать, а только потом список. Самое замечательное, что мы можем с легкостью доконструировать Observable так, как хотим. В этом, конечно же, RxJava замечательна, а Subject , который был в прошлой статье, — не помощник.
Обработка ошибок
Но что, если при загрузке данных произошла какая-нибудь кратковременная ошибка, типа «пропала сеть» и т.д? У нас должна быть возможность осуществления повторной попытки запроса данных. Конечно, напрашивается оператор retry(long count) (оператор retry() я избегаю из-за возможности зависания, если ошибка окажется не кратковременной). Тогда:
Но вот в чем проблема. Если произошла ошибка и пользователь долистал до конца списка — ничего не произойдет, повторный запрос не отправится. Все дело в том, что оператор retry(long count) в случае ошибки заново подписывает Subscriber к Observable , и мы снова «прослушиваем» скроллинг списка. А список-то дошел до конца, поэтому повторного запроса не происходит. Лечится это только «подергиванием» списка, чтобы сработал скроллинг. Но это, конечно же, не правильно.
Поэтому пришлось изворачиваться так, чтобы в случае ошибки запрос все равно повторно отправлялся в независимости от скроллинга списка и не большее количество раз, что разработчик задаст.
Решение такое:
Параметр retryCount задает разработчик. Это максимальное количество повторных запросов в случае ошибки. То есть это не максимальное количество попыток для всех запросов, а максимальное — только для конкретного запроса.
Как работает данный код, а точнее метод getPagingObservable ?
К параметру observable применяем оператор onErrorResumeNext , который в случае ошибки подставляет другой Observable . Внутри данного оператора мы сначала проверяем количество уже совершенных попыток. Если их еще меньше retryCount :
, то мы инкрементируем счетчик совершенных попыток:
, и рекурсивно вызываем этот же метод с обновленным счетчиком попыток, который снова осуществляет тот же запрос через listener.onNextPage(offset) :
Если количество попыток превысило максимально допустимое, то просто возвращает пустой Observable :
Пример
А теперь вашему вниманию полный пример использования PaginationTool .
Источник
Реализация списка с заголовком, футером и пагинацией в Андроид
RecyclerView
RecyclerView — это расширенная версия ListView с некоторыми улучшениями в производительности и с новыми функциями. Как следует из названия, RecyclerView перерабатывает или повторно использует представления элементов при прокрутке. В RecyclerView гораздо проще добавлять анимации по сравнению с ListView. В этом уроке мы разберем, как создать RecyclerView с заголовком, футером, разбиением на страницы и анимацией.
Настройка Gradle
Добавьте следующую зависимость в файл build.gradle:
Добавление RecyclerView в XML представление
После того, как проект будет синхронизирован, добавьте компонент RecyclerView в ваш макет:
Привязка XML с классом JAVA
Теперь в методе onCreate вашей активности добавьте следующий код:
Прежде чем идти дальше, давайте подробно рассмотрим приведенный выше код
- Layout Manager — Простыми словами, Layout Manager помогает нам определить структуру нашего RecyclerView. Есть три встроенных Layout Managers. Помимо этого, мы можем создать собственный пользовательский Layout Manager, чтобы удовлетворить наши требования.
- LinearLayoutManager показывает элементы в списке с вертикальной или горизонтальной прокруткой.
- GridLayoutManager показывает элементы в сетке.
- StaggeredGridLayoutManager показывает элементы в шахматной сетке.
RecyclerView ItemDecoration
ItemDecoration позволяет приложению добавлять специальный полосы и смещения к определенным представлениям элементов из набора данных адаптера. Это может быть полезно для рисования разделителей между элементами, выделениями, границами визуальной группировки и т. д. – developer.android.com
В этом примере мы будем использовать ItemDecoration для добавления отступов к каждому элементу.
В вышеприведенном классе мы устанавливаем отступы к нулевому элементу.
RecyclerView Adapter
Теперь давайте настроим адаптер ReeyclerView с заголовком и футером.
Пагинация
Теперь, когда адаптер готов, давайте посмотрим, как добавить пагинацию в список RecyclerView. Это довольно легко сделать и должно быть добавлено в onCreate после установки Adapter to Recycler-View.
Всякий раз, когда данные изменяются в mList, вызывайте функцию ниже, чтобы обновить адаптер RecyclerView и показать новые данные.
Надеюсь, что этот пост поможет вам получить общее представление о настройке RecyclerView с заголовком, подвалом и пагинацией.
Источник
Кэшируем пагинацию в Android
Наверняка каждый Android разработчик работал со списками, используя RecyclerView. А также многие успели посмотреть как организовать пагинацию в списке, используя Paging Library из Android Architecture Components.
Все просто: устанавливаем PositionalDataSource, задаем конфиги, создаем PagedList и скармливаем все это вместе с адаптером и DiffUtilCallback нашему RecyclerView.
Но что если у нас несколько источников данных? Например, мы хотим иметь кэш в Room и получать данные из сети.
Кейс получается довольно кастомный и в интернете не так уж много информации на эту тему. Я постараюсь это исправить и показать как можно решить такой кейс.
Если вы все еще не знакомы с реализацией пагинации с одним источником данных, то советую перед чтением статьи ознакомиться с этим.
Как бы выглядело решение без пагинации:
- Обращение к кэшу (в нашем случае это БД)
- Если кэш пуст — отправка запроса на сервер
- Получаем данные с сервера
- Отображаем их в листе
- Пишем в кэш
- Если кэш имеется — отображаем его в списке
- Получаем актуальные данные с сервера
- Отображаем их в списке○
- Пишем в кэш
Такая удобная штука как пагинация, которая упрощает жизнь пользователям, тут нам ее усложняет. Давайте попробуем представить какие проблемы могут возникнуть при реализации пагинируемого списка с несколькими источниками данных.
Алгоритм примерно следующий:
- Получаем данные из кэша для первой страницы
- Если кэш пуст — получаем данные сервера, отображаем их в списке и пишем в БД
- Если кэш есть — загружаем его в список
- Если доходим до конца БД, то запрашиваем данные с сервера, отображаем их
- в списке и пишем в БД
Из особенностей такого подхода можно заметить, что для отображения списка в первую очередь опрашивается кэш, и сигналом загрузки новых данных является конец кэша.
В Google задумались над этим и создали решение, которое идет из коробки PagingLibrary — BoundaryCallback.
BoundaryCallback сообщает когда локальный источник данных “заканчивается” и уведомляет об этом репозиторий для загрузки новых данных.
На официальном сайте Android Dev есть ссылка на репозиторий с примером проекта, использующего список с пагинацией с двумя источниками данных: Network (Retrofit 2) + Database (Room). Для того, чтобы лучше понять как работает такая система попробуем разобрать этот пример, немного его упростим.
Начнем со слоя data. Создадим два DataSource.
В этом интерфейсе описаны запросы к API Reddit и классы модели (ListingResponse, ListingData, RedditChildrenResponse), в объекты которых будут сворачиваться ответы API.
И сразу сделаем модель для Retrofit и Room
Класс RedditDb.kt, который будет наследовать RoomDatabase.
Помним, что создавать класс RoomDatabase каждый раз для выполнения запроса к БД очень затратно, поэтому в реальном кейсе создавайте его единожды за все время жизни приложения!
И класс Dao с запросами к БД RedditPostDao.kt
Вы наверное заметили, что метод получения записей postsBySubreddit возвращает
DataSource.Factory. Это необходимо для создания нашего PagedList, используя
LivePagedListBuilder, в фоновом потоке. Подробнее об этом вы можете почитать в
уроке.
Отлично, слой data готов. Переходим к слою бизнес логики.Для реализации паттерна “Репозиторий” принято создавать интерфейс репозитория отдельно от его реализации. Поэтому создадим интерфейс RedditPostRepository.kt
И сразу вопрос — что за Listing? Это дата класс, необходимый для отображения списка.
Создаем реализацию репозитория MainRepository.kt
Давайте посмотрим что происходит в нашем репозитории.
Создаем инстансы наших датасорсов и интерфейсы доступа к данным. Для базы данных:
RoomDatabase и Dao, для сети: Retrofit и интерфейс апи.
Далее реализуем обязательный метод репозитория
который настраивает пагинацию:
- Создаем SubRedditBoundaryCallback, наследующий PagedList.BoundaryCallback<>
- Используем конструктор с параметрами и передадим все, что нужно для работы BoundaryCallback
- Создаем триггер refreshTrigger для уведомления репозитория о необходимости обновить данные
- Создаем и возвращаем Listing объект
В Listing объекте:
- livePagedList
- networkState — состояние сети
- retry — callback для вызова повторного получения данных с сервера
- refresh — тригер для обновления данных
- refreshState — состояние процесса обновления
Реализуем вспомогательный метод
для записи ответа сети в БД. Он будет использоваться, когда нужно будет обновить список или записать новую порцию данных.
Реализуем вспомогательный метод
для тригера обновления данных. Тут все довольно просто: получаем данные с сервера, чистим БД, записываем новые данные в БД.
С репозиторием разобрались. Теперь давайте взглянем поближе на SubredditBoundaryCallback.
В классе, который наследует BoundaryCallback есть несколько обязательных методов:
Метод вызывается, когда БД пуста, здесь мы должны выполнить запрос на сервер для получения первой страницы.
Метод вызывается, когда “итератор” дошел до “дна” БД, здесь мы должны выполнить запрос на сервер для получения следующей страницы, передав ключ, с помощью которого сервер выдаст данные, следующие сразу за последней записью локального стора.
Метод вызывается, когда “итератор” дошел до первого элемента нашего стора. Для реализации нашего кейса можем проигнорировать реализацию этого метода.
Дописываем колбэк для получения данных и передачи их дальше
Дописываем метод записи полученных данных в БД
Что за хэлпер PagingRequestHelper? Это ЗДОРОВЕННЫЙ класс, который нам любезно предоставил Google и предлагает вынести его в библиотеку, но мы просто скопируем его в пакет слоя логики.
* The helper provides an API to observe combined request status, which can be reported back to the * application based on your business rules. * */ // THIS class is likely to be moved into the library in a future release. Feel free to copy it // from this sample. public class PagingRequestHelper < private final Object mLock = new Object(); private final Executor mRetryService; @GuardedBy("mLock") private final RequestQueue[] mRequestQueues = new RequestQueue[]
mListeners = new CopyOnWriteArrayList<>(); /** * Creates a new com.memebattle.pagingwithrepository.domain.util.PagingRequestHelper with the given <@link Executor>which is used to run * retry actions. * * @param retryService The <@link Executor>that can run the retry actions. */ public PagingRequestHelper(@NonNull Executor retryService) < mRetryService = retryService; >/** * Adds a new listener that will be notified when any request changes <@link Status state>. * * @param listener The listener that will be notified each time a request’s status changes. * @return True if it is added, false otherwise (e.g. it already exists in the list). */ @AnyThread public boolean addListener(@NonNull Listener listener) < return mListeners.add(listener); >/** * Removes the given listener from the listeners list. * * @param listener The listener that will be removed. * @return True if the listener is removed, false otherwise (e.g. it never existed) */ public boolean removeListener(@NonNull Listener listener) < return mListeners.remove(listener); >/** * Runs the given <@link Request>if no other requests in the given request type is already * running. *
* If run, the request will be run in the current thread. * * @param type The type of the request. * @param request The request to run. * @return True if the request is run, false otherwise. */ @SuppressWarnings(«WeakerAccess») @AnyThread public boolean runIfNotRunning(@NonNull RequestType type, @NonNull Request request) < boolean hasListeners = !mListeners.isEmpty(); StatusReport report = null; synchronized (mLock) < RequestQueue queue = mRequestQueues[type.ordinal()]; if (queue.mRunning != null) < return false; >queue.mRunning = request; queue.mStatus = Status.RUNNING; queue.mFailed = null; queue.mLastError = null; if (hasListeners) < report = prepareStatusReportLocked(); >> if (report != null) < dispatchReport(report); >final RequestWrapper wrapper = new RequestWrapper(request, this, type); wrapper.run(); return true; > @GuardedBy(«mLock») private StatusReport prepareStatusReportLocked() < Throwable[] errors = new Throwable[]< mRequestQueues[0].mLastError, mRequestQueues[1].mLastError, mRequestQueues[2].mLastError >; return new StatusReport( getStatusForLocked(RequestType.INITIAL), getStatusForLocked(RequestType.BEFORE), getStatusForLocked(RequestType.AFTER), errors ); > @GuardedBy(«mLock») private Status getStatusForLocked(RequestType type) < return mRequestQueues[type.ordinal()].mStatus; >@AnyThread @VisibleForTesting void recordResult(@NonNull RequestWrapper wrapper, @Nullable Throwable throwable) < StatusReport report = null; final boolean success = throwable == null; boolean hasListeners = !mListeners.isEmpty(); synchronized (mLock) < RequestQueue queue = mRequestQueues[wrapper.mType.ordinal()]; queue.mRunning = null; queue.mLastError = throwable; if (success) < queue.mFailed = null; queue.mStatus = Status.SUCCESS; >else < queue.mFailed = wrapper; queue.mStatus = Status.FAILED; >if (hasListeners) < report = prepareStatusReportLocked(); >> if (report != null) < dispatchReport(report); >> private void dispatchReport(StatusReport report) < for (Listener listener : mListeners) < listener.onStatusChange(report); >> /** * Retries all failed requests. * * @return True if any request is retried, false otherwise. */ public boolean retryAllFailed() < final RequestWrapper[] toBeRetried = new RequestWrapper[RequestType.values().length]; boolean retried = false; synchronized (mLock) < for (int i = 0; i
Со слоем бизнес логики закончили, можем переходить к реализации представления.
В слое представления у нас новая MVVM от Google на ViewModel и LiveData.
В методе onCreate инициализируем ViewModel, адаптер списка, подписываемся на изменение названия подписки и вызываем через модель запуск работы репозитория.
Если вы не знакомы с механизмами LiveData и ViewModel, то рекомендую ознакомиться с уроками.
В модели реализуем методы, которые будут дергать методы репозитория: retry и refesh.
Адаптер списка будет наследовать PagedListAdapter. Тут все также как и работе с пагинацией и одним источником данных.
И все те же ViewHolder ы для отображения записи и итема состояния загрузки данных из сети.
Если мы запустим приложение, то можем увидеть прогресс бар, а затем и данные с Reddit по запросу androiddev. Если отключим сеть и долистаем до конца нашего списка, то будет сообщение об ошибке и предложение попытаться загрузить данные снова.
Все работает, супер!
И мой репозиторий, где я постарался немного упростить пример от Google.
На этом все. Если вы знаете другие способы как “закэшировать” пагинацию, то обязательно напишите в комменты.
Источник