In nearly each sort of cellular challenge, we cellular builders typically discover ourselves coping with paginated knowledge. It is important if the info checklist is an excessive amount of to retrieve from the server in a single name. Thus, our backend colleagues present us with an endpoint that returns a listing of knowledge in pages and expects us to know learn how to deal with it on the shopper aspect.
On this article, we are going to concentrate on learn how to fetch, cache, and show paginated knowledge utilizing the newest strategies advisable by Android as of June 2023. comply with these steps:
- Get checklist of Pokemon knowledge in pages from a Public GraphQL API
- Retailer fetched knowledge in native database utilizing Room
- Use the newest pagination library parts to deal with pagination
- Show web page gadgets intelligently (present solely what’s seen) utilizing LazyColumn
For the pattern challenge that I’ll share the GitHub repository with on the finish of the article, we are going to use hilt of sword as our dependency injection library and use Clear structure (presentation → area ← knowledge). So I’ll clarify the whole lot ranging from knowledge
layer, then scroll to area
layer, and end with presentation
class.
This layer is the place by far most of what’s going on by way of paging and caching. So if you may get via this half, you are nearly completed.
Distant knowledge supply
As a distant knowledge supply, we are going to use a Public Pokemon GraphQL API. Versus Retrofit which is what we use to work together with REST APIs we use Apollo‘S Kotlin . shopper for the GraphQL API. It permits us to execute GraphQL queries and mechanically generate Kotlin fashions from requests and responses.
First we have to add the next traces to our module degree construct.gradle
doc:
plugins {
// ...
id "com.apollographql.apollo3" model "$apollo_version"
}
apollo {
service("pokemon") {
packageName.set("dev.thunderbolt.pokemonpager.knowledge")
}
}
dependencies {
// ...
implementation "com.apollographql.apollo3:apollo-runtime:$apollo_version"
}
right here in apollo
block, we set the configuration of the Apollo library. It presents many settings, all of which you’ll be able to examine via doc. For now, we simply must set the package deal title to dev.thunderbolt.pokemonpager.knowledge
in order that the generated Kotlin recordsdata can be within the appropriate package deal, which is the package deal knowledge
class.
Then we have to obtain the server’s schema in order that the library can generate fashions and we will write queries utilizing autocomplete. To obtain the schema we use the next command offered by Apollo:
./gradlew :app:downloadApolloSchema --endpoint=' --schema=app/src/foremost/graphql/schema.graphqls
This can obtain the server’s schema in ‘s listing app/src/foremost/graphql/schema.graphqls
.
Now it is time to write our question in a file named pokemon.graphql
which we created in the identical listing because the schema file.
question PokemonList(
$offset: Int!
$restrict: Int!
) {
pokemons(
offset: $offset,
restrict: $restrict
) {
nextOffset
outcomes {
id
title
picture
}
}
}
Once we construct our challenge, Apollo Kotlin will generate fashions for this question by mechanically operating a Gradle process named generateApolloSources
.
Again within the Kotlin world, we are going to outline PokemonApi
class to encapsulate all of our interactions with GraphQL, like this:
class PokemonApi {
non-public val BASE_URL = "https://graphql-pokeapi.graphcdn.app/graphql"
non-public val apolloClient = ApolloClient.Builder()
.serverUrl(BASE_URL)
.addHttpInterceptor(LoggingInterceptor())
.construct()
droop enjoyable getPokemonList(offset: Int, restrict: Int): PokemonListQuery.Pokemons? {
val response = apolloClient.question(
PokemonListQuery(
offset = offset,
restrict = restrict,
)
).execute()
// IF RESPONSE HAS ERRORS OR DATA IS NULL, THROW EXCEPTION
if (response.hasErrors() || response.knowledge == null) {
throw ApolloException(response.errors.toString())
}
return response.knowledge!!.pokemons
}
}
Right here, we initialize Apollo clients instance with the required configuration and implementing our operate to execute the generated Kotlin model of the question we wrote in pokemon.graphql
doc. This operate is mainly offset
And restrict
parameters, execute the question, and if all goes properly, return the question response, which is once more mechanically generated by Apollo.
Native knowledge supply/Storage
To retailer relational knowledge domestically and create offline most popular purposes, we are going to rely on Roomis an Android persistence library written on SQLite.
First we have to add the Room dependencies in construct.gradle
doc:
dependencies {
// ...
implementation "androidx.room:room-ktx:$room_version"
kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-paging:$room_version"
}
Then we are going to outline two entity lessons, one to retailer Pokemon knowledge in our database and one other to maintain monitor of what number of pages can be fetched subsequent.
@Entity("pokemon")
knowledge class PokemonEntity(
@PrimaryKey val id: Int,
val title: String,
val imageUrl: String,
)
@Entity("remote_key")
knowledge class RemoteKeyEntity(
@PrimaryKey val id: String,
val nextOffset: Int,
)
Relating to these, we additionally want two DAO (Information Entry Object) to determine all of our database interactions therein.
@Dao
interface PokemonDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
droop enjoyable insertAll(gadgets: Record)
@Question("SELECT * FROM pokemon")
enjoyable pagingSource(): PagingSource
@Question("DELETE FROM pokemon")
droop enjoyable clearAll()
}
@Dao
interface RemoteKeyDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
droop enjoyable insert(merchandise: RemoteKeyEntity)
@Question("SELECT * FROM remote_key WHERE id = :id")
droop enjoyable getById(id: String): RemoteKeyEntity?
@Question("DELETE FROM remote_key WHERE id = :id")
droop enjoyable deleteById(id: String)
}
Right here, the essential operate we have to take note of is pagingSource()
one. Room can return knowledge checklist as PagingSource
allow us to Pager
object (which we are going to create later) will use it as the only supply to create the stream PagingData
.
Lastly, we have to have a RoomDatabase
the category creates tables for these entities within the native database and offers DAOs to work together with the tables.
@Database(
entities = [PokemonEntity::class, RemoteKeyEntity::class],
model = 1,
)
summary class PokemonDatabase : RoomDatabase() {
summary val pokemonDao: PokemonDao
summary val remoteKeyDao: RemoteKeyDao
}
each of those PokemonDatabase
and predefined PokemonApi
lessons are instantiated and offered as singleton cases by our Hilt module knowledge
class.
@Module
@InstallIn(SingletonComponent::class)
class DataModule {
@Gives
@Singleton
enjoyable providePokemonDatabase(@ApplicationContext context: Context): PokemonDatabase {
return Room.databaseBuilder(
context,
PokemonDatabase::class.java,
"pokemon.db",
).fallbackToDestructiveMigration().construct()
}
@Gives
@Singleton
enjoyable providePokemonApi(): PokemonApi {
return PokemonApi()
}
// ...
}
Distant mediator
Now it is time to do it RemoteMediator
can be liable for loading the paginated knowledge from the distant API into the native database every time wanted. It is very important notice that the distant mediator doesn’t present knowledge on to the person interface. If the paginated knowledge is exhausted, the paging library will set off a distant mediator load(…)
strategies to fetch and retailer extra knowledge domestically. Due to this fact, our native database can all the time be the one supply of fact.
class PokemonRemoteMediator @Inject constructor(
non-public val pokemonDatabase: PokemonDatabase,
non-public val pokemonApi: PokemonApi,
) : RemoteMediator() {
non-public val REMOTE_KEY_ID = "pokemon"
override droop enjoyable load(
loadType: LoadType,
state: PagingState,
): MediatorResult {
return strive {
val offset = when (loadType) {
LoadType.REFRESH -> 0
LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
LoadType.APPEND ->
}
// MAKE API CALL
val apiResponse = pokemonApi.getPokemonList(
offset = offset,
restrict = state.config.pageSize,
)
val outcomes = apiResponse?.outcomes ?: emptyList()
val nextOffset = apiResponse?.nextOffset ?: 0
// SAVE RESULTS AND NEXT OFFSET TO DATABASE
pokemonDatabase.withTransaction {
if (loadType == LoadType.REFRESH) {
// IF REFRESHING, CLEAR DATABASE FIRST
pokemonDatabase.pokemonDao.clearAll()
pokemonDatabase.remoteKeyDao.deleteById(REMOTE_KEY_ID)
}
pokemonDatabase.pokemonDao.insertAll(
outcomes.mapNotNull { it?.toPokemonEntity() }
)
pokemonDatabase.remoteKeyDao.insert(
RemoteKeyEntity(
id = REMOTE_KEY_ID,
nextOffset = nextOffset,
)
)
}
// CHECK IF END OF PAGINATION REACHED
MediatorResult.Success(endOfPaginationReached = outcomes.measurement < state.config.pageSize)
} catch (e: ApolloException) {
MediatorResult.Error(e)
}
}
}
inside load(…)
operate, we have to examine first what sort of load we’re coping with. If LoadType
To be…
-
REFRESH
, it means we’re on preliminary load or knowledge is by some means disabled and we have to fetch knowledge from scratch. So if that is so, we putoffset
worth to “0” as a result of we need to fetch the primary knowledge web page. -
PREPEND
, we have to fetch the info web page earlier than the present web page. That is pointless within the scope of this instance as we do not need to fetch something whereas scrolling to the highest. So we’re merely againMediatorResult.Success(endOfPaginationReached = true)
to point that knowledge loading will now not happen. -
APPEND
, we have to fetch the info web page after the present web page. On this case, we go and fetch the distant key object that was saved in our native database utilizing the earlier load of the info. If there isn’t a or itnextOffset
worth is “0”, this implies no extra knowledge is loaded and appended. By the best way, that is how this API works. Your API could symbolize the tip of the info otherwise, so it’s worthwhile to writeAPPEND
respective logic.
After we determine the precise worth of offset
now it is time to make API name by this offset
and in addition pageSize
offered within the configuration. We’ll set the web page measurement after we create Pager
object within the subsequent step.
If the API name efficiently returns a brand new web page of knowledge, we retailer the entries and in addition the following offset in our database utilizing the corresponding DAO features. Right here, we have to do all of the database interactions in a single transaction
blocking, in order that if any interplay fails, no modifications can be made to the database.
Lastly, if the whole lot goes properly after calling the database, we are going to return a MediatorResult.Success
examine if we’ve got reached the tip of pagination by evaluating the variety of gadgets returned by the newest load with the web page measurement we are going to outline within the configuration.
pager
Now, we’ll come again to ours knowledge
layer hilt module once more and we are going to create Pager
Factor. This object will collect all that we’ve got outlined to this point and act as a constructor for PagingData
run.
@Module
@InstallIn(SingletonComponent::class)
class DataModule {
// ...
@Gives
@Singleton
enjoyable providePokemonPager(
pokemonDatabase: PokemonDatabase,
pokemonApi: PokemonApi,
): Pager {
return Pager(
config = PagingConfig(pageSize = 20),
remoteMediator = PokemonRemoteMediator(
pokemonDatabase = pokemonDatabase,
pokemonApi = pokemonApi,
),
pagingSourceFactory = {
pokemonDatabase.pokemonDao.pagingSource()
},
)
}
}
Right here we offer three issues to the constructor of Pager
. First, we arrange a PagingConfig
with the specified web page measurement, as I discussed earlier than. Second, we provide a distant model of mediation. And third, we set the pagination supply offered by Room as the only knowledge supply for Pager
.
Warehouse
Since we’ve got accomplished many of the work within the distant mediator, we deploy repository can be fairly easy.
class PokemonRepositoryImpl @Inject constructor(
non-public val pokemonPager: Pager
) : PokemonRepository {
override enjoyable getPokemonList(): Movement> {
return pokemonPager.movement.map { pagingData ->
pagingData.map { it.toPokemon() }
}
}
}
our use Pager
for instance we simply return its stream PagingData
to customers. Nonetheless earlier than we do this, we additionally must map PokemonEntity
to area title Pokemon
mannequin. It is as a result of we area
class is aware of nothing about knowledge
or presentation
lessons kind the idea of Clear Structure, so we must always not deliver knowledge
mannequin to area
class.
On this pure Kotlin class, not a lot is definitely happening. Right here we’ve got Pokemon
our mannequin repository interfaceand easy In case of used class that interacts with this repository.
// REPOSITORY INTERFACE
interface PokemonRepository {
enjoyable getPokemonList(): Movement>
}
// USE CASE
class GetPokemonList @Inject constructor(
non-public val pokemonRepository: PokemonRepository
) {
operator enjoyable invoke(): Movement> {
return pokemonRepository.getPokemonList()
.flowOn(Dispatchers.IO)
}
}
// MODEL
knowledge class Pokemon(
val id: Int,
val title: String,
val imageUrl: String,
)
Right here, a query you might have in your thoughts is likely to be learn how to use PagingData
in a pure Kotlin class the place we do not rely on any Android parts. It is actually easy: There’s a particular dependency offered by the Pagination library for non-Android modules, in order that we will entry all of the Pagination parts so simple as PagingSource
, PagingData
, Pager
and even RemoteMediator
.
dependencies {
// ...
implementation "androidx.paging:paging-common:$paging_version"
}
After reporting this fast information area
class, let’s leap immediately in presentation
layer, the place the remainder of the essential stuff is going on. However first we have to add the next Pagination dependencies construct.gradle
doc:
dependencies {
// ...
implementation "androidx.paging:paging-runtime-ktx:$paging_version"
implementation "androidx.paging:paging-compose:$paging_version"
}
Extra differrent runtime-ktx
dependence, dependence compose
dependency can also be required right here because it offers some center floor between the pagination knowledge movement and our UI.
ViewModel
That is once more one of many easy lessons of this text the place we merely take the stream offered by the use case (which is already offered by the repository) and retailer it in an rvalue.
@HiltViewModel
class PokemonListViewModel @Inject constructor(
non-public val getPokemonList: GetPokemonList
) : ViewModel() {
val pokemonPagingDataFlow: Movement> = getPokemonList()
.cachedIn(viewModelScope)
}
We retailer the stream by calling cachedIn(viewModelScope)
let it proceed to work so long as our lifetime ViewModel
. In addition to, it persists on configuration modifications like display screen rotation, so that you get the identical current knowledge as a substitute of fetching it from scratch.
This technique additionally preserves our chilly stream and does not flip it into sizzling stream (StateFlow
) like stateIn(…)
technique will do. Because of this if the stream is just not collected, then no pointless code can be executed.
Display (Person Interface)
Now we’re on the ultimate step of the pagination course of the place we are going to show our pagination gadgets in a LazyColumn
. Wouldn’t have RecylerView
s or extra adapters in Jetpack Compose. All of that is now dealt with beneath, and our giant variety of gadgets are nonetheless intelligently sorted with out inflicting any efficiency points.
@Composable
enjoyable PokemonListScreen(
snackbarHostState: SnackbarHostState
) {
val viewModel = hiltViewModel()
val pokemonPagingItems = viewModel.pokemonPagingDataFlow.collectAsLazyPagingItems()
if (pokemonPagingItems.loadState.refresh is LoadState.Error) {
LaunchedEffect(key1 = snackbarHostState) {
snackbarHostState.showSnackbar(
(pokemonPagingItems.loadState.refresh as LoadState.Error).error.message ?: ""
)
}
}
Field(modifier = Modifier.fillMaxSize()) {
if (pokemonPagingItems.loadState.refresh is LoadState.Loading) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Middle))
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
gadgets(
rely = pokemonPagingItems.itemCount,
key = pokemonPagingItems.itemKey { it.id },
) { index ->
val pokemon = pokemonPagingItems[index]
if (pokemon != null) {
PokemonItem(
pokemon,
modifier = Modifier.fillMaxWidth(),
)
}
}
merchandise {
if (pokemonPagingItems.loadState.append is LoadState.Loading) {
CircularProgressIndicator(modifier = Modifier.padding(16.dp))
}
}
}
}
}
}
The very first thing to do in our compositing display screen is to create ViewModel
instance and gather the paging knowledge stream saved in it utilizing a helper operate collectAsLazyPagingItems()
. This converts the chilly movement right into a LazyPagingItems
For instance. By this case we will entry the gadgets already loaded, in addition to the totally different loading states to vary the UI accordingly. Along with these, we will even allow knowledge refresh or retry a beforehand failed load utilizing this model.
in a single Field
format, if the “refresh” load state of LazyPagingItems
To be Loading
, then we all know that we’re on first load and there aren’t any gadgets to show but. Due to this fact, we present a progress indicator. In any other case, we present a LazyColumn
together with the checklist gadgets
whose rely
And key
Parameters are set utilizing our LazyPagingItems
For instance. And in every merchandise we simply must entry the corresponding Pokemon
object utilizing the given index and show a PokemonItem
composable, its implementation particulars will not be given right here for the sake of simplicity.
We even have a selected case the place we have to show a loading indicator under this stuff. And it occurs each time we’re within the technique of fetching extra knowledge, which might be detected via the “append” load standing of LazyPagingItems
. Due to this fact, if that’s the case, we are going to add a progress indicator to the tip of the checklist.
And lastly, do not suppose we missed out LaunchedEffect
half at first. The LaunchedEffect
composables are used to soundly name droop features inside a composable. We’d like a coroutine vary to show a Snackbar
in Jetpack Compose phrases SnackbarHostState.showSnackbar(…)
is a droop operate. And right here we present a message on the snackbar in case of a refresh failure, which mainly corresponds to the “preliminary load” error in our case. Nonetheless, as I discussed earlier, right here we’ve got created an offline desire app, so if we’ve got cached knowledge in Rooms, the person will see that together with error message.