Codelab avançado da Android Paging

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:

23643514cb9cf43e.png

O que é necessário

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:

  1. Descompacte o código e abra o projeto no Android Studio.
  2. Faça a configuração de execução app em um dispositivo ou emulador.

89af884fa2d4e709.png

O app vai ser executado e exibirá uma lista de repositórios do GitHub parecida com esta:

50d1d2aa6e79e473.png

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, e RepoSearchResult, classe usada pela IU para observar dados de resultados de pesquisas e erros de rede.
  • ui: classes relacionadas à exibição de uma Activity usando uma RecyclerView.

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:

  1. Um LiveData<UiState>
  2. 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 em LiveData 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 o LiveData.
  • 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 ou filter, na lista que será exibida, independentemente de você estar usando Flow, LiveData ou um Flowable ou Observable 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á um PagingData separado correspondente.
  • PagingSource: a PagingSource é a classe base para carregar snapshots de dados em um stream do PagingData.
  • Pager.flow: cria um Flow<PagingData> com base em uma PagingConfig e uma função que define como construir a PagingSource implementada.
  • PagingDataAdapter: um RecyclerView.Adapter que apresenta o PagingData em uma RecyclerView. O PagingDataAdapter pode ser conectado a um Flow do Kotlin, um LiveData ou um Flowable ou Observable RxJava. O PagingDataAdapter detecta eventos internos de carregamento do PagingData, à medida que as páginas são carregadas. Também usa o DiffUtil em uma linha de execução em segundo plano para computar atualizações detalhadas à medida que conteúdo atualizado é recebido na forma de novos objetos PagingData.
  • 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 o GithubService.

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 constante GITHUB_STARTING_PAGE_INDEX do GithubRepository para sua implementação da PagingSource, 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: use Pager.flow.
  • LiveData: use Pager.liveData.
  • Flowable RxJava: use Pager.flowable.
  • Observable RxJava: use Pager.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 uma PagingSource. 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âmetro maxSize em PagingConfig. 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ção enablePlaceholders for verdadeira. Dessa forma, você poderá exibir uma visualização de marcador no adaptador. Para simplificar o trabalho neste codelab, vamos desativar os marcadores transmitindo enablePlaceholders = false.
  • Uma função que define como criar a PagingSource. Em nosso caso, criaremos uma nova GithubPagingSource 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:

  1. UiAction.Search para cada vez que o usuário insere uma consulta específica.
  2. 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:

  1. shareIn: necessário porque, quando esse Flow é consumido, ele usa o operador flatmapLatest. Cada vez que o upstream for emitido, o flatmapLatest vai cancelar o último Flow 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 operador Flow com um valor replay de 1 para armazenar o último valor em cache e, assim, não perder o valor ao receber uma nova consulta.
  2. 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 a ListAdapter. Em vez disso, faça com que ele implemente o PagingDataAdapter. 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 carregar PagingData 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".

3f6f2cd47b55de92.png 661da51b58c32b8c.png

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 no LoadState da Paging.
  • O arquivo do adaptador, que define como criar e vincular o ViewHolder. Em vez de estender um RecyclerView.Adapter, vamos usar o LoadStateAdapter 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".

ab9ff1b8b033179e.png bd744ff3ddc280c3.png

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.

573969750b4c719c.png

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 de Repo para UiModel.
  • Implemente um comparador UiModel e substitua o REPO_COMPARATOR por ele.
  • Crie o SeparatorViewHolder e vincule-o à descrição do UiModel.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:

  1. Criar um banco de dados da Room, uma tabela para salvar os objetos Repo e um DAO que será usado para trabalhar com os objetos Repo.
  2. Definir como carregar dados da rede quando chegarmos ao fim do banco de dados, implementando um RemoteMediator.
  3. Criar um Pager baseado na tabela Repos como fonte de dados e o RemoteMediator 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 objetos Repo 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>, retorne PagingSource<Int, Repo>. Dessa forma, a tabela repos 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 estenda RoomDatabase.
  • Faça uma anotação @Database na classe, defina a lista de entidades que devem conter a classe Repo 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 um companion object que crie o objeto RepoDatabase, 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 a PagingConfig 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 que Repos forem recebidos da rede, chaves remotas serão geradas para eles.
  • Ter uma **RemoteKey** com base em um Repo 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():

  1. Descubra qual página precisa ser carregada da rede, com base no LoadType.
  2. Acione a solicitação de rede.
  3. Quando a solicitação de rede for concluída, se a lista de repositórios recebidos não estiver vazia, faça o seguinte:
  4. Calcule as RemoteKeys para cada Repo.
  5. Caso seja uma nova consulta (loadType = REFRESH), limpe o banco de dados.
  6. Salve RemoteKeys e Repos no banco de dados.
  7. Retorne MediatorResult.Success(endOfPaginationReached = false).
  8. Se a lista de repositórios estiver vazia, retorne MediatorResult.Success(endOfPaginationReached = true). Se ocorrer um erro ao solicitar os dados, retorne MediatorResult.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.

  1. 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)
                }
    }
  1. 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) com endOfPaginationReached = 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 como null, mas o parâmetro nextKey dela estiver marcado como null, 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.

  1. 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)
            }
}
  1. 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) com endOfPaginationReached = 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 como null, mas o parâmetro prevKey dela estiver marcado como null, 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.

  1. Com base na anchorPosition do state, é possível colocar o item Repo mais próximo nessa posição chamando state.closestItemToPosition().
  2. Com base no item Repo, é possível descobrir as RemoteKeys 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)
        }
    }
}
  1. Se a remoteKey não for nula, poderemos receber a nextKey. 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 de remoteKey.nextKey.
  2. Se RemoteKey for null (porque anchorPosition é 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 construtor GithubRepository, invoque RepoDatabase.getInstance.
  • O método provideViewModelFactory precisa receber um contexto como parâmetro e transmiti-lo para provideGithubRepository.
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 um Flow<PagingData> com base em uma configuração e uma função definidas para instanciar a PagingSource.
  • O Flow emite um novo PagingData sempre que novos dados são carregados pela PagingSource.
  • A IU observa o PagingData modificado e usa um PagingDataAdapter para atualizar a RecyclerView 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étodo PagingSource.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 um LoadStateAdapter. Caso queira executar outras ações com base no estado de carregamento, use o callback PagingDataAdapter.addLoadStateListener().
  • Para trabalhar com a rede e o banco de dados, implemente um RemoteMediator.
  • Adicionar um RemoteMediator faz com que o campo mediator seja atualizado no LoadStatesFlow.