Caching and Pagination with Paging 3 in Android & Jetpack Compose

mrntlu

Burak Fidan

Posted on December 30, 2022

Caching and Pagination with Paging 3 in Android & Jetpack Compose

In this article we’ll be implementing caching and pagination with Paging 3. We’ll use Jetpack Compose but you can also follow this article and learn from it even if you are not going to use Jetpack Compose. Except the UI layer, most of it will be similar.

Table of Contents

  • Getting Started

  • API Endpoint & Creating Models

  • Room & Retrofit Setup

  • Remote Mediator

  • Pager

  • UI Layer

  • List Setup

  • Loading and Error Handling

Prerequisites

We’ll use Room, Retrofit and Hilt in this article, so it’s better you know how they work.

I’ll also assume that you know the basics of how Paging 3 works. If you don’t, I recommend you check this article before this.
Pagination in Jetpack Compose with and without Paging 3

Getting Started

App level build.gradle file,

//Paging 3
def paging_version = "3.1.1"
implementation "androidx.paging:paging-runtime:$paging_version"
implementation "androidx.paging:paging-compose:1.0.0-alpha17"

//Retrofit
def retrofit_version = "2.9.0"
implementation "com.squareup.retrofit2:retrofit:$retrofit_version"
implementation "com.squareup.retrofit2:converter-gson:$retrofit_version"

//Hilt
def hilt_version = "2.44"
implementation "com.google.dagger:hilt-android:$hilt_version"
kapt "com.google.dagger:hilt-compiler:$hilt_version"
implementation "androidx.hilt:hilt-navigation-compose:1.0.0"

//Room
def room_version = "2.4.3"
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$room_version"
implementation "androidx.room:room-paging:$room_version"

//Coil
implementation "io.coil-kt:coil-compose:2.2.2"
Enter fullscreen mode Exit fullscreen mode

Don’t forget to add Internet permission in AndroidManifest.xml,

<uses-permission android:name="android.permission.INTERNET" />
Enter fullscreen mode Exit fullscreen mode

API Endpoint & Creating Models

We’re going to use TheMovieDB API version 3. You can register and get your API key from this link. We will use /movie/popular endpoint.

API Key

Response models,

Please put them into different files. I’ve put them into one code block to make it easier to read.

data class MovieResponse(
    val page: Int,
    @SerializedName(value = "results")
    val movies: List<Movie>,
    @SerializedName("total_pages")
    val totalPages: Int,
    @SerializedName("total_results")
    val totalResults: Int
)

@Entity(tableName = "movies")
data class Movie(
    @PrimaryKey(autoGenerate = false)
    val id: Int,
    @ColumnInfo(name = "original_title")
    @SerializedName("original_title")
    val ogTitle: String,
    @ColumnInfo(name = "overview")
    val overview: String,
    @ColumnInfo(name = "popularity")
    val popularity: Double,
    @ColumnInfo(name = "poster_path")
    @SerializedName("poster_path")
    val posterPath: String?,
    @ColumnInfo(name = "release_date")
    @SerializedName("release_date")
    val releaseDate: String,
    @ColumnInfo(name = "title")
    val title: String,
    @ColumnInfo(name = "page")
    var page: Int,
)
Enter fullscreen mode Exit fullscreen mode

That’s it for this part.

Room & Retrofit Setup

Let’s start by creating and implementing Retrofit. API service will be very simple since we are going to use only 1 endpoint.

interface MoviesApiService {
    @GET("movie/popular?api_key=${MOVIE_API_KEY}&language=en-US")
    suspend fun getPopularMovies(
        @Query("page") page: Int
    ): MovieResponse
}
Enter fullscreen mode Exit fullscreen mode

API service is ready, we’ll create Retrofit instance at the end of this part after finishing Room implementation.

That’s it for Retrofit, now we can implement Room. Before we start, we’ll need to create a new model for caching.

@Entity(tableName = "remote_key")
data class RemoteKeys(
    @PrimaryKey(autoGenerate = false)
    @ColumnInfo(name = "movie_id")
    val movieID: Int,
    val prevKey: Int?,
    val currentPage: Int,
    val nextKey: Int?,
    @ColumnInfo(name = "created_at")
    val createdAt: Long = System.currentTimeMillis()
)
Enter fullscreen mode Exit fullscreen mode

When remote keys are not directly associated with list items, it is best to store them in a separate table in the local database. While this can be done in the Movie table, creating a new table for the next and previous remote keys associated with a Movie allows us to have a better separation of concerns.

This model is necessary to keep track of pagination. When we get the last item loaded from the PagingState, there’s no way to know the index of the page it belonged to. To solve this problem, we added another table that stores the next, current and previous page keys for each Movie. Keys are page numbers. createdAt is necessary for cache timeout. If you don’t need to check when was the last time we’ve cached the data, you can remove it.

Now we can create Dao for both Movie and RemoteKeys,

@Dao
interface MoviesDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(movies: List<Movie>)

    @Query("Select * From movies Order By page")
    fun getMovies(): PagingSource<Int, Movie>

    @Query("Delete From movies")
    suspend fun clearAllMovies()
}

@Dao
interface RemoteKeysDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(remoteKey: List<RemoteKeys>)

    @Query("Select * From remote_key Where movie_id = :id")
    suspend fun getRemoteKeyByMovieID(id: Int): RemoteKeys?

    @Query("Delete From remote_key")
    suspend fun clearRemoteKeys()

    @Query("Select created_at From remote_key Order By created_at DESC LIMIT 1")
    suspend fun getCreationTime(): Long?
}
Enter fullscreen mode Exit fullscreen mode

Finally, we need to create Database class.

@Database(
    entities = [Movie::class, RemoteKeys::class],
    version = 1,
)
abstract class MoviesDatabase: RoomDatabase() {
    abstract fun getMoviesDao(): MoviesDao
    abstract fun getRemoteKeysDao(): RemoteKeysDao
}
Enter fullscreen mode Exit fullscreen mode

That’s it. Now let’s create Retrofit & Room instances.

@Module
@InstallIn(SingletonComponent::class)
class SingletonModule {
    @Singleton
    @Provides
    fun provideRetrofitInstance(): MoviesApiService =
        Retrofit.Builder()
            .baseUrl("https://api.themoviedb.org/3/")
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(MoviesApiService::class.java)

    @Singleton
    @Provides
    fun provideMovieDatabase(@ApplicationContext context: Context): MoviesDatabase =
        Room
            .databaseBuilder(context, MoviesDatabase::class.java, "movies_database")
            .build()

    @Singleton
    @Provides
    fun provideMoviesDao(moviesDatabase: MoviesDatabase): MoviesDao = moviesDatabase.getMoviesDao()

    @Singleton
    @Provides
    fun provideRemoteKeysDao(moviesDatabase: MoviesDatabase): RemoteKeysDao = moviesDatabase.getRemoteKeysDao()
}
Enter fullscreen mode Exit fullscreen mode

Remote Mediator

Before we start implementing, let’s try to understand what Remote Mediator is and why do we need it.

Remote Mediator acts as a signal from the Paging library when the app has run out of cached data. You can use this signal to load additional data from the network and store it in the local database, where a PagingSource can load it and provide it to the UI to display.

When additional data is needed, the Paging library calls the load() method from the Remote Mediator implementation. This function typically fetches the new data from a network source and saves it to local storage.

A Remote Mediator implementation helps load paged data from the network into the database but doesn't load data directly into the UI. Instead, the app uses the database as the source of truth. In other words, the app only displays data that has been cached in the database.

Graph

Now, we can start implementing Remote Mediator. Let’s implement part by part. First, we’ll implement load method.

@OptIn(ExperimentalPagingApi::class)
class MoviesRemoteMediator (
    private val moviesApiService: MoviesApiService,
    private val moviesDatabase: MoviesDatabase,
): RemoteMediator<Int, Movie>() {

    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, Movie>
    ): MediatorResult {
        val page: Int = when (loadType) {
            LoadType.REFRESH -> {
                //...
            }
            LoadType.PREPEND -> {
                //...
            }
            LoadType.APPEND -> {
                //...
            }
        }

        try {
            val apiResponse = moviesApiService.getPopularMovies(page = page)

            val movies = apiResponse.movies
            val endOfPaginationReached = movies.isEmpty()

            moviesDatabase.withTransaction {
                if (loadType == LoadType.REFRESH) {
                    moviesDatabase.getRemoteKeysDao().clearRemoteKeys()
                    moviesDatabase.getMoviesDao().clearAllMovies()
                }
                val prevKey = if (page > 1) page - 1 else null
                val nextKey = if (endOfPaginationReached) null else page + 1
                val remoteKeys = movies.map {
                    RemoteKeys(movieID = it.id, prevKey = prevKey, currentPage = page, nextKey = nextKey)
                }

                moviesDatabase.getRemoteKeysDao().insertAll(remoteKeys)
                moviesDatabase.getMoviesDao().insertAll(movies.onEachIndexed { _, movie -> movie.page = page })
            }
            return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
        } catch (error: IOException) {
            return MediatorResult.Error(error)
        } catch (error: HttpException) {
            return MediatorResult.Error(error)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

state parameter gives us information about the pages that were loaded before, the most recently accessed index in the list, and the PagingConfig we defined when initializing the paging stream.
loadType tells us whether we need to load data at the end (LoadType.APPEND) or at the beginning of the data (LoadType.PREPEND) that we previously loaded,
or if this the first time we’re loading data (LoadType.REFRESH).

We’ll implement page attribute later, so let’s start with try/catch block. First, we make API request and get movies and set endOfPaginationReach to movies.isEmpty. If there is no item left to load, we assume it’s exhausted.

Then we start database transaction. Inside of it, we check If loadType is REFRESH and we delete caches. After that, we create RemoteKeys by mapping movies and extract movie.id. Finally, we cache all retrieved movies and remoteKeys.

Now, let’s check how we retrieve page number with RemoteKeys,

@OptIn(ExperimentalPagingApi::class)
class MoviesRemoteMediator (
    private val moviesApiService: MoviesApiService,
    private val moviesDatabase: MoviesDatabase,
): RemoteMediator<Int, Movie>() {
    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, Movie>
    ): MediatorResult {
        val page: Int = when (loadType) {
            LoadType.REFRESH -> {
                val remoteKeys = getRemoteKeyClosestToCurrentPosition(state)
                remoteKeys?.nextKey?.minus(1) ?: 1
            }
            LoadType.PREPEND -> {
                val remoteKeys = getRemoteKeyForFirstItem(state)
                val prevKey = remoteKeys?.prevKey
                prevKey ?: return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
            }
            LoadType.APPEND -> {
                val remoteKeys = getRemoteKeyForLastItem(state)
                val nextKey = remoteKeys?.nextKey
                nextKey ?: return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
            }
        }

        try {
          //Previously implemented
        } //...
    }

    private suspend fun getRemoteKeyClosestToCurrentPosition(state: PagingState<Int, Movie>): RemoteKeys? {
        return state.anchorPosition?.let { position ->
            state.closestItemToPosition(position)?.id?.let { id ->
                moviesDatabase.getRemoteKeysDao().getRemoteKeyByMovieID(id)
            }
        }
    }

    private suspend fun getRemoteKeyForFirstItem(state: PagingState<Int, Movie>): RemoteKeys? {
        return state.pages.firstOrNull {
            it.data.isNotEmpty()
        }?.data?.firstOrNull()?.let { movie ->
            moviesDatabase.getRemoteKeysDao().getRemoteKeyByMovieID(movie.id)
        }
    }

    private suspend fun getRemoteKeyForLastItem(state: PagingState<Int, Movie>): RemoteKeys? {
        return state.pages.lastOrNull {
            it.data.isNotEmpty()
        }?.data?.lastOrNull()?.let { movie ->
            moviesDatabase.getRemoteKeysDao().getRemoteKeyByMovieID(movie.id)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

LoadType.REFRESH, gets called when it’s the first time we’re loading data, or when refresh() is called.

LoadType.PREPEND, when we need to load data at the beginning of the currently loaded data set, the load parameter is LoadType.PREPEND.

LoadType.APPEND, when we need to load data at the end of the currently loaded data set, the load parameter is LoadType.APPEND.

getRemoteKeyClosestToCurrentPosition, based on anchorPosition from the state, we can get the closest Movie item to that position by calling closestItemToPosition and retrieve RemoteKeys from database. If RemoteKeys is null, we return the first page number which is 1 in our example.

getRemoteKeyForFirstItem, we get the first Movie item loaded from the database.

**getRemoteKeyForLastItem, **we get the last Movie item loaded from the database.

Finally, let’s implement caching timeout,

@OptIn(ExperimentalPagingApi::class)
class MoviesRemoteMediator (
    private val moviesApiService: MoviesApiService,
    private val moviesDatabase: MoviesDatabase,
): RemoteMediator<Int, Movie>() {

    override suspend fun initialize(): InitializeAction {
        val cacheTimeout = TimeUnit.MILLISECONDS.convert(1, TimeUnit.HOURS)

        return if (System.currentTimeMillis() - (moviesDatabase.getRemoteKeysDao().getCreationTime() ?: 0) < cacheTimeout) {
            InitializeAction.SKIP_INITIAL_REFRESH
        } else {
            InitializeAction.LAUNCH_INITIAL_REFRESH
        }
    }

    //...
}
Enter fullscreen mode Exit fullscreen mode

initialize this method is to check whether cached data is out of date and decide whether to trigger a remote refresh. This method runs before any loading is performed, so you can manipulate the database (for example, to clear old data) before triggering any local or remote loads.

In cases where the local data needs to be fully refreshed, initialize should return LAUNCH_INITIAL_REFRESH. This causes the Remote Mediator to perform a remote refresh to fully reload the data.

In cases where the local data doesn’t need to be refreshed, initialize should return SKIP_INITIAL_REFRESH. This causes the Remote Mediator to skip the remote refresh and load the cached data.

In our example, we’ve set the timeout to 1 hour and retrieve cache time from RemoteKeys database.

That’s it. You can find the RemoteMediator code here, also you can find the full code at the end of this article.

Pager

This is going to be a simple one,

const val PAGE_SIZE = 20

@HiltViewModel
class MoviesViewModel @Inject constructor(
    private val moviesApiService: MoviesApiService,
    private val moviesDatabase: MoviesDatabase,
): ViewModel() {
    @OptIn(ExperimentalPagingApi::class)
    fun getPopularMovies(): Flow<PagingData<Movie>> =
        Pager(
            config = PagingConfig(
                pageSize = PAGE_SIZE,
                prefetchDistance = 10,
                initialLoadSize = PAGE_SIZE,
            ),
            pagingSourceFactory = {
                moviesDatabase.getMoviesDao().getMovies()
            },
            remoteMediator = MoviesRemoteMediator(
                moviesApiService,
                moviesDatabase,
            )
        ).flow
}
Enter fullscreen mode Exit fullscreen mode

This is similar to creating a Pager from a simple network data source, but there are two things you must do differently:
Instead of passing a PagingSource constructor directly, you must provide the query method that returns a PagingSource object from the DAO.
You must provide an instance of your RemoteMediator implementation as the remoteMediator parameter.

The pagingSourceFactory lambda should always return a brand new PagingSource when invoked as PagingSource instances are not reusable.

Finally, we can start implementing UI Layer.

UI Layer

List Setup

List implementation will be very simple,

@Composable
fun MainScreen() {
    val moviesViewModel = hiltViewModel<MoviesViewModel>()

    val movies = moviesViewModel.getPopularMovies().collectAsLazyPagingItems()

    LazyColumn {
        items(
            items = movies
        ) { movie ->
            movie?.let {
                Row(
                    horizontalArrangement = Arrangement.Center,
                    verticalAlignment = Alignment.CenterVertically,
                ) {
                    if (movie.posterPath != null) {
                        var isImageLoading by remember { mutableStateOf(false) }

                        val painter = rememberAsyncImagePainter(
                            model = "https://image.tmdb.org/t/p/w154" + movie.posterPath,
                        )

                        isImageLoading = when(painter.state) {
                            is AsyncImagePainter.State.Loading -> true
                            else -> false
                        }

                        Box (
                            contentAlignment = Alignment.Center
                        ) {
                            Image(
                                modifier = Modifier
                                    .padding(horizontal = 6.dp, vertical = 3.dp)
                                    .height(115.dp)
                                    .width(77.dp)
                                    .clip(RoundedCornerShape(8.dp)),
                                painter = painter,
                                contentDescription = "Poster Image",
                                contentScale = ContentScale.FillBounds,
                            )

                            if (isImageLoading) {
                                CircularProgressIndicator(
                                    modifier = Modifier
                                        .padding(horizontal = 6.dp, vertical = 3.dp),
                                    color = MaterialTheme.colors.primary,
                                )
                            }
                        }
                    }
                    Text(
                        modifier = Modifier
                            .padding(vertical = 18.dp, horizontal = 8.dp),
                        text = it.title
                    )
                }
                Divider()
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

For detailed explanation of list implementation, you can check this link.

List UI

Loading and Error Handling

@Composable
fun MainScreen() {
    val moviesViewModel = hiltViewModel<MoviesViewModel>()

    val movies = moviesViewModel.getPopularMovies().collectAsLazyPagingItems()

    LazyColumn {
        //... Movie items

        val loadState = movies.loadState.mediator
        item {
            if (loadState?.refresh == LoadState.Loading) {
                Column(
                    modifier = Modifier
                        .fillParentMaxSize(),
                    horizontalAlignment = Alignment.CenterHorizontally,
                    verticalArrangement = Arrangement.Center,
                ) {
                    Text(
                        modifier = Modifier
                            .padding(8.dp),
                        text = "Refresh Loading"
                    )

                    CircularProgressIndicator(color = MaterialTheme.colors.primary)
                }
            }

            if (loadState?.append == LoadState.Loading) {
                Box(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(16.dp),
                    contentAlignment = Alignment.Center,
                ) {
                    CircularProgressIndicator(color = MaterialTheme.colors.primary)
                }
            }

            if (loadState?.refresh is LoadState.Error || loadState?.append is LoadState.Error) {
                val isPaginatingError = (loadState.append is LoadState.Error) || movies.itemCount > 1
                val error = if (loadState.append is LoadState.Error)
                        (loadState.append as LoadState.Error).error
                else
                        (loadState.refresh as LoadState.Error).error

                val modifier = if (isPaginatingError) {
                    Modifier.padding(8.dp)
                } else {
                    Modifier.fillParentMaxSize()
                }
                Column(
                    modifier = modifier,
                    verticalArrangement = Arrangement.Center,
                    horizontalAlignment = Alignment.CenterHorizontally,
                ) {
                    if (!isPaginatingError) {
                        Icon(
                            modifier = Modifier
                                .size(64.dp),
                            imageVector = Icons.Rounded.Warning, contentDescription = null
                        )
                    }

                    Text(
                        modifier = Modifier
                            .padding(8.dp),
                        text = error.message ?: error.toString(),
                        textAlign = TextAlign.Center,
                    )

                    Button(
                        onClick = {
                            movies.refresh()
                        },
                        content = {
                            Text(text = "Refresh")
                        },
                        colors = ButtonDefaults.buttonColors(
                            backgroundColor = MaterialTheme.colors.primary,
                            contentColor = Color.White,
                        )
                    )
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Since we are using Remote Mediator, we’ll use loadState.mediator. We’ll only check refresh and append,

When refresh is LoadState.Loading we’ll show loading screen.

refresh Loading State

When append is LoadState.Loading we’ll show pagination loading.

append Loading

For errors, we check if either refresh or append is LoadState.Error. If we got error on refresh that means, we got an error on initial fetch and we’ll show error screen. If we got error on append that means, we got an error while paginating and we’ll show error at the end of the list.

Let’s see the final result.

Result

That’s it! I hope it was useful. 👋👋

Full Code

MrNtlu/JetpackCompose-PaginationCaching (github.com)

Sources:

You can contact me on,

💖 💪 🙅 🚩
mrntlu
Burak Fidan

Posted on December 30, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related