Кешуємо пагінацію в 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.
Інтерфейс RedditApi.kt
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
RedditPost.kt
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.
RedditDb.kt
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
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 posts FROM WHERE subreddit = :subreddit")
fun getNextIndexInSubreddit(subreddit: String) : Int
}
Ви напевно помітили, що метод отримання записів postsBySubreddit повертає
DataSource.Factory. Це необхідно для створення нашого PagedList, використовуючи
LivePagedListBuilder, у фоновому потоці. Докладніше про це ви можете почитати в
уроці.
Відмінно, шар data готовий. Переходимо до шару бізнес логіки.Для реалізації патерну “Репозиторій” прийнято створювати інтерфейс репозиторію окремо від його реалізації. Тому створимо інтерфейс RedditPostRepository.kt
RedditPostRepository.kt
interface RedditPostRepository {
fun postsOfSubreddit(subReddit: String, pageSize: Int): Listing<RedditPost>
}
І відразу питання — що за Listing? Це дата, клас, необхідний для відображення списку.
Listing.kt
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 status to request to show 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
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 by request 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.
SubredditBoundaryCallback.kt
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 it does so 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 і пропонує винести його в бібліотеку, але ми просто скопіювати його в пакет шару логіки.
PagingRequestHelper.kt
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 {@error code} 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 that The error 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 out 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 or type
* {@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 a current 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 for state
* 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;
}
}
}
PagingRequestHelperExt.kt
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.
MainActivity.kt
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, то рекомендую ознайомитися з уроками.
MainViewModel.kt
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. Тут все також як і роботі з пагінація і одним джерелом даних.
PostAdapter.kt
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 и для відображення запису і итема стану завантаження даних з мережі.
RedditPostViewHolder.kt
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}"
}
}
NetworkStateItemViewHolder.kt
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
/**
* Holder A View 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.
На цьому все. Якщо ви знаєте інші способи як “закешувати” пагінацію, то обов’язково напишіть в коменти.
Всім гарного коду!