Até agora nós já conseguimos mostrar todos os pokémon da região de Kanto (os 151 primeiros) na tela do nosso app, junto com seus nomes, id
s e tipos. Porém, nossa aplicação ainda está muito lenta: são necessários mais que 10 segundos para carregar apenas os primeiros monstrinhos de bolso. Por esse motivo, nesse post nós vamos melhorar a performance da nossa Pokédex usando a versão 3 da biblioteca Paging
e a Flow API
.
O que é Paging 3
?
A Paging
é uma biblioteca capaz de implementar um mecanismo chamado paginação, trata-se de carregar grandes quantidades de dados de forma gradual, reduzindo o uso de rede e de recursos do sistema. A versão 3 dessa biblioteca está estável, usa Kotlin coroutines por default e é por isso que vamos usá-la.
Vantagens de Paging 3
:
- Controla as chaves a serem utilizadas para recuperar a página seguinte e a anterior.
- Automaticamente faz a requisição da página seguinte correta quando o usuário percorre a tela para o fim dos dados carregados.
- Garante que múltiplas requisições não sejam disparadas ao mesmo tempo.
- Rastreia o estado de carregamento e nos permite exibí-lo em uma
RecyclerView
e provê uma funcionalidade de retentativa fácil para casos de falha no carregamento. - Permite o uso de operações comuns como
map
oufilter
na lista a ser exibida. - Provê um jeito fácil de implementar um separador de lista, como um footer, que iremos fazer.
- Simplifica o cache dos dados, garantindo que nós não estaremos executando transformações de dados a cada mudança de configuração.
Como podemos ver, é uma ótima solução para o nosso problema de lentidão e limitação de exibição dos pokémon, com essa biblioteca seremos capazes de exibir todos os monstrinhos 🤩
O que é Flow API
?
De forma resumida, em coroutines, um Flow
é um tipo que pode emitir vários valores sequencialmente, ao contrário das suspend functions
, que retornam apenas um valor. No nosso caso, o usaremos para emitir um fluxo de dados de pokémon a partir do Paging
para criar a nossa pokédex.
Conceitualmente, um Flow
é um stream (fluxo) de dados que pode ser computado de forma assíncrona. Os valores emitidos precisam ser do mesmo tipo. Por exemplo, um Flow<Int>
é um fluxo que emite valores inteiros. Vejamos o exemplo abaixo:
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.delay
suspend fun main() {
val flow: Flow<Int> = flow {
(0..10).forEach {
delay(2000)
emit(it)
}
}.map {
it * it
}
flow.collect {
println(it)
}
}
0
1
4
9
16
25
36
49
64
81
100
Podemos considerar esse exemplo como um Hello World de Flow
, nele nós criamos um fluxo de dados de inteiros que são emitidos a cada 2 (dois) segundos. Esses dados são transformados usando a função map
e depois coletados usando a função collect
para enfim serem exibidos no console.
Com esse simples exemplo nós podemos enxergar e entender os componentes e entidades envolvidas no fluxo de dados: o Flow Builder
(produtor), o Operator
(intermediário) e o Collector
(consumidor).
Em resumo, o Flow Builder
realiza as tarefas e emite os dados, o Operator
transforma os dados de um formato para outro e o Collector
coleta os dados emitidos pelo Flow Builder
que foram transformados pelo Operator
.
Na nossa aplicação, a fonte da dados e o repositório representarão as tarefas do consumidor e intermediário, enquanto a UI será a consumidora.
Implementando a solução
Para implementar a nossa solução precisaremos criar um PagingSource
, um Pager
, um Flow
de PagingData
e um adaptador para a UI chamado PagingDataAdapter
.
Ok, mas o que é essa sopa de letrinhas? Calma, vamos entender um por um.
PagingSource
Um PagingSource
é a definição de uma fonte de dados para a paginação e a forma como esses dados serão recuperados de uma única fonte. O PagingSource
deve ser parte da camada repository.
Vamos sobrescrever as funções getRefreshKey()
, que provê uma chave para a função load()
, e a própria função load()
, que recupera os dados paginados da fonte de dados e retorna esses dados carregados junto com as informações sobre as chaves posterior e anterior. Ambas essas funções são suspend functions
e por isso poderemos usar as funções que fazem as requisições à PokéAPI.
Primeiro de tudo, adicionaremos a dependência da biblioteca Paging
ao nosso arquivo gradle.build
:
def pagingVersion = "3.1.1"
dependencies {
...
// Paging
implementation "androidx.paging:paging-runtime:$pagingVersion"
...
}
Agora procederemos para o desenvolvimento da PagingSource
, que chamaremos de PokedexPagingSource
. O construtor primário da nossa classe vai receber uma instância de PokemonApi
e ela vai herdar de PagingSource<Int, SinglePokemon>
, ou seja, ela recebe uma chave inteira e devolve os dados de um pokémon. Iniciaremos com a construção do método getRefreshKey()
, responsável por prover uma chave para o método load()
:
package br.com.pokedex.data.datasource.repository
import androidx.paging.PagingSource
import androidx.paging.PagingState
import br.com.pokedex.data.api.PokemonApi
import br.com.pokedex.domain.model.SinglePokemon
class PokedexPagingSource(
private val api: PokemonApi
) : PagingSource<Int, SinglePokemon>() {
override fun getRefreshKey(state: PagingState<Int, SinglePokemon>): Int? {
return state.anchorPosition?.let { anchorPosition ->
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
}
}
}
A chave que esse método retorna deve fazer com que load()
carregue itens suficientes para preencher a viewport ao redor da última posição acessada, permitindo que a próxima geração os anime de forma transparente.
A última posição acessada pode ser recuperada via state.anchorPosition
, a qual é tipicamente o mais alto ou mais baixo item na viewport devido ao acesso ser disparado pela ligação dos itens conforme eles rolam na tela. Mas no nosso caso, a última posição acessada não é o bastante, nós precisamos saber qual a chave/página mais próxima a essa posição e é por isso que usamos a função closestPageToPosition
.
Por exemplo, digamos que temos que carregar 20 itens por página, carregar duas páginas significariam usar as chaves 1 e 2 e ter 40 itens carregados no total. Nessa situação, quando formos procurar pelo item na posição 30, state.anchorPosition
retornaria 30, mas essa não pode ser nossa chave, nós precisa saber em qual página esse item está, ou seja, precisamos saber que é a página 2. É por isso que usamos state.anchorPosition
e tentamos recuperar sua chave anterior, chave 1, plus(1)
(”mais um”), resultando na chave 2. Caso a chave anterior seja nula, tentamos recuperar através da chave posterior, chave 3, minus(1)
(”menos um”), resultando também na chave 2.
Chegou a hora de implementar a tão falada função load()
. O seu retorno padrão é do tipo LoadResult<Key, Value>
, no nosso caso é LoadResult<Int, SinglePokemon>
. A classe LoadResult
é uma sealed class
. Uma sealed class
é uma classe em que todas as suas subclasses devem ser desenvolvidas dentro dela mesma ou no mesmo arquivo. Essa nossa classe de retorno tem três subclasses: Error
, Invalid
e Page
. Error
representa um erro esperado (como uma falha de conexão de rede), Invalid
representa a invalidação de qualquer futura requisição do PagingSource
e Page
representa o objeto retornado com sucesso.
A classe Page
tem as seguintes propriedades relevantes para nós:
-
data
: os dados carregados -
prevKey
: a chave para a página anterior, caso ela exista -
nextKey
: a chave para a página posterior, caso ela exista
Implementação da função load()
:
package br.com.pokedex.data.datasource.repository
import androidx.paging.PagingSource
import androidx.paging.PagingState
import br.com.pokedex.data.api.PokemonApi
import br.com.pokedex.data.datasource.Constants.LAST_OFFSET
import br.com.pokedex.data.datasource.Constants.LAST_POSITION
import br.com.pokedex.data.datasource.Constants.POKEMON_OFFSET
import br.com.pokedex.data.datasource.Constants.POKEMON_STARTING_OFFSET
import br.com.pokedex.data.mapper.toModel
import br.com.pokedex.domain.model.SinglePokemon
import okio.IOException
import retrofit2.HttpException
class PokedexPagingSource(
private val api: PokemonApi
) : PagingSource<Int, SinglePokemon>() {
// ...
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, SinglePokemon> {
val position = params.key ?: POKEMON_STARTING_OFFSET
return try {
val response = api.getPokemon(
if (position == LAST_POSITION) {
LAST_OFFSET
} else {
position * POKEMON_OFFSET
}
)
val pokemon = mutableListOf<SinglePokemon>()
response.body()?.results?.map { result ->
val singlePokemon = api.getSinglePokemon(result.name)
singlePokemon.body()?.toModel()?.let { pokemon.add(it) }
}
LoadResult.Page(
data = pokemon,
prevKey = if (position == POKEMON_STARTING_OFFSET) null else position,
nextKey = if (position == LAST_POSITION) null else position + 1
)
} catch (exception: IOException) {
return LoadResult.Error(exception)
} catch (exception: HttpException) {
return LoadResult.Error(exception)
}
}
}
Objeto Constants
:
package br.com.pokedex.data.datasource
object Constants {
const val BASE_URL = "https://pokeapi.co/api/v2/"
const val POKEMON_STARTING_OFFSET = 0
const val POKEMON_OFFSET = 20
const val LAST_OFFSET = 885
const val LAST_POSITION = 45
const val PAGE_SIZE = 20
}
Também foi necessário modificar a PokemonApi
com dois novos métodos: getPokemon()
, que retorna certo número de pokémon de acordo com um offset e getSinglePokemon()
, que obtêm os dados do pokémon a partir do seu nome. Além disso, o retorno dos métodos agora é do tipo Response<T>
, necessário para a implementação com Paging
:
package br.com.pokedex.data.api
import br.com.pokedex.data.api.dto.PokemonDTO
import br.com.pokedex.data.api.dto.SinglePokemonDTO
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
interface PokemonApi {
@GET("pokemon/")
suspend fun getPokemon(
@Query("offset") offset: Int?
): Response<PokemonDTO>
@GET("pokemon/{name}")
suspend fun getSinglePokemon(
@Path("name") name: String?
) : Response<SinglePokemonDTO>
}
Classe PokemonDTO
:
package br.com.pokedex.data.api.dto
import com.google.gson.annotations.SerializedName
data class PokemonDTO(
@SerializedName("results") val results: List<PokemonResultDTO>
)
Iremos entender o código passo-a-passo.
Obtemos o valor da chave atual usando params.key
, ou seja, usamos a função getRefreshKey()
, caso seja nulo, usamos a constante POKEMON_STARTING_OFFSET
:
val position = params.key ?: POKEMON_STARTING_OFFSET
O retorno da função é um try-catch
em que tentamos recuperar os dados de 20 pokémon usando as constantes LAST_POSITION
, LAST_OFFSET
e POKEMON_OFFSET
. A lógica é simples: para evitar obter pokémon com dados inconsistentes (além do id
905) nós passamos apenas o offset final, caso seja a última posição, caso não, fazemos o cálculo de position * POKEMON_OFFSET
para obter os dados dos pokémon da vez. Além disso mapeamos o resultado dessa requisição para uma lista do tipo SinglePokemon
. Por fim instanciamos um LoadResult
e damos os valores às suas propriedades:
-
data
recebe a lista de pokémon antes mencionada -
prevKey
recebenull
, caso a posição atual seja 0 (zero), caso não, recebe a posição atual -
nextKey
recebenull
, caso seja a última posição, caso não, recebe a posição atual mais 1
val response = api.getPokemon(
if (position == LAST_POSITION) {
LAST_OFFSET
} else {
position * POKEMON_OFFSET
}
)
val pokemon = mutableListOf<SinglePokemon>()
response.body()?.results?.map { result ->
val singlePokemon = api.getSinglePokemon(result.name)
singlePokemon.body()?.toModel()?.let { pokemon.add(it) }
}
LoadResult.Page(
data = pokemon,
prevKey = if (position == POKEMON_STARTING_OFFSET) null else position,
nextKey = if (position == LAST_POSITION) null else position + 1
)
Finalmente temos os catchs
para protegermos nossa aplicação quanto a problemas de IO
(conexão de rede, por exemplo) e HTTP
(respostas com códigos diferentes de 2XX):
catch (exception: IOException) {
return LoadResult.Error(exception)
} catch (exception: HttpException) {
return LoadResult.Error(exception)
}
Ufa! Bastante coisa né? Mas calma que ainda tem mais.
Pager
e Flow<PagingData<T>>
Agora que implementamos uma forma de lidar com nossa fonte de dados remota, já podemos modificar nosso repositório para essa mudança. A partir desse momento nosso repositório irá retornar um fluxo de dados do tipo PagingData<SinglePokemon>
a partir de um Pager
, mas o que é um Pager
?
Um Pager
nada mais é do que um construtor para um fluxo reativo (reactive stream) de PagingData
, ele, unido ao Flow
, lida com todos os estados da paginação, retornando um objeto em caso de sucesso e um erro em caso contrário.
Para instanciar um Pager
, precisamos de um PageConfig
, uma configuração para um Pager
, no qual nós informamos o tamanho da página, 20, e além disso também é necessário informar a pagingSourceFactory
, ou seja, como serão fabricados os dados daquele Pager
, a saber, o PokédexPagingSource
que definimos a pouco:
package br.com.pokedex.data.datasource.repository
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import br.com.pokedex.data.api.PokemonApi
import br.com.pokedex.data.datasource.Constants.PAGE_SIZE
import br.com.pokedex.domain.model.SinglePokemon
import br.com.pokedex.domain.repository.PokemonRepository
import kotlinx.coroutines.flow.Flow
class PokemonRepositoryImpl(private val api: PokemonApi) : PokemonRepository {
override fun getSinglePokemon(): Flow<PagingData<SinglePokemon>> {
return Pager(
config = PagingConfig(
pageSize = PAGE_SIZE
),
pagingSourceFactory = { PokedexPagingSource(api) }
).flow
}
}
Por fim usamos a propriedade flow
de Pager
para indicar que será retornado um fluxo de PagingData
, isto é, serão emitidas novas instâncias de PagingData
em certas circunstâncias.
Interface PokemonRepository
modificada:
package br.com.pokedex.domain.repository
import androidx.paging.PagingData
import br.com.pokedex.data.api.Resource
import br.com.pokedex.data.api.dto.SinglePokemonDTO
import br.com.pokedex.domain.model.SinglePokemon
import kotlinx.coroutines.flow.Flow
interface PokemonRepository {
fun getSinglePokemon(): Flow<PagingData<SinglePokemon>>
}
PagingDataAdapter
Como já implementamos a camada de repository, vamos modificar a ViewModel para refletir essas mudanças. Não usaremos mais LiveData, usaremos Flow
:
package br.com.pokedex.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import br.com.pokedex.domain.interactor.GetSinglePokemonUseCase
import br.com.pokedex.domain.model.SinglePokemon
import kotlinx.coroutines.flow.Flow
class PokedexViewModel(
private val useCase: GetSinglePokemonUseCase
) : ViewModel() {
fun getPokemonFlow(): Flow<PagingData<SinglePokemon>> {
return useCase.execute().cachedIn(viewModelScope)
}
}
Ficou bem mais simples, né? Nós basicamente mudamos o tipo de retorno da função getPokemonFlow()
, antes chamada de getPokemon()
, para Flow<PagingData<SinglePokemon>>
e chamamos o UseCase, mas com a diferença que aplicamos uma nova função a essa chamada: cachedIn()
. O que essa função faz?
A função cachedIn()
cria um cache (área de memória de acesso rápido) de PagingData
. Isso faz com que o flow
seja mantido ativo enquanto o dado escopo está ativo, no nosso caso o escopo da ViewModel. Dessa forma garantimos que, qualquer que seja a mudança de configuração (rotação da tela, por exemplo), a nova Activity já irá receber os dados existentes, ao invés de fazer novas requisições para obter os dados do zero.
Note que o código do UseCase não foi alterado, graças a sintaxe enxuta do Kotlin:
package br.com.pokedex.domain.interactor
import br.com.pokedex.domain.repository.PokemonRepository
class GetSinglePokemonUseCase(private val repository: PokemonRepository) {
fun execute() = repository.getSinglePokemon()
}
Pronto, já podemos criar o PagingDataAdapter
que vai tornar possível conectar tudo isso com a UI. Basicamente vamos alterar o adapter já existente para refletir as mudanças que fizemos. O PokemonViewHolder
se manterá o mesmo:
class PokemonViewHolder(binding: PokemonCardBinding) :
RecyclerView.ViewHolder(binding.root) {
private val image = binding.pokemonImage
private val name = binding.pokemonName
private val id = binding.pokemonId
private val firstType = binding.firstPokemonType
private val secondType = binding.secondPokemonType
fun bind(singlePokemon: SinglePokemon) {
loadPokemonImage(image, singlePokemon.imageUrl)
name.text = singlePokemon.name
id.text = singlePokemon.id.toString()
firstType.text = singlePokemon.types.first().name
secondType.text = singlePokemon.types.last().name
secondType.apply {
showIf(text.isNotEmpty())
}
}
private fun loadPokemonImage(image: ImageView, imageUrl: String) {
image.load(imageUrl)
}
}
O retorno da nossa classe será PagingDataAdapter
, no entanto, para utilizarmos esse tipo de adapter é necessário implementar a classe DiffUtil.ItemCallback
. Essa classe é responsável por realizar um callback (passar uma função como parâmetro de outra) que calcula a diferença entre dois itens não-nulos na lista. Em outras palavras, ela serve para verificar se dois objetos representam o mesmo item e checar se dois itens tem os mesmos dados.
Vamos implementá-la como uma singleton class
, uma classe de instância única. Kotlin desenvolve esse padrão de projeto com o uso da palavra chave object
e como essa singleton class
ficará dentro de outra, ela será um companion object
:
companion object {
private val POKEMON_COMPARATOR = object
: DiffUtil.ItemCallback<SinglePokemon>() {
override fun areItemsTheSame(
oldItem: SinglePokemon,
newItem: SinglePokemon
): Boolean =
oldItem.id == newItem.id
override fun areContentsTheSame(
oldItem: SinglePokemon,
newItem: SinglePokemon
): Boolean =
oldItem == newItem
}
}
Como podemos ver, sobrescrevemos dois métodos de DiffUtil.ItemCallback
: areItemsTheSame()
e areContentsTheSame()
. Eles são encarregados de checar se dois itens são o mesmo item e se dois itens têm o mesmo conteúdo, respectivamente.
Nossa implementação é bem simples: para verificar se dois itens são o mesmo item, basta compararmos os id
s dos pokémon e para verificar se dois itens têm o mesmo conteúdo podemos fazer uma comparação direta mesmo.
Podemos partir para o desenvolvimento das funções onBindViewHolder()
e onCreateViewHolder()
, não será necessário sobrescrevermos o método getItemCount()
, o PagingDataAdapter
será responsável por isso.
Nosso onBindViewHolder()
ficará dessa forma:
override fun onBindViewHolder(holder: PokemonViewHolder, position: Int) {
val singlePokemon = getItem(position)
singlePokemon?.let {
holder.bind(it)
}
}
Aqui usamos o método getItem()
fornecido pelo PagingDataAdapter
que nos retorna o item que precisamos a partir da sua posição, visto que o retorno pode ser nulo, usamos uma safe call e finalmente realizamos o bind()
.
E quanto ao nosso método onCreateViewHolder()
, nada muda:
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PokemonViewHolder {
return PokemonViewHolder(
PokemonCardBinding.inflate(
LayoutInflater.from(context),
parent,
false
)
)
}
Está feito! Temos o nosso adapter:
package br.com.pokedex.presentation
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.ImageView
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import br.com.pokedex.databinding.PokemonCardBinding
import br.com.pokedex.domain.model.SinglePokemon
import br.com.pokedex.util.showIf
import coil.load
class PokedexAdapter(
private val context: Context
) : PagingDataAdapter<SinglePokemon, PokedexAdapter.PokemonViewHolder>(
POKEMON_COMPARATOR
) {
override fun onBindViewHolder(holder: PokemonViewHolder, position: Int) {
val singlePokemon = getItem(position)
singlePokemon?.let {
holder.bind(it)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PokemonViewHolder {
return PokemonViewHolder(
PokemonCardBinding.inflate(
LayoutInflater.from(context),
parent,
false
)
)
}
inner class PokemonViewHolder(binding: PokemonCardBinding) :
RecyclerView.ViewHolder(binding.root) {
private val image = binding.pokemonImage
private val name = binding.pokemonName
private val id = binding.pokemonId
private val firstType = binding.firstPokemonType
private val secondType = binding.secondPokemonType
fun bind(singlePokemon: SinglePokemon) {
loadPokemonImage(image, singlePokemon.imageUrl)
name.text = singlePokemon.name
id.text = singlePokemon.id.toString()
firstType.text = singlePokemon.types.first().name
secondType.text = singlePokemon.types.last().name
secondType.apply {
showIf(text.isNotEmpty())
}
}
private fun loadPokemonImage(image: ImageView, imageUrl: String) {
image.load(imageUrl)
}
}
companion object {
private val POKEMON_COMPARATOR = object
: DiffUtil.ItemCallback<SinglePokemon>() {
override fun areItemsTheSame(
oldItem: SinglePokemon,
newItem: SinglePokemon
): Boolean =
oldItem.id == newItem.id
override fun areContentsTheSame(
oldItem: SinglePokemon,
newItem: SinglePokemon
): Boolean =
oldItem == newItem
}
}
}
Conectando o adapter na UI
Procederemos para a conexão do adapter na UI. As duas funções mais importantes que construiremos serão as seguintes: setUpAdapter()
e getPokemon()
. A função setUpAdapter()
se encarregará de configurar o nosso adapter:
private fun setUpAdapter() {
pokedexAdapter.addLoadStateListener { loadState ->
when(loadState.refresh) {
is LoadState.Loading -> setUpLoadingView()
is LoadState.Error -> setUpErrorView()
else -> setUpSuccessView()
}
}
}
O PagingDataAdapter
nos fornece uma excelente classe para tratamento de erros chamada CombinedLoadStates
, que provê todos os estados de carregamento do adapter. O que nos interessa aqui é o estado refresh, que representa o estado do adapter no seu primeiro carregamento: Loading
, Error
e NotLoading
, nos interessa apenas os dois primeiros. Caso esse estado seja Loading
, configuramos a UI para representar um carregamento, caso seja Erorr
, configuramos a UI para representar um erro e caso não seja nenhum desses dois, consideramos sucesso e apresentamos a lista de pokémon.
Métodos de configuração da UI:
private fun setUpSuccessView() {
binding.apply {
pokedexRecyclerView.showView()
pokedexCircularProgressIndicator.hideView()
pokedexErrorMessage.hideView()
}
}
private fun setUpErrorView() {
binding.apply {
pokedexRecyclerView.hideView()
pokedexCircularProgressIndicator.hideView()
pokedexErrorMessage.showView()
}
}
private fun setUpLoadingView() {
binding.apply {
pokedexRecyclerView.hideView()
pokedexCircularProgressIndicator.showView()
pokedexErrorMessage.hideView()
}
}
Já conhecemos pokedexRecyclerView
, mas aqui referenciamos duas outras views: pokedexCircularProgressIndicator
e pokedexErrorMessage
. A primeira se trata de uma progress bar em forma de círculo fornecida pelo Material Design e a segunda é apensa um TextView que apresenta uma mensagem de erro. Segue código modificado de activity_main.xml
:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/pokedexRecyclerView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:listitem="@layout/pokemon_card"
android:visibility="visible"/>
<TextView
android:id="@+id/pokedexErrorMessage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAlignment="center"
app:layout_constraintTop_toBottomOf="@id/pokedexRecyclerView"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:text="@string/error_message"
android:visibility="gone"/>
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/pokedexCircularProgressIndicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:indeterminate="true"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
Deixamos as duas views auxiliares gone
por padrão e controlamos as suas visibilidades por meio dos seguintes métodos:
package br.com.pokedex.util
import com.google.android.material.progressindicator.CircularProgressIndicator
fun CircularProgressIndicator.showView() {
visibility = CircularProgressIndicator.VISIBLE
}
fun CircularProgressIndicator.hideView() {
visibility = CircularProgressIndicator.GONE
}
package br.com.pokedex.util
import android.widget.TextView
fun TextView.showIf(condition: Boolean) {
if(condition) {
visibility = TextView.VISIBLE
}
}
fun TextView.showView() {
visibility = TextView.VISIBLE
}
fun TextView.hideView() {
visibility = TextView.GONE
}
package br.com.pokedex.util
import androidx.recyclerview.widget.RecyclerView
fun RecyclerView.showView() {
visibility = RecyclerView.VISIBLE
}
fun RecyclerView.hideView() {
visibility = RecyclerView.GONE
}
Também atualizamos o arquivo strings.xml
, que contém as strings do nosso projeto:
<resources>
<string name="app_name">Pokédex</string>
<string name="error_message">Something went wrong\nPlease, check your network connection and try reopen the app</string>
</resources>
Agora vamos analisar o método getPokemon()
. Esse método é responsável por coletar os dados a partir da ViewModel e submetê-los para o adapter. Para fazer isso usamos as funções collectLatest
do Flow
e submitData()
do PagingDataAdapter
, respectivamente:
private fun getPokemon() {
lifecycleScope.launch {
viewModel.getPokemonFlow().collectLatest { pokemon ->
pokedexAdapter.submitData(pokemon)
}
}
}
Por fim, nossa classe PokedexActivity
ficará dessa forma:
package br.com.pokedex.presentation
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.paging.LoadState
import androidx.recyclerview.widget.LinearLayoutManager
import br.com.pokedex.databinding.ActivityPokedexBinding
import br.com.pokedex.util.hideView
import br.com.pokedex.util.showView
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.koin.androidx.viewmodel.ext.android.viewModel
class PokedexActivity : AppCompatActivity() {
private val binding by lazy {
ActivityPokedexBinding.inflate(layoutInflater)
}
private val viewModel: PokedexViewModel by viewModel()
private val pokedexAdapter by lazy {
PokedexAdapter(this)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
setUpAdapter()
setUpPokedexRecyclerView()
getPokemon()
}
private fun setUpAdapter() {
pokedexAdapter.addLoadStateListener { loadState ->
when(loadState.refresh) {
is LoadState.Loading -> setUpLoadingView()
is LoadState.Error -> setUpErrorView()
else -> setUpSuccessView()
}
}
}
private fun setUpSuccessView() {
binding.apply {
pokedexRecyclerView.showView()
pokedexCircularProgressIndicator.hideView()
pokedexErrorMessage.hideView()
}
}
private fun setUpErrorView() {
binding.apply {
pokedexRecyclerView.hideView()
pokedexCircularProgressIndicator.hideView()
pokedexErrorMessage.showView()
}
}
private fun setUpLoadingView() {
binding.apply {
pokedexRecyclerView.hideView()
pokedexCircularProgressIndicator.showView()
pokedexErrorMessage.hideView()
}
}
private fun setUpPokedexRecyclerView() {
binding.pokedexRecyclerView.apply {
layoutManager = LinearLayoutManager(context)
adapter = pokedexAdapter
}
}
private fun getPokemon() {
lifecycleScope.launch {
viewModel.getPokemonFlow().collectLatest { pokemon ->
pokedexAdapter.submitData(pokemon)
}
}
}
}
Já podemos rodar o app e vermos os três tipos de visualização:
Tela de loading:
Tela de erro:
Tela de sucesso:
Com essa implementação conseguimos reduzir o tempo de espera de carregamento dos pokémon para menos de 5 segundos, uma melhora de mais de 50%! Parabéns para nós 😎👊
Mas ainda temos um problema: apesar de tratarmos possíveis erros no primeiro carregamento da tela, não fazemos isso depois. Ou seja, caso a internet do celular pare de funcionar por algum motivo, o usuário ficará sem saber o que fazer. Precisamos melhorar essa experiência. Como podemos fazer isso? Adicionando um footer!
Melhorando a experiência com footer
A biblioteca Paging
oferece diversas ferramentas para melhorar a experiência da sua implementação, uma delas é o footer, que nada mais é do que um rodapé, um texto, botão ou imagem que sempre vai ficar no fim da tela. Usaremos esse footer para exibir uma progress bar circular de carregamento ou uma mensagem de erro e um botão para tentar carregar os dados restantes novamente.
Antes de tudo vamos criar um layout para esse footer, ele será composto de um CircularProgressIndicator
, para representar o carregamento, um TextView
, o texto de erro e um MaterialButton
, o botão para tentar novamente. Todos eles terão visibilidade gone
por default:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/loadStateErrorMessage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/error_message"
android:layout_gravity="center"
android:textAlignment="center"
android:visibility="gone"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/loadStateTryAgainButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/try_again"
android:layout_gravity="center"
android:visibility="gone"
android:layout_marginTop="16dp"/>
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/loadStateProgressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:indeterminate="true"
android:layout_gravity="center" />
</LinearLayout>
Arquivo strings.xml
modificado:
<resources>
<string name="app_name">Pokédex</string>
<string name="error_message">Something went wrong\nCheck your network connection and try again</string>
<string name="try_again">Try again</string>
<string name="load_error_message">Something went wrong\nTry again</string>
</resources>
Para implementarmos esse footer também precisaremos criar um adapter, o LoadStateAdapter
e esse adapter precisa de um ViewHolder. Vamos chamá-lo de PokedexLoadStateViewHolder
:
package br.com.pokedex.presentation
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.LoadState
import androidx.recyclerview.widget.RecyclerView
import br.com.pokedex.R
import br.com.pokedex.databinding.PokedexLoadStateFooterBinding
import br.com.pokedex.util.hideView
import br.com.pokedex.util.showView
class PokedexLoadStateViewHolder(
private val binding: PokedexLoadStateFooterBinding,
tryAgain: () -> Unit
) : RecyclerView.ViewHolder(binding.root) {
init {
binding.loadStateTryAgainButton.setOnClickListener { tryAgain.invoke() }
}
fun bind(loadState: LoadState) {
when(loadState) {
is LoadState.Loading -> {
binding.loadStateProgressBar.showView()
binding.loadStateErrorMessage.hideView()
binding.loadStateTryAgainButton.hideView()
}
is LoadState.Error -> {
binding.loadStateErrorMessage.showView()
binding.loadStateTryAgainButton.showView()
binding.loadStateProgressBar.hideView()
}
is LoadState.NotLoading -> {
// Do nothing
}
}
}
companion object {
fun create(parent: ViewGroup, tryAgain: () -> Unit) : PokedexLoadStateViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.pokedex_load_state_footer, parent, false)
val binding = PokedexLoadStateFooterBinding.bind(view)
return PokedexLoadStateViewHolder((binding), tryAgain)
}
}
}
Código das extension functions do MaterialButton
:
package br.com.pokedex.util
import com.google.android.material.button.MaterialButton
fun MaterialButton.showView() {
visibility = MaterialButton.VISIBLE
}
fun MaterialButton.hideView() {
visibility = MaterialButton.GONE
}
O nosso ViewHolder recebe duas coisas no seu construtor: uma propriedade binding
, que contém as referências às views do nosso layout, um callback da função que será executada ao clicar no botão de tentar novamente. Aqui também usamos uma initializer block, trata-se de um bloco de código que será executado sempre que a classe for criada. Nele nós informamos que a ação de clique no botão irá invocar a função callback tryAgain()
, ou seja, irá executá-la.
class PokedexLoadStateViewHolder(
private val binding: PokedexLoadStateFooterBinding,
tryAgain: () -> Unit
) : RecyclerView.ViewHolder(binding.root) {
init {
binding.loadStateTryAgainButton.setOnClickListener { tryAgain.invoke() }
}
// ...
}
Depois disso criamos a função bind()
, que irá ligar o adapter ao ViewHolder. Em resumo, ela usa os loadStates
para tornar a UI reativa, faz com que ela reaja aos estados de sucesso, erro e carregamento:
fun bind(loadState: LoadState) {
when(loadState) {
is LoadState.Loading -> {
binding.loadStateProgressBar.showView()
binding.loadStateErrorMessage.hideView()
binding.loadStateTryAgainButton.hideView()
}
is LoadState.Error -> {
binding.loadStateErrorMessage.showView()
binding.loadStateTryAgainButton.showView()
binding.loadStateProgressBar.hideView()
}
is LoadState.NotLoading -> {
// Do nothing
}
}
}
Por fim, desenvolvemos uma função que retorna uma instância desse ViewHolder em um companion object
, ela recebe uma ViewGroup, que chamamos de parent
, e uma função callback tryAgain()
. O parent
trata-se do layout-pai desse outro layout, isto é, o activity_main.xml
, e o tryAgain()
é a função que será executada ao clicar no botão de tentar novamente:
companion object {
fun create(parent: ViewGroup, tryAgain: () -> Unit) : PokedexLoadStateViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.pokedex_load_state_footer, parent, false)
val binding = PokedexLoadStateFooterBinding.bind(view)
return PokedexLoadStateViewHolder((binding), tryAgain)
}
}
O código do nosso adapter será bem simples: sobrescrevemos os métodos onBindViewHolder()
e onCreateViewHolder()
. No primeiro, chamamos a função bind()
do ViewHolder e no segundo chamamos a função create()
:
package br.com.pokedex.presentation
import android.view.ViewGroup
import androidx.paging.LoadState
import androidx.paging.LoadStateAdapter
class PokedexLoadStateAdapter(
private val tryAgain: () -> Unit
) : LoadStateAdapter<PokedexLoadStateViewHolder>() {
override fun onBindViewHolder(holder: PokedexLoadStateViewHolder, loadState: LoadState) {
holder.bind(loadState)
}
override fun onCreateViewHolder(
parent: ViewGroup,
loadState: LoadState
): PokedexLoadStateViewHolder {
return PokedexLoadStateViewHolder.create(parent, tryAgain)
}
}
Note que o PokedexLoadStateAdapter
recebe por parâmetro uma função callback, se trata da função que é executada ao clicar no botão de tentar novamente. Estamos a propagando desde a UI até o ViewHolder a passando na função create()
no método onCreateViewHolder()
.
Estamos quase finalizando, mas antes precisamos fazer uma alteração em PokedexAdapter
. Da forma como desenvolvemos o footer ele será exibido com o tamanho de um único span no RecyclerView e como planejamos apresentar os pokémon na forma de um grid de duas colunas, temos que consertar isso.
A lógica é a seguinte: caso o item de visualização seja um pokémon, esse item terá tamanho de span um, caso não seja, terá tamanho de span dois. Vamos começar sobrescrevendo a função getViewType()
da PokedexAdapter
:
override fun getItemViewType(position: Int): Int {
return if (position == itemCount) {
NETWORK_VIEW_TYPE
} else {
POKEMON_VIEW_TYPE
}
}
Se a posição atual for igual a última, ou seja, igual a itemCount
, o tipo de item de visualização será NETWORK_VIEW_TYPE
, isto é, será um item que irá exibir a progress bar ou o erro. Caso não seja, será um item do tipo POKEMON_VIEW_TYPE
, em outras palavras, o item exibirá um pokémon.
Objeto Constants
modificado:
package br.com.pokedex.util
object Constants {
const val BASE_URL = "https://pokeapi.co/api/v2/"
const val POKEMON_STARTING_OFFSET = 0
const val POKEMON_OFFSET = 20
const val LAST_OFFSET = 885
const val LAST_POSITION = 45
const val PAGE_SIZE = 20
const val NETWORK_VIEW_TYPE = 2
const val POKEMON_VIEW_TYPE = 1
}
Pronto, já podemos começar a conectar o adpter à UI. A principal modifição será no método setUpPokedexRecyclerView()
. É nele que vamos implementar essa lógica de tamanho de span:
package br.com.pokedex.presentation
import ...
private const val SPAN_COUNT = 2
class PokedexActivity : AppCompatActivity() {
//...
private fun setUpPokedexRecyclerView() {
binding.pokedexRecyclerView.apply {
val gridLayoutManager = GridLayoutManager(context, SPAN_COUNT)
gridLayoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
val viewType = pokedexAdapter.getItemViewType(position)
return if (viewType == POKEMON_VIEW_TYPE) ONE_SPAN_SIZE
else TWO_SPANS_SIZE
}
}
layoutManager = gridLayoutManager
adapter = pokedexAdapter.withLoadStateFooter(
footer = PokedexLoadStateAdapter { pokedexAdapter.retry() }
)
}
}
//...
}
Objeto Constants
modificado:
package br.com.pokedex.util
object Constants {
const val BASE_URL = "https://pokeapi.co/api/v2/"
const val POKEMON_STARTING_OFFSET = 0
const val POKEMON_OFFSET = 20
const val LAST_OFFSET = 885
const val LAST_POSITION = 45
const val PAGE_SIZE = 20
const val NETWORK_VIEW_TYPE = 2
const val POKEMON_VIEW_TYPE = 1
const val ONE_SPAN_SIZE = 1
const val TWO_SPANS_SIZE = 2
}
Primeiro nós criamos um GridLayoutManager
, com span_count igual a dois. Depois nós instanciamos uma nova classe de SpanSizeLookUp
como uma classe singleton
e sobrescrevemos o método getSpanSizeLookUp()
. Dentro desse método, nós obtemos o viewType
do item atual, caso seja um pokémon, seu span terá tamanho um, caso não, seu span terá tamanho dois. Após isso, definimos que o gridLayout
que configuramos será o layoutManager
do RecyclerView e na definição do adapter fazemos uma modificação no código: informamos que agora a pokedexAdapter
terá um footer, o PokedexLoadStateAdapter
. Note que aqui finalmente passamos a função que será executada ao clicar no botão de tentar novamente: pokedexAdapter.retry()
, essa função tenta recarregar qualquer requisição que falhou durante a paginação.
Para prover essa funcionalidade de retry()
também no carregamento inicial, modificamos nosso layout da activity, inserindo um MaterialButton
que quando clicado executa a função retry()
:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/pokedexRecyclerView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:listitem="@layout/pokemon_card" />
<TextView
android:id="@+id/pokedexErrorMessage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/error_message"
android:textAlignment="center"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/pokedexRecyclerView" />
<com.google.android.material.button.MaterialButton
android:id="@+id/pokedexTryAgainButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/try_again"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/pokedexErrorMessage" />
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/pokedexCircularProgressIndicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:indeterminate="true"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
Para relembrar como está o strings.xml
:
<resources>
<string name="app_name">Pokédex</string>
<string name="error_message">Something went wrong\nCheck your network connection and try again</string>
<string name="try_again">Try again</string>
<string name="load_error_message">Something went wrong\nTry again</string>
</resources>
Sendo assim, a nossa classe PokedexActivity
ficará dessa forma:
package br.com.pokedex.presentation
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.paging.LoadState
import androidx.recyclerview.widget.GridLayoutManager
import br.com.pokedex.databinding.ActivityPokedexBinding
import br.com.pokedex.util.Constants.NETWORK_VIEW_TYPE
import br.com.pokedex.util.Constants.ONE_SPAN_SIZE
import br.com.pokedex.util.Constants.POKEMON_VIEW_TYPE
import br.com.pokedex.util.Constants.TWO_SPANS_SIZE
import br.com.pokedex.util.hideView
import br.com.pokedex.util.showView
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.koin.androidx.viewmodel.ext.android.viewModel
private const val SPAN_COUNT = 2
class PokedexActivity : AppCompatActivity() {
private val binding by lazy { ActivityPokedexBinding.inflate(layoutInflater) }
private val pokedexAdapter by lazy { PokedexAdapter(this) }
private val viewModel: PokedexViewModel by viewModel()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
setUpAdapter()
setUpTryAgainButton()
setUpPokedexRecyclerView()
getPokemon()
}
private fun setUpAdapter() {
pokedexAdapter.addLoadStateListener { loadState ->
when(loadState.refresh) {
is LoadState.Loading -> setUpLoadingView()
is LoadState.Error -> setUpErrorView()
else -> setUpSuccessView()
}
}
}
private fun setUpSuccessView() {
binding.apply {
pokedexRecyclerView.showView()
pokedexCircularProgressIndicator.hideView()
pokedexErrorMessage.hideView()
pokedexTryAgainButton.hideView()
}
}
private fun setUpErrorView() {
binding.apply {
pokedexRecyclerView.hideView()
pokedexCircularProgressIndicator.hideView()
pokedexErrorMessage.showView()
pokedexTryAgainButton.showView()
}
}
private fun setUpLoadingView() {
binding.apply {
pokedexRecyclerView.hideView()
pokedexCircularProgressIndicator.showView()
pokedexErrorMessage.hideView()
pokedexTryAgainButton.hideView()
}
}
private fun setUpTryAgainButton() {
binding.pokedexTryAgainButton.setOnClickListener {
pokedexAdapter.refresh()
}
}
private fun setUpPokedexRecyclerView() {
binding.pokedexRecyclerView.apply {
val gridLayoutManager = GridLayoutManager(context, SPAN_COUNT)
gridLayoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
val viewType = pokedexAdapter.getItemViewType(position)
return if (viewType == POKEMON_VIEW_TYPE) ONE_SPAN_SIZE
else TWO_SPANS_SIZE
}
}
layoutManager = gridLayoutManager
adapter = pokedexAdapter.withLoadStateFooter(
footer = PokedexLoadStateAdapter { pokedexAdapter.retry() }
)
}
}
private fun getPokemon() {
lifecycleScope.launch {
viewModel.getPokemonFlow().collectLatest { pokemon ->
pokedexAdapter.submitData(pokemon)
}
}
}
}
Finalmente nosso trabalho por hoje acabou! Vamos rodar o app e ver como ficaram as telas.
Tela de erro modificada:
Tela de pokémon com footer de carregamento:
Tela de pokémon com o footer de erro:
Próximos posts
Nos próximos posts vamos embelezar nossa Pokédex usando Navigation e Material Design, também vamos salvar os dados dos pokémon em um banco de dados usando Android Room e testar nosso app usando JUnit e Mockk.
Obrigado pela atenção e até a próxima!
Repo no github:
Post anterior:
Top comments (0)