Como fazer Paging Library + Marvel API com Jetpack Compose
A Paging Library é a biblioteca de paginação de dados para Android disponibilizada pelo Google no pacote Jetpack. Ela tem o objetivo de dividir uma grande listagem de dados em partes menores que são comumente chamados de “páginas”. Por exemplo, em uma listagem de produtos com 1.000 itens, é possível requisitar do servidor os primeiros 20 itens e à medida que o usuário for realizando a rolagem da tela, os próximos 20 itens serão carregados até chegar ao final da listagem.
Quem já conversou comigo ou já assistiu alguma palestra minha sobre as bibliotecas do Jetpack sabe que eu nunca fui muito fã da Paging Library, pois achava ela muito intrusiva em uma arquitetura e achava que não era um trabalho tão grande a ponto de precisar dela. Mas recentemente testei a biblioteca novamente com o Jetpack Compose
e devo admitir que estou mudando de ideia em relação a ela e queria deixar registrado nesse artigo.
Conteudo
Marvel API
Nesse artigo, utilizarei esse mesmo serviço e para isso é necessário criar uma conta gratuita que permite até 3.000 chamadas diárias à API gratuitamente. Ao criar a conta, selecione a opção “My Developer Account” para visualizar as chaves privada e pública que são necessárias para realizar as requisições.
Clicando na opção “Interactive Documentation”, os diversos métodos disponibilizados por essa API serão exibidos. Nesse exemplo, será utilizada apenas a listagem de personagens por meio da chamada GET v1/public/characters
.
Estrutura de retorno da API
De modo a representar o retorno da API, foi definida a seguinte estrutura de classes:
data class Response(val data: Data) data class Data( val total: Int, val results: List<Character> ) data class Character( val id: Int, val name: String, val description: String, val thumbnail: Thumbnail ) data class Thumbnail( val path: String, val extension: String ) { val pathSec: String get() = path.replace("http:", "https:") }
- A API possui alguns atributos como
code
estatus
que indicam respectivamente o código de resposta da requisição e a descrição. Mas na classeResponse
foi definido apenas o atributodata
, pois é o único necessário para o propósito deste artigo. - A estrutura representada pela classe
Data
também possui diversos outros atributos descritos na documentação, mas aqui será necessário apenas: ototal
que indica o tamanho total da lista; eresults
que contém a lista dos personagens. - Cada personagem possui um identificador único, nome, descrição e uma imagem. A classe
Character
armazenará esse conteúdo respectivamente nos atributos:id
,name
,description
ethumbnail
. - A imagem do personagem possui um endereço e uma extensão que são definidas pelos campos
path
eextension
da classeThumbnail
. Até o momento da escrita desse artigo, o caminho da imagem não vinha com o protocolo HTTPS, por isso o atributopathSec
substitui o “http” por “https”.
Dependências
Ao criar um novo projeto Jetpack Compose
no Android Studio as principais dependências são incluídas automaticamente. As dependências específicas desse exemplo são as listadas a seguir:
implementation "com.squareup.retrofit2:retrofit:2.9.0" implementation "com.squareup.retrofit2:converter-gson:2.9.0" implementation "com.squareup.okhttp3:logging-interceptor:4.10.0" implementation "androidx.paging:paging-runtime-ktx:3.1.1" implementation "androidx.paging:paging-compose:1.0.0-alpha15" implementation "com.google.accompanist:accompanist-swiperefresh:0.24.12-rc" implementation "io.coil-kt:coil-compose:2.0.0" implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.4.1"
- Retrofit para realizar as requisições para a API da Marvel;
- O conversor do Gson para Retrofit;
- O interceptor de logging é opcional, mas ajuda na depuração das chamadas da API;
- A biblioteca de Paging do Jetpack;
- A biblioteca de Paging para Compose;
- SwipeRefresh para fazer um refresh na lista quando realizar o gesto de swipe para baixo;
- Coil para carregar as imagens dos personagens;
- View Model para Compose.
Interface Retrofit para acessar API da Marvel
A seguir, está a definição da interface MarvelApi
do Retrofit que permitirá o acesso a API da Marvel.
interface MarvelApi { @GET("characters") suspend fun allCharacters( @Query("offset") offset: Int? = 0 ): Response companion object { const val API_KEY = "SUA_PUBLIC_KEY" const val PRIVATE_KEY = "SUA_PRIVATE_KEY" fun getService(): MarvelApi { val logging = HttpLoggingInterceptor() logging.level = HttpLoggingInterceptor.Level.BODY val httpClient = OkHttpClient.Builder() httpClient.addInterceptor(logging) httpClient.addInterceptor { chain -> val original = chain.request() val originalHttpUrl = original.url val ts = (Calendar.getInstance( TimeZone.getTimeZone("UTC") ).timeInMillis / 1000L).toString() val url = originalHttpUrl.newBuilder() .addQueryParameter("apikey", API_KEY) .addQueryParameter("ts", ts) .addQueryParameter( "hash", md5("$ts$PRIVATE_KEY$API_KEY") ) .build() chain.proceed( original.newBuilder().url(url).build() ) } val gson = GsonBuilder().setLenient().create() val retrofit = Retrofit.Builder() .baseUrl("http://gateway.marvel.com/v1/public/") .addConverterFactory( GsonConverterFactory.create(gson) ) .client(httpClient.build()) .build() return retrofit.create(MarvelApi::class.java) } private fun md5(input:String): String { val md = MessageDigest.getInstance("MD5") return BigInteger(1, md.digest(input.toByteArray())) .toString(16) .padStart(32, '0') } } }
Para obter a listagem de personagens foi definida a função allCharacters
. Perceba que ela é uma suspending function (da API de Coroutines) que retorna um objeto Response
(definida anteriormente). Como parâmetro, é passado o offset
que indica a posição inicial de onde serão buscados os próximos itens da lista. Por exemplo: na primeira página o offset
é zero, se cada página contiver 20 elementos, para buscar a próxima página dever ser passado o offset
igual a 20, a terceira página seria 40, e assim sucessivamente.
O método getService
retorna a instância da MarvelApi
. Inicialmente é adicionado o interceptor de Log. Em seguida, é adicionado um interceptor que incluirá alguns parâmetros na requisição:
apikey
é a “Public Key” apresentada no site da Marvel;ts
é o timestamp em segundos no time zone UTC;hash
é uma string que concatena: o timestamp, a “Private Key” (também apresentada no site da Marvel) e a “Public Key”. Nessa string é aplicado o algoritmo de hash MD5.
Após adicionar esses parâmetros, a requisição é realizada utilizando chain.proceed
com a nova URL.
Finalmente a instância da MarvelApi
é criada passando o conversor do Gson, a URL base da API e o OkHttp client.
Definindo o Paging Source
Até aqui só foi apresentado como acessar a API da Marvel. Mas como utilizar a Paging Library?
Como mencionado anteriormente, a biblioteca de paginação é bem fácil de utilizar. São basicamente duas etapas: declarar um PagingSource
e em seguida um objeto Pager
. Nessa seção será explicada a primeira etapa e na seção seguinte, a segunda etapa.
A subclasse de PagingSource
é duplamente “tipada”:
- O primeiro tipo indica qual a classe que representa o identificador da página. Algumas APIs representam a página por meio de uma
String
e cada resposta/página fornece o identificador da próxima página. Mas normalmente esse tipo é umInt
representando uma sequência de páginas (1, 2, 3, … etc.) ou um offset (como é o caso da API da Marvel); - O segundo tipo indica o dado que será retornado. Como serão retornadas páginas contendo objetos do tipo
Character
, essa classe é utilizada.
class MarvelApiPagingSource( private val marvelApi: MarvelApi, ) : PagingSource<Int, Character>() { override fun getRefreshKey( state: PagingState<Int, Character> ): Int? { return state.anchorPosition?.let { anchorPosition -> val anchorPage = state.closestPageToPosition( anchorPosition ) anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1) } } override suspend fun load( params: LoadParams<Int> ): LoadResult<Int, Character> { return try { val page = params.key ?: 0 val offset = page * PAGE_SIZE val response = marvelApi.allCharacters(offset = offset) val nextKey = if (offset >= response.data.total) null else page + 1 return LoadResult.Page( data = response.data.results, prevKey = null, // Only paging forward. nextKey = nextKey ) } catch (e: Exception) { LoadResult.Error(e) } } companion object { const val PAGE_SIZE = 20 } }
No construtor da classe é passada uma instância da MarvelApi
. O método load
é o responsável por carregar cada página de dados. Essa função começa tentando obter qual página deve ser carregada por meio do parâmetro params.key
. Caso seja nulo, isso significa que será a primeira página, ou seja, a página zero. Depois é calculado o offset
que será passado para a API (página 0, offset 0 |página 1, offset 20 | página 2, offset 40 | …).
Em seguida, a função allCharacters
da API é chamada e o resultado é armazenado no objeto response
. Caso o offset seja maior ou igual ao total de registros (response.data.total
) , então o final da lista foi alcançado e será passado nulo como chave da próxima página.
O retorno da função load
é um objeto do tipo LoadResult
que pode ser: Page
ou Error
. No primeiro caso, o objeto Page
requer como parâmetros: os dados da página (uma lista de Character
); a chave/número da página anterior (que aqui não está sendo utilizado); e a chave/número da página seguinte, ou nulo caso seja a última página.
A função getRefreshKey
é utilizada para retornar a chave da página quando é necessário atualizar a lista por meio da função refresh
. Verifique a documentação para mais detalhes.
Implementando o ViewModel
Após implementar o acesso à API e o PagingSource
não há mais tanto trabalho a ser feito pelo ViewModel. É preciso apenas criar um objeto Pager
onde por meio de um PagingConfig
é possível passar configurações da paginação como: a quantidade de registros por página; a distância da rolagem para carregar a próxima página; entre outras.
class MarvelCharactersViewModel : ViewModel() { val charactersPagedListFlow = Pager( PagingConfig(pageSize = MarvelApiPagingSource.PAGE_SIZE) ) { MarvelApiPagingSource(MarvelApi.getService()) }.flow.cachedIn(viewModelScope) }
O segundo parâmetro para criação do objeto Pager
é uma função lambda que retorna um PagingSource
, então está sendo retornada uma instância da classe MarvelApiPagingSource
criada na seção anterior.
Finalmente o objeto Pager
fornece um Flow
que permitirá ser observado pela UI usando o Compose.
Paging + Compose
A última etapa é implementar a UI da listagem de personagens com Jetpack Compose. A função MarvelCharactersScreen
recebe como parâmetro o ViewModel criado na seção anterior.
Para receber os dados da paginação é muito simples, basta acessar o Flow
exposto pelo ViewModel e usar a função collectAsLazyPagingItems
que é um objeto do tipo LazyPagingItems
, ambos fornecidos pela Paging Library para Compose. A biblioteca também fornece uma função items
para a LazyColumn
que recebe um objeto LazyPagingItems
(use o import da função androidx.paging.compose.items
).
@Composable fun MarvelCharactersScreen( viewModel: MarvelCharactersViewModel ) { val lazyPagingItems = viewModel.charactersPagedListFlow.collectAsLazyPagingItems() val swipeRefreshState = rememberSwipeRefreshState(false) SwipeRefresh( state = swipeRefreshState, onRefresh = { lazyPagingItems.refresh() }, ) { LazyColumn(state = rememberLazyListState()) { items(lazyPagingItems) { character -> character?.let { CharacterItem(it) } } val state = lazyPagingItems.loadState when { state.refresh is LoadState.Loading || state.append is LoadState.Loading -> { item { LoadingIndicator() } } state.append is LoadState.Error || state.refresh is LoadState.Error -> { item { ErrorRetryIndicator( onRefresh = { lazyPagingItems.refresh() } ) } } } } } }
A classe LazyPagingItems
ainda fornece o estado atual do carregamento dos dados por meio do atributo loadState
. O refresh
indica que toda a lista está sendo atualizada (o famoso “refresh” na tela), enquanto que o append
indica que itens estão sendo adicionados à lista vindos de uma nova página.
Cada item da lista será representado pela função CharacterItem
. A indicação de que os dados estão sendo carregados é feita pela função LoadingIndicator
e caso algum erro ocorra, a função ErrorRetryIndicator
será exibida permitindo reiniciar a lista por meio da função refresh
.
@Composable private fun CharacterItem( character: Character ) { Row( modifier = Modifier .padding(8.dp) .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { val thumbnail = character.thumbnail Image( painter = rememberAsyncImagePainter( "${thumbnail.pathSec}.${thumbnail.extension}" ), contentDescription = null, modifier = Modifier.size(144.dp, 144.dp), ) Spacer(modifier = Modifier.width(8.dp)) Text( text = character.name, style = MaterialTheme.typography.h6 ) } } @Composable private fun LoadingIndicator() { Box( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center ) { CircularProgressIndicator() } } @Composable fun ErrorRetryIndicator( onRefresh: () -> Unit ) { Box( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center ) { Button( onClick = onRefresh ) { Text(text = "Retry") } } }
A interface gráfica não tem nada muito especial se você já possui alguma experiência com Compose.
Para exibir essa tela, pode-se usar:
MarvelCharactersScreen(viewModel())
Não esqueça de adicionar a permissão de internet no AndroidManifest.xml (como eu sempre esqueço 🤦🏻♂️).
<uses-permission android:name="android.permission.INTERNET" />
O resultado pode ser observado na imagem a seguir:
Conclusão
Como foi possível observar nesse artigo, a utilização da Paging Library é muito simples e evita termos que adicionar lógica de rolagem da lista para carregar mais dados. Além de não ser necessário ter que ficar controlando a paginação no View Model. Tudo é feito de uma forma bem estruturada e abstraída pela biblioteca de paginação. Espero que esse artigo ajude a dar os primeiros passos com essa biblioteca.