Paging Android

O que você aprenderá

  • Quais são os principais componentes da Paging 3.0.
  • Como adicionar a Paging 3.0 ao projeto.
  • Como adicionar um cabeçalho ou rodapé a uma lista usando a API Paging 3.0.
  • Como adicionar separadores de lista usando a API Paging 3.0.
  • Como criar páginas usando a rede e o banco de dados

O que você 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 terá esta aparência:

e662a697dd078356.png

Pré-requisitos

Para uma introdução aos componentes da arquitetura, consulte o codelab Room com uma View. Para uma introdução ao fluxo, confira o codelab Corrotinas avançadas com fluxo do Kotlin e LiveData.

Nesta etapa, você fará 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 a seguir 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 Paging 3.0 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 para este codelab:

Fazer o download do código-fonte

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

b3c0dfdb92dfed77.png

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

86fcb1b9b845c2f6.png

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 exibe para a 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 de LiveData será exibido na tela.

Do ponto de vista da usabilidade, ocorrem os seguintes problemas:

  • 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.

Do ponto de vista da implementação, ocorrem os seguintes problemas:

  • 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.

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ê implementará exemplos de cada um dos componentes descritos acima.

A implementação da PagingSource define a fonte dos dados e como recuperar 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)
        }
    }

}

Na implementação atual, usamos um Flow<RepoSearchResult> no GitHubRepository para receber os dados da rede e transmiti-los 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.0, 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 substituí-lo 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.0 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 {
        private const val NETWORK_PAGE_SIZE = 50
    }
}

Você notará erros de compilação no SearchRepositoriesViewModel. Vamos ver quais mudanças precisam ser feitas.

No SearchRepositoriesViewModel, exibimos um repoResult: LiveData<RepoSearchResult>. A função do repoResult é ser um cache de resultados de pesquisa na memória, que sobreviva às mudanças de configuração. Com a Paging 3.0, não é mais necessário converter o Flow em LiveData. Em vez disso, o SearchRepositoriesViewModel terá um membro Flow<PagingData<Repo>> particular com a mesma função de repoResult.

Em vez de usar um objeto LiveData para cada nova consulta, podemos usar somente uma String. Isso ajudará a garantir que sempre que recebermos uma nova consulta de pesquisa igual à atual, retornaremos apenas o Flow atual. Só precisaremos chamar repository.getSearchResultStream() se a nova consulta de pesquisa for diferente.

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.

Vamos recriar a maior parte do SearchRepositoriesViewModel para aproveitar a funcionalidade integrada da Paging 3.0. O SearchRepositoriesViewModel ficará assim:

class SearchRepositoriesViewModel(private val repository: GithubRepository) : ViewModel() {

    private var currentQueryValue: String? = null

    private var currentSearchResult: Flow<PagingData<Repo>>? = null

    fun searchRepo(queryString: String): Flow<PagingData<Repo>> {
        val lastResult = currentSearchResult
        if (queryString == currentQueryValue && lastResult != null) {
            return lastResult
        }
        currentQueryValue = queryString
        val newResult: Flow<PagingData<Repo>> = repository.getSearchResultStream(queryString)
                .cachedIn(viewModelScope)
        currentSearchResult = newResult
        return newResult
    }
}

Agora, vamos ver as mudanças que fizemos no SearchRepositoriesViewModel:

  • Adicionamos a nova String de consulta e os membros Flow do resultado da pesquisa.
  • Atualizamos o método searchRepo() com a funcionalidade descrita anteriormente.
  • Removemos queryLiveData e repoResult, porque as mesmas funções são abrangidas pela Paging 3.0 e pelo Flow.
  • Removemos listScrolled(), porque a biblioteca Paging processará isso.
  • Removemos o companion object, porque o VISIBLE_THRESHOLD não é mais necessário.

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.

Vamos atualizar a SearchRepositoriesActivity para que funcione com a Paging 3.0. Para funcionar com o Flow<PagingData>, precisamos iniciar uma nova corrotina. Faremos isso no lifecycleScope, responsável por cancelar a solicitação quando a atividade é recriada.

Também queremos garantir que, sempre que o usuário pesquisar uma nova consulta, a consulta anterior será cancelada. Para fazer isso, nossa SearchRepositoriesActivity pode manter uma referência a um novo Job que será cancelado sempre que pesquisarmos uma nova consulta.

Vamos criar uma nova função de pesquisa que recebe uma consulta como parâmetro. A função precisa:

  • cancelar o job de pesquisa anterior;
  • iniciar um novo job no lifecycleScope;
  • chamar o viewModel.searchRepo;
  • coletar o resultado do PagingData;
  • transmitir o PagingData para o ReposAdapter, chamando adapter.submitData(pagingData).
private var searchJob: Job? = null

private fun search(query: String) {
   // Make sure we cancel the previous job before creating a new one
   searchJob?.cancel()
   searchJob = lifecycleScope.launch {
       viewModel.searchRepo(query).collectLatest {
           adapter.submitData(it)
       }
   }
}

A função de pesquisa precisa ser chamada na SearchRepositoriesActivity do método onCreate(). Em updateRepoListFromInput(), substitua as chamadas viewModel e adapter por search():

private fun updateRepoListFromInput() {
    binding.searchRepo.text.trim().let {
        if (it.isNotEmpty()) {
            binding.list.scrollToPosition(0)
            search(it.toString())
        }
    }
}

Como queremos garantir que a posição de rolagem será redefinida a cada nova pesquisa, consideramos binding.list.scrollToPosition(0). Mas, em vez de redefinir a posição em uma nova pesquisa, precisamos redefini-la quando o adaptador de lista for atualizado com o resultado de uma nova pesquisa. Para isso, podemos usar a API PagingDataAdapter.loadStateFlow. Esse Flow é emitido sempre que há uma mudança no estado de carregamento usando um objeto CombinedLoadStates.

O 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.

Vamos coletar esse fluxo ao inicializar a pesquisa no método initSearch, e a cada nova emissão do fluxo rolaremos para a posição 0.

private fun initSearch(query: String) {
    ...
    // First part of the method is unchanged

        // Scroll to top when the list is refreshed from network.
        lifecycleScope.launch {
            adapter.loadStateFlow
                   // Only emit when REFRESH LoadState changes.
                   .distinctUntilChangedBy { it.refresh }
                   // Only react to cases where REFRESH completes i.e., NotLoading.
                   .filter { it.refresh is LoadState.NotLoading }
                   .collect { binding.list.scrollToPosition(0) }
        }
}

Agora, podemos remover binding.list.scrollToPosition(0) de updateRepoListFromInput().

No momento, usamos um OnScrollListener anexado à RecyclerView para saber quando acionar mais dados. Podemos deixar que a biblioteca Paging processe a rolagem de lista para nós. Remova o método setupScrollListener() e todas as referências a ele.

Remova também o uso de repoResult. Sua atividade ficará assim:

class SearchRepositoriesActivity : AppCompatActivity() {

    private lateinit var binding: ActivitySearchRepositoriesBinding
    private lateinit var viewModel: SearchRepositoriesViewModel
    private val adapter = ReposAdapter()

    private var searchJob: Job? = null

    private fun search(query: String) {
        // Make sure we cancel the previous job before creating a new one
        searchJob?.cancel()
        searchJob = lifecycleScope.launch {
            viewModel.searchRepo(query).collect {
                adapter.submitData(it)
            }
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivitySearchRepositoriesBinding.inflate(layoutInflater)
        val view = binding.root
        setContentView(view)

        // get the view model
        viewModel = ViewModelProvider(this, Injection.provideViewModelFactory())
                .get(SearchRepositoriesViewModel::class.java)

        // add dividers between RecyclerView's row items
        val decoration = DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
        binding.list.addItemDecoration(decoration)

        initAdapter()
        val query = savedInstanceState?.getString(LAST_SEARCH_QUERY) ?: DEFAULT_QUERY
        search(query)
        initSearch(query)
    }

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        outState.putString(LAST_SEARCH_QUERY, binding.searchRepo.text.trim().toString())
    }

    private fun initAdapter() {
        binding.list.adapter = adapter
    }

    private fun initSearch(query: String) {
        binding.searchRepo.setText(query)

        binding.searchRepo.setOnEditorActionListener { _, actionId, _ ->
            if (actionId == EditorInfo.IME_ACTION_GO) {
                updateRepoListFromInput()
                true
            } else {
                false
            }
        }
        binding.searchRepo.setOnKeyListener { _, keyCode, event ->
            if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_ENTER) {
                updateRepoListFromInput()
                true
            } else {
                false
            }
        }

        // Scroll to top when the list is refreshed from network.
        lifecycleScope.launch {
            adapter.loadStateFlow
                    // Only emit when REFRESH LoadState for RemoteMediator changes.
                    .distinctUntilChangedBy { it.refresh }
                    // Only react to cases where Remote REFRESH completes i.e., NotLoading.
                    .filter { it.refresh is LoadState.NotLoading }
                    .collect { binding.list.scrollToPosition(0) }
        }
    }

    private fun updateRepoListFromInput() {
        binding.searchRepo.text.trim().let {
            if (it.isNotEmpty()) {
                search(it.toString())
            }
        }
    }

    private fun showEmptyList(show: Boolean) {
        if (show) {
            binding.emptyList.visibility = View.VISIBLE
            binding.list.visibility = View.GONE
        } else {
            binding.emptyList.visibility = View.GONE
            binding.list.visibility = View.VISIBLE
        }
    }

    companion object {
        private const val LAST_SEARCH_QUERY: String = "last_search_query"
        private const val DEFAULT_QUERY = "Android"
    }
}

O app 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).

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.0 nos ajuda 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, usaremos o LoadStateAdapter da Paging 3.0

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)
    }
}

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 SearchRepositoriesActivity.initAdapter() e chame withLoadStateHeaderAndFooter() no adaptador. Como função de repetição, podemos chamar adapter.retry().

private fun initAdapter() {
    binding.list.adapter = adapter.withLoadStateHeaderAndFooter(
            header = ReposLoadStateAdapter { adapter.retry() },
            footer = ReposLoadStateAdapter { adapter.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).

Talvez você tenha percebido que temos dois problemas no momento:

  • Ao migrar para a Paging 3.0, não podemos 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 do carregamento na nossa SearchRepositoriesActivity.

Exibir a mensagem de lista vazia

Primeiro, vamos trazer de volta a mensagem de lista vazia. Ela 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 o método PagingDataAdapter.addLoadStateListener(). Esse callback envia uma notificação sempre que há uma mudança no estado de carregamento usando um objeto CombinedLoadStates.

O CombinedLoadStates informa o estado de carregamento para a PageSource definida ou para a RemoteMediator necessária para os casos de rede e de banco de dados, que serão abordados posteriormente.

Em SearchRepositoriesActivity.initAdapter(), chamamos addLoadStateListener. A lista fica vazia quando o estado refresh do CombinedLoadStates é NotLoading e adapter.itemCount == 0. Em seguida, chamamos showEmptyList:

private fun initAdapter() {
    binding.list.adapter = adapter.withLoadStateHeaderAndFooter(
            header = ReposLoadStateAdapter { adapter.retry() },
            footer = ReposLoadStateAdapter { adapter.retry() }
    )
    adapter.addLoadStateListener { loadState ->
        // show empty list
        val isListEmpty = loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0
        showEmptyList(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 da interface onClickListener, como fizemos para o cabeçalho/rodapé:

// SearchRepositoriesActivity.kt

override fun onCreate(savedInstanceState: Bundle?) {
    ...
    binding.retryButton.setOnClickListener { adapter.retry() }
}

Em seguida, vamos reagir às mudanças de estado de carregamento em SearchRepositoriesActivity.initAdapter. Como só queremos que a barra de progresso seja exibida quando houver uma nova consulta, precisamos contar com o 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, precisaremos conferir se CombinedLoadStates.prepend ou CombinedLoadStates.append são uma instância do LoadState.Error e recuperar a mensagem do erro.

Vamos atualizar o método SearchRepositoriesActivity.initAdapter para incluir essa funcionalidade:

private fun initAdapter() {
    binding.list.adapter = adapter.withLoadStateHeaderAndFooter(
            header = ReposLoadStateAdapter { adapter.retry() },
            footer = ReposLoadStateAdapter { adapter.retry() }
    )
    adapter.addLoadStateListener { loadState ->
        // show empty list
        val isListEmpty = loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0
        showEmptyList(isListEmpty)

        // Only show the list if refresh succeeds.
        binding.list.isVisible = loadState.source.refresh is LoadState.NotLoading
        // Show loading spinner during initial load or refresh.
        binding.progressBar.isVisible = loadState.source.refresh is LoadState.Loading
        // Show the retry state if initial load or refresh fails.
        binding.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,
                    "\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).

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.0 permite inserir separadores no PagingData.

170f5fa2945e7d95.png

A adição de separadores nos PagingData resultará 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 retornará Flow<PagingData<UiModel>>. Faça com que currentSearchResult seja do mesmo tipo.

class SearchRepositoriesViewModel(private val repository: GithubRepository) : ViewModel() {

    private var currentQueryValue: String? = null

    private var currentSearchResult: Flow<PagingData<UiModel>>? = null

    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:

fun searchRepo(queryString: String): Flow<PagingData<UiModel>> {
    val lastResult = currentSearchResult
    if (queryString == currentQueryValue && lastResult != null) {
        return lastResult
    }
    currentQueryValue = queryString
    val newResult: Flow<PagingData<UiModel>> = repository.getSearchResultStream(queryString)
            .map { pagingData -> pagingData.map { UiModel.RepoItem(it) } }
            .map {
                it.insertSeparators<UiModel.RepoItem, UiModel> { 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
                    }
                }
            }
            .cachedIn(viewModelScope)
    currentSearchResult = newResult
    return newResult
}

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).

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.

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 salvar no banco de dados.

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.0 define a classe abstrata RemoteMediator com um método que precisa ser implementado: load(). Esse método 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ê descobrirá como calcular as chaves das páginas seguintes e anteriores a serem carregadas.

Para a 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 seguintes recursos:

  • Inserir uma lista 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 a classe RemoteKeys, que será usada 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
}

Agora que salvamos as chaves remotas, voltaremos para o GithubRemoteMediator e veremos 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 RemoteKeys para cada Repo.
  5. Se for 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.

Agora que sabemos o que acontece no método GithubRemoteMediator.load() quando temos a chave de página, vamos ver como calculá-la. 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ê poderá retornar Success com endOfPaginationReached = false, porque a Paging 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 prevKey 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 prevKey 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ê poderá retornar Success com endOfPaginationReached = false, porque a Paging 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 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.
        // 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 prevKey is null, that means we've reached
        // the end of pagination for prepend.
        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 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
    }
}

Agora que o GithubRemoteMediator e a PagingSource foram implementados no ReposDao, precisamos atualizar o GithubRepository.getSearchResultStream para usá-los.

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): ViewModelProvider.Factory {
        return ViewModelFactory(provideGithubRepository(context))
    }
}

Atualize o método SearchRepositoriesActivity.onCreate() e transmita o contexto para Injection.provideViewModelFactory():

// get the view model
viewModel = ViewModelProvider(this, Injection.provideViewModelFactory(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 atualizar a origem do estado de carregamento

Agora, nosso app carrega dados da rede e os salva no banco de dados, mas, ao exibir um ícone durante o carregamento inicial da página (em SearchRepositoriesActivity.initAdapter), o app ainda depende de LoadState.source. O que queremos é mostrar um ícone apenas para carregamentos da RemoteMediator. Para isso, precisamos mudar de LoadState.source para LoadState.mediator:

private fun initAdapter() {
         ...
        adapter.addLoadStateListener { loadState ->
            // Only show the list if refresh succeeds.
            binding.list.isVisible = loadState.mediator?.refresh is LoadState.NotLoading
            // Show loading spinner during initial load or refresh.
            binding.progressBar.isVisible = loadState.mediator?.refresh is LoadState.Loading
            // Show the retry state if initial load or refresh fails.
            binding.retryButton.isVisible = loadState.mediator?.refresh is LoadState.Error
            ... // everything else stays the same
    }

O código completo das etapas realizadas até aqui pode ser encontrado na ramificação step13-19_network_and_database (link em inglês).

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.