Розробка

Кешуємо пагінацію в 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&lt;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&lt;ApiResponse>() {
 * {@literal @}Override
 * public void onResponse(Call&lt;ApiResponse> call,
 * Response&lt;ApiResponse> response) {
 * // TODO insert new records into database
 * helperCallback.recordSuccess();
 * }
*
 * {@literal @}Override
 * public void onFailure(Call&lt;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&lt;ApiResponse>() {
 * {@literal @}Override
 * public void onResponse(Call&lt;ApiResponse> call,
 * Response&lt;ApiResponse> response) {
 * // TODO insert new records into database
 * helperCallback.recordSuccess();
 * }
*
 * {@literal @}Override
 * public void onFailure(Call&lt;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&lt;com.memebattle.pagingwithrepository.domain.util.PagingRequestHelper.Status> combined = new MutableLiveData&lt;>();
 * 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([email protected])
@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.

 

На цьому все. Якщо ви знаєте інші способи як “закешувати” пагінацію, то обов’язково напишіть в коменти.

 

Всім гарного коду!

Related Articles

Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *

Close