Наверняка каждый Android разработчик работал со списками, используя RecyclerView. А также многие успели посмотреть как организовать пагинацию в списке, используя Paging Library из Android Architecture Components.
Все просто: устанавливаем PositionalDataSource, задаем конфиги, создаем PagedList и скармливаем все это вместе с адаптером и DiffUtilCallback нашему RecyclerView.
Но что если у нас несколько источников данных? Например, мы хотим иметь кэш в Room и получать данные из сети.
Кейс получается довольно кастомный и в интернете не так уж много информации на эту тему. Я постараюсь это исправить и показать как можно решить такой кейс.
Если вы все еще не знакомы с реализацией пагинации с одним источником данных, то советую перед чтением статьи ознакомиться с этим.
Как бы выглядело решение без пагинации:
- Обращение к кэшу (в нашем случае это БД)
- Если кэш пуст — отправка запроса на сервер
- Получаем данные с сервера
- Отображаем их в листе
- Пишем в кэш
- Если кэш имеется — отображаем его в списке
- Получаем актуальные данные с сервера
- Отображаем их в списке0
- Пишем в кэш
Такая удобная штука как пагинация, которая упрощает жизнь пользователям, тут нам ее усложняет. Давайте попробуем представить какие проблемы могут возникнуть при реализации пагинируемого списка с несколькими источниками данных.
Алгоритм примерно следующий:
- Получаем данные из кэша для первой страницы
- Если кэш пуст — получаем данные сервера, отображаем их в списке и пишем в БД
- Если кэш есть — загружаем его в список
- Если доходим до конца БД, то запрашиваем данные с сервера, отображаем их
- в списке и пишем в БД
Из особенностей такого подхода можно заметить, что для отображения списка в первую очередь опрашивается кэш, и сигналом загрузки новых данных является конец кэша.
В Google задумались над этим и создали решение, которое идет из коробки PagingLibrary — BoundaryCallback.
BoundaryCallback сообщает когда локальный источник данных “заканчивается” и уведомляет об этом репозиторий для загрузки новых данных.
На официальном сайте Android Dev есть ссылка на ?репозиторий? с примером проекта, использующего список с пагинацией с двумя источниками данных: Network (Retrofit 2) + Database (Room). Для того, чтобы лучше понять как работает такая система попробуем разобрать этот пример, немного его упростим.
Начнем со слоя data. Создадим два DataSource.
import com.memebattle.pagingwithrepository.domain.model.RedditPost
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
/**
* API communication setup
*/
interface RedditApi {
@GET("/r/{subreddit}/hot.json")
fun getTop(
@Path("subreddit") subreddit: String,
@Query("limit") limit: Int): Call<ListingResponse>
// for after/before param, either get from RedditDataResponse.after/before,
// or pass RedditNewsDataResponse.name (though this is technically incorrect)
@GET("/r/{subreddit}/hot.json")
fun getTopAfter(
@Path("subreddit") subreddit: String,
@Query("after") after: String,
@Query("limit") limit: Int): Call<ListingResponse>
@GET("/r/{subreddit}/hot.json")
fun getTopBefore(
@Path("subreddit") subreddit: String,
@Query("before") before: String,
@Query("limit") limit: Int): Call<ListingResponse>
class ListingResponse(val data: ListingData)
class ListingData(
val children: List<RedditChildrenResponse>,
val after: String?,
val before: String?
)
data class RedditChildrenResponse(val data: RedditPost)
}
В этом интерфейсе описаны запросы к API Reddit и классы модели (ListingResponse, ListingData, RedditChildrenResponse), в объекты которых будут сворачиваться ответы API.
И сразу сделаем модель для Retrofit и Room
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import com.google.gson.annotations.SerializedName
@Entity(tableName = "posts",
indices = [Index(value = ["subreddit"], unique = false)])
data class RedditPost(
@PrimaryKey
@SerializedName("name")
val name: String,
@SerializedName("title")
val title: String,
@SerializedName("score")
val score: Int,
@SerializedName("author")
val author: String,
@SerializedName("subreddit") // this seems mutable but fine for a demo
@ColumnInfo(collate = ColumnInfo.NOCASE)
val subreddit: String,
@SerializedName("num_comments")
val num_comments: Int,
@SerializedName("created_utc")
val created: Long,
val thumbnail: String?,
val url: String?) {
// to be consistent w/ changing backend order, we need to keep a data like this
var indexInResponse: Int = -1
}
Класс RedditDb.kt, который будет наследовать RoomDatabase.
import androidx.room.Database
import androidx.room.RoomDatabase
import com.memebattle.pagingwithrepository.domain.model.RedditPost
/**
* Database schema used by the DbRedditPostRepository
*/
@Database(
entities = [RedditPost::class],
version = 1,
exportSchema = false
)
abstract class RedditDb : RoomDatabase() {
abstract fun posts(): RedditPostDao
}
Помним, что создавать класс RoomDatabase каждый раз для выполнения запроса к БД очень затратно, поэтому в реальном кейсе создавайте его единожды за все время жизни приложения!
И класс Dao с запросами к БД RedditPostDao.kt
import androidx.paging.DataSource
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.memebattle.pagingwithrepository.domain.model.RedditPost
@Dao
interface RedditPostDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(posts : List<RedditPost>)
@Query("SELECT * FROM posts WHERE subreddit = :subreddit ORDER BY indexInResponse ASC")
fun postsBySubreddit(subreddit : String) : DataSource.Factory<Int, RedditPost>
@Query("DELETE FROM posts WHERE subreddit = :subreddit")
fun deleteBySubreddit(subreddit: String)
@Query("SELECT MAX(indexInResponse) + 1 FROM posts WHERE subreddit = :subreddit")
fun getNextIndexInSubreddit(subreddit: String) : Int
}
Вы наверное заметили, что метод получения записей postsBySubreddit возвращает
DataSource.Factory. Это необходимо для создания нашего PagedList, используя
LivePagedListBuilder, в фоновом потоке. Подробнее об этом вы можете почитать в
уроке.
Отлично, слой data готов. Переходим к слою бизнес логики.Для реализации паттерна “Репозиторий” принято создавать интерфейс репозитория отдельно от его реализации. Поэтому создадим интерфейс RedditPostRepository.kt
interface RedditPostRepository {
fun postsOfSubreddit(subReddit: String, pageSize: Int): Listing<RedditPost>
}
И сразу вопрос — что за Listing? Это дата класс, необходимый для отображения списка.
import androidx.lifecycle.LiveData
import androidx.paging.PagedList
import com.memebattle.pagingwithrepository.domain.repository.network.NetworkState
/**
* Data class that is necessary for a UI to show a listing and interact w/ the rest of the system
*/
data class Listing<T>(
// the LiveData of paged lists for the UI to observe
val pagedList: LiveData<PagedList<T>>,
// represents the network request status to show to the user
val networkState: LiveData<NetworkState>,
// represents the refresh status to show to the user. Separate from networkState, this
// value is importantly only when refresh is requested.
val refreshState: LiveData<NetworkState>,
// refreshes the whole data and fetches it from scratch.
val refresh: () -> Unit,
// retries any failed requests.
val retry: () -> Unit)
Создаем реализацию репозитория MainRepository.kt
import android.content.Context
import androidx.annotation.MainThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import com.android.example.paging.pagingwithnetwork.reddit.api.RedditApi
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import androidx.room.Room
import com.android.example.paging.pagingwithnetwork.reddit.db.RedditDb
import com.android.example.paging.pagingwithnetwork.reddit.db.RedditPostDao
import com.memebattle.pagingwithrepository.domain.model.RedditPost
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.util.concurrent.Executors
import androidx.paging.LivePagedListBuilder
import com.memebattle.pagingwithrepository.domain.repository.core.Listing
import com.memebattle.pagingwithrepository.domain.repository.boundary.SubredditBoundaryCallback
import com.memebattle.pagingwithrepository.domain.repository.network.NetworkState
import com.memebattle.pagingwithrepository.domain.repository.core.RedditPostRepository
class MainRepository(context: Context) : RedditPostRepository {
private var retrofit: Retrofit = Retrofit.Builder()
.baseUrl("https://www.reddit.com/") //Базовая часть адреса
.addConverterFactory(GsonConverterFactory.create()) //Конвертер, необходимый для преобразования JSON'а в объекты
.build()
var db = Room.databaseBuilder(context,
RedditDb::class.java, "database").build()
private var redditApi: RedditApi
private var dao: RedditPostDao
val ioExecutor = Executors.newSingleThreadExecutor()
init {
redditApi = retrofit.create(RedditApi::class.java) //Создаем объект, при помощи которого будем выполнять запросы
dao = db.posts()
}
/**
* Inserts the response into the database while also assigning position indices to items.
*/
private fun insertResultIntoDb(subredditName: String, body: RedditApi.ListingResponse?) {
body!!.data.children.let { posts ->
db.runInTransaction {
val start = db.posts().getNextIndexInSubreddit(subredditName)
val items = posts.mapIndexed { index, child ->
child.data.indexInResponse = start + index
child.data
}
db.posts().insert(items)
}
}
}
/**
* When refresh is called, we simply run a fresh network request and when it arrives, clear
* the database table and insert all new items in a transaction.
* <p>
* Since the PagedList already uses a database bound data source, it will automatically be
* updated after the database transaction is finished.
*/
@MainThread
private fun refresh(subredditName: String): LiveData<NetworkState> {
val networkState = MutableLiveData<NetworkState>()
networkState.value = NetworkState.LOADING
redditApi.getTop(subredditName, 10).enqueue(
object : Callback<RedditApi.ListingResponse> {
override fun onFailure(call: Call<RedditApi.ListingResponse>, t: Throwable) {
// retrofit calls this on main thread so safe to call set value
networkState.value = NetworkState.error(t.message)
}
override fun onResponse(call: Call<RedditApi.ListingResponse>, response: Response<RedditApi.ListingResponse>) {
ioExecutor.execute {
db.runInTransaction {
db.posts().deleteBySubreddit(subredditName)
insertResultIntoDb(subredditName, response.body())
}
// since we are in bg thread now, post the result.
networkState.postValue(NetworkState.LOADED)
}
}
}
)
return networkState
}
/**
* Returns a Listing for the given subreddit.
*/
override fun postsOfSubreddit(subReddit: String, pageSize: Int): Listing<RedditPost> {
// create a boundary callback which will observe when the user reaches to the edges of
// the list and update the database with extra data.
val boundaryCallback = SubredditBoundaryCallback(
webservice = redditApi,
subredditName = subReddit,
handleResponse = this::insertResultIntoDb,
ioExecutor = ioExecutor,
networkPageSize = pageSize)
// we are using a mutable live data to trigger refresh requests which eventually calls
// refresh method and gets a new live data. Each refresh request by the user becomes a newly
// dispatched data in refreshTrigger
val refreshTrigger = MutableLiveData<Unit>()
val refreshState = Transformations.switchMap(refreshTrigger) {
refresh(subReddit)
}
// We use toLiveData Kotlin extension function here, you could also use LivePagedListBuilder
val livePagedList = LivePagedListBuilder(db.posts().postsBySubreddit(subReddit), pageSize)
.setBoundaryCallback(boundaryCallback)
.build()
return Listing(
pagedList = livePagedList,
networkState = boundaryCallback.networkState,
retry = {
boundaryCallback.helper.retryAllFailed()
},
refresh = {
refreshTrigger.value = null
},
refreshState = refreshState
)
}
}
Давайте посмотрим что происходит в нашем репозитории.
Создаем инстансы наших датасорсов и интерфейсы доступа к данным. Для базы данных:
RoomDatabase и Dao, для сети: Retrofit и интерфейс апи.
Далее реализуем обязательный метод репозитория
fun postsOfSubreddit(subReddit: String, pageSize: Int): Listing<RedditPost>
который настраивает пагинацию:
- Создаем SubRedditBoundaryCallback, наследующий PagedList.BoundaryCallback<>
- Используем конструктор с параметрами и передадим все, что нужно для работы BoundaryCallback
- Создаем триггер refreshTrigger для уведомления репозитория о необходимости обновить данные
- Создаем и возвращаем Listing объект
В Listing объекте:
- livePagedList
- networkState — состояние сети
- retry — callback для вызова повторного получения данных с сервера
- refresh — тригер для обновления данных
- refreshState — состояние процесса обновления
Реализуем вспомогательный метод
private fun insertResultIntoDb(subredditName: String, body: RedditApi.ListingResponse?)
для записи ответа сети в БД. Он будет использоваться, когда нужно будет обновить список или записать новую порцию данных.
Реализуем вспомогательный метод
private fun refresh(subredditName: String): LiveData<NetworkState>
для тригера обновления данных. Тут все довольно просто: получаем данные с сервера, чистим БД, записываем новые данные в БД.
С репозиторием разобрались. Теперь давайте взглянем поближе на SubredditBoundaryCallback.
import androidx.paging.PagedList
import androidx.annotation.MainThread
import com.android.example.paging.pagingwithnetwork.reddit.api.RedditApi
import com.memebattle.pagingwithrepository.domain.model.RedditPost
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.util.concurrent.Executor
import com.memebattle.pagingwithrepository.domain.util.PagingRequestHelper
import com.memebattle.pagingwithrepository.domain.repository.network.createStatusLiveData
/**
* This boundary callback gets notified when user reaches to the edges of the list such that the
* database cannot provide any more data.
* <p>
* The boundary callback might be called multiple times for the same direction so it does its own
* rate limiting using the com.memebattle.pagingwithrepository.domain.util.PagingRequestHelper class.
*/
class SubredditBoundaryCallback(
private val subredditName: String,
private val webservice: RedditApi,
private val handleResponse: (String, RedditApi.ListingResponse?) -> Unit,
private val ioExecutor: Executor,
private val networkPageSize: Int)
: PagedList.BoundaryCallback<RedditPost>() {
val helper = PagingRequestHelper(ioExecutor)
val networkState = helper.createStatusLiveData()
/**
* Database returned 0 items. We should query the backend for more items.
*/
@MainThread
override fun onZeroItemsLoaded() {
helper.runIfNotRunning(PagingRequestHelper.RequestType.INITIAL) {
webservice.getTop(
subreddit = subredditName,
limit = networkPageSize)
.enqueue(createWebserviceCallback(it))
}
}
/**
* User reached to the end of the list.
*/
@MainThread
override fun onItemAtEndLoaded(itemAtEnd: RedditPost) {
helper.runIfNotRunning(PagingRequestHelper.RequestType.AFTER) {
webservice.getTopAfter(
subreddit = subredditName,
after = itemAtEnd.name,
limit = networkPageSize)
.enqueue(createWebserviceCallback(it))
}
}
/**
* every time it gets new items, boundary callback simply inserts them into the database and
* paging library takes care of refreshing the list if necessary.
*/
private fun insertItemsIntoDb(
response: Response<RedditApi.ListingResponse>,
it: PagingRequestHelper.Request.Callback) {
ioExecutor.execute {
handleResponse(subredditName, response.body())
it.recordSuccess()
}
}
override fun onItemAtFrontLoaded(itemAtFront: RedditPost) {
// ignored, since we only ever append to what's in the DB
}
private fun createWebserviceCallback(it: PagingRequestHelper.Request.Callback)
: Callback<RedditApi.ListingResponse> {
return object : Callback<RedditApi.ListingResponse> {
override fun onFailure(call: Call<RedditApi.ListingResponse>, t: Throwable) {
it.recordFailure(t)
}
override fun onResponse(
call: Call<RedditApi.ListingResponse>,
response: Response<RedditApi.ListingResponse>) {
insertItemsIntoDb(response, it)
}
}
}
}
В классе, который наследует BoundaryCallback есть несколько обязательных методов:
override fun onZeroItemsLoaded()
Метод вызывается, когда БД пуста, здесь мы должны выполнить запрос на сервер для получения первой страницы.
override fun onItemAtEndLoaded(itemAtEnd: RedditPost)
Метод вызывается, когда “итератор” дошел до “дна” БД, здесь мы должны выполнить запрос на сервер для получения следующей страницы, передав ключ, с помощью которого сервер выдаст данные, следующие сразу за последней записью локального стора.
override fun onItemAtFrontLoaded(itemAtFront: RedditPost)
Метод вызывается, когда “итератор” дошел до первого элемента нашего стора. Для реализации нашего кейса можем проигнорировать реализацию этого метода.
Дописываем колбэк для получения данных и передачи их дальше
fun createWebserviceCallback(it: PagingRequestHelper.Request.Callback)
: Callback<RedditApi.ListingResponse>
Дописываем метод записи полученных данных в БД
insertItemsIntoDb(
response: Response<RedditApi.ListingResponse>,
it: PagingRequestHelper.Request.Callback)
Что за хэлпер PagingRequestHelper? Это ЗДОРОВЕННЫЙ класс, который нам любезно предоставил Google и предлагает вынести его в библиотеку, но мы просто скопируем его в пакет слоя логики.
package com.memebattle.pagingwithrepository.domain.util;/*
* Copyright 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import java.util.Arrays;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicBoolean;
import androidx.annotation.AnyThread;
import androidx.annotation.GuardedBy;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.paging.DataSource;
/**
* A helper class for {@link androidx.paging.PagedList.BoundaryCallback BoundaryCallback}s and
* {@link DataSource}s to help with tracking network requests.
* <p>
* It is designed to support 3 types of requests, {@link RequestType#INITIAL INITIAL},
* {@link RequestType#BEFORE BEFORE} and {@link RequestType#AFTER AFTER} and runs only 1 request
* for each of them via {@link #runIfNotRunning(RequestType, Request)}.
* <p>
* It tracks a {@link Status} and an {@code error} for each {@link RequestType}.
* <p>
* A sample usage of this class to limit requests looks like this:
* <pre>
* class PagingBoundaryCallback extends PagedList.BoundaryCallback<MyItem> {
* // TODO replace with an executor from your application
* Executor executor = Executors.newSingleThreadExecutor();
* com.memebattle.pagingwithrepository.domain.util.PagingRequestHelper helper = new com.memebattle.pagingwithrepository.domain.util.PagingRequestHelper(executor);
* // imaginary API service, using Retrofit
* MyApi api;
*
* {@literal @}Override
* public void onItemAtFrontLoaded({@literal @}NonNull MyItem itemAtFront) {
* helper.runIfNotRunning(com.memebattle.pagingwithrepository.domain.util.PagingRequestHelper.RequestType.BEFORE,
* helperCallback -> api.getTopBefore(itemAtFront.getName(), 10).enqueue(
* new Callback<ApiResponse>() {
* {@literal @}Override
* public void onResponse(Call<ApiResponse> call,
* Response<ApiResponse> response) {
* // TODO insert new records into database
* helperCallback.recordSuccess();
* }
*
* {@literal @}Override
* public void onFailure(Call<ApiResponse> call, Throwable t) {
* helperCallback.recordFailure(t);
* }
* }));
* }
*
* {@literal @}Override
* public void onItemAtEndLoaded({@literal @}NonNull MyItem itemAtEnd) {
* helper.runIfNotRunning(com.memebattle.pagingwithrepository.domain.util.PagingRequestHelper.RequestType.AFTER,
* helperCallback -> api.getTopBefore(itemAtEnd.getName(), 10).enqueue(
* new Callback<ApiResponse>() {
* {@literal @}Override
* public void onResponse(Call<ApiResponse> call,
* Response<ApiResponse> response) {
* // TODO insert new records into database
* helperCallback.recordSuccess();
* }
*
* {@literal @}Override
* public void onFailure(Call<ApiResponse> call, Throwable t) {
* helperCallback.recordFailure(t);
* }
* }));
* }
* }
* </pre>
* <p>
* The helper provides an API to observe combined request status, which can be reported back to the
* application based on your business rules.
* <pre>
* MutableLiveData<com.memebattle.pagingwithrepository.domain.util.PagingRequestHelper.Status> combined = new MutableLiveData<>();
* helper.addListener(status -> {
* // merge multiple states per request type into one, or dispatch separately depending on
* // your application logic.
* if (status.hasRunning()) {
* combined.postValue(com.memebattle.pagingwithrepository.domain.util.PagingRequestHelper.Status.RUNNING);
* } else if (status.hasError()) {
* // can also obtain the error via {@link StatusReport#getErrorFor(RequestType)}
* combined.postValue(com.memebattle.pagingwithrepository.domain.util.PagingRequestHelper.Status.FAILED);
* } else {
* combined.postValue(com.memebattle.pagingwithrepository.domain.util.PagingRequestHelper.Status.SUCCESS);
* }
* });
* </pre>
*/
// 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[]
{new RequestQueue(RequestType.INITIAL),
new RequestQueue(RequestType.BEFORE),
new RequestQueue(RequestType.AFTER)};
@NonNull
final CopyOnWriteArrayList<Listener> 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.
* <p>
* 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 < RequestType.values().length; i++) {
toBeRetried[i] = mRequestQueues[i].mFailed;
mRequestQueues[i].mFailed = null;
}
}
for (RequestWrapper failed : toBeRetried) {
if (failed != null) {
failed.retry(mRetryService);
retried = true;
}
}
return retried;
}
static class RequestWrapper implements Runnable {
@NonNull
final Request mRequest;
@NonNull
final PagingRequestHelper mHelper;
@NonNull
final RequestType mType;
RequestWrapper(@NonNull Request request, @NonNull PagingRequestHelper helper,
@NonNull RequestType type) {
mRequest = request;
mHelper = helper;
mType = type;
}
@Override
public void run() {
mRequest.run(new Request.Callback(this, mHelper));
}
void retry(Executor service) {
service.execute(new Runnable() {
@Override
public void run() {
mHelper.runIfNotRunning(mType, mRequest);
}
});
}
}
/**
* Runner class that runs a request tracked by the {@link PagingRequestHelper}.
* <p>
* When a request is invoked, it must call one of {@link Callback#recordFailure(Throwable)}
* or {@link Callback#recordSuccess()} once and only once. This call
* can be made any time. Until that method call is made, {@link PagingRequestHelper} will
* consider the request is running.
*/
@FunctionalInterface
public interface Request {
/**
* Should run the request and call the given {@link Callback} with the result of the
* request.
*
* @param callback The callback that should be invoked with the result.
*/
void run(Callback callback);
/**
* Callback class provided to the {@link #run(Callback)} method to report the result.
*/
class Callback {
private final AtomicBoolean mCalled = new AtomicBoolean();
private final RequestWrapper mWrapper;
private final PagingRequestHelper mHelper;
Callback(RequestWrapper wrapper, PagingRequestHelper helper) {
mWrapper = wrapper;
mHelper = helper;
}
/**
* Call this method when the request succeeds and new data is fetched.
*/
@SuppressWarnings("unused")
public final void recordSuccess() {
if (mCalled.compareAndSet(false, true)) {
mHelper.recordResult(mWrapper, null);
} else {
throw new IllegalStateException(
"already called recordSuccess or recordFailure");
}
}
/**
* Call this method with the failure message and the request can be retried via
* {@link #retryAllFailed()}.
*
* @param throwable The error that occured while carrying out the request.
*/
@SuppressWarnings("unused")
public final void recordFailure(@NonNull Throwable throwable) {
//noinspection ConstantConditions
if (throwable == null) {
throw new IllegalArgumentException("You must provide a throwable describing"
+ " the error to record the failure");
}
if (mCalled.compareAndSet(false, true)) {
mHelper.recordResult(mWrapper, throwable);
} else {
throw new IllegalStateException(
"already called recordSuccess or recordFailure");
}
}
}
}
/**
* Data class that holds the information about the current status of the ongoing requests
* using this helper.
*/
public static final class StatusReport {
/**
* Status of the latest request that were submitted with {@link RequestType#INITIAL}.
*/
@NonNull
public final Status initial;
/**
* Status of the latest request that were submitted with {@link RequestType#BEFORE}.
*/
@NonNull
public final Status before;
/**
* Status of the latest request that were submitted with {@link RequestType#AFTER}.
*/
@NonNull
public final Status after;
@NonNull
private final Throwable[] mErrors;
StatusReport(@NonNull Status initial, @NonNull Status before, @NonNull Status after,
@NonNull Throwable[] errors) {
this.initial = initial;
this.before = before;
this.after = after;
this.mErrors = errors;
}
/**
* Convenience method to check if there are any running requests.
*
* @return True if there are any running requests, false otherwise.
*/
public boolean hasRunning() {
return initial == Status.RUNNING
|| before == Status.RUNNING
|| after == Status.RUNNING;
}
/**
* Convenience method to check if there are any requests that resulted in an error.
*
* @return True if there are any requests that finished with error, false otherwise.
*/
public boolean hasError() {
return initial == Status.FAILED
|| before == Status.FAILED
|| after == Status.FAILED;
}
/**
* Returns the error for the given request type.
*
* @param type The request type for which the error should be returned.
* @return The {@link Throwable} returned by the failing request with the given type or
* {@code null} if the request for the given type did not fail.
*/
@Nullable
public Throwable getErrorFor(@NonNull RequestType type) {
return mErrors[type.ordinal()];
}
@Override
public String toString() {
return "StatusReport{"
+ "initial=" + initial
+ ", before=" + before
+ ", after=" + after
+ ", mErrors=" + Arrays.toString(mErrors)
+ '}';
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
StatusReport that = (StatusReport) o;
if (initial != that.initial) return false;
if (before != that.before) return false;
if (after != that.after) return false;
// Probably incorrect - comparing Object[] arrays with Arrays.equals
return Arrays.equals(mErrors, that.mErrors);
}
@Override
public int hashCode() {
int result = initial.hashCode();
result = 31 * result + before.hashCode();
result = 31 * result + after.hashCode();
result = 31 * result + Arrays.hashCode(mErrors);
return result;
}
}
/**
* Listener interface to get notified by request status changes.
*/
public interface Listener {
/**
* Called when the status for any of the requests has changed.
*
* @param report The current status report that has all the information about the requests.
*/
void onStatusChange(@NonNull StatusReport report);
}
/**
* Represents the status of a Request for each {@link RequestType}.
*/
public enum Status {
/**
* There is current a running request.
*/
RUNNING,
/**
* The last request has succeeded or no such requests have ever been run.
*/
SUCCESS,
/**
* The last request has failed.
*/
FAILED
}
/**
* Available request types.
*/
public enum RequestType {
/**
* Corresponds to an initial request made to a {@link DataSource} or the empty state for
* a {@link androidx.paging.PagedList.BoundaryCallback BoundaryCallback}.
*/
INITIAL,
/**
* Corresponds to the {@code loadBefore} calls in {@link DataSource} or
* {@code onItemAtFrontLoaded} in
* {@link androidx.paging.PagedList.BoundaryCallback BoundaryCallback}.
*/
BEFORE,
/**
* Corresponds to the {@code loadAfter} calls in {@link DataSource} or
* {@code onItemAtEndLoaded} in
* {@link androidx.paging.PagedList.BoundaryCallback BoundaryCallback}.
*/
AFTER
}
class RequestQueue {
@NonNull
final RequestType mRequestType;
@Nullable
RequestWrapper mFailed;
@Nullable
Request mRunning;
@Nullable
Throwable mLastError;
@NonNull
Status mStatus = Status.SUCCESS;
RequestQueue(@NonNull RequestType requestType) {
mRequestType = requestType;
}
}
}
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.memebattle.pagingwithrepository.domain.util.PagingRequestHelper
private fun getErrorMessage(report: PagingRequestHelper.StatusReport): String {
return PagingRequestHelper.RequestType.values().mapNotNull {
report.getErrorFor(it)?.message
}.first()
}
fun PagingRequestHelper.createStatusLiveData(): LiveData<NetworkState> {
val liveData = MutableLiveData<NetworkState>()
addListener { report ->
when {
report.hasRunning() -> liveData.postValue(NetworkState.LOADING)
report.hasError() -> liveData.postValue(
NetworkState.error(getErrorMessage(report)))
else -> liveData.postValue(NetworkState.LOADED)
}
}
return liveData
}
Со слоем бизнес логики закончили, можем переходить к реализации представления.
В слое представления у нас новая MVVM от Google на ViewModel и LiveData.
import android.os.Bundle
import android.view.KeyEvent
import android.view.inputmethod.EditorInfo
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProviders
import androidx.paging.PagedList
import com.memebattle.pagingwithrepository.R
import com.memebattle.pagingwithrepository.domain.model.RedditPost
import com.memebattle.pagingwithrepository.domain.repository.MainRepository
import com.memebattle.pagingwithrepository.domain.repository.network.NetworkState
import com.memebattle.pagingwithrepository.presentation.recycler.PostsAdapter
import kotlinx.android.synthetic.main.activity_main.*
class MainActivity : AppCompatActivity() {
companion object {
const val KEY_SUBREDDIT = "subreddit"
const val DEFAULT_SUBREDDIT = "androiddev"
}
lateinit var model: MainViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
model = getViewModel()
initAdapter()
initSwipeToRefresh()
initSearch()
val subreddit = savedInstanceState?.getString(KEY_SUBREDDIT) ?: DEFAULT_SUBREDDIT
model.showSubReddit(subreddit)
}
private fun getViewModel(): MainViewModel {
return ViewModelProviders.of(this, object : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
val repo = MainRepository(this@MainActivity)
@Suppress("UNCHECKED_CAST")
return MainViewModel(repo) as T
}
})[MainViewModel::class.java]
}
private fun initAdapter() {
val adapter = PostsAdapter {
model.retry()
}
list.adapter = adapter
model.posts.observe(this, Observer<PagedList<RedditPost>> {
adapter.submitList(it)
})
model.networkState.observe(this, Observer {
adapter.setNetworkState(it)
})
}
private fun initSwipeToRefresh() {
model.refreshState.observe(this, Observer {
swipe_refresh.isRefreshing = it == NetworkState.LOADING
})
swipe_refresh.setOnRefreshListener {
model.refresh()
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putString(KEY_SUBREDDIT, model.currentSubreddit())
}
private fun initSearch() {
input.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_GO) {
updatedSubredditFromInput()
true
} else {
false
}
}
input.setOnKeyListener { _, keyCode, event ->
if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_ENTER) {
updatedSubredditFromInput()
true
} else {
false
}
}
}
private fun updatedSubredditFromInput() {
input.text.trim().toString().let {
if (it.isNotEmpty()) {
if (model.showSubReddit(it)) {
list.scrollToPosition(0)
(list.adapter as? PostsAdapter)?.submitList(null)
}
}
}
}
}
В методе onCreate инициализируем ViewModel, адаптер списка, подписываемся на изменение названия подписки и вызываем через модель запуск работы репозитория.
Если вы не знакомы с механизмами LiveData и ViewModel, то рекомендую ознакомиться с уроками.
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import com.memebattle.pagingwithrepository.domain.repository.core.RedditPostRepository
class MainViewModel(private val repository: RedditPostRepository) : ViewModel() {
private val subredditName = MutableLiveData<String>()
private val repoResult = Transformations.map(subredditName) {
repository.postsOfSubreddit(it, 10)
}
val posts = Transformations.switchMap(repoResult) { it.pagedList }!!
val networkState = Transformations.switchMap(repoResult) { it.networkState }!!
val refreshState = Transformations.switchMap(repoResult) { it.refreshState }!!
fun refresh() {
repoResult.value?.refresh?.invoke()
}
fun showSubReddit(subreddit: String): Boolean {
if (subredditName.value == subreddit) {
return false
}
subredditName.value = subreddit
return true
}
fun retry() {
val listing = repoResult?.value
listing?.retry?.invoke()
}
fun currentSubreddit(): String? = subredditName.value
}
В модели реализуем методы, которые будут дергать методы репозитория: retry и refesh.
Адаптер списка будет наследовать PagedListAdapter. Тут все также как и работе с пагинацией и одним источником данных.
import androidx.paging.PagedListAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import android.view.ViewGroup
import com.memebattle.pagingwithrepository.R
import com.memebattle.pagingwithrepository.domain.model.RedditPost
import com.memebattle.pagingwithrepository.domain.repository.network.NetworkState
import com.memebattle.pagingwithrepository.presentation.recycler.viewholder.NetworkStateItemViewHolder
import com.memebattle.pagingwithrepository.presentation.recycler.viewholder.RedditPostViewHolder
/**
* A simple adapter implementation that shows Reddit posts.
*/
class PostsAdapter(
private val retryCallback: () -> Unit)
: PagedListAdapter<RedditPost, RecyclerView.ViewHolder>(POST_COMPARATOR) {
private var networkState: NetworkState? = null
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (getItemViewType(position)) {
R.layout.reddit_post_item -> (holder as RedditPostViewHolder).bind(getItem(position))
R.layout.network_state_item -> (holder as NetworkStateItemViewHolder).bindTo(
networkState)
}
}
override fun onBindViewHolder(
holder: RecyclerView.ViewHolder,
position: Int,
payloads: MutableList<Any>) {
if (payloads.isNotEmpty()) {
val item = getItem(position)
(holder as RedditPostViewHolder).updateScore(item)
} else {
onBindViewHolder(holder, position)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
R.layout.reddit_post_item -> RedditPostViewHolder.create(parent)
R.layout.network_state_item -> NetworkStateItemViewHolder.create(parent, retryCallback)
else -> throw IllegalArgumentException("unknown view type $viewType")
}
}
private fun hasExtraRow() = networkState != null && networkState != NetworkState.LOADED
override fun getItemViewType(position: Int): Int {
return if (hasExtraRow() && position == itemCount - 1) {
R.layout.network_state_item
} else {
R.layout.reddit_post_item
}
}
override fun getItemCount(): Int {
return super.getItemCount() + if (hasExtraRow()) 1 else 0
}
fun setNetworkState(newNetworkState: NetworkState?) {
val previousState = this.networkState
val hadExtraRow = hasExtraRow()
this.networkState = newNetworkState
val hasExtraRow = hasExtraRow()
if (hadExtraRow != hasExtraRow) {
if (hadExtraRow) {
notifyItemRemoved(super.getItemCount())
} else {
notifyItemInserted(super.getItemCount())
}
} else if (hasExtraRow && previousState != newNetworkState) {
notifyItemChanged(itemCount - 1)
}
}
companion object {
private val PAYLOAD_SCORE = Any()
val POST_COMPARATOR = object : DiffUtil.ItemCallback<RedditPost>() {
override fun areContentsTheSame(oldItem: RedditPost, newItem: RedditPost): Boolean =
oldItem == newItem
override fun areItemsTheSame(oldItem: RedditPost, newItem: RedditPost): Boolean =
oldItem.name == newItem.name
override fun getChangePayload(oldItem: RedditPost, newItem: RedditPost): Any? {
return if (sameExceptScore(oldItem, newItem)) {
PAYLOAD_SCORE
} else {
null
}
}
}
private fun sameExceptScore(oldItem: RedditPost, newItem: RedditPost): Boolean {
// DON'T do this copy in a real app, it is just convenient here for the demo :)
// because reddit randomizes scores, we want to pass it as a payload to minimize
// UI updates between refreshes
return oldItem.copy(score = newItem.score) == newItem
}
}
}
И все те же ViewHolder ы для отображения записи и итема состояния загрузки данных из сети.
import android.content.Intent
import android.net.Uri
import androidx.recyclerview.widget.RecyclerView
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import com.memebattle.pagingwithrepository.R
import com.memebattle.pagingwithrepository.domain.model.RedditPost
/**
* A RecyclerView ViewHolder that displays a reddit post.
*/
class RedditPostViewHolder(view: View)
: RecyclerView.ViewHolder(view) {
private val title: TextView = view.findViewById(R.id.title)
private val subtitle: TextView = view.findViewById(R.id.subtitle)
private val score: TextView = view.findViewById(R.id.score)
private var post : RedditPost? = null
init {
view.setOnClickListener {
post?.url?.let { url ->
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
view.context.startActivity(intent)
}
}
}
fun bind(post: RedditPost?) {
this.post = post
title.text = post?.title ?: "loading"
subtitle.text = itemView.context.resources.getString(R.string.post_subtitle,
post?.author ?: "unknown")
score.text = "${post?.score ?: 0}"
}
companion object {
fun create(parent: ViewGroup): RedditPostViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.reddit_post_item, parent, false)
return RedditPostViewHolder(view)
}
}
fun updateScore(item: RedditPost?) {
post = item
score.text = "${item?.score ?: 0}"
}
}
import androidx.recyclerview.widget.RecyclerView
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.ProgressBar
import android.widget.TextView
import com.memebattle.pagingwithrepository.R
import com.memebattle.pagingwithrepository.domain.repository.network.NetworkState
import com.memebattle.pagingwithrepository.domain.repository.network.Status
/**
* A View Holder that can display a loading or have click action.
* It is used to show the network state of paging.
*/
class NetworkStateItemViewHolder(view: View,
private val retryCallback: () -> Unit)
: RecyclerView.ViewHolder(view) {
private val progressBar = view.findViewById<ProgressBar>(R.id.progress_bar)
private val retry = view.findViewById<Button>(R.id.retry_button)
private val errorMsg = view.findViewById<TextView>(R.id.error_msg)
init {
retry.setOnClickListener {
retryCallback()
}
}
fun bindTo(networkState: NetworkState?) {
progressBar.visibility = toVisibility(networkState?.status == Status.RUNNING)
retry.visibility = toVisibility(networkState?.status == Status.FAILED)
errorMsg.visibility = toVisibility(networkState?.msg != null)
errorMsg.text = networkState?.msg
}
companion object {
fun create(parent: ViewGroup, retryCallback: () -> Unit): NetworkStateItemViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.network_state_item, parent, false)
return NetworkStateItemViewHolder(view, retryCallback)
}
fun toVisibility(constraint : Boolean): Int {
return if (constraint) {
View.VISIBLE
} else {
View.GONE
}
}
}
}
Если мы запустим приложение, то можем увидеть прогресс бар, а затем и данные с Reddit по запросу androiddev. Если отключим сеть и долистаем до конца нашего списка, то будет сообщение об ошибке и предложение попытаться загрузить данные снова.
Все работает, супер!
И мой репозиторий, где я постарался немного упростить пример от Google.
На этом все. Если вы знаете другие способы как “закэшировать” пагинацию, то обязательно напишите в комменты.
Всем хорошего кода!
Комментарии (4)
MrSwimmer Автор
29.11.2018 16:48Если кто собрался использовать у себя на проекте такой подход, то я запилил библиотеку github.com/MrSwimmer/Pwc чтобы не переписывать/копировать хэлперы.
oleg_shishkin
Пример github.com/shishkin1966/microservice/tree/master/app/src/main/java/microservices/shishkin/example/screen/fragment/paged_load
С возможностью задания любых источников данных/любой стратегии, определяющей размеры начальной и последующих страниц — по умолчанию выбирается 20/40/80 записей. Для быстрого скрола по картам (сетевые запросы) оптимально 5/10/20 записей. Для больших БД в режиме не оптимизированного поиска 5/20/80. И пр. и пр.