Кэшируем пагинацию в Android

Наверняка каждый Android разработчик работал со списками, используя RecyclerView. А также многие успели посмотреть как организовать пагинацию в списке, используя Paging Library из Android Architecture Components.


Все просто: устанавливаем PositionalDataSource, задаем конфиги, создаем PagedList и скармливаем все это вместе с адаптером и DiffUtilCallback нашему RecyclerView.


Но что если у нас несколько источников данных? Например, мы хотим иметь кэш в Room и получать данные из сети.


Кейс получается довольно кастомный и в интернете не так уж много информации на эту тему. Я постараюсь это исправить и показать как можно решить такой кейс.


image


Если вы все еще не знакомы с реализацией пагинации с одним источником данных, то советую перед чтением статьи ознакомиться с этим.


Как бы выглядело решение без пагинации:


  • Обращение к кэшу (в нашем случае это БД)
  • Если кэш пуст — отправка запроса на сервер
  • Получаем данные с сервера
  • Отображаем их в листе
  • Пишем в кэш
  • Если кэш имеется — отображаем его в списке
  • Получаем актуальные данные с сервера
  • Отображаем их в списке○
  • Пишем в кэш

image


Такая удобная штука как пагинация, которая упрощает жизнь пользователям, тут нам ее усложняет. Давайте попробуем представить какие проблемы могут возникнуть при реализации пагинируемого списка с несколькими источниками данных.


Алгоритм примерно следующий:


  • Получаем данные из кэша для первой страницы
  • Если кэш пуст — получаем данные сервера, отображаем их в списке и пишем в БД
  • Если кэш есть — загружаем его в список
  • Если доходим до конца БД, то запрашиваем данные с сервера, отображаем их
  • в списке и пишем в БД

Из особенностей такого подхода можно заметить, что для отображения списка в первую очередь опрашивается кэш, и сигналом загрузки новых данных является конец кэша.


image


В Google задумались над этим и создали решение, которое идет из коробки PagingLibrary — BoundaryCallback.


BoundaryCallback сообщает когда локальный источник данных “заканчивается” и уведомляет об этом репозиторий для загрузки новых данных.


image


На официальном сайте 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 FROM posts 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 request status to show to the user
        val networkState: LiveData<NetworkState>,
        // represents the refresh status to show to the user. Separate from networkState, this
        // value is importantly only when refresh is requested.
        val refreshState: LiveData<NetworkState>,
        // refreshes the whole data and fetches it from scratch.
        val refresh: () -> Unit,
        // retries any failed requests.
        val retry: () -> Unit)

Создаем реализацию репозитория MainRepository.kt


MainRepository.kt
import android.content.Context
import androidx.annotation.MainThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import com.android.example.paging.pagingwithnetwork.reddit.api.RedditApi
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import androidx.room.Room
import com.android.example.paging.pagingwithnetwork.reddit.db.RedditDb
import com.android.example.paging.pagingwithnetwork.reddit.db.RedditPostDao
import com.memebattle.pagingwithrepository.domain.model.RedditPost
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.util.concurrent.Executors
import androidx.paging.LivePagedListBuilder
import com.memebattle.pagingwithrepository.domain.repository.core.Listing
import com.memebattle.pagingwithrepository.domain.repository.boundary.SubredditBoundaryCallback
import com.memebattle.pagingwithrepository.domain.repository.network.NetworkState
import com.memebattle.pagingwithrepository.domain.repository.core.RedditPostRepository

class MainRepository(context: Context) : RedditPostRepository {

    private var retrofit: Retrofit = Retrofit.Builder()
            .baseUrl("https://www.reddit.com/") //Базовая часть адреса
            .addConverterFactory(GsonConverterFactory.create()) //Конвертер, необходимый для преобразования JSON'а в объекты
            .build()

    var db = Room.databaseBuilder(context,
            RedditDb::class.java, "database").build()

    private var redditApi: RedditApi
    private var dao: RedditPostDao

    val ioExecutor = Executors.newSingleThreadExecutor()

    init {
        redditApi = retrofit.create(RedditApi::class.java) //Создаем объект, при помощи которого будем выполнять запросы
        dao = db.posts()
    }

    /**
     * Inserts the response into the database while also assigning position indices to items.
     */
    private fun insertResultIntoDb(subredditName: String, body: RedditApi.ListingResponse?) {
        body!!.data.children.let { posts ->
            db.runInTransaction {
                val start = db.posts().getNextIndexInSubreddit(subredditName)
                val items = posts.mapIndexed { index, child ->
                    child.data.indexInResponse = start + index
                    child.data
                }
                db.posts().insert(items)
            }
        }
    }

    /**
     * When refresh is called, we simply run a fresh network request and when it arrives, clear
     * the database table and insert all new items in a transaction.
     * <p>
     * Since the PagedList already uses a database bound data source, it will automatically be
     * updated after the database transaction is finished.
     */
    @MainThread
    private fun refresh(subredditName: String): LiveData<NetworkState> {
        val networkState = MutableLiveData<NetworkState>()
        networkState.value = NetworkState.LOADING
        redditApi.getTop(subredditName, 10).enqueue(
                object : Callback<RedditApi.ListingResponse> {
                    override fun onFailure(call: Call<RedditApi.ListingResponse>, t: Throwable) {
                        // retrofit calls this on main thread so safe to call set value
                        networkState.value = NetworkState.error(t.message)
                    }

                    override fun onResponse(call: Call<RedditApi.ListingResponse>, response: Response<RedditApi.ListingResponse>) {
                        ioExecutor.execute {
                            db.runInTransaction {
                                db.posts().deleteBySubreddit(subredditName)
                                insertResultIntoDb(subredditName, response.body())
                            }
                            // since we are in bg thread now, post the result.
                            networkState.postValue(NetworkState.LOADED)
                        }
                    }
                }
        )
        return networkState
    }

    /**
     * Returns a Listing for the given subreddit.
     */
    override fun postsOfSubreddit(subReddit: String, pageSize: Int): Listing<RedditPost> {
        // create a boundary callback which will observe when the user reaches to the edges of
        // the list and update the database with extra data.
        val boundaryCallback = SubredditBoundaryCallback(
                webservice = redditApi,
                subredditName = subReddit,
                handleResponse = this::insertResultIntoDb,
                ioExecutor = ioExecutor,
                networkPageSize = pageSize)
        // we are using a mutable live data to trigger refresh requests which eventually calls
        // refresh method and gets a new live data. Each refresh request by the user becomes a newly
        // dispatched data in refreshTrigger
        val refreshTrigger = MutableLiveData<Unit>()
        val refreshState = Transformations.switchMap(refreshTrigger) {
            refresh(subReddit)
        }
        // We use toLiveData Kotlin extension function here, you could also use LivePagedListBuilder
        val livePagedList = LivePagedListBuilder(db.posts().postsBySubreddit(subReddit), pageSize)
                .setBoundaryCallback(boundaryCallback)
                .build()

        return Listing(
                pagedList = livePagedList,
                networkState = boundaryCallback.networkState,
                retry = {
                    boundaryCallback.helper.retryAllFailed()
                },
                refresh = {
                    refreshTrigger.value = null
                },
                refreshState = refreshState
        )
    }
}

Давайте посмотрим что происходит в нашем репозитории.


Создаем инстансы наших датасорсов и интерфейсы доступа к данным. Для базы данных:


RoomDatabase и Dao, для сети: Retrofit и интерфейс апи.


Далее реализуем обязательный метод репозитория


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

который настраивает пагинацию:


  • Создаем SubRedditBoundaryCallback, наследующий PagedList.BoundaryCallback<>
  • Используем конструктор с параметрами и передадим все, что нужно для работы BoundaryCallback
  • Создаем триггер refreshTrigger для уведомления репозитория о необходимости обновить данные
  • Создаем и возвращаем Listing объект

В Listing объекте:


  • livePagedList
  • networkState — состояние сети
  • retry — callback для вызова повторного получения данных с сервера
  • refresh — тригер для обновления данных
  • refreshState — состояние процесса обновления

Реализуем вспомогательный метод


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

для записи ответа сети в БД. Он будет использоваться, когда нужно будет обновить список или записать новую порцию данных.


Реализуем вспомогательный метод


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

для тригера обновления данных. Тут все довольно просто: получаем данные с сервера, чистим БД, записываем новые данные в БД.


С репозиторием разобрались. Теперь давайте взглянем поближе на SubredditBoundaryCallback.


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

В классе, который наследует 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 {@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
}

Со слоем бизнес логики закончили, можем переходить к реализации представления.
В слое представления у нас новая MVVM от Google на ViewModel и LiveData.


MainActivity.kt
import android.os.Bundle
import android.view.KeyEvent
import android.view.inputmethod.EditorInfo
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProviders
import androidx.paging.PagedList
import com.memebattle.pagingwithrepository.R
import com.memebattle.pagingwithrepository.domain.model.RedditPost
import com.memebattle.pagingwithrepository.domain.repository.MainRepository
import com.memebattle.pagingwithrepository.domain.repository.network.NetworkState
import com.memebattle.pagingwithrepository.presentation.recycler.PostsAdapter
import kotlinx.android.synthetic.main.activity_main.*

class MainActivity : AppCompatActivity() {

   companion object {
       const val KEY_SUBREDDIT = "subreddit"
       const val DEFAULT_SUBREDDIT = "androiddev"
   }

   lateinit var model: MainViewModel

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_main)

       model = getViewModel()

       initAdapter()
       initSwipeToRefresh()
       initSearch()

       val subreddit = savedInstanceState?.getString(KEY_SUBREDDIT) ?: DEFAULT_SUBREDDIT
       model.showSubReddit(subreddit)
   }

   private fun getViewModel(): MainViewModel {
       return ViewModelProviders.of(this, object : ViewModelProvider.Factory {
           override fun <T : ViewModel?> create(modelClass: Class<T>): T {
               val repo = MainRepository(this@MainActivity)
               @Suppress("UNCHECKED_CAST")
               return MainViewModel(repo) as T
           }
       })[MainViewModel::class.java]
   }

   private fun initAdapter() {
       val adapter = PostsAdapter {
           model.retry()
       }
       list.adapter = adapter
       model.posts.observe(this, Observer<PagedList<RedditPost>> {
           adapter.submitList(it)
       })
       model.networkState.observe(this, Observer {
           adapter.setNetworkState(it)
       })
   }

   private fun initSwipeToRefresh() {
       model.refreshState.observe(this, Observer {
           swipe_refresh.isRefreshing = it == NetworkState.LOADING
       })
       swipe_refresh.setOnRefreshListener {
           model.refresh()
       }
   }

   override fun onSaveInstanceState(outState: Bundle) {
       super.onSaveInstanceState(outState)
       outState.putString(KEY_SUBREDDIT, model.currentSubreddit())
   }

   private fun initSearch() {
       input.setOnEditorActionListener { _, actionId, _ ->
           if (actionId == EditorInfo.IME_ACTION_GO) {
               updatedSubredditFromInput()
               true
           } else {
               false
           }
       }
       input.setOnKeyListener { _, keyCode, event ->
           if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_ENTER) {
               updatedSubredditFromInput()
               true
           } else {
               false
           }
       }
   }

   private fun updatedSubredditFromInput() {
       input.text.trim().toString().let {
           if (it.isNotEmpty()) {
               if (model.showSubReddit(it)) {
                   list.scrollToPosition(0)
                   (list.adapter as? PostsAdapter)?.submitList(null)
               }
           }
       }
   }
}

В методе onCreate инициализируем ViewModel, адаптер списка, подписываемся на изменение названия подписки и вызываем через модель запуск работы репозитория.


Если вы не знакомы с механизмами LiveData и ViewModel, то рекомендую ознакомиться с уроками.


MainViewModel.kt
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import com.memebattle.pagingwithrepository.domain.repository.core.RedditPostRepository

class MainViewModel(private val repository: RedditPostRepository) : ViewModel() {

    private val subredditName = MutableLiveData<String>()
    private val repoResult = Transformations.map(subredditName) {
        repository.postsOfSubreddit(it, 10)
    }
    val posts = Transformations.switchMap(repoResult) { it.pagedList }!!
    val networkState = Transformations.switchMap(repoResult) { it.networkState }!!
    val refreshState = Transformations.switchMap(repoResult) { it.refreshState }!!

    fun refresh() {
        repoResult.value?.refresh?.invoke()
    }

    fun showSubReddit(subreddit: String): Boolean {
        if (subredditName.value == subreddit) {
            return false
        }
        subredditName.value = subreddit
        return true
    }

    fun retry() {
        val listing = repoResult?.value
        listing?.retry?.invoke()
    }

    fun currentSubreddit(): String? = subredditName.value
}

В модели реализуем методы, которые будут дергать методы репозитория: retry и refesh.


Адаптер списка будет наследовать PagedListAdapter. Тут все также как и работе с пагинацией и одним источником данных.


PostAdapter.kt
import androidx.paging.PagedListAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import android.view.ViewGroup
import com.memebattle.pagingwithrepository.R
import com.memebattle.pagingwithrepository.domain.model.RedditPost
import com.memebattle.pagingwithrepository.domain.repository.network.NetworkState
import com.memebattle.pagingwithrepository.presentation.recycler.viewholder.NetworkStateItemViewHolder
import com.memebattle.pagingwithrepository.presentation.recycler.viewholder.RedditPostViewHolder

/**
 * A simple adapter implementation that shows Reddit posts.
 */
class PostsAdapter(
        private val retryCallback: () -> Unit)
    : PagedListAdapter<RedditPost, RecyclerView.ViewHolder>(POST_COMPARATOR) {
    private var networkState: NetworkState? = null
    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        when (getItemViewType(position)) {
            R.layout.reddit_post_item -> (holder as RedditPostViewHolder).bind(getItem(position))
            R.layout.network_state_item -> (holder as NetworkStateItemViewHolder).bindTo(
                    networkState)
        }
    }

    override fun onBindViewHolder(
            holder: RecyclerView.ViewHolder,
            position: Int,
            payloads: MutableList<Any>) {
        if (payloads.isNotEmpty()) {
            val item = getItem(position)
            (holder as RedditPostViewHolder).updateScore(item)
        } else {
            onBindViewHolder(holder, position)
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        return when (viewType) {
            R.layout.reddit_post_item -> RedditPostViewHolder.create(parent)
            R.layout.network_state_item -> NetworkStateItemViewHolder.create(parent, retryCallback)
            else -> throw IllegalArgumentException("unknown view type $viewType")
        }
    }

    private fun hasExtraRow() = networkState != null && networkState != NetworkState.LOADED

    override fun getItemViewType(position: Int): Int {
        return if (hasExtraRow() && position == itemCount - 1) {
            R.layout.network_state_item
        } else {
            R.layout.reddit_post_item
        }
    }

    override fun getItemCount(): Int {
        return super.getItemCount() + if (hasExtraRow()) 1 else 0
    }

    fun setNetworkState(newNetworkState: NetworkState?) {
        val previousState = this.networkState
        val hadExtraRow = hasExtraRow()
        this.networkState = newNetworkState
        val hasExtraRow = hasExtraRow()
        if (hadExtraRow != hasExtraRow) {
            if (hadExtraRow) {
                notifyItemRemoved(super.getItemCount())
            } else {
                notifyItemInserted(super.getItemCount())
            }
        } else if (hasExtraRow && previousState != newNetworkState) {
            notifyItemChanged(itemCount - 1)
        }
    }

    companion object {
        private val PAYLOAD_SCORE = Any()
        val POST_COMPARATOR = object : DiffUtil.ItemCallback<RedditPost>() {
            override fun areContentsTheSame(oldItem: RedditPost, newItem: RedditPost): Boolean =
                    oldItem == newItem

            override fun areItemsTheSame(oldItem: RedditPost, newItem: RedditPost): Boolean =
                    oldItem.name == newItem.name

            override fun getChangePayload(oldItem: RedditPost, newItem: RedditPost): Any? {
                return if (sameExceptScore(oldItem, newItem)) {
                    PAYLOAD_SCORE
                } else {
                    null
                }
            }
        }

        private fun sameExceptScore(oldItem: RedditPost, newItem: RedditPost): Boolean {
            // DON'T do this copy in a real app, it is just convenient here for the demo :)
            // because reddit randomizes scores, we want to pass it as a payload to minimize
            // UI updates between refreshes
            return oldItem.copy(score = newItem.score) == newItem
        }
    }
}

И все те же ViewHolder ы для отображения записи и итема состояния загрузки данных из сети.


RedditPostViewHolder.kt
import android.content.Intent
import android.net.Uri
import androidx.recyclerview.widget.RecyclerView
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import com.memebattle.pagingwithrepository.R
import com.memebattle.pagingwithrepository.domain.model.RedditPost

/**
 * A RecyclerView ViewHolder that displays a reddit post.
 */
class RedditPostViewHolder(view: View)
    : RecyclerView.ViewHolder(view) {
    private val title: TextView = view.findViewById(R.id.title)
    private val subtitle: TextView = view.findViewById(R.id.subtitle)
    private val score: TextView = view.findViewById(R.id.score)
    private var post : RedditPost? = null
    init {
        view.setOnClickListener {
            post?.url?.let { url ->
                val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
                view.context.startActivity(intent)
            }
        }
    }

    fun bind(post: RedditPost?) {
        this.post = post
        title.text = post?.title ?: "loading"
        subtitle.text = itemView.context.resources.getString(R.string.post_subtitle,
                post?.author ?: "unknown")
        score.text = "${post?.score ?: 0}"
    }

    companion object {
        fun create(parent: ViewGroup): RedditPostViewHolder {
            val view = LayoutInflater.from(parent.context)
                    .inflate(R.layout.reddit_post_item, parent, false)
            return RedditPostViewHolder(view)
        }
    }

    fun updateScore(item: RedditPost?) {
        post = item
        score.text = "${item?.score ?: 0}"
    }
}

NetworkStateItemViewHolder.kt
import androidx.recyclerview.widget.RecyclerView
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.ProgressBar
import android.widget.TextView
import com.memebattle.pagingwithrepository.R
import com.memebattle.pagingwithrepository.domain.repository.network.NetworkState
import com.memebattle.pagingwithrepository.domain.repository.network.Status

/**
 * A View Holder that can display a loading or have click action.
 * It is used to show the network state of paging.
 */
class NetworkStateItemViewHolder(view: View,
                                 private val retryCallback: () -> Unit)
    : RecyclerView.ViewHolder(view) {
    private val progressBar = view.findViewById<ProgressBar>(R.id.progress_bar)
    private val retry = view.findViewById<Button>(R.id.retry_button)
    private val errorMsg = view.findViewById<TextView>(R.id.error_msg)
    init {
        retry.setOnClickListener {
            retryCallback()
        }
    }
    fun bindTo(networkState: NetworkState?) {
        progressBar.visibility = toVisibility(networkState?.status == Status.RUNNING)
        retry.visibility = toVisibility(networkState?.status == Status.FAILED)
        errorMsg.visibility = toVisibility(networkState?.msg != null)
        errorMsg.text = networkState?.msg
    }

    companion object {
        fun create(parent: ViewGroup, retryCallback: () -> Unit): NetworkStateItemViewHolder {
            val view = LayoutInflater.from(parent.context)
                    .inflate(R.layout.network_state_item, parent, false)
            return NetworkStateItemViewHolder(view, retryCallback)
        }

        fun toVisibility(constraint : Boolean): Int {
            return if (constraint) {
                View.VISIBLE
            } else {
                View.GONE
            }
        }
    }
}

Если мы запустим приложение, то можем увидеть прогресс бар, а затем и данные с Reddit по запросу androiddev. Если отключим сеть и долистаем до конца нашего списка, то будет сообщение об ошибке и предложение попытаться загрузить данные снова.


image


Все работает, супер!


И мой репозиторий, где я постарался немного упростить пример от Google.


На этом все. Если вы знаете другие способы как “закэшировать” пагинацию, то обязательно напишите в комменты.


Всем хорошего кода!

Поделиться публикацией

Похожие публикации

Комментарии 4

Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

Самое читаемое