1. Introdução
O que você vai aprender
- Quais são os principais componentes da Paging 3.
- Como adicionar a Paging 3 ao projeto.
- Como adicionar um cabeçalho ou rodapé a uma lista usando a API Paging 3.
- Como adicionar separadores de lista usando a API Paging 3.
- Como criar páginas usando a rede e o banco de dados.
O que você vai criar
Neste codelab, você começará com um app de exemplo que já exibe uma lista de repositórios do GitHub. Sempre que o usuário rolar para o fim da lista exibida, uma nova solicitação de rede será acionada e o resultado será exibido na tela.
Você adicionará o código seguindo várias etapas, para fazer o seguinte:
- Migrar para os componentes da biblioteca Paging
- Adicionar um cabeçalho e um rodapé com o status de carregamento à lista
- Mostrar o progresso do carregamento entre cada nova pesquisa de repositórios
- Adicionar separadores à lista
- Adicionar compatibilidade com o banco de dados para paginação na rede e no banco de dados
O app vai ter esta aparência:
O que é necessário
- Android Studio Arctic Fox.
- Familiaridade com estes componentes da arquitetura: LiveData, ViewModel, Vinculação de visualizações e com o Guia para a arquitetura do app.
- Familiaridade com corrotinas e fluxo Kotlin (links em inglês).
Para uma introdução aos componentes da arquitetura, consulte o codelab Room com View. Para uma introdução ao fluxo, confira o codelab Corrotinas avançadas com fluxo do Kotlin e LiveData.
2. Configurar o ambiente
Nesta etapa, você vai fazer o download do código para o codelab inteiro e executará um app simples de exemplo.
Para começar o mais rápido possível, preparamos um projeto inicial para você desenvolver.
Caso você tenha o git instalado, basta executar o comando abaixo. Para verificar se o git está instalado, digite git --version
na linha de comando ou terminal e veja se ele é executado corretamente.
git clone https://github.com/googlecodelabs/android-paging
O estado inicial fica na ramificação mestre. Veja abaixo onde encontrar a solução para algumas etapas (links em inglês):
- Ramificação step5-9_paging_3.0: solução para as etapas de 5 a 9, em que adicionamos a versão mais recente da Paging ao projeto.
- Remificação step10_loading_state_footer: solução para a etapa 10, em que adicionamos um rodapé que exibe o estado de carregamento.
- Ramificação step11_loading_state: solução para a etapa 11, em que adicionamos uma tela para o estado de carregamento entre as consultas.
- Ramificação step12_separators: solução para a etapa 12, em que adicionamos separadores ao app.
- Ramificação step13-19_network_and_database: solução para as etapas de 13 a 19, em que adicionamos suporte off-line ao app.
Caso você não tenha o git, clique no botão abaixo para fazer o download de todo o código deste codelab:
- Descompacte o código e abra o projeto no Android Studio.
- Faça a configuração de execução
app
em um dispositivo ou emulador.
O app vai ser executado e exibirá uma lista de repositórios do GitHub parecida com esta:
3. Visão geral do projeto
O app permite pesquisar no GitHub repositórios que contenham uma palavra específica no nome ou na descrição. A lista de repositórios é exibida primeiro em ordem decrescente, com base no número de estrelas e depois em ordem alfabética pelo nome.
O app segue a arquitetura recomendada no Guia para a arquitetura do app. Veja o que você encontrará em cada pacote:
- api: chamadas de API do GitHub que usam o Retrofit.
- data: a classe do repositório, responsável por acionar solicitações de API e armazenar em cache as respostas na memória.
- model: o modelo de dados
Repo
, que também é uma tabela do banco de dados da Room, eRepoSearchResult
, classe usada pela IU para observar dados de resultados de pesquisas e erros de rede. - ui: classes relacionadas à exibição de uma
Activity
usando umaRecyclerView
.
A classe GithubRepository
recupera a lista de nomes de repositório da rede sempre que o usuário rola até o fim da lista ou quando procura um novo repositório. A lista de resultados de uma consulta é mantida na memória no GithubRepository
em um ConflatedBroadcastChannel
e exibida como um Flow
.
O SearchRepositoriesViewModel
solicita os dados do GithubRepository
e os expõe à SearchRepositoriesActivity
. Como queremos garantir que os dados não sejam solicitados várias vezes a cada mudança de configuração (por exemplo, rotação), convertemos o Flow
para LiveData
no ViewModel
, usando o método liveData()
do builder. Dessa forma, o LiveData
armazena em cache a lista mais recente de resultados na memória. Quando a SearchRepositoriesActivity
for recriada, o conteúdo do LiveData
vai ser exibido na tela. O ViewModel
expõe:
- Um
LiveData<UiState>
- Uma função
(UiAction) -> Unit
O UiState
é uma representação de todos os elementos necessários para renderizar a IU do app, incluindo campos diferentes que correspondem a diferentes componentes da IU. Esse é um objeto imutável, de modo que não é possível fazer modificações nele, mas é possível produzir novas versões do objeto que podem ser observadas pela IU. Em nosso caso, as novas versões vão ser produzidas como resultado de uma ação do usuário, seja fazer uma nova consulta ou rolar a lista para buscar mais dados.
As ações do usuário são representadas adequadamente pelo tipo UiAction
. Delimitar a API para interações com o ViewModel
em um único tipo gera estes benefícios:
- Superfície de API pequena: as ações podem ser adicionadas, removidas ou modificadas, mas a assinatura do método
ViewModel
nunca muda. Isso faz com que a refatoração ocorra localmente e diminui as chances de vazamento de abstrações ou implementações de interface. - Gerenciamento de simultaneidade facilitado: como veremos mais adiante neste codelab, é importante garantir a ordem de execução de algumas solicitações. Ao tipar a API com
UiAction
, podemos programar códigos que tenham requisitos rigorosos com relação ao que pode acontecer e quando isso pode acontecer.
Do ponto de vista da usabilidade, ocorrem os problemas abaixo:
- O usuário não recebe informações sobre o estado de carregamento da lista. Ele vê uma tela vazia ao pesquisar um novo repositório ou apenas um fim repentino da lista enquanto mais resultados da mesma consulta são carregados.
- O usuário não consegue repetir uma consulta que falhou.
- A lista sempre rola para a parte de cima da tela depois de mudar a orientação ou interromper o processo.
Do ponto de vista da implementação, ocorrem os problemas abaixo:
- A lista aumenta de forma ilimitada, desperdiçando memória à medida que o usuário rola a tela.
- É preciso converter os resultados do
Flow
emLiveData
para armazená-los em cache, o que aumenta a complexidade do código. - Caso o app precise exibir várias listas, vemos que há muito código boilerplate a ser programado para cada uma delas.
Vamos descobrir como a biblioteca Paging pode nos ajudar com esses problemas e quais componentes ela inclui.
4. Componentes da biblioteca Paging
A biblioteca Paging facilita o carregamento gradual e controlado de dados para a IU do app. A API Paging oferece compatibilidade com muitas das funcionalidades que precisariam ser implementadas manualmente ao tentar carregar dados para páginas:
- Monitora as chaves a serem usadas para recuperar a página seguinte e a anterior.
- Solicita automaticamente a página correta quando o usuário rola até o fim da lista.
- Garante que várias solicitações não sejam acionadas ao mesmo tempo.
- Autoriza o armazenamento de dados em cache. Se você estiver usando Kotlin, isso será feito em um
CoroutineScope
. Se estiver usando Java, isso poderá ser feito com oLiveData
. - Monitora o estado de carregamento e permite exibi-lo em um item de lista da
RecyclerView
ou em outro lugar da IU, além de repetir com facilidade carregamentos que falharam. - Autoriza a execução de operações comuns, como
map
oufilter
, na lista que será exibida, independentemente de você estar usandoFlow
,LiveData
ou umFlowable
ouObservable
RxJava: - Oferece uma forma fácil de implementar separadores de lista.
O Guia para a arquitetura do app propõe uma arquitetura com os seguintes componentes principais:
- Um banco de dados local que serve como a única referência para dados apresentados ao usuário e manipulados por ele.
- Um serviço de API da Web.
- Um repositório que funciona com o banco de dados e o serviço de API da Web, fornecendo uma interface de dados unificada.
- Um
ViewModel
que fornece dados específicos para a IU. - A IU, que exibe uma representação visual dos dados no
ViewModel
.
A biblioteca Paging funciona com todos esses componentes e coordena as interações entre eles, para que seja possível carregar "páginas" do conteúdo localizado em uma fonte de dados e exibi-las na IU.
Este codelab introduz a biblioteca Paging e seus principais componentes:
PagingData
: contêiner para dados paginados. Cada atualização de dados terá umPagingData
separado correspondente.PagingSource
: aPagingSource
é a classe base para carregar snapshots de dados em um stream doPagingData
.Pager.flow
: cria umFlow<PagingData>
com base em umaPagingConfig
e uma função que define como construir aPagingSource
implementada.PagingDataAdapter
: umRecyclerView.Adapter
que apresenta oPagingData
em umaRecyclerView
. OPagingDataAdapter
pode ser conectado a umFlow
do Kotlin, umLiveData
ou umFlowable
ouObservable
RxJava. OPagingDataAdapter
detecta eventos internos de carregamento doPagingData
, à medida que as páginas são carregadas. Também usa oDiffUtil
em uma linha de execução em segundo plano para computar atualizações detalhadas à medida que conteúdo atualizado é recebido na forma de novos objetosPagingData
.RemoteMediator
: ajuda a implementar a paginação na rede e no banco de dados.
Neste codelab, você vai implementar exemplos de cada um dos componentes descritos acima.
5. Definir a origem dos dados
A implementação da PagingSource
define a fonte dos dados e como extrair dados dela. O objeto PagingData
consulta dados da PagingSource
em resposta a dicas de carregamento que são geradas conforme o usuário rola a tela em uma RecyclerView
.
Atualmente, o GithubRepository
tem muitas das responsabilidades de uma fonte de dados, que passarão a ser processadas pela biblioteca Paging quando terminarmos de adicioná-la:
- Carrega os dados do
GithubService
, garantindo que não sejam acionadas várias solicitações ao mesmo tempo. - Mantém os dados recuperados em um cache na memória.
- Monitora a página a ser solicitada.
Para criar a PagingSource
, é necessário definir o seguinte:
- O tipo da chave de paginação: em nosso caso, a API do GitHub usa números de índice com base 1 para as páginas. Portanto, o tipo é
Int
. - O tipo de dados carregados: nesse caso, estamos carregando itens
Repo
. - O local de origem dos dados: estamos recebendo os dados do
GithubService
. A fonte de dados será específica para determinada consulta. Portanto, também é necessário transmitir as informações da consulta para oGithubService
.
No pacote data
, vamos criar uma implementação da PagingSource
chamada GithubPagingSource
:
class GithubPagingSource(
private val service: GithubService,
private val query: String
) : PagingSource<Int, Repo>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Repo> {
TODO("Not yet implemented")
}
override fun getRefreshKey(state: PagingState<Int, Repo>): Int? {
TODO("Not yet implemented")
}
}
Veremos que a PagingSource
requer a implementação de duas funções: load()
e getRefreshKey()
.
A função load()
será chamada pela biblioteca Paging para buscar de forma assíncrona mais dados que serão exibidos à medida que o usuário rolar a tela. O objeto LoadParams
mantém as informações relacionadas à operação de carregamento, incluindo o seguinte:
- Chave da página a ser carregada. Se essa for a primeira vez que o carregamento é chamado,
LoadParams.key
seránull
. Nesse caso, será necessário definir a chave da página inicial. Para nosso projeto, será necessário mover a constanteGITHUB_STARTING_PAGE_INDEX
doGithubRepository
para sua implementação daPagingSource
, já que essa é a chave da página inicial. - Tamanho do carregamento: o número de itens solicitados a serem carregados.
A função de carregamento retorna um LoadResult
. Ele substituirá o uso de RepoSearchResult
no app, já que o LoadResult
pode usar um dos seguintes tipos:
LoadResult.Page
, se o resultado for bem-sucedido.LoadResult.Error
, em caso de erro.
Ao criar a LoadResult.Page
, transmita null
para a nextKey
ou a prevKey
caso não seja possível carregar a lista na direção correspondente. Em nosso caso, por exemplo, podemos considerar que, se a resposta da rede for bem-sucedida, mas a lista estiver vazia, não teremos dados para carregar. Portanto, a nextKey
pode ser null
.
Com base em todas essas informações, conseguiremos implementar a função load()
.
Em seguida, precisaremos implementar getRefreshKey()
. A chave de atualização é usada nas chamadas de atualização subsequentes do PagingSource.load()
. A primeira chamada é o carregamento inicial, que usa o parâmetro initialKey
fornecido pela Pager
. Uma atualização acontece sempre que a biblioteca Paging quer carregar novos dados para substituir a lista atual, como ao deslizar para atualizar ou na invalidação, devido a atualizações do banco de dados, mudanças de configuração, encerramento de processos etc. Normalmente, as chamadas de atualização subsequentes tentam reiniciar o carregamento dos dados centralizados em PagingState.anchorPosition
, que representa o índice acessado mais recentemente.
A implementação da GithubPagingSource
ficará assim:
// GitHub page API is 1 based: https://developer.github.com/v3/#pagination
private const val GITHUB_STARTING_PAGE_INDEX = 1
class GithubPagingSource(
private val service: GithubService,
private val query: String
) : PagingSource<Int, Repo>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Repo> {
val position = params.key ?: GITHUB_STARTING_PAGE_INDEX
val apiQuery = query + IN_QUALIFIER
return try {
val response = service.searchRepos(apiQuery, position, params.loadSize)
val repos = response.items
val nextKey = if (repos.isEmpty()) {
null
} else {
// initial load size = 3 * NETWORK_PAGE_SIZE
// ensure we're not requesting duplicating items, at the 2nd request
position + (params.loadSize / NETWORK_PAGE_SIZE)
}
LoadResult.Page(
data = repos,
prevKey = if (position == GITHUB_STARTING_PAGE_INDEX) null else position - 1,
nextKey = nextKey
)
} catch (exception: IOException) {
return LoadResult.Error(exception)
} catch (exception: HttpException) {
return LoadResult.Error(exception)
}
}
// The refresh key is used for subsequent refresh calls to PagingSource.load after the initial load
override fun getRefreshKey(state: PagingState<Int, Repo>): Int? {
// We need to get the previous key (or next key if previous is null) of the page
// that was closest to the most recently accessed index.
// Anchor position is the most recently accessed index
return state.anchorPosition?.let { anchorPosition ->
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
}
}
}
6. Criar e configurar o PagingData
Na implementação atual, usamos um Flow<RepoSearchResult>
no GitHubRepository
para receber os dados da rede e os transmitir para o ViewModel
. Em seguida, o ViewModel
transforma esses dados em um LiveData
e exibe-os para a IU. Sempre que chegarmos ao fim da lista exibida e mais dados forem carregados da rede, o Flow<RepoSearchResult>
terá uma lista completa dos dados recuperados anteriormente para a consulta, além dos dados mais recentes.
O RepoSearchResult
encapsula os casos de sucesso e os casos com erro. O caso concluído corretamente armazena os dados do repositório. O caso com erro contém o motivo Exception
. Com a Paging 3, não precisamos mais do RepoSearchResult
, porque a biblioteca modela tanto os casos de sucesso quanto os com erro com LoadResult
. Você pode excluir RepoSearchResult
, porque vamos substituir esse elemento nas próximas etapas.
Para construir o PagingData
, primeiro precisamos decidir qual API será usada para transmitir o PagingData
a outras camadas do app:
Flow
do Kotlin: usePager.flow
.LiveData
: usePager.liveData
.Flowable
RxJava: usePager.flowable
.Observable
RxJava: usePager.observable
.
Como já estamos usando o Flow
em nosso app, continuaremos com essa abordagem. Mas, em vez de usarmos o Flow<RepoSearchResult>
, usaremos o Flow<PagingData<Repo>>
.
Independentemente do builder PagingData
usado, será necessário transmitir os seguintes parâmetros:
PagingConfig
. Essa classe define opções sobre a forma de carregamento do conteúdo de umaPagingSource
. Por exemplo, até onde o conteúdo será carregado antecipadamente, a solicitação de tamanho do carregamento inicial, entre outras. O único parâmetro obrigatório a ser definido é o tamanho da página, ou seja, quantos itens serão carregados em cada página. Por padrão, a Paging manterá todas as páginas carregadas na memória. Para garantir que você não desperdice memória conforme o usuário rola a tela, defina o parâmetromaxSize
emPagingConfig
. Por padrão, a Paging retornará itens nulos como um marcador de conteúdo que ainda não foi carregado se ela puder contar os itens descarregados e se a sinalização de configuraçãoenablePlaceholders
for verdadeira. Dessa forma, você poderá exibir uma visualização de marcador no adaptador. Para simplificar o trabalho neste codelab, vamos desativar os marcadores transmitindoenablePlaceholders = false
.- Uma função que define como criar a
PagingSource
. Em nosso caso, criaremos uma novaGithubPagingSource
para cada nova consulta.
Agora, vamos modificar a classe GithubRepository
.
Atualizar GithubRepository.getSearchResultStream
- Remova o modificador
suspend
. - Retorne o
Flow<PagingData<Repo>>
. - Construa o
Pager
.
fun getSearchResultStream(query: String): Flow<PagingData<Repo>> {
return Pager(
config = PagingConfig(
pageSize = NETWORK_PAGE_SIZE,
enablePlaceholders = false
),
pagingSourceFactory = { GithubPagingSource(service, query) }
).flow
}
Limpar o GithubRepository
A Paging 3 faz muitas coisas:
- Processa a memória em cache.
- Solicita dados quando o usuário chega perto do fim da lista.
Isso significa que todo o restante no GithubRepository
pode ser removido, exceto getSearchResultStream
e o objeto complementar em que o NETWORK_PAGE_SIZE
foi definido. Seu GithubRepository
ficará assim:
class GithubRepository(private val service: GithubService) {
fun getSearchResultStream(query: String): Flow<PagingData<Repo>> {
return Pager(
config = PagingConfig(
pageSize = NETWORK_PAGE_SIZE,
enablePlaceholders = false
),
pagingSourceFactory = { GithubPagingSource(service, query) }
).flow
}
companion object {
const val NETWORK_PAGE_SIZE = 50
}
}
Você notará erros de compilação no SearchRepositoriesViewModel
. Vamos ver quais mudanças precisam ser feitas.
7. Solicitar e armazenar em cache o PagingData no ViewModel
Antes de resolver os erros de compilação, vamos analisar os tipos no ViewModel
:
sealed class UiAction {
data class Search(val query: String) : UiAction()
data class Scroll(
val visibleItemCount: Int,
val lastVisibleItemPosition: Int,
val totalItemCount: Int
) : UiAction()
}
data class UiState(
val query: String,
val searchResult: RepoSearchResult
)
No UiState
, expomos um searchResult
. A função de searchResult
é operar como um cache de resultados de pesquisa na memória que sobreviva às mudanças de configuração. Com a Paging 3, não é mais necessário converter o Flow
em LiveData
. Em vez disso, SearchRepositoriesViewModel
agora expõe um StateFlow<UiState>
. Além disso, descartamos o valor searchResult
inteiro, optando por expor um Flow<PagingData<Repo>>
separado que tenha a mesma finalidade de searchResult
.
PagingData
é um tipo independente que contém um fluxo mutável de atualizações para os dados que vão ser exibidos no RecyclerView
. Cada emissão de PagingData
é completamente independente, e vários PagingData
podem ser emitidos para uma única consulta. Dessa forma, os Flows
de PagingData
precisam ser exposto independente de outros Flows
.
Além disso, para melhorar a experiência do usuário, a cada nova consulta inserida, precisamos rolar a lista até o início e exibir o primeiro resultado da pesquisa. No entanto, como os dados de paginação podem ser emitidos várias vezes, só vamos rolar para o início da lista se o usuário ainda não tiver começado a rolar.
Para fazer isso, vamos atualizar o UiState
e adicionar campos para lastQueryScrolled
e hasNotScrolledForCurrentSearch
. Essas sinalizações vão impedir que o sistema role para o início da lista quando não for necessário:
data class UiState(
val query: String = DEFAULT_QUERY,
val lastQueryScrolled: String = DEFAULT_QUERY,
val hasNotScrolledForCurrentSearch: Boolean = false
)
Vamos analisar a arquitetura novamente. Como todas as solicitações para o ViewModel
passam por um único ponto de entrada, o campo accept
definido como (UiAction) -> Unit
, precisamos fazer o seguinte:
- Converta esse ponto de entrada em streams que contenham os tipos de que precisamos
- Transforme esses streams.
- Combine os streams novamente em um
StateFlow<UiState>
.
Em termos mais funcionais, vamos UiAction
(reduzir) as emissões de reduce
em UiState
. Isso funciona como uma linha de montagem: os tipos UiAction
são as matérias-primas que chegam e geram um efeito, às vezes chamado de mutação, e a UiState
é o resultado final, pronto para ser vinculado à IU. Algumas vezes, isso é chamado de transformar a IU em uma função do UiState
.
Vamos reprogramar o ViewModel
para processar cada tipo de UiAction
em dois streams diferentes. Em seguida, vamos transformá-los em um StateFlow<UiState>
usando alguns operadores Flow
do Kotlin.
Primeiramente, vamos atualizar as definições de state
no ViewModel
para usar um StateFlow
, em vez de um LiveData
, e também adicionar um campo para expor um Flow
da PagingData
:
/**
* Stream of immutable states representative of the UI.
*/
val state: StateFlow<UiState>
val pagingDataFlow: Flow<PagingData<Repo>>
Em seguida, vamos atualizar a definição da subclasse UiAction.Scroll
:
sealed class UiAction {
...
data class Scroll(val currentQuery: String) : UiAction()
}
Observe que removemos todos os campos da classe de dados UiAction.Scroll
e os substituímos pela string currentQuery
. Dessa forma, é possível associar uma ação de rolagem a uma consulta específica. Além disso, excluímos a extensão shouldFetchMore
, porque ela não é mais usada. Isso também precisa ser restaurado após uma interrupção do processo. Por esse motivo, é importante atualizar o método onCleared()
no SearchRepositoriesViewModel
:
class SearchRepositoriesViewModel{
...
override fun onCleared() {
savedStateHandle[LAST_SEARCH_QUERY] = state.value.query
savedStateHandle[LAST_QUERY_SCROLLED] = state.value.lastQueryScrolled
super.onCleared()
}
}
// This is outside the ViewModel class, but in the same file
private const val LAST_QUERY_SCROLLED: String = "last_query_scrolled"
Esse também é um bom momento para introduzir o método responsável por criar o pagingData
Flow
do GithubRepository
:
class SearchRepositoriesViewModel(
...
) : ViewModel() {
override fun onCleared() {
...
}
private fun searchRepo(queryString: String): Flow<PagingData<Repo>> =
repository.getSearchResultStream(queryString)
}
O Flow<PagingData>
tem um método cachedIn()
útil que permite armazenar em cache o conteúdo de um Flow<PagingData>
em um CoroutineScope
. Como estamos em um ViewModel
, usaremos o androidx.lifecycle.viewModelScope
.
Agora, podemos começar a converter o campo accept
no ViewModel em um stream UiAction
. Substitua o bloco init
do SearchRepositoriesViewModel
pelo seguinte:
class SearchRepositoriesViewModel(
...
) : ViewModel() {
...
init {
val initialQuery: String = savedStateHandle.get(LAST_SEARCH_QUERY) ?: DEFAULT_QUERY
val lastQueryScrolled: String = savedStateHandle.get(LAST_QUERY_SCROLLED) ?: DEFAULT_QUERY
val actionStateFlow = MutableSharedFlow<UiAction>()
val searches = actionStateFlow
.filterIsInstance<UiAction.Search>()
.distinctUntilChanged()
.onStart { emit(UiAction.Search(query = initialQuery)) }
val queriesScrolled = actionStateFlow
.filterIsInstance<UiAction.Scroll>()
.distinctUntilChanged()
// This is shared to keep the flow "hot" while caching the last query scrolled,
// otherwise each flatMapLatest invocation would lose the last query scrolled,
.shareIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000),
replay = 1
)
.onStart { emit(UiAction.Scroll(currentQuery = lastQueryScrolled)) }
}
}
Vamos analisar o snippet de código acima. Começamos com dois itens, a String
initialQuery
, extraída do estado salvo ou de um estado padrão, junto com lastQueryScrolled
, uma String
que representa o último termo pesquisado em que o usuário interagiu com a lista. Em seguida, vamos começar a dividir o Flow
em tipos UiAction
específicos:
UiAction.Search
para cada vez que o usuário insere uma consulta específica.UiAction.Scroll
para cada vez que o usuário rola a lista com uma consulta específica em foco.
A UiAction.Scroll Flow
gera algumas outras transformações. Vamos analisar:
shareIn
: necessário porque, quando esseFlow
é consumido, ele usa o operadorflatmapLatest
. Cada vez que o upstream for emitido, oflatmapLatest
vai cancelar o últimoFlow
em que ele estava operando e começará a trabalhar com base no novo fluxo informado. Em nosso caso, isso faria com que perdessemos o valor da última consulta em que o usuário rolou a lista de resultados retornados. Portanto, usamos o operadorFlow
com um valorreplay
de 1 para armazenar o último valor em cache e, assim, não perder o valor ao receber uma nova consulta.onStart
: também é usado para armazenamento em cache. Em casos em o app foi encerrado, mas o usuário já tinha rolado a lista de resultados retornados para uma consulta, não é recomendável voltar ao início da lista e fazer com que o usuário perca o ponto em que estava.
Ainda vai haver erros de compilação, porque ainda não definimos os campos state
, pagingDataFlow
e accept
. Vamos corrigir isso. Após aplicar as transformações a cada UiAction
, podemos usá-las para criar fluxos de PagingData
e de UiState
.
init {
...
pagingDataFlow = searches
.flatMapLatest { searchRepo(queryString = it.query) }
.cachedIn(viewModelScope)
state = combine(
searches,
queriesScrolled,
::Pair
).map { (search, scroll) ->
UiState(
query = search.query,
lastQueryScrolled = scroll.currentQuery,
// If the search query matches the scroll query, the user has scrolled
hasNotScrolledForCurrentSearch = search.query != scroll.currentQuery
)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000),
initialValue = UiState()
)
accept = { action ->
viewModelScope.launch { actionStateFlow.emit(action) }
}
}
}
Vamos usar o operador flatmapLatest
no fluxo searches
, porque cada nova consulta de pesquisa requer a criação de um novo Pager
. Em seguida, vamos aplicar o operador cachedIn
ao fluxo PagingData
para o manter ativo no viewModelScope
e atribuir o resultado ao campo pagingDataFlow
. No UiState
, vamos usar o operador de combinação para preencher os campos obrigatórios de UiState
e atribuir o Flow
resultante ao campo state
exposto. Também vamos definir accept
como uma lambda que inicia uma função de suspensão que alimenta o estado.
Pronto! Agora temos um ViewModel
funcional de uma perspectiva de programação literal e reativa.
8. Fazer o adaptador funcionar com o PagingData
Para vincular um PagingData
a uma RecyclerView
, use um PagingDataAdapter
. O PagingDataAdapter
será notificado sempre que o conteúdo do PagingData
for carregado e, então, sinalizará à RecyclerView
que é necessário fazer uma atualização.
Atualizar a ui.ReposAdapter
para que ela funcione com um stream do PagingData
- No momento, a
ReposAdapter
implementa aListAdapter
. Em vez disso, faça com que ele implemente oPagingDataAdapter
. O restante do corpo da classe permanecerá inalterado:
class ReposAdapter : PagingDataAdapter<Repo, RepoViewHolder>(REPO_COMPARATOR) {
// body is unchanged
}
Fizemos muitas mudanças até aqui, e agora falta pouco para executar o app. Basta conectar a IU.
9. Acionar atualizações de rede
Substituir LiveData pelo fluxo
Vamos atualizar a SearchRepositoriesActivity
para que ela funcione com a Paging 3. Para trabalhar com o Flow<PagingData>
, precisamos iniciar uma nova corrotina. Faremos isso no lifecycleScope
, responsável por cancelar a solicitação quando a atividade é recriada.
Felizmente, não precisamos fazer muitas mudanças. Em vez de observe()
(observar) um objeto LiveData
, vamos launch()
(inicializar) uma coroutine
e collect()
(coletar) um Flow
. O UiState
vai ser combinado com o PagingAdapter
LoadState
Flow
para garantir que a lista não volte ao ínicio quando houver novas emissões de PagingData
caso o usuário já tenha rolado a lista de resultados retornados.
Primeiramente, como retornamos o estado como StateFlow
, em vez de LiveData
, todas as referências de LiveData
na Activity
precisam ser substituídas por um StateFlow
. Também é necessário adicionar um argumento para o pagingData
Flow
. O primeiro lugar é no método bindState
:
private fun ActivitySearchRepositoriesBinding.bindState(
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
uiActions: (UiAction) -> Unit
) {
...
}
Essa mudança tem um efeito em cascata, já que agora vamos precisar atualizar bindSearch()
e bindList()
. A menor mudança ocorre em bindSearch()
, então, vamos começar por lá:
private fun ActivitySearchRepositoriesBinding.bindSearch(
uiState: StateFlow<UiState>,
onQueryChanged: (UiAction.Search) -> Unit
) {
searchRepo.setOnEditorActionListener {...}
searchRepo.setOnKeyListener {...}
lifecycleScope.launch {
uiState
.map { it.query }
.distinctUntilChanged()
.collect(searchRepo::setText)
}
}
A principal mudança aqui é a necessidade de iniciar uma corrotina e coletar a mudança da consulta do Flow
do UiState
.
Resolver o problema de rolagem e vincular dados
Agora, vamos abordar a questão da rolagem. Primeiramente, assim como nas duas últimas mudanças, vamos substituir o LiveData
por um StateFlow
e adicionar um argumento para o pagingData
Flow
. Feito isso, podemos avançar para o listener de rolagem. Anteriormente, usamos um OnScrollListener
anexado à RecyclerView
para saber quando era necessário acionar mais dados. A biblioteca Paging processa a rolagem de lista, mas ainda é necessário usar o OnScrollListener
como indicador se o usuário tiver rolado a lista para a consulta atual. No método bindList()
, vamos substituir setupScrollListener()
por um RecyclerView.OnScrollListener
in-line. O método setupScrollListener()
também vai ser excluído.
private fun ActivitySearchRepositoriesBinding.bindList(
repoAdapter: ReposAdapter,
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
onScrollChanged: (UiAction.Scroll) -> Unit
) {
list.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (dy != 0) onScrollChanged(UiAction.Scroll(currentQuery = uiState.value.query))
}
})
// the rest of the code is unchanged
}
Em seguida, vamos configurar o pipeline para criar uma sinalização booleana shouldScrollToTop
. Com isso, teremos dois fluxos de collect
: o PagingData
Flow
e o shouldScrollToTop
Flow
.
private fun ActivitySearchRepositoriesBinding.bindList(
repoAdapter: ReposAdapter,
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
onScrollChanged: (UiAction.Scroll) -> Unit
) {
list.addOnScrollListener(...)
val notLoading = repoAdapter.loadStateFlow
// Only emit when REFRESH LoadState for the paging source changes.
.distinctUntilChangedBy { it.source.refresh }
// Only react to cases where REFRESH completes i.e., NotLoading.
.map { it.source.refresh is LoadState.NotLoading }
val hasNotScrolledForCurrentSearch = uiState
.map { it.hasNotScrolledForCurrentSearch }
.distinctUntilChanged()
val shouldScrollToTop = combine(
notLoading,
hasNotScrolledForCurrentSearch,
Boolean::and
)
.distinctUntilChanged()
lifecycleScope.launch {
pagingData.collectLatest(repoAdapter::submitData)
}
lifecycleScope.launch {
shouldScrollToTop.collect { shouldScroll ->
if (shouldScroll) list.scrollToPosition(0)
}
}
}
No exemplo acima, usamos o collectLatest
no pagingData
Flow
para que fosse possível cancelar a coleta de emissões anteriores de pagingData
em novas emissões de pagingData
. Para a sinalização shouldScrollToTop
, as emissões de PagingDataAdapter.loadStateFlow
são síncronas com os dados exibidos na IU. Portanto, é seguro chamar list.scrollToPosition(0)
logo que a sinalização booleana emitida for considerada verdadeira.
O tipo em um LoadStateFlow é um objeto CombinedLoadStates
.
CombinedLoadStates
permite ver o estado de carregamento dos três tipos diferentes de operações de carregamento:
CombinedLoadStates.refresh
: representa o estado ao carregarPagingData
pela primeira vez.CombinedLoadStates.prepend
: representa o estado para carregar dados no início da lista.CombinedLoadStates.append
: representa o estado para carregar dados no fim da lista.
Em nosso caso, queremos redefinir a posição de rolagem somente quando a atualização for concluída, ou seja, quando LoadState
for refresh
, NotLoading
.
Agora, podemos remover binding.list.scrollToPosition(0)
de updateRepoListFromInput()
.
Depois de fazer tudo isso, sua atividade vai ficar assim:
class SearchRepositoriesActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivitySearchRepositoriesBinding.inflate(layoutInflater)
val view = binding.root
setContentView(view)
// get the view model
val viewModel = ViewModelProvider(this, Injection.provideViewModelFactory(owner = this))
.get(SearchRepositoriesViewModel::class.java)
// add dividers between RecyclerView's row items
val decoration = DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
binding.list.addItemDecoration(decoration)
// bind the state
binding.bindState(
uiState = viewModel.state,
pagingData = viewModel.pagingDataFlow,
uiActions = viewModel.accept
)
}
/**
* Binds the [UiState] provided by the [SearchRepositoriesViewModel] to the UI,
* and allows the UI to feed back user actions to it.
*/
private fun ActivitySearchRepositoriesBinding.bindState(
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
uiActions: (UiAction) -> Unit
) {
val repoAdapter = ReposAdapter()
list.adapter = repoAdapter
bindSearch(
uiState = uiState,
onQueryChanged = uiActions
)
bindList(
repoAdapter = repoAdapter,
uiState = uiState,
pagingData = pagingData,
onScrollChanged = uiActions
)
}
private fun ActivitySearchRepositoriesBinding.bindSearch(
uiState: StateFlow<UiState>,
onQueryChanged: (UiAction.Search) -> Unit
) {
searchRepo.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_GO) {
updateRepoListFromInput(onQueryChanged)
true
} else {
false
}
}
searchRepo.setOnKeyListener { _, keyCode, event ->
if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_ENTER) {
updateRepoListFromInput(onQueryChanged)
true
} else {
false
}
}
lifecycleScope.launch {
uiState
.map { it.query }
.distinctUntilChanged()
.collect(searchRepo::setText)
}
}
private fun ActivitySearchRepositoriesBinding.updateRepoListFromInput(onQueryChanged: (UiAction.Search) -> Unit) {
searchRepo.text.trim().let {
if (it.isNotEmpty()) {
list.scrollToPosition(0)
onQueryChanged(UiAction.Search(query = it.toString()))
}
}
}
private fun ActivitySearchRepositoriesBinding.bindList(
repoAdapter: ReposAdapter,
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
onScrollChanged: (UiAction.Scroll) -> Unit
) {
list.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (dy != 0) onScrollChanged(UiAction.Scroll(currentQuery = uiState.value.query))
}
})
val notLoading = repoAdapter.loadStateFlow
// Only emit when REFRESH LoadState for the paging source changes.
.distinctUntilChangedBy { it.source.refresh }
// Only react to cases where REFRESH completes i.e., NotLoading.
.map { it.source.refresh is LoadState.NotLoading }
val hasNotScrolledForCurrentSearch = uiState
.map { it.hasNotScrolledForCurrentSearch }
.distinctUntilChanged()
val shouldScrollToTop = combine(
notLoading,
hasNotScrolledForCurrentSearch,
Boolean::and
)
.distinctUntilChanged()
lifecycleScope.launch {
pagingData.collectLatest(repoAdapter::submitData)
}
lifecycleScope.launch {
shouldScrollToTop.collect { shouldScroll ->
if (shouldScroll) list.scrollToPosition(0)
}
}
}
}
O app vai ser compilado e executado, mas sem o rodapé com estado de carregamento e o Toast
que é exibido em caso de erro. Na próxima etapa, veremos como exibir o rodapé com estado de carregamento.
O código completo das etapas realizadas até aqui pode ser encontrado na ramificação step5-9_paging_3.0 (link em inglês).
10. Como exibir o estado de carregamento em um rodapé
Em nosso app, queremos exibir um rodapé com base no status de carregamento. Assim, quando a lista estiver sendo carregada, exibiremos um ícone de progresso do carregamento. Em caso de erro, exibiremos o erro e um botão "Repetir".
O cabeçalho/rodapé que precisamos criar segue a ideia de uma lista que precisa ser anexada no início (como cabeçalho) ou no fim (como rodapé) da lista real de itens exibidos. O cabeçalho/rodapé é uma lista com apenas um elemento: uma visualização que exibe uma barra de progresso ou um erro com um botão "Repetir", de acordo com o LoadState
da Paging.
Como a exibição de um cabeçalho/rodapé baseado no estado de carregamento e a implementação de um mecanismo de repetição são tarefas comuns, a API Paging 3 pode nos ajudar com ambas.
Para implementar o cabeçalho/rodapé, usaremos um LoadStateAdapter
. Essa implementação do RecyclerView.Adapter
é notificada automaticamente sobre mudanças no estado de carregamento. Isso garante que somente os estados Loading
e Error
façam com que itens sejam exibidos e notifica a RecyclerView
quando um item é removido, inserido ou alterado, dependendo do LoadState
.
Para o mecanismo de repetição, usamos adapter.retry()
. Internamente, esse método acaba chamando sua implementação da PagingSource
para a página correta. A resposta será propagada automaticamente pelo Flow<PagingData>
.
Vejamos como ficou a implementação de cabeçalho/rodapé.
Como acontece com qualquer lista, temos que criar três arquivos:
- O arquivo de layout, que contém os elementos da IU para exibir o progresso, o erro e o botão de repetição
- O **arquivo** **
ViewHolder
** que deixa os itens da IU visíveis com base noLoadState
da Paging. - O arquivo do adaptador, que define como criar e vincular o
ViewHolder
. Em vez de estender umRecyclerView.Adapter
, vamos usar oLoadStateAdapter
da Paging 3.
Criar o layout da visualização
Crie o layout repos_load_state_footer_view_item
para o estado de carregamento do repositório. Ele precisa incluir uma ProgressBar
, uma TextView
(para exibir o erro) e um Button
"Repetir". As strings e dimensões necessárias já foram declaradas no projeto.
<?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"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical"
android:padding="8dp">
<TextView
android:id="@+id/error_msg"
android:textColor="?android:textColorPrimary"
android:textSize="@dimen/error_text_size"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:textAlignment="center"
tools:text="Timeout"/>
<ProgressBar
android:id="@+id/progress_bar"
style="?android:attr/progressBarStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"/>
<Button
android:id="@+id/retry_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/retry"/>
</LinearLayout>
Criar o ViewHolder
Crie um novo ViewHolder
, chamado ReposLoadStateViewHolder
, na pasta ui
**.** Ele precisa receber uma função de repetição como parâmetro, que será chamada quando o botão "Repetir" for pressionado. Crie uma função bind()
que receba o LoadState
como parâmetro e defina a visibilidade de cada visualização, de acordo com o LoadState
. Uma implementação do ReposLoadStateViewHolder
usando ViewBinding
ficará assim:
class ReposLoadStateViewHolder(
private val binding: ReposLoadStateFooterViewItemBinding,
retry: () -> Unit
) : RecyclerView.ViewHolder(binding.root) {
init {
binding.retryButton.setOnClickListener { retry.invoke() }
}
fun bind(loadState: LoadState) {
if (loadState is LoadState.Error) {
binding.errorMsg.text = loadState.error.localizedMessage
}
binding.progressBar.isVisible = loadState is LoadState.Loading
binding.retryButton.isVisible = loadState is LoadState.Error
binding.errorMsg.isVisible = loadState is LoadState.Error
}
companion object {
fun create(parent: ViewGroup, retry: () -> Unit): ReposLoadStateViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.repos_load_state_footer_view_item, parent, false)
val binding = ReposLoadStateFooterViewItemBinding.bind(view)
return ReposLoadStateViewHolder(binding, retry)
}
}
}
Criar o LoadStateAdapter
Crie um ReposLoadStateAdapter
que também estenda o LoadStateAdapter
na pasta ui
. O adaptador receberá a função de repetição como um parâmetro, já que ela será passada ao ViewHolder
quando construída.
Como acontece com qualquer Adapter
, é necessário implementar os métodos onBind()
e onCreate()
. O LoadStateAdapter
facilita esse processo, porque transmite LoadState
em ambas as funções. No onBindViewHolder()
, vincule o ViewHolder
. No onCreateViewHolder()
, defina como criar o ReposLoadStateViewHolder
com base no ViewGroup
pai e na função de repetição:
class ReposLoadStateAdapter(private val retry: () -> Unit) : LoadStateAdapter<ReposLoadStateViewHolder>() {
override fun onBindViewHolder(holder: ReposLoadStateViewHolder, loadState: LoadState) {
holder.bind(loadState)
}
override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): ReposLoadStateViewHolder {
return ReposLoadStateViewHolder.create(parent, retry)
}
}
Vincular o adaptador de rodapé à lista
Agora que todos os elementos do rodapé estão prontos, vamos vinculá-los à nossa lista. Para fazer isso, o PagingDataAdapter
tem três métodos úteis:
withLoadStateHeader
: se quisermos exibir somente um cabeçalho. Use essa opção quando a lista só for compatível com a adição de itens ao início da lista.withLoadStateFooter
: se quisermos exibir somente um rodapé. Use essa opção quando a lista só for compatível com a adição de itens ao fim da lista.withLoadStateHeaderAndFooter
: para exibir um cabeçalho e um rodapé, caso a lista possa ser paginada nas duas direções.
Atualize o método ActivitySearchRepositoriesBinding.bindState()
e chame withLoadStateHeaderAndFooter()
no adaptador. Como função de repetição, podemos chamar adapter.retry()
.
private fun ActivitySearchRepositoriesBinding.bindState(
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
uiActions: (UiAction) -> Unit
) {
val repoAdapter = ReposAdapter()
list.adapter = repoAdapter.withLoadStateHeaderAndFooter(
header = ReposLoadStateAdapter { repoAdapter.retry() },
footer = ReposLoadStateAdapter { repoAdapter.retry() }
)
...
}
Como temos uma lista de rolagem infinita, uma forma fácil de ver o rodapé é colocar o smartphone ou emulador no modo avião e rolar até o fim da lista.
Vamos executar o app.
O código completo das etapas realizadas até aqui pode ser encontrado na ramificação step10_loading_state_footer (link em inglês).
11. Como exibir o estado de carregamento na atividade
Talvez você tenha percebido que temos dois problemas no momento:
- Depois de migrar para a Paging 3, não conseguimos mais exibir uma mensagem quando a lista de resultados está vazia.
- Sempre que você pesquisa uma nova consulta, o resultado atual permanece na tela até recebermos uma resposta de rede. Isso gera uma experiência ruim para o usuário. Em vez disso, precisamos exibir uma barra de progresso ou um botão "Repetir".
A solução para os dois problemas é responder às mudanças de estado de carregamento na SearchRepositoriesActivity
.
Exibir a mensagem de lista vazia
Primeiro, vamos trazer de volta a mensagem de lista vazia. Ela vai ser exibida somente depois que a lista tiver sido carregada e o número de itens na lista for 0. Para saber quando a lista foi carregada, usaremos a propriedade PagingDataAdapter.loadStateFlow
. Esse Flow
é emitido sempre que há uma mudança no estado de carregamento usando um objeto CombinedLoadStates
.
O CombinedLoadStates
informa o estado de carregamento da PageSource
definida ou do RemoteMediator
necessário para os casos de rede e banco de dados, que vão ser abordados mais tarde.
Na SearchRepositoriesActivity.bindList()
, vamos coletar os dados diretamente do loadStateFlow
. A lista fica vazia quando o estado refresh
do CombinedLoadStates
é NotLoading
e adapter.itemCount == 0
. Em seguida, vamos alternar a visibilidade de emptyList
e list
, respectivamente:
private fun ActivitySearchRepositoriesBinding.bindList(
repoAdapter: ReposAdapter,
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
onScrollChanged: (UiAction.Scroll) -> Unit
) {
...
lifecycleScope.launch {
repoAdapter.loadStateFlow.collect { loadState ->
val isListEmpty = loadState.refresh is LoadState.NotLoading && repoAdapter.itemCount == 0
// show empty list
emptyList.isVisible = isListEmpty
// Only show the list if refresh succeeds.
list.isVisible = !isListEmpty
}
}
}
}
Exibir o estado de carregamento
Vamos atualizar nosso activity_search_repositories.xml
para incluir um botão "Repetir" e um elemento da IU para a barra de progresso:
<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"
tools:context=".ui.SearchRepositoriesActivity">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/input_layout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginLeft="8dp"
android:layout_marginRight="8dp"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<EditText
android:id="@+id/search_repo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/search_hint"
android:imeOptions="actionSearch"
android:inputType="textNoSuggestions"
android:selectAllOnFocus="true"
tools:text="Android"/>
</com.google.android.material.textfield.TextInputLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list"
android:layout_width="0dp"
android:layout_height="0dp"
android:paddingVertical="@dimen/row_item_margin_vertical"
android:scrollbars="vertical"
app:layoutManager="LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/input_layout"
tools:ignore="UnusedAttribute"/>
<ProgressBar
android:id="@+id/progress_bar"
style="?android:attr/progressBarStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<Button
android:id="@+id/retry_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/retry"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<TextView android:id="@+id/emptyList"
android:layout_width="0dp"
android:layout_height="match_parent"
android:gravity="center"
android:text="@string/no_results"
android:textSize="@dimen/repo_name_size"
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>
O botão "Repetir" aciona o recarregamento do PagingData
. Para fazer isso, chamaremos adapter.retry()
na implementação do onClickListener
, como fizemos para o cabeçalho/rodapé:
// SearchRepositoriesActivity.kt
private fun ActivitySearchRepositoriesBinding.bindList(
repoAdapter: ReposAdapter,
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
onScrollChanged: (UiAction.Scroll) -> Unit
) {
retryButton.setOnClickListener { repoAdapter.retry() }
...
}
Em seguida, vamos reagir às mudanças de estado de carregamento em SearchRepositoriesActivity.bindList
. Como só queremos que a barra de progresso seja exibida quando há uma nova consulta, precisamos contar com o tipo de carregamento da fonte de paginação, especificamente CombinedLoadStates.source.refresh
, e com o LoadState
: Loading
ou Error
. Além disso, uma funcionalidade abordada em uma etapa anterior exibia um Toast
em caso de erro, então vamos incluí-la aqui também. Para exibir a mensagem de erro, precisamos conferir se CombinedLoadStates.prepend
ou CombinedLoadStates.append
são uma instância do LoadState.Error
e extrair a mensagem do erro.
Vamos atualizar ActivitySearchRepositoriesBinding.bindList
no método SearchRepositoriesActivity
para incluir essa funcionalidade:
private fun ActivitySearchRepositoriesBinding.bindList(
repoAdapter: ReposAdapter,
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
onScrollChanged: (UiAction.Scroll) -> Unit
) {
...
lifecycleScope.launch {
repoAdapter.loadStateFlow.collect { loadState ->
val isListEmpty = loadState.refresh is LoadState.NotLoading && repoAdapter.itemCount == 0
// show empty list
emptyList.isVisible = isListEmpty
// Only show the list if refresh succeeds.
list.isVisible = !isListEmpty
// Show loading spinner during initial load or refresh.
progressBar.isVisible = loadState.source.refresh is LoadState.Loading
// Show the retry state if initial load or refresh fails.
retryButton.isVisible = loadState.source.refresh is LoadState.Error
// Toast on any error, regardless of whether it came from RemoteMediator or PagingSource
val errorState = loadState.source.append as? LoadState.Error
?: loadState.source.prepend as? LoadState.Error
?: loadState.append as? LoadState.Error
?: loadState.prepend as? LoadState.Error
errorState?.let {
Toast.makeText(
this@SearchRepositoriesActivity,
"\uD83D\uDE28 Wooops ${it.error}",
Toast.LENGTH_LONG
).show()
}
}
}
}
Agora, execute o app e veja como ele funciona.
Pronto! Com a configuração atual, os componentes da biblioteca Paging são aqueles que acionam as solicitações de API no momento certo, processando o cache na memória e exibindo os dados. Execute o app e tente pesquisar repositórios.
O código completo das etapas realizadas até aqui pode ser encontrado na ramificação step11_loading_state (link em inglês).
12. Como adicionar separadores de lista
Uma forma de melhorar a legibilidade da lista é adicionar separadores. No app desse projeto, por exemplo, como os repositórios são ordenados por número de estrelas em ordem decrescente, os separadores poderiam ser incluídos a cada 10 mil estrelas. Para ajudar a implementar isso, a API Paging 3 permite inserir separadores no PagingData
.
Adicionar separadores no PagingData
resulta na modificação da lista exibida na tela. Não serão mais exibidos somente objetos Repo
, mas também objetos separadores. Portanto, é necessário mudar o modelo de IU exibido do ViewModel
de Repo
para outro tipo que possa encapsular ambos: RepoItem
e SeparatorItem
. Em seguida, precisamos atualizar a IU para oferecer compatibilidade com separadores:
- Adicione um layout e o
ViewHolder
a separadores. - Atualize o
RepoAdapter
para oferecer compatibilidade com a criação e vinculação de separadores e repositórios.
Analisaremos etapa por etapa para ver como a implementação funciona.
Mudar o modelo da IU
Atualmente, o SearchRepositoriesViewModel.searchRepo()
retorna Flow<PagingData<Repo>>
. Para ter compatibilidade com repositórios e separadores, criaremos uma classe selada UiModel
no mesmo arquivo com SearchRepositoriesViewModel
. Podemos ter dois tipos de objetos UiModel
: RepoItem
e SeparatorItem
.
sealed class UiModel {
data class RepoItem(val repo: Repo) : UiModel()
data class SeparatorItem(val description: String) : UiModel()
}
Como queremos separar repositórios a cada 10 mil estrelas, criaremos uma propriedade de extensão no RepoItem
, que arredonda o número de estrelas:
private val UiModel.RepoItem.roundedStarCount: Int
get() = this.repo.stars / 10_000
Inserir separadores
SearchRepositoriesViewModel.searchRepo()
agora vai retornar Flow<PagingData<UiModel>>
.
class SearchRepositoriesViewModel(
private val repository: GithubRepository,
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
...
fun searchRepo(queryString: String): Flow<PagingData<UiModel>> {
...
}
}
Vamos ver como a implementação muda. Atualmente, repository.getSearchResultStream(queryString)
retorna um Flow<PagingData<Repo>>
. Portanto, a primeira operação que precisamos adicionar é a transformação de cada Repo
em um UiModel.RepoItem
. Para fazer isso, podemos usar o operador Flow.map
e mapear cada PagingData
para criar um novo UiModel.Repo
no item Repo
atual, o que resulta em um Flow<PagingData<UiModel.RepoItem>>
:
...
val newResult: Flow<PagingData<UiModel>> = repository.getSearchResultStream(queryString)
.map { pagingData -> pagingData.map { UiModel.RepoItem(it) } }
...
Agora, podemos inserir os separadores. Para cada emissão do Flow
, chamaremos PagingData.insertSeparators()
. Esse método retorna um PagingData
que contém cada elemento original, com um separador opcional que você gerará de acordo com os elementos anteriores e posteriores. Em condições de limitação (no início ou no fim da lista), os respectivos elementos anteriores ou posteriores serão null
. Se não for necessário criar um separador, retorne null
.
Como estamos mudando o tipo de elementos PagingData
de UiModel.Repo
para UiModel
, defina explicitamente os argumentos de tipo do método insertSeparators()
.
O método searchRepo()
ficará assim:
private fun searchRepo(queryString: String): Flow<PagingData<UiModel>> =
repository.getSearchResultStream(queryString)
.map { pagingData -> pagingData.map { UiModel.RepoItem(it) } }
.map {
it.insertSeparators { before, after ->
if (after == null) {
// we're at the end of the list
return@insertSeparators null
}
if (before == null) {
// we're at the beginning of the list
return@insertSeparators UiModel.SeparatorItem("${after.roundedStarCount}0.000+ stars")
}
// check between 2 items
if (before.roundedStarCount > after.roundedStarCount) {
if (after.roundedStarCount >= 1) {
UiModel.SeparatorItem("${after.roundedStarCount}0.000+ stars")
} else {
UiModel.SeparatorItem("< 10.000+ stars")
}
} else {
// no separator
null
}
}
}
Compatibilidade com vários tipos de visualização
Os objetos SeparatorItem
precisam ser exibidos na RecyclerView
. Estamos exibindo somente uma string aqui. Por isso, vamos criar um layout separator_view_item
com uma TextView
na pasta res/layout
:
<?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="wrap_content"
android:background="@color/separatorBackground">
<TextView
android:id="@+id/separator_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="@dimen/row_item_margin_horizontal"
android:textColor="@color/separatorText"
android:textSize="@dimen/repo_name_size"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="10000+ stars" />
</androidx.constraintlayout.widget.ConstraintLayout>
Vamos criar um SeparatorViewHolder
na pasta ui
, em que só vinculamos uma string à TextView
:
class SeparatorViewHolder(view: View) : RecyclerView.ViewHolder(view) {
private val description: TextView = view.findViewById(R.id.separator_description)
fun bind(separatorText: String) {
description.text = separatorText
}
companion object {
fun create(parent: ViewGroup): SeparatorViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.separator_view_item, parent, false)
return SeparatorViewHolder(view)
}
}
}
Atualize ReposAdapter
para oferecer compatibilidade com um UiModel
, em vez de um Repo
:
- Atualize o parâmetro
PagingDataAdapter
deRepo
paraUiModel
. - Implemente um comparador
UiModel
e substitua oREPO_COMPARATOR
por ele. - Crie o
SeparatorViewHolder
e vincule-o à descrição doUiModel.SeparatorItem
.
Como agora precisamos exibir dois ViewHolders diferentes, substitua RepoViewHolder por ViewHolder:
- Atualize o parâmetro
PagingDataAdapter
- Atualize o tipo de retorno
onCreateViewHolder
- Atualize o parâmetro
holder
onBindViewHolder
O ReposAdapter
final ficará assim:
class ReposAdapter : PagingDataAdapter<UiModel, ViewHolder>(UIMODEL_COMPARATOR) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return if (viewType == R.layout.repo_view_item) {
RepoViewHolder.create(parent)
} else {
SeparatorViewHolder.create(parent)
}
}
override fun getItemViewType(position: Int): Int {
return when (getItem(position)) {
is UiModel.RepoItem -> R.layout.repo_view_item
is UiModel.SeparatorItem -> R.layout.separator_view_item
null -> throw UnsupportedOperationException("Unknown view")
}
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val uiModel = getItem(position)
uiModel.let {
when (uiModel) {
is UiModel.RepoItem -> (holder as RepoViewHolder).bind(uiModel.repo)
is UiModel.SeparatorItem -> (holder as SeparatorViewHolder).bind(uiModel.description)
}
}
}
companion object {
private val UIMODEL_COMPARATOR = object : DiffUtil.ItemCallback<UiModel>() {
override fun areItemsTheSame(oldItem: UiModel, newItem: UiModel): Boolean {
return (oldItem is UiModel.RepoItem && newItem is UiModel.RepoItem &&
oldItem.repo.fullName == newItem.repo.fullName) ||
(oldItem is UiModel.SeparatorItem && newItem is UiModel.SeparatorItem &&
oldItem.description == newItem.description)
}
override fun areContentsTheSame(oldItem: UiModel, newItem: UiModel): Boolean =
oldItem == newItem
}
}
}
Pronto! Quando o app for executado, você verá os separadores.
O código completo das etapas realizadas até aqui pode ser encontrado na ramificação step12_separators (link em inglês).
13. Paging na rede e no banco de dados
Para adicionar o suporte off-line ao app, salve os dados em um banco de dados local. Assim, esse banco será a fonte da verdade para o app, e os dados sempre serão carregados dele. Quando não tivermos mais dados, solicitaremos outros da rede e os salvaremos no banco de dados. Como o banco de dados é a fonte da verdade, a IU será atualizada automaticamente quando mais dados forem salvos.
Veja o que é preciso fazer para adicionar o suporte off-line:
- Criar um banco de dados da Room, uma tabela para salvar os objetos
Repo
e um DAO que será usado para trabalhar com os objetosRepo
. - Definir como carregar dados da rede quando chegarmos ao fim do banco de dados, implementando um
RemoteMediator
. - Criar um
Pager
baseado na tabela Repos como fonte de dados e oRemoteMediator
para carregar e salvar dados.
Vamos analisar cada uma dessas etapas.
14. Definir o banco de dados do Room, a tabela e o DAO
Os objetos Repo
precisam ser salvos no banco de dados. Portanto, vamos começar tornando a classe Repo
uma entidade, com tableName = "repos"
, em que Repo.id
é a chave primária. Para fazer isso, faça uma anotação na classe Repo
com @Entity(tableName = "repos")
e adicione a anotação @PrimaryKey
ao id
. Agora, a classe Repo
ficará assim:
@Entity(tableName = "repos")
data class Repo(
@PrimaryKey @field:SerializedName("id") val id: Long,
@field:SerializedName("name") val name: String,
@field:SerializedName("full_name") val fullName: String,
@field:SerializedName("description") val description: String?,
@field:SerializedName("html_url") val url: String,
@field:SerializedName("stargazers_count") val stars: Int,
@field:SerializedName("forks_count") val forks: Int,
@field:SerializedName("language") val language: String?
)
Crie um novo pacote db
. É nele que implementaremos a classe que acessa os dados no banco de dados e a classe que define o banco de dados.
Implemente o objeto de acesso a dados (DAO, na sigla em inglês) para acessar a tabela repos
criando uma interface RepoDao
com a anotação @Dao
. Precisamos das seguintes ações no Repo
:
- Inserir uma lista de objetos
Repo
. Se os objetosRepo
já estiverem na tabela, substitua-os.
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(repos: List<Repo>)
- Consultar repositórios contendo a string de consulta no nome ou na descrição e classificar os resultados primeiro em ordem decrescente pelo número de estrelas e depois em ordem alfabética pelo nome. Em vez de retornar um
List<Repo>
, retornePagingSource<Int, Repo>
. Dessa forma, a tabelarepos
se tornará a fonte de dados para Paging.
@Query("SELECT * FROM repos WHERE " +
"name LIKE :queryString OR description LIKE :queryString " +
"ORDER BY stars DESC, name ASC")
fun reposByName(queryString: String): PagingSource<Int, Repo>
- Limpar todos os dados na tabela
Repos
.
@Query("DELETE FROM repos")
suspend fun clearRepos()
O RepoDao
ficará assim:
@Dao
interface RepoDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(repos: List<Repo>)
@Query("SELECT * FROM repos WHERE " +
"name LIKE :queryString OR description LIKE :queryString " +
"ORDER BY stars DESC, name ASC")
fun reposByName(queryString: String): PagingSource<Int, Repo>
@Query("DELETE FROM repos")
suspend fun clearRepos()
}
Implemente o banco de dados do Repo:
- Crie uma classe abstrata
RepoDatabase
que estendaRoomDatabase
. - Faça uma anotação
@Database
na classe, defina a lista de entidades que devem conter a classeRepo
e defina a versão do banco de dados como 1. Para os fins deste codelab, não precisamos exportar o esquema. - Defina uma função abstrata que retorne o
ReposDao
. - Crie uma função
getInstance()
em umcompanion object
que crie o objetoRepoDatabase
, caso ele ainda não exista.
O RepoDatabase
ficará assim:
@Database(
entities = [Repo::class],
version = 1,
exportSchema = false
)
abstract class RepoDatabase : RoomDatabase() {
abstract fun reposDao(): RepoDao
companion object {
@Volatile
private var INSTANCE: RepoDatabase? = null
fun getInstance(context: Context): RepoDatabase =
INSTANCE ?: synchronized(this) {
INSTANCE
?: buildDatabase(context).also { INSTANCE = it }
}
private fun buildDatabase(context: Context) =
Room.databaseBuilder(context.applicationContext,
RepoDatabase::class.java, "Github.db")
.build()
}
}
Agora que o banco de dados foi configurado, vamos ver como solicitar os dados da rede e os salvar no banco de dados.
15. Solicitar e salvar dados: visão geral
A biblioteca Paging usa o banco de dados como uma fonte da verdade para os dados que precisam ser exibidos na IU. Precisaremos solicitar mais dados da rede sempre que não houver mais nenhum no banco de dados. Para ajudar nisso, a Paging 3 define a classe abstrata RemoteMediator
com um método que precisa ser implementado: load()
. Esse método vai ser chamado sempre que for necessário carregar mais dados da rede. Essa classe retorna um objeto MediatorResult
, que pode ser:
Error
: em caso de erro ao solicitar dados da rede;Success
: se os dados da rede forem recebidos corretamente. Aqui, também precisamos transmitir um sinal que indique se é possível carregar mais dados. Por exemplo, se a resposta da rede foi bem-sucedida, mas recebemos uma lista vazia de repositórios, isso significa que não há mais dados a serem carregados.
Vamos criar uma nova classe chamada GithubRemoteMediator
, que estende RemoteMediator
no pacote data
. Essa classe será recriada para cada nova consulta, de modo que ela receberá o seguinte como parâmetros:
- A consulta
String
. - O
GithubService
para fazer solicitações de rede. - O
RepoDatabase
para salvar os dados recebidos da solicitação de rede.
@OptIn(ExperimentalPagingApi::class)
class GithubRemoteMediator(
private val query: String,
private val service: GithubService,
private val repoDatabase: RepoDatabase
) : RemoteMediator<Int, Repo>() {
override suspend fun load(loadType: LoadType, state: PagingState<Int, Repo>): MediatorResult {
}
}
Para criar a solicitação de rede, o método de carregamento tem dois parâmetros que fornecerão todas as informações necessárias:
PagingState
: fornece informações sobre as páginas que foram carregadas anteriormente, o índice acessado mais recentemente na lista e aPagingConfig
definida ao inicializar o fluxo de paginação.LoadType
: informa se precisamos carregar os dados no final (LoadType.APPEND
) ou no início dos dados (LoadType.PREPEND
) carregados anteriormente ou se essa é a primeira vez que os dados são carregados (LoadType.REFRESH
).
Por exemplo, se o tipo de carregamento for LoadType.APPEND
, o último item carregado em PagingState
será recuperado. Sabendo disso, podemos descobrir como carregar o próximo lote de objetos Repo
, calculando a próxima página a ser carregada.
Na próxima seção, você vai descobrir como calcular as chaves das próximas páginas a serem carregadas e das anteriores.
16. Calcular e salvar chaves de página remotas
Na API GitHub, a chave de página que usamos para solicitar páginas de repositórios é apenas um índice incrementado ao receber a próxima página. Isso significa que, considerando um objeto Repo
, o próximo lote de objetos Repo
pode ser solicitado com base no índice da página + 1. O lote anterior de objetos Repo
pode ser solicitado com base no índice da página - 1. Todos os objetos Repo
recebidos em determinada resposta de página terão as mesmas chaves seguintes e anteriores.
Quando recebemos o último item carregado do PagingState
, não há como saber a que índice da página ele pertence. Para resolver esse problema, podemos adicionar outra tabela que armazene as chaves das páginas seguintes e anteriores para cada Repo
. Podemos chamá-la de remote_keys
. Embora isso possa ser feito na tabela Repo
, criar uma nova tabela para as chaves seguintes e anteriores remotas associadas a um Repo
permite ter uma melhor separação de conceitos.
No pacote db
, criaremos uma nova classe de dados chamada RemoteKeys
, faremos uma anotação @Entity
nela e adicionaremos três propriedades: o repositório id
(que também é a chave primária) e as chaves anteriores e seguintes (que podem ser null
quando não for possível incluir dados no início ou fim).
@Entity(tableName = "remote_keys")
data class RemoteKeys(
@PrimaryKey
val repoId: Long,
val prevKey: Int?,
val nextKey: Int?
)
Vamos criar uma interface RemoteKeysDao
. Precisaremos dos recursos abaixo:
- Inserir uma lista de de **
RemoteKeys
**, já que sempre queRepos
forem recebidos da rede, chaves remotas serão geradas para eles. - Ter uma **
RemoteKey
** com base em umRepo
id
. - Limpar as **
RemoteKeys
**, que são usadas sempre que houver uma nova consulta.
@Dao
interface RemoteKeysDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(remoteKey: List<RemoteKeys>)
@Query("SELECT * FROM remote_keys WHERE repoId = :repoId")
suspend fun remoteKeysRepoId(repoId: Long): RemoteKeys?
@Query("DELETE FROM remote_keys")
suspend fun clearRemoteKeys()
}
Vamos adicionar a tabela RemoteKeys
ao banco de dados e fornecer acesso ao RemoteKeysDao
. Para fazer isso, atualize RepoDatabase
da seguinte maneira:
- Adicione RemoteKeys à lista de entidades.
- Exiba o
RemoteKeysDao
como uma função abstrata.
@Database(
entities = [Repo::class, RemoteKeys::class],
version = 1,
exportSchema = false
)
abstract class RepoDatabase : RoomDatabase() {
abstract fun reposDao(): RepoDao
abstract fun remoteKeysDao(): RemoteKeysDao
...
// rest of the class doesn't change
}
17. Solicitar e salvar dados: implementação
Agora que salvamos as chaves remotas, vamos voltar ao GithubRemoteMediator
e ver como usá-las. Essa classe substituirá a GithubPagingSource
. Copie a declaração GITHUB_STARTING_PAGE_INDEX
de GithubPagingSource
no GithubRemoteMediator
e exclua a classe GithubPagingSource
.
Vamos ver como podemos implementar o método GithubRemoteMediator.load()
:
- Descubra qual página precisa ser carregada da rede, com base no
LoadType
. - Acione a solicitação de rede.
- Quando a solicitação de rede for concluída, se a lista de repositórios recebidos não estiver vazia, faça o seguinte:
- Calcule as
RemoteKeys
para cadaRepo
. - Caso seja uma nova consulta (
loadType = REFRESH
), limpe o banco de dados. - Salve
RemoteKeys
eRepos
no banco de dados. - Retorne
MediatorResult.Success(endOfPaginationReached = false)
. - Se a lista de repositórios estiver vazia, retorne
MediatorResult.Success(endOfPaginationReached = true)
. Se ocorrer um erro ao solicitar os dados, retorneMediatorResult.Error
.
De forma geral, o código ficará parecido com o exemplo abaixo. Substituiremos os "TODOs" futuramente.
override suspend fun load(loadType: LoadType, state: PagingState<Int, Repo>): MediatorResult {
val page = when (loadType) {
LoadType.REFRESH -> {
// TODO
}
LoadType.PREPEND -> {
// TODO
}
LoadType.APPEND -> {
// TODO
}
}
val apiQuery = query + IN_QUALIFIER
try {
val apiResponse = service.searchRepos(apiQuery, page, state.config.pageSize)
val repos = apiResponse.items
val endOfPaginationReached = repos.isEmpty()
repoDatabase.withTransaction {
// clear all tables in the database
if (loadType == LoadType.REFRESH) {
repoDatabase.remoteKeysDao().clearRemoteKeys()
repoDatabase.reposDao().clearRepos()
}
val prevKey = if (page == GITHUB_STARTING_PAGE_INDEX) null else page - 1
val nextKey = if (endOfPaginationReached) null else page + 1
val keys = repos.map {
RemoteKeys(repoId = it.id, prevKey = prevKey, nextKey = nextKey)
}
repoDatabase.remoteKeysDao().insertAll(keys)
repoDatabase.reposDao().insertAll(repos)
}
return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
} catch (exception: IOException) {
return MediatorResult.Error(exception)
} catch (exception: HttpException) {
return MediatorResult.Error(exception)
}
}
Vejamos como encontrar a página a ser carregada com base no LoadType
.
18. Como descobrir a página com base no LoadType
Agora que sabemos o que acontece no método GithubRemoteMediator.load()
quando temos a chave de página, vamos ver como a calcular. Isso depende do LoadType
.
LoadType.APPEND
Quando precisamos carregar dados no fim do conjunto de dados carregados atualmente, o parâmetro de carregamento é LoadType.APPEND
. Agora, com base no último item do banco de dados, precisamos calcular a chave da página de rede.
- Precisamos descobrir a chave remota do último item
Repo
carregado do banco de dados. Vamos separar isso em uma função:
private suspend fun getRemoteKeyForLastItem(state: PagingState<Int, Repo>): RemoteKeys? {
// Get the last page that was retrieved, that contained items.
// From that last page, get the last item
return state.pages.lastOrNull() { it.data.isNotEmpty() }?.data?.lastOrNull()
?.let { repo ->
// Get the remote keys of the last item retrieved
repoDatabase.remoteKeysDao().remoteKeysRepoId(repo.id)
}
}
- Se a classe
remoteKeys
estiver marcada como nula, o resultado da atualização ainda não estará no banco de dados. Você pode retornar "Success" (sucesso) comendOfPaginationReached = false
, porque a Paging vai chamar esse método novamente se a classe RemoteKeys deixar de ser nula. Se a remoteKeys não estiver marcada comonull
, mas o parâmetronextKey
dela estiver marcado comonull
, teremos chegado ao fim da paginação para anexação.
val page = when (loadType) {
LoadType.APPEND -> {
val remoteKeys = getRemoteKeyForLastItem(state)
// If remoteKeys is null, that means the refresh result is not in the database yet.
// We can return Success with endOfPaginationReached = false because Paging
// will call this method again if RemoteKeys becomes non-null.
// If remoteKeys is NOT NULL but its nextKey is null, that means we've reached
// the end of pagination for append.
val nextKey = remoteKeys?.nextKey
if (nextKey == null) {
return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
}
nextKey
}
...
}
LoadType.PREPEND
Quando precisamos carregar dados no início do conjunto de dados carregados atualmente, o parâmetro de carregamento é LoadType.PREPEND
. Com base no primeiro item do banco de dados, precisamos calcular a chave da página de rede.
- Precisamos descobrir a chave remota do primeiro item
Repo
carregado do banco de dados. Vamos separar isso em uma função:
private suspend fun getRemoteKeyForFirstItem(state: PagingState<Int, Repo>): RemoteKeys? {
// Get the first page that was retrieved, that contained items.
// From that first page, get the first item
return state.pages.firstOrNull { it.data.isNotEmpty() }?.data?.firstOrNull()
?.let { repo ->
// Get the remote keys of the first items retrieved
repoDatabase.remoteKeysDao().remoteKeysRepoId(repo.id)
}
}
- Se a classe
remoteKeys
estiver marcada como nula, o resultado da atualização ainda não estará no banco de dados. Você pode retornar "Success" (sucesso) comendOfPaginationReached = false
, porque a Paging vai chamar esse método novamente se a classe RemoteKeys deixar de ser nula. Se a classe remoteKeys não estiver marcada comonull
, mas o parâmetroprevKey
dela estiver marcado comonull
, teremos chegado ao fim da paginação para anexação.
val page = when (loadType) {
LoadType.PREPEND -> {
val remoteKeys = getRemoteKeyForFirstItem(state)
// If remoteKeys is null, that means the refresh result is not in the database yet.
val prevKey = remoteKeys?.prevKey
if (prevKey == null) {
return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
}
prevKey
}
...
}
LoadType.REFRESH
LoadType.REFRESH
é chamado na primeira vez em que os dados são carregados ou quando PagingDataAdapter.refresh()
é chamado. Portanto, o ponto de referência para carregar os dados é state.anchorPosition
. Caso seja o primeiro carregamento, a anchorPosition
será null
. Quando PagingDataAdapter.refresh()
é chamado, a anchorPosition
é a primeira posição visível na lista exibida. Portanto, será necessário carregar a página que contém esse item específico.
- Com base na
anchorPosition
dostate
, é possível colocar o itemRepo
mais próximo nessa posição chamandostate.closestItemToPosition()
. - Com base no item
Repo
, é possível descobrir asRemoteKeys
do banco de dados.
private suspend fun getRemoteKeyClosestToCurrentPosition(
state: PagingState<Int, Repo>
): RemoteKeys? {
// The paging library is trying to load data after the anchor position
// Get the item closest to the anchor position
return state.anchorPosition?.let { position ->
state.closestItemToPosition(position)?.id?.let { repoId ->
repoDatabase.remoteKeysDao().remoteKeysRepoId(repoId)
}
}
}
- Se a
remoteKey
não for nula, poderemos receber anextKey
. Na API do GitHub, as chaves de página são incrementadas de forma sequencial. Assim, para descobrir a página que contém o item atual, basta subtrair 1 deremoteKey.nextKey
. - Se
RemoteKey
fornull
(porqueanchorPosition
énull
), a página que precisa ser carregada é a inicial:GITHUB_STARTING_PAGE_INDEX
.
Agora, a computação de páginas completa vai ficar assim:
val page = when (loadType) {
LoadType.REFRESH -> {
val remoteKeys = getRemoteKeyClosestToCurrentPosition(state)
remoteKeys?.nextKey?.minus(1) ?: GITHUB_STARTING_PAGE_INDEX
}
LoadType.PREPEND -> {
val remoteKeys = getRemoteKeyForFirstItem(state)
val prevKey = remoteKeys?.prevKey
if (prevKey == null) {
return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
}
prevKey
}
LoadType.APPEND -> {
val remoteKeys = getRemoteKeyForLastItem(state)
val nextKey = remoteKeys?.nextKey
if (nextKey == null) {
return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
}
nextKey
}
}
19. Atualizar a criação do fluxo de paginação
Agora que o GithubRemoteMediator
e a PagingSource
foram implementados no ReposDao
, precisamos atualizar o GithubRepository.getSearchResultStream
para os usar.
Para fazer isso, o GithubRepository
precisa acessar o banco de dados. Transmitiremos o banco de dados como um parâmetro no construtor. Além disso, como essa classe usará GithubRemoteMediator
:
class GithubRepository(
private val service: GithubService,
private val database: RepoDatabase
) { ... }
Atualize o arquivo Injection
:
- O método
provideGithubRepository
precisa receber um contexto como parâmetro. No construtorGithubRepository
, invoqueRepoDatabase.getInstance
. - O método
provideViewModelFactory
precisa receber um contexto como parâmetro e transmiti-lo paraprovideGithubRepository
.
object Injection {
private fun provideGithubRepository(context: Context): GithubRepository {
return GithubRepository(GithubService.create(), RepoDatabase.getInstance(context))
}
fun provideViewModelFactory(context: Context, owner: SavedStateRegistryOwner): ViewModelProvider.Factory {
return ViewModelFactory(owner, provideGithubRepository(context))
}
}
Atualize o método SearchRepositoriesActivity.onCreate()
e transmita o contexto para Injection.provideViewModelFactory()
:
// get the view model
val viewModel = ViewModelProvider(
this, Injection.provideViewModelFactory(
context = this,
owner = this
)
)
.get(SearchRepositoriesViewModel::class.java)
Voltemos para o GithubRepository
. Primeiro, para pesquisar repositórios pelo nome, é necessário adicionar %
ao início e ao fim da string de consulta. Em seguida, ao chamar reposDao.reposByName
, descobriremos uma PagingSource
. Como a PagingSource
é invalidada sempre que uma mudança é feita no banco de dados, é necessário informar à Paging como conseguir uma nova instância da PagingSource
. Para isso, basta criar uma função que chame a consulta do banco de dados:
// appending '%' so we can allow other characters to be before and after the query string
val dbQuery = "%${query.replace(' ', '%')}%"
val pagingSourceFactory = { database.reposDao().reposByName(dbQuery)}
Agora podemos mudar o builder da Pager
para usar uma classe GithubRemoteMediator
e o lambda pagingSourceFactory
. A Pager
é uma API experimental, então precisaremos anotá-la com @OptIn
:
@OptIn(ExperimentalPagingApi::class)
return Pager(
config = PagingConfig(
pageSize = NETWORK_PAGE_SIZE,
enablePlaceholders = false
),
remoteMediator = GithubRemoteMediator(
query,
service,
database
),
pagingSourceFactory = pagingSourceFactory
).flow
Pronto! Vamos executar o app.
Como responder a estados de carregamento ao usar um RemoteMediator
Até agora, para ler os dados de CombinedLoadStates
, sempre consultamos CombinedLoadStates.source
. No entanto, ao usar um RemoteMediator
, as informações de carregamento precisas só podem ser acessadas ao verificar CombinedLoadStates.source
e CombinedLoadStates.mediator
. Mais especificamente, nós acionamos uma rolagem até o início da lista em novas consultas quando o LoadState
da source
é NotLoading
. Também precisamos garantir que o RemoteMediator
adicionado recentemente tenha um LoadState
de NotLoading
.
Para fazer isso, vamos definir um tipo enumerado que resume os estados de apresentação da lista conforme buscado pelo Pager
:
enum class RemotePresentationState {
INITIAL, REMOTE_LOADING, SOURCE_LOADING, PRESENTED
}
Com a definição acima, podemos comparar as emissões consecutivas de CombinedLoadStates
e as usar para determinar o estado exato dos itens na lista.
@OptIn(ExperimentalCoroutinesApi::class)
fun Flow<CombinedLoadStates>.asRemotePresentationState(): Flow<RemotePresentationState> =
scan(RemotePresentationState.INITIAL) { state, loadState ->
when (state) {
RemotePresentationState.PRESENTED -> when (loadState.mediator?.refresh) {
is LoadState.Loading -> RemotePresentationState.REMOTE_LOADING
else -> state
}
RemotePresentationState.INITIAL -> when (loadState.mediator?.refresh) {
is LoadState.Loading -> RemotePresentationState.REMOTE_LOADING
else -> state
}
RemotePresentationState.REMOTE_LOADING -> when (loadState.source.refresh) {
is LoadState.Loading -> RemotePresentationState.SOURCE_LOADING
else -> state
}
RemotePresentationState.SOURCE_LOADING -> when (loadState.source.refresh) {
is LoadState.NotLoading -> RemotePresentationState.PRESENTED
else -> state
}
}
}
.distinctUntilChanged()
No exemplo acima, é possível atualizar a definição do notLoading
Flow
que usamos para verificar se podemos rolar até o início da lista:
val notLoading = repoAdapter.loadStateFlow
.asRemotePresentationState()
.map { it == RemotePresentationState.PRESENTED }
Da mesma forma, quando se trata de exibir um ícone de carregamento durante o carregamento inicial da página (na extensão bindList
em SearchRepositoriesActivity
), o app ainda depende da LoadState.source
. O objetivo é mostrar um ícone apenas para carregamentos do RemoteMediator
. Esse comportamento também se aplica a outros elementos da IU com visibilidade que depende de LoadStates
. Por isso, vamos atualizar a vinculação de LoadStates
com os elementos da IU desta maneira:
private fun ActivitySearchRepositoriesBinding.bindList(
header: ReposLoadStateAdapter,
repoAdapter: ReposAdapter,
uiState: StateFlow<UiState>,
onScrollChanged: (UiAction.Scroll) -> Unit
) {
...
lifecycleScope.launch {
repoAdapter.loadStateFlow.collect { loadState ->
...
val isListEmpty = loadState.refresh is LoadState.NotLoading && repoAdapter.itemCount == 0
// show empty list
emptyList.isVisible = isListEmpty
// Only show the list if refresh succeeds, either from the the local db or the remote.
list.isVisible = loadState.source.refresh is LoadState.NotLoading || loadState.mediator?.refresh is LoadState.NotLoading
// Show loading spinner during initial load or refresh.
progressBar.isVisible = loadState.mediator?.refresh is LoadState.Loading
// Show the retry state if initial load or refresh fails.
retryButton.isVisible = loadState.mediator?.refresh is LoadState.Error && repoAdapter.itemCount == 0
}
}
}
}
Além disso, como o banco de dados é a única fonte de verdade, é possível iniciar o app em um estado que inclua os dados do banco de dados, mas que gere falhas para atualizações RemoteMediator
. Essa é uma falha de caso extremo, mas que pode ser resolvida facilmente. Para fazer isso, podemos manter uma referência ao cabeçalho LoadStateAdapter
e só substituir o LoadState
pelo estado do RemoteMediator se a atualização apresentar um erro. Caso contrário, usaremos o comportamento padrão.
private fun ActivitySearchRepositoriesBinding.bindList(
header: ReposLoadStateAdapter,
repoAdapter: ReposAdapter,
uiState: StateFlow<UiState>,
onScrollChanged: (UiAction.Scroll) -> Unit
) {
...
lifecycleScope.launch {
repoAdapter.loadStateFlow.collect { loadState ->
// Show a retry header if there was an error refreshing, and items were previously
// cached OR default to the default prepend state
header.loadState = loadState.mediator
?.refresh
?.takeIf { it is LoadState.Error && repoAdapter.itemCount > 0 }
?: loadState.prepend
...
}
}
}
O código completo das etapas realizadas até aqui pode ser encontrado na ramificação step13-19_network_and_database (link em inglês).
20. Resumo
Agora que adicionamos todos os componentes, vamos recapitular o que foi aprendido.
- A
PagingSource
carrega de forma assíncrona os dados de uma fonte definida. - O
Pager.flow
cria umFlow<PagingData>
com base em uma configuração e uma função definidas para instanciar aPagingSource
. - O
Flow
emite um novoPagingData
sempre que novos dados são carregados pelaPagingSource
. - A IU observa o
PagingData
modificado e usa umPagingDataAdapter
para atualizar aRecyclerView
que apresenta os dados. - Para repetir um carregamento em que houve falha na interface, use o método
PagingDataAdapter.retry
. Internamente, a biblioteca Paging acionará o métodoPagingSource.load()
. - Para adicionar separadores à lista, crie um tipo de alto nível com separadores como um dos tipos compatíveis. Em seguida, use o método
PagingData.insertSeparators()
para implementar sua lógica de geração de separadores. - Para exibir o estado de carregamento como cabeçalho ou rodapé, use o método
PagingDataAdapter.withLoadStateHeaderAndFooter()
e implemente umLoadStateAdapter
. Caso queira executar outras ações com base no estado de carregamento, use o callbackPagingDataAdapter.addLoadStateListener()
. - Para trabalhar com a rede e o banco de dados, implemente um
RemoteMediator
. - Adicionar um
RemoteMediator
faz com que o campomediator
seja atualizado noLoadStatesFlow
.