Como fazer Paging Library + Marvel API com Jetpack Compose

Tempo de leitura: 7 minutes

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.

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 e status que indicam respectivamente o código de resposta da requisição e a descrição. Mas na classe Response foi definido apenas o atributo data, 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: o total que indica o tamanho total da lista; e results 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: idnamedescription e thumbnail.
  • A imagem do personagem possui um endereço e uma extensão que são definidas pelos campos path e extension da classe Thumbnail. Até o momento da escrita desse artigo, o caminho da imagem não vinha com o protocolo HTTPS, por isso o atributo pathSec 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"

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 é um Int 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.