Cache pagination in Android

Surely every Android developer worked with lists using RecyclerView. And also many managed to see how to organize pagination in the list using the Paging Library from the Android Architecture Components.


It's simple: set the PositionalDataSource, set the configs, create a PagedList and feed it all together with the adapter and DiffUtilCallback to our RecyclerView.


But what if we have multiple data sources? For example, we want to have a cache in the Room and receive data from the network.


The case turns out to be quite custom and there is not a lot of information on this topic on the Internet. I will try to fix it and show how this case can be solved.


image


If you are still not familiar with the implementation of pagination with one data source, then I advise you to read this before reading the article.


What a solution without pagination would look like:


  • Access to the cache (in our case it is a DB)
  • If the cache is empty - sending a request to the server
  • We receive data from the server
  • We display them in the sheet
  • Write to cache
  • If there is a cache, we display it in the list.
  • We receive actual data from the server
  • Display them in the list ○
  • Write to cache

image


Such a handy thing as pagination, which simplifies the life of users, here it complicates us. Let's try to imagine what problems may arise when implementing a paged list with multiple data sources.


The algorithm is about the following:


  • Get data from the cache for the first page.
  • If the cache is empty, we receive server data, display them in the list and write to the database
  • If there is a cache, we load it into the list.
  • If we reach the end of the database, then we request data from the server, display them
  • in the list and write to the database

From the features of this approach, it can be noted that to display the list, the cache is first polled, and the signal to load new data is the end of the cache.


image


Google thought about it and created a solution that comes out of the PagingLibrary box - BoundaryCallback.


BoundaryCallback reports when the local data source “runs out” and notifies the repository to download new data.


image


On Android Dev official website has a link to the repository with the example of a project that uses a list with pagination with two data sources: Network (Retrofit 2) + Database (Room). In order to better understand how such a system works, let's try to make out this example, let's simplify it a little.


Let's start with the data layer. Let's create two DataSource.


Interface 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)
}

This interface describes Reddit API requests and model classes (ListingResponse, ListingData, RedditChildrenResponse) in which objects the API responses will be minimized.


And immediately make a model for Retrofit and 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
}

The RedditDb.kt class, which will inherit 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
}

Remember that it is very expensive to create the RoomDatabase class every time to execute a query to the database, so create it once in a real case for the entire lifetime of the application!


And Dao class with queries to the RedditPostDao.kt database


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
}

You may have noticed that the postsBySubreddit posting method returns a
DataSource.Factory. This is necessary to create our PagedList using
LivePagedListBuilder in the background thread. You can read more about this in the
lesson .


Ok, data layer is ready. We turn to the business logic layer. In order to implement the “Repository” pattern, it is customary to create a repository interface separately from its implementation. Therefore, we will create the RedditPostRepository.kt interface.


RedditPostRepository.kt
interface RedditPostRepository {
    fun postsOfSubreddit(subReddit: String, pageSize: Int): Listing<RedditPost>
}

And immediately the question - what kind of Listing? This is the date class required to display the list.


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 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)

Create the implementation of the Repository 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 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
        )
    }
}

Let's see what happens in our repository.


We create our datasor instances and data access interfaces. For the database:


RoomDatabase and Dao, for the network: Retrofit and interface api.


Next, we implement the required repository method.


fun postsOfSubreddit(subReddit: String, pageSize: Int): Listing<RedditPost>

which adjusts pagination:


  • Create a SubRedditBoundaryCallback inheriting PagedList.BoundaryCallback <>
  • Use the constructor with the parameters and pass everything you need to work BoundaryCallback
  • Create a refreshTrigger trigger to notify the repository to refresh the data.
  • Create and return a Listing object.

In the Listing object:


  • livePagedList
  • networkState - network status
  • retry - callback to call to re-receive data from the server
  • refresh - update trigger
  • refreshState - the status of the refresh process

We implement an auxiliary method


private fun insertResultIntoDb(subredditName: String, body: RedditApi.ListingResponse?)

to record the network response in the database. It will be used when you need to update the list or record a new piece of data.


We implement an auxiliary method


private fun refresh(subredditName: String): LiveData<NetworkState>

for trigger update data. Everything is quite simple here: we receive data from the server, clean the database, write new data to the database.


Dealt with a repository. Now let's take a closer look at 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 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)
            }
        }
    }
}

In the class that inherits BoundaryCallback there are several required methods:


override fun onZeroItemsLoaded()

The method is called when the database is empty; here we have to execute a request to the server to get the first page.


override fun onItemAtEndLoaded(itemAtEnd: RedditPost)

The method is called when the “iterator” has reached the “bottom” of the database, here we have to perform a request to the server to get the next page, passing the key with which the server will issue the data immediately following the last record of the local storage.


override fun onItemAtFrontLoaded(itemAtFront: RedditPost)

The method is called when the “iterator” has reached the first element of our story. To implement our case, we can ignore the implementation of this method.


We add callback to receive data and transfer them further


fun createWebserviceCallback(it: PagingRequestHelper.Request.Callback)
       : Callback<RedditApi.ListingResponse>

We add the method of recording the received data in the database


insertItemsIntoDb(
       response: Response<RedditApi.ListingResponse>,
       it: PagingRequestHelper.Request.Callback)

What kind of helper is PagingRequestHelper? This is the HEALTHY class that Google has kindly provided for us and offers to bring it to the library, but we will just copy it into a packet of logic layer.


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 {@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&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 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;
        }
    }
}

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
}

With the business logic layer finished, we can proceed to the implementation of the presentation.
In the presentation layer we have new MVVM from Google on ViewModel and 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)
               }
           }
       }
   }
}

In the onCreate method, we initialize the ViewModel, the list adapter, subscribe to the change of the subscription name and call the launch of the repository through the model.


If you are not familiar with the mechanisms LiveData and ViewModel, I recommend to get acquainted with the lessons .


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
}

In the model, we implement methods that will pull the repository methods: retry and refesh.


The list adapter will inherit the PagedListAdapter. It's all the same as working with pagination and a single data source.


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
        }
    }
}

And all the same ViewHolder s to display the record and the item of the status of downloading data from the network.


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
/**
 * 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
            }
        }
    }
}

If we run the application, we can see the progress bar, and then the data from Reddit on request androiddev. If we turn off the network and browse to the end of our list, there will be an error message and a suggestion to try to load the data again.


image


Everything works, super!


And my repository , where I tried to simplify a little example from Google.


That's all. If you know other ways to “cache” pagination, be sure to write in the comments.


All good code!


Also popular now: