Android Paging

Points abordés

  • Principaux composants de Paging 3.0 :
  • Ajouter Paging 3.0 à votre projet
  • Ajouter un en-tête ou un pied de page à votre liste avec l'API Paging 3.0
  • Ajouter des séparateurs de listes avec l'API Paging 3.0
  • Paginer à partir du réseau et de la base de données

Objectifs de l'atelier

Dans cet atelier de programmation, vous commencerez avec un exemple d'application qui affiche déjà une liste de dépôts GitHub. Chaque fois que l'utilisateur arrive à la fin de la liste affichée, une nouvelle requête réseau est déclenchée, et son résultat s'affiche à l'écran.

Vous suivrez les étapes pour ajouter du code afin d'obtenir les résultats suivants :

  • Migrer vers les composants de la bibliothèque Paging
  • Ajouter à la liste un en-tête et un pied de page affichant l'état de chargement
  • Afficher la progression du chargement après chaque nouvelle recherche dans le dépôt
  • Ajouter des séparateurs à votre liste
  • Ajouter une prise en charge de base de données pour la pagination depuis un réseau ou une base de données

Voici à quoi ressemblera l'application en fin d'atelier :

e662a697dd078356.png

Prérequis

Pour une présentation des composants d'architecture, consultez notre atelier de programmation dans Room. Pour plus d'informations sur Flow, consultez l'atelier de programmation Coroutines avancées avec Kotlin Flow et LiveData.

Au cours de cette étape, vous téléchargerez l'intégralité du code de cet atelier de programmation, puis exécuterez un exemple d'application simple.

Pour vous aider à démarrer le plus rapidement possible, nous avons préparé un projet de démarrage.

Si git est installé, vous pouvez simplement exécuter la commande ci-dessous. (Pour vérifier, saisissez git --version dans le terminal ou l'outil de ligne de commande et vérifiez qu'il s'exécute correctement.)

 git clone https://github.com/googlecodelabs/android-paging

L'état initial apparaît dans la branche "master". Les solutions de certaines étapes sont accessibles comme suit :

  • Branche step5-9_paging_3.0 – solution pour les étapes 5 à 9 (ajout de Paging 3.0 au projet)
  • Branche step10_loading_state_footer – solution pour l'étape 10 (ajout d'un pied de page qui affiche un état de chargement)
  • Branche step11_loading_state – solution pour l'étape 11 (affichage de l'état de chargement entre les requêtes)
  • Branche step12_separators – solution pour l'étape 12 (ajout des séparateurs à l'application)
  • Branche step13-19_network_and_database – solution pour les étapes 13 à 19 (prise en charge du fonctionnement hors connexion de l'application)

Si vous n'avez pas git, cliquez sur le bouton ci-dessous pour télécharger l'ensemble du code de cet atelier de programmation :

Télécharger le code source

  1. Décompressez le code, puis ouvrez le projet dans Android Studio (version 3.6.1 ou ultérieure).
  2. Exécutez la configuration d'exécution app sur un appareil ou un émulateur.

b3c0dfdb92dfed77.png

L'application s'exécute et affiche une liste de dépôts GitHub semblables à celui-ci :

86fcb1b9b845c2f6.png

L'appli vous permet de rechercher les dépôts GitHub dont le nom ou la description contiennent un mot spécifique. Les dépôts sont triés par nombre décroissant d'étoiles, puis par ordre alphabétique.

L'application suit l'architecture recommandée dans le Guide de l'architecture des applications. Voici le contenu de chaque package :

  • api – appels d'API GitHub via Retrofit.
  • data – classe de dépôt chargée de déclencher les requêtes API et de mettre en cache les réponses en mémoire.
  • model – le modèle de données Repo, qui est également une table de la base de données Room, et RepoSearchResult, une classe utilisée par l'interface utilisateur pour observer à la fois les données de résultats de recherche et les erreurs réseau.
  • ui – classes liées à l'affichage d'une Activity avec une RecyclerView.

La classe GithubRepository récupère la liste des noms de dépôts du réseau chaque fois que l'utilisateur arrive à la fin de la liste ou lorsqu'il recherche un nouveau dépôt. La liste des résultats d'une requête est conservée en mémoire dans le GithubRepository d'un ConflatedBroadcastChannel et exposée en tant que Flow.

SearchRepositoriesViewModel appelle les données de GithubRepository et les expose dans SearchRepositoriesActivity. Pour éviter de multiplier les requêtes de données lors d'un changement de configuration (par exemple, une rotation), nous convertissons le Flow en LiveData dans le ViewModel à l'aide de la méthode de compilateur liveData(). Ainsi, LiveData met en cache la dernière liste de résultats en mémoire. Lorsque SearchRepositoriesActivity est recréé, le contenu LiveData s'affiche à l'écran.

Les problèmes d'utilisation suivants se posent :

  • L'utilisateur ne dispose d'aucune information sur l'état de chargement de la liste. En effet, l'écran reste vide lors de la recherche d'un nouveau dépôt, et la liste est simplement coupée en attendant le chargement de résultats supplémentaires pour la même requête.
  • L'utilisateur ne peut pas réitérer une requête ayant échoué.

Les problèmes d'implémentation suivants se posent :

  • La liste n'est pas limitée en volume et occupe de plus en plus de mémoire à mesure que l'utilisateur fait défiler la page.
  • Les résultats de Flow doivent être convertis en LiveData pour vous permettre de les mettre en cache, ce qui augmente la complexité du code.
  • Une application qui doit afficher plusieurs listes nous obligerait à rédiger beaucoup de code récurrent pour chaque liste.

Voyons à présent comment la bibliothèque Paging peut nous aider à résoudre ces problèmes et quels sont les composants qu'elle contient.

La bibliothèque Paging facilite le chargement incrémentiel et fluide des données dans l'interface utilisateur de votre application. L'API Paging prend en charge de nombreuses fonctionnalités que vous devriez sinon implémenter manuellement pour charger des données dans des pages :

  • Suivi des clés à utiliser pour récupérer la page précédente ou suivante.
  • Requête automatique de la bonne page lorsque l'utilisateur arrive en fin de liste.
  • Prévention du déclenchement de requêtes multiples simultanées.
  • Mise en cache des données. Si vous utilisez Kotlin, cette opération s'effectue dans une CoroutineScope. Si vous utilisez Java, vous pouvez employer LiveData.
  • Suivi de l'état de chargement, qui peut être affiché dans un élément de liste RecyclerView ou ailleurs sur l'interface utilisateur, et relance facilitée des chargements ayant échoué.
  • Exécution d'opérations courantes telles que map ou filter dans la liste affichée, indépendamment de l'utilisation de Flow, LiveData ou Flowable/Observable (RxJava).
  • Moyen simple d'implémenter des séparateurs de liste.

Le Guide sur l'architecture des applications propose une architecture dont les principaux composants sont les suivants :

  • Une base de données locale qui sert de source d'informations unique pour les données présentées à l'utilisateur et manipulées par ce dernier.
  • Un service d'API Web.
  • Un dépôt qui fonctionne avec la base de données et le service d'API Web, fournissant une interface de données unifiée.
  • Un objet ViewModel qui fournit des données spécifiques à l'interface utilisateur.
  • L'interface utilisateur qui affiche une représentation visuelle des données dans le ViewModel.

La bibliothèque Paging fonctionne avec tous ces composants et coordonne leurs interactions de manière à charger des "pages" de contenu à partir d'une source de données et à afficher ce contenu dans l'interface utilisateur.

Cet atelier de programmation présente la bibliothèque Paging et ses principaux composants :

  • PagingData – un conteneur pour les données paginées. Chaque actualisation des données correspond à un PagingData distinct.
  • PagingSource – une PagingSource est la classe de base permettant de charger des instantanés de données dans un flux de PagingData.
  • Pager.flow – crée un Flow<PagingData> basé sur une PagingConfig et sur une fonction définissant la construction de la PagingSource implémentée.
  • PagingDataAdapter – un RecyclerView.Adapter qui présente les PagingData dans une RecyclerView. Le PagingDataAdapter peut être connecté à un Flow Kotlin, des LiveData, un élément RxJava Flowable ou un élément RxJava Observable. Le PagingDataAdapter suit les événements de chargement PagingData internes lors du chargement des pages et utilise DiffUtil dans un thread en arrière-plan pour calculer les mises à jour granulaires à mesure que le contenu actualisé est reçu sous la forme de nouveaux objets PagingData.
  • RemoteMediator – aide à implémenter la pagination à partir du réseau et de la base de données.

Dans cet atelier de programmation, vous allez implémenter des exemples de chacun des composants décrits ci-dessus.

L'implémentation de la PagingSource définit la source et le mode de récupération des données. L'objet PagingData interroge les données de la PagingSource en réponse aux indices de chargement générés lorsque l'utilisateur fait défiler une RecyclerView.

Actuellement, l'objet GithubRepository joue une large part du rôle d'une source de données que la bibliothèque Paging gérera une fois l'ajout terminé :

  • Charger les données à partir de GithubService, empêchant ainsi le déclenchement de plusieurs requêtes en même temps.
  • Conserver un cache en mémoire des données récupérées.
  • Effectuer le suivi de page à requérir.

Pour créer la PagingSource, vous devez définir les éléments suivants :

  • Le type de clé de pagination. Dans notre cas, l'API GitHub utilise des indices en base 1 pour les pages, le type est donc Int.
  • Type de données chargées. Dans notre cas, il s'agit d'éléments Repo.
  • L'origine des données récupérées. Nos données proviennent du GithubService. La source de données étant spécifique à la requête, il faut également transmettre les informations de requête au GithubService.

Commençons par créer une implémentation PagingSource appelée GithubPagingSource dans le package data :

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

}

Nous constatons que PagingSource nous oblige à implémenter deux fonctions : load et getRefreshKey.

La fonction load() sera appelée par la bibliothèque Paging pour extraire de façon asynchrone plus de données à afficher lorsque l'utilisateur fera défiler le contenu. L'objet LoadParams conserve les informations liées à l'opération de chargement, y compris les éléments suivants :

  • Clé de la page à charger. Si cette charge est appelée pour la première fois, la valeur de LoadParams.key sera null. Dans ce cas, vous devez définir la clé de page initiale. Pour ce projet, la constante GITHUB_STARTING_PAGE_INDEX devrait être déplacée de GithubRepository vers votre implémentation PagingSource, car il s'agit de la clé de page initiale.
  • Taille de chargement – le nombre d'éléments à charger.

La fonction de chargement renvoie un LoadResult. Ceci remplace l'utilisation de RepoSearchResult dans notre application, car le LoadResult peut être de l'un des types suivants :

  • LoadResult.Page, si le résultat a abouti.
  • LoadResult.Error, en cas d'erreur.

Lorsque vous construisez la LoadResult.Page, transmettez null pour nextKey ou prevKey si la liste ne peut pas être chargée dans la direction correspondante. Dans notre exemple, nous pouvons considérer que si la réponse du réseau a abouti, mais que la liste était vide, nous ne disposons d'aucune donnée à charger. nextKey peut donc être null.

Sur la base de toutes ces informations, nous devrions être en mesure d'implémenter la fonction load().

Nous devons ensuite implémenter getRefreshKey(). La clé d'actualisation est utilisée pour les appels d'actualisation ultérieurs de PagingSource.load() (le premier appel correspond au chargement initial qui utilise la clé initialKey fournie par Pager). Une actualisation se produit chaque fois que la bibliothèque Paging souhaite charger de nouvelles données pour remplacer la liste actuelle, par exemple lors du balayage de l'écran pour l'actualiser ou en cas invalidation en raison de mises à jour de la base de données, de modifications de la configuration, de la mort du processus, etc. En général, les appels d'actualisation suivants souhaitent redémarrer le chargement de données centrées sur PagingState.anchorPosition, qui représente l'index le plus récemment consulté.

L'implémentation de GithubPagingSource se présente comme suit :

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

}

Dans l'implémentation actuelle, nous utilisons un Flow<RepoSearchResult> dans le GitHubRepository pour obtenir les données du réseau et les transmettre au ViewModel. Le ViewModel les transforme ensuite en LiveData et les expose via l'interface utilisateur. Chaque fois que la fin de la liste affichée est atteinte et que des données supplémentaires sont chargées à partir du réseau, le Flow<RepoSearchResult> contient la totalité des éléments précédemment récupérés pour cette requête, en plus des nouvelles données.

RepoSearchResult intègre les aboutissements et les erreurs. Les cas d'aboutissement contiennent les données du dépôt. Les cas d'erreur contiennent le motif Exception. Avec Paging 3.0, RepoSearchResult n'est plus nécessaire, car la bibliothèque modélise les aboutissements et les erreurs avec le LoadResult. N'hésitez pas à supprimer RepoSearchResult, qui sera remplacé dans les prochaines étapes.

Pour construire les PagingData, nous devons déterminer l'API à utiliser pour transmettre les PagingData à d'autres couches de notre application :

  • Utilisez Pager.flow avec un Flow Kotlin.
  • Utilisez Pager.liveData avec LiveData.
  • Utilisez Pager.flowable avec un élément RxJava Flowable.
  • Utilisez Pager.observable avec un élément RxJava Observable.

Comme nous utilisons déjà Flow dans notre application, nous continuerons à suivre cette approche, mais en utilisant Flow<RepoSearchResult> au lieu de Flow<PagingData<Repo>>.

Quel que soit le compilateur PagingData utilisé, vous devrez transmettre les paramètres suivants :

  • PagingConfig, la classe qui définit les options relatives au chargement de contenu à partir d'une PagingSource (par exemple, la limite de chargement anticipé, la requête de taille du chargement initial, etc.). La seule valeur que vous devez définir est la taille de page, qui définit le nombre d'éléments devant être chargés sur chaque page. Par défaut, Paging conserve en mémoire toutes les pages que vous chargez. Pour éviter de gaspiller la mémoire lorsque l'utilisateur fait défiler sa liste, définissez le paramètre maxSize dans PagingConfig. Par défaut, Paging renvoie des éléments nuls pour réserver l'espace du contenu qui reste à charger si ces éléments non chargés peuvent être comptés et si l'indicateur de configuration enablePlaceholders est défini sur "true". Ceci vous permet d'afficher un espace réservé dans l'adaptateur. Pour simplifier le travail dans cet atelier de programmation, désactivez les espaces réservés en transmettant enablePlaceholders = false.
  • Une fonction qui définit la façon de créer la PagingSource. Dans notre cas, une GithubPagingSource sera créée pour chaque nouvelle requête.

Modifions à présent notre GithubRepository.

Mettre à jour GithubRepository.getSearchResultStream

  • Supprimez le modificateur suspend.
  • Renvoyez Flow<PagingData<Repo>>.
  • Construisez Pager.
fun getSearchResultStream(query: String): Flow<PagingData<Repo>> {
    return Pager(
          config = PagingConfig(
            pageSize = NETWORK_PAGE_SIZE,
            enablePlaceholders = false
         ),
          pagingSourceFactory = { GithubPagingSource(service, query) }
    ).flow
}

Nettoyer GithubRepository

Paging 3.0 répond à plusieurs de nos besoins :

  • Gestion du cache en mémoire
  • Demande de données lorsque l'utilisateur approche de la fin de la liste

En conséquence, tous les autres éléments de notre GithubRepository peuvent être supprimés, à l'exception du getSearchResultStream et de l'objet associé où la valeur NETWORK_PAGE_SIZE a été définie. Votre GithubRepository devrait se présenter comme suit :

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

Des erreurs de compilation devraient à présent apparaître dans SearchRepositoriesViewModel. Voyons quelles modifications y apporter.

Depuis notre SearchRepositoriesViewModel, nous affichons un repoResult: LiveData<RepoSearchResult>. repoResult sert de cache en mémoire pour les recherches dans les résultats et survit aux modifications de configuration. Avec Paging 3.0, il n'est plus nécessaire de convertir notre Flow en LiveData. À la place, SearchRepositoriesViewModel disposera d'un membre Flow<PagingData<Repo>> privé dont la fonction correspond à celle assurée par repoResult.

Au lieu d'utiliser un objet LiveData pour chaque nouvelle requête, nous pouvons simplement utiliser un String. Ainsi, lorsque la requête de recherche reçue est identique à la requête actuelle, le Flow existant est simplement renvoyé. Il n'est nécessaire d'appeler repository.getSearchResultStream() que si la nouvelle requête de recherche est différente.

Flow<PagingData> dispose d'une méthode cachedIn() pratique qui nous permet de mettre en cache le contenu d'un Flow<PagingData> dans une CoroutineScope. Étant dans un ViewModel, nous allons utiliser androidx.lifecycle.viewModelScope.

Nous allons réécrire la majeure partie de notre SearchRepositoriesViewModel pour exploiter les fonctionnalités intégrées de Paging 3.0. Votre SearchRepositoriesViewModel se présentera comme suit :

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

Voyons maintenant les modifications apportées au SearchRepositoriesViewModel :

  • Ajout d'un String pour les nouvelles requêtes et de membres du Flow pour les résultats de recherche
  • Mise à jour de la méthode searchRepo() avec la fonctionnalité décrite précédemment
  • Suppression de queryLiveData et repoResult, dont la fonction est assurée par Paging 3.0 et Flow
  • Suppression de listScrolled(), dont la fonction est assurée par la bibliothèque Paging
  • Suppression de companion object, car VISIBLE_THRESHOLD n'est plus nécessaire

Pour rattacher les PagingData à une RecyclerView, utilisez un PagingDataAdapter. Le PagingDataAdapter est alerté à chaque chargement du contenu des PagingData, puis indique à la RecyclerView de se mettre à jour.

Mettre à jour ui.ReposAdapter pour qu'il fonctionne avec un flux PagingData

  • Pour le moment, ReposAdapter implémente ListAdapter. Remplacez cela par l'implémentation de PagingDataAdapter. Le reste du corps de classe reste inchangé :
class ReposAdapter : PagingDataAdapter<Repo, RepoViewHolder>(REPO_COMPARATOR) {
// body is unchanged
}

Nous avons effectué de nombreuses modifications. Il ne reste qu'une étape avant de pouvoir exécuter l'application. Il suffit de connecter l'interface utilisateur !

Nous allons modifier SearchRepositoriesActivity pour la faire fonctionner avec Paging 3.0. Pour pouvoir utiliser Flow<PagingData>, nous devons lancer une nouvelle coroutine, ce que nous ferons dans lifecycleScope, dont la fonction est d'annuler la requête lorsque l'activité est recréée.

Nous voulons également nous assurer que, chaque fois que l'utilisateur envoie une nouvelle requête, la précédente est annulée. Pour ce faire, notre SearchRepositoriesActivity peut conserver une référence à un nouveau Job, qui sera annulée à chaque nouvelle requête.

Nous allons créer une fonction de recherche avec une requête comme paramètre. Cette fonction doit effectuer les opérations suivantes :

  • Annuler la tâche de recherche précédente
  • Lancer une nouvelle tâche dans lifecycleScope
  • Appeler viewModel.searchRepo
  • Collecter le résultat PagingData
  • Transmettre les PagingData au ReposAdapter en appelant 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)
       }
   }
}

La fonction de recherche doit être appelée dans la SearchRepositoriesActivity de la méthode onCreate(). Dans updateRepoListFromInput(), remplacez les appels viewModel et adapter par search() :

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

Nous utilisions binding.list.scrollToPosition(0) pour nous assurer que la position de défilement est réinitialisée à chaque nouvelle recherche. Au lieu de réinitialiser la position pour une nouvelle recherche, nous devons réinitialiser la position lorsque l'adaptateur de liste est mis à jour avec le résultat d'une nouvelle recherche. Pour ce faire, nous pouvons utiliser l'API PagingDataAdapter.loadStateFlow. Ce Flow émet à chaque fois que l'état de chargement est modifié via un objet CombinedLoadStates.

CombinedLoadStates permet d'obtenir l'état de chargement pour les trois types d'opérations de chargement :

  • CombinedLoadStates.refresh représente l'état de chargement initial des PagingData.
  • CombinedLoadStates.prepend représente l'état de chargement des données au début de la liste.
  • CombinedLoadStates.append représente l'état de chargement des données à la fin de la liste.

Dans notre cas, nous souhaitons uniquement réinitialiser la position de défilement lorsque l'actualisation est terminée (quand le LoadState correspond à refresh ou NotLoading).

La collecte sera effectuée à partir de ce Flow lors de la réinitialisation de la recherche, dans la méthode initSearch. Nous ferons défiler le contenu jusqu'à la position 0 à chaque nouvelle émission du Flow.

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

Nous pouvons désormais supprimer binding.list.scrollToPosition(0) de updateRepoListFromInput().

Nous utilisons actuellement un OnScrollListener rattaché à une RecyclerView pour déterminer quand déclencher la récupération de données supplémentaires. Nous pouvons laisser la bibliothèque Paging gérer le défilement. Supprimez la méthode setupScrollListener() et toutes les références s'y rapportant.

Supprimez également l'utilisation de repoResult. Votre activité devrait se présenter comme suit :

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

Notre application devrait se compiler et s'exécuter, mais sans le pied de page d'état de chargement ni le composant Toast, qui s'affiche en cas d'erreur. À l'étape suivante, nous verrons comment afficher le pied de page de l'état de chargement.

Vous trouverez le code complet des étapes précédentes dans la branche step5-9_paging_3.0.

Dans notre application, nous voulons être en mesure d'afficher un pied de page en fonction de l'état de chargement, avec une icône pour indiquer que la liste est en cours de chargement. Lorsqu'une erreur se produit, nous voulons l'afficher avec un bouton "Réessayer".

3f6f2cd47b55de92.png 661da51b58c32b8c.png

L'en-tête ou le pied de page que nous voulons créer part du principe qu'une nouvelle liste doit être ajoutée au début (en tant qu'en-tête) ou à la fin (en tant que pied de page) de la liste actuellement affichée. Cet en-tête/pied de page est une liste qui ne comporte qu'un seul élément : une vue qui affiche une barre de progression ou une erreur avec un bouton "Retry" (Réessayer), selon le LoadState de Paging.

L'affichage d'un en-tête ou d'un pied de page en fonction de l'état de chargement et l'implémentation d'un mécanisme de nouvelle tentative sont des tâches courantes que l'API Paging 3.0 nous aide à effectuer.

Nous utiliserons un LoadStateAdapter pour l'implémentation de l'en-tête/du pied de page. L'implémentation de RecyclerView.Adapter est automatiquement alertée des changements de l'état de chargement. Ainsi, seuls les états Loading et Error conduisent à l'affichage des éléments, et la RecyclerView est alertée lorsqu'un élément est supprimé, inséré ou modifié, selon le LoadState.

Nous utiliserons adapter.retry() pour le mécanisme de nouvelle tentative. En coulisse, cette méthode finit par appeler votre implémentation PagingSource pour la bonne page. La réponse sera automatiquement propagée via Flow<PagingData>.

Voyons à quoi ressemble l'implémentation de l'en-tête/du pied de page.

Comme pour toute liste, nous avons trois fichiers à créer :

  • Le fichier de mise en page, qui contient les éléments de l'interface utilisateur permettant d'afficher la progression, les erreurs et le bouton "Retry" (Réessayer).
  • Le fichier ViewHolder, qui permet de rendre visibles les éléments de l'interface utilisateur à partir de Paging LoadState.
  • Le fichier adaptateur, qui définit comment créer et rattacher le ViewHolder. Au lieu d'étendre un RecyclerView.Adapter, nous étendrons un LoadStateAdapter à partir de Paging 3.0.

Créer la mise en page de vue

Créez la mise en page repos_load_state_footer_view_item pour l'état de chargement de notre dépôt. Celle-ci doit comporter une ProgressBar, une TextView (pour afficher l'erreur) et un Button "Retry" (Réessayer). Les chaînes et les dimensions nécessaires sont déjà déclarées dans le projet.

<?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>

Créer le ViewHolder

Créez un nouveau ViewHolder nommé ReposLoadStateViewHolder dans le dossier ui**.** Il devrait recevoir une fonction de nouvelle tentative comme paramètre, à appeler lorsque l'utilisateur appuie sur le bouton "Retry" (Réessayer). Créez une fonction bind() qui reçoit le LoadState en tant que paramètre et définit la visibilité de chaque vue en fonction de ce LoadState. Une implémentation de ReposLoadStateViewHolder avec ViewBinding se présente comme suit :

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

Créer le LoadStateAdapter

Créez un ReposLoadStateAdapter qui étend LoadStateAdapter dans le dossier ui également. L'adaptateur doit recevoir la fonction de nouvelle tentative comme paramètre, car celle-ci sera transmise au ViewHolder lors de sa construction.

Comme pour tout Adapter, nous devons implémenter les méthodes onBind() et onCreate(). LoadStateAdapter facilite les choses en transmettant le LoadState aux deux fonctions. Dans onBindViewHolder(), rattachez votre ViewHolder. Dans onCreateViewHolder(), définissez comment créer le ReposLoadStateViewHolder en fonction du ViewGroup parent et de la fonction de nouvelle tentative :

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

Maintenant que nous avons tous les éléments de notre pied de page, nous allons les rattacher à notre liste. Pour ce faire, le PagingDataAdapter propose trois méthodes utiles :

  • withLoadStateHeader – pour uniquement afficher un en-tête. Utilisez cette option si vous prenez seulement en charge l'ajout d'éléments en début de liste.
  • withLoadStateFooter – pour uniquement afficher un pied de page. Utilisez cette option si vous prenez seulement en charge l'ajout d'éléments en fin de liste.
  • withLoadStateHeaderAndFooter – pour afficher un en-tête et un pied de page (pour les listes qui peuvent être paginées dans les deux sens).

Mettez à jour la méthode SearchRepositoriesActivity.initAdapter(), puis appelez withLoadStateHeaderAndFooter() sur l'adaptateur. Nous pouvons appeler adapter.retry() en tant que fonction "réessayer".

private fun initAdapter() {
    binding.list.adapter = adapter.withLoadStateHeaderAndFooter(
            header = ReposLoadStateAdapter { adapter.retry() },
            footer = ReposLoadStateAdapter { adapter.retry() }
    )
}

Notre liste est à défilement infini. Pour visualiser facilement le pied de page, placez votre téléphone ou votre émulateur en mode Avion et faites défiler la page jusqu'à la fin de la liste.

Maintenant, exécutons l'application.

Vous trouverez le code complet des étapes précédentes dans la branche step10_loading_state_footer.

Vous avez peut-être remarqué deux problèmes :

  • Pendant la migration vers la Paging 3.0, nous avons perdu la possibilité d'afficher un message lorsque la liste des résultats est vide.
  • Chaque fois que vous lancez une nouvelle requête, le résultat de la requête actuelle reste affiché jusqu'à ce que vous obteniez une réponse du réseau. C'est mauvais pour l'expérience utilisateur ! Affichons plutôt une barre de progression ou un bouton "Retry" (Réessayer).

ab9ff1b8b033179e.png bd744ff3ddc280c3.png

La solution à ces deux problèmes consiste à réagir aux changements d'état de charge dans notre SearchRepositoriesActivity.

Afficher un message de liste vide

Tout d'abord, affichons à nouveau le message de liste vide. Celui-ci ne doit apparaître qu'une fois la liste chargée et si aucun élément ne figure dans cette liste. Pour savoir quand la liste a été chargée, nous allons utiliser la méthode PagingDataAdapter.addLoadStateListener(). Ce rappel nous avertit chaque fois que l'état de chargement est modifié via un objet CombinedLoadStates.

CombinedLoadStates nous indique l'état de chargement de la PageSource que nous avons définie ou du RemoteMediator nécessaire dans les cas utilisant le réseau ou une base de données (nous reviendrons plus en détail sur ce sujet ultérieurement).

Dans SearchRepositoriesActivity.initAdapter(), nous appelons addLoadStateListener. Cette liste est vide lorsque l'état refresh de CombinedLoadStates est NotLoading et adapter.itemCount == 0. Ensuite, nous appelons 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)
  }
}

Afficher l'état de chargement

Mettons à jour notre activity_search_repositories.xml pour inclure un bouton "Retry" (Réessayer) et des éléments d'interface utilisateur de la barre de progression :

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

Notre bouton "Retry" (Réessayer) doit déclencher l'actualisation des PagingData. Pour cela, nous appelons adapter.retry() dans l'implémentation de onClickListener, comme pour l'en-tête et le pied de page :

// SearchRepositoriesActivity.kt

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

Ensuite, réagissons aux changements d'état de chargement, dans SearchRepositoriesActivity.initAdapter. Comme nous ne voulons afficher la barre de progression que lors de nouvelles requêtes, nous devons utiliser le chargement de notre source de pagination (spécifiquement, sur CombinedLoadStates.source.refresh et sur le LoadState: Loading ou Error). De plus, une fonctionnalité que nous avions laissée en commentaire lors d'une étape précédente affichait un Toast en cas d'erreur. N'oublions pas de l'intégrer. Pour afficher le message d'erreur, nous devons vérifier si CombinedLoadStates.prepend ou CombinedLoadStates.append est une instance de LoadState.Error, puis récupérer le message à partir de l'erreur.

Mettons à jour notre méthode SearchRepositoriesActivity.initAdapter pour obtenir cette fonctionnalité :

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

Exécutons à présent l'application pour voir si elle fonctionne.

Et voilà ! Avec la configuration actuelle, les composants de la bibliothèque Paging déclenchent les requêtes API au moment opportun, gèrent le cache en mémoire et affichent les données. Exécutez l'application et essayez de rechercher des dépôts.

Vous trouverez le code complet des étapes précédentes dans la branche step11_loading_state.

Pour améliorer la lisibilité de votre liste, vous pouvez ajouter des séparateurs. Dans notre application, les dépôts sont classés par nombre décroissant d'étoiles. Nous pourrions, par exemple, séparer chaque tranche de 10 000 étoiles. Pour faciliter l'implémentation, l'API Paging 3.0 permet d'insérer des séparateurs dans les PagingData.

170f5fa2945e7d95.png

L'ajout de séparateurs dans les PagingData affecte la liste affichée à l'écran. Nous n'affichons plus seulement des objets Repo, mais également des objets séparateurs. Par conséquent, nous devons remplacer le modèle Repo d'interface utilisateur exposé depuis le ViewModel par un autre, à même d'encapsuler les deux types : RepoItem et SeparatorItem. Nous devrons ensuite mettre à jour notre interface utilisateur pour prendre en charge les séparateurs :

  • Ajoutez une mise en page et un ViewHolder pour les séparateurs.
  • Mettez à jour le RepoAdapter pour permettre de créer et de rattacher les séparateurs en plus des dépôts.

Procédons étape par étape, puis examinons l'implémentation.

Modifier le modèle d'interface utilisateur

Actuellement, SearchRepositoriesViewModel.searchRepo() renvoie Flow<PagingData<Repo>>. Pour permettre l'utilisation des dépôts et des séparateurs, nous allons créer une classe scellée UiModel dans le même fichier que SearchRepositoriesViewModel. Nous pouvons avoir deux types d'objets UiModel : RepoItem et SeparatorItem.

sealed class UiModel {
    data class RepoItem(val repo: Repo) : UiModel()
    data class SeparatorItem(val description: String) : UiModel()
}

Comme nous voulons séparer les dépôts par tranche de 10 000 étoiles, nous allons créer une propriété d'extension sur RepoItem afin d'arrondir le nombre d'étoiles :

private val UiModel.RepoItem.roundedStarCount: Int
    get() = this.repo.stars / 10_000

Insérer des séparateurs

SearchRepositoriesViewModel.searchRepo() doit désormais renvoyer Flow<PagingData<UiModel>>. Alignez currentSearchResult sur le même type.

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>> {
        ...
    }
}

Voyons comment l'implémentation évolue. Actuellement, repository.getSearchResultStream(queryString) renvoie un Flow<PagingData<Repo>>. La première opération à ajouter consiste donc à transformer chaque Repo en UiModel.RepoItem. Pour ce faire, nous pouvons utiliser l'opérateur Flow.map, puis mapper chaque PagingData afin de créer un nouveau UiModel.Repo à partir du Repo actuel, avec pour résultat un Flow<PagingData<UiModel.RepoItem>> :

...
val newResult: Flow<PagingData<UiModel>> = repository.getSearchResultStream(queryString)
                .map { pagingData -> pagingData.map { UiModel.RepoItem(it) } }
...

Nous pouvons maintenant insérer les séparateurs. Pour chaque émission du Flow, nous appellerons PagingData.insertSeparators(). Cette méthode renvoie PagingData contenant chacun des éléments d'origine, avec un séparateur facultatif qui sera généré en fonction des éléments suivant et précédent. Dans les conditions limites (au début ou à la fin de la liste), les éléments précédent ou suivant seront null. S'il n'est pas nécessaire de créer un séparateur, renvoyez null.

Comme le type des éléments PagingData passe de UiModel.Repo à UiModel, assurez-vous de définir explicitement les arguments de type de la méthode insertSeparators().

La méthode searchRepo() devrait se présenter comme suit :

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
}

Compatibilité avec plusieurs types de vues

Les objets SeparatorItem doivent être affichés dans notre RecyclerView. Nous n'affichons qu'une chaîne ici. Nous allons donc créer une mise en page separator_view_item avec une TextView dans le dossier 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>

Nous allons maintenant créer une SeparatorViewHolder dans le dossier ui, et simplement rattacher la chaîne à la 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)
        }
    }
}

Modifiez ReposAdapter afin de prendre en charge un UiModel plutôt qu'un Repo :

  • Remplacez la valeur Repo du paramètre PagingDataAdapter par UiModel.
  • Implémentez un comparateur UiModel et remplacez le REPO_COMPARATOR par celui-ci.
  • Créez la SeparatorViewHolder et rattachez-la à la description de UiModel.SeparatorItem.

Comme nous devons maintenant afficher deux ViewHolders différents, remplacez RepoViewHolder par ViewHolder :

  • Mettez à jour le paramètre PagingDataAdapter.
  • Mettez à jour le type renvoyé onCreateViewHolder.
  • Mettez à jour le paramètre holder du onBindViewHolder.

Après ces opérations, votre ReposAdapter devrait se présenter comme suit :

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

Et voilà ! Lors de l'exécution de l'application, vous devriez voir les séparateurs.

Vous trouverez le code complet des étapes précédentes dans la branche step12_separators.

Nous allons permettre à notre application de fonctionner hors connexion en enregistrant les données dans une base de données locale. Celle-ci servira de source d'informations à notre application. Les données seront systématiquement chargées depuis cette base de données. Lorsque nous arrivons à court de données, des données supplémentaires sont sollicitées par le biais du réseau puis enregistrées dans la base. Comme la base de données est notre source de référence, l'interface utilisateur est automatiquement mise à jour lorsque de nouvelles données sont enregistrées.

Pour prendre en charge le fonctionnement hors connexion, procédez comme suit :

  1. Créez une base de données Room, une table dans laquelle enregistrer les objets Repo et un objet d'accès aux données qui nous servira à travailler avec les objets Repo.
  2. Implémentez un RemoteMediator pour définir le mode de chargement à partir du réseau lorsque les données de la base ne suffisent plus.
  3. Créez un Pager basé sur la table de dépôts en tant que source de données, et le RemoteMediator pour charger et enregistrer les données.

Procédons par étape.

Nos objets Repo doivent être enregistrés dans la base de données. Commençons donc par faire de la classe Repo une entité, avec tableName = "repos", où Repo.id est la clé primaire. Pour ce faire, annotez la classe Repo avec @Entity(tableName = "repos"), puis ajoutez l'annotation @PrimaryKey à id. Votre classe Repo devrait maintenant se présenter comme suit :

@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?
)

Créez un package db. C'est là que nous implémentons la classe qui accède aux données de la base de données et la classe qui définit celle-ci.

Implémentez l'objet d'accès aux données pour accéder à la table repos en créant une interface RepoDao, annotée avec @Dao. Repo nécessite les actions suivantes :

  • Insérer une liste d'objets Repo Si les objets Repo figurent déjà dans la table, les remplacer.
 @Insert(onConflict = OnConflictStrategy.REPLACE)
 suspend fun insertAll(repos: List<Repo>)
  • Rechercher les dépôts contenant la chaîne de requête dans leur nom ou leur description, puis trier les résultats par ordre décroissant en fonction du nombre d'étoiles, puis par ordre alphabétique. Au lieu de renvoyer un objet List<Repo>, renvoyer PagingSource<Int, Repo>. Ainsi, la table repos devient la source de données de 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>
  • Effacer toutes les données de la table Repos.
@Query("DELETE FROM repos")
suspend fun clearRepos()

Votre fichier RepoDao devrait se présenter comme suit :

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

Implémentez la base de données de dépôts :

  • Créez une classe abstraite RepoDatabase qui étend RoomDatabase.
  • Annotez la classe avec @Database, définissez la liste des entités pour contenir la classe Repo, puis définissez la version de la base de données sur 1. Il n'est pas nécessaire d'exporter le schéma dans cet atelier de programmation.
  • Définissez une fonction abstraite qui renvoie le ReposDao.
  • Créez une fonction getInstance() dans un companion object qui crée l'objet RepoDatabase si celui-ci n'existe pas encore.

Votre RepoDatabase se présente comme suit :

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

Maintenant que nous avons configuré notre base de données, voyons comment requérir des données par le réseau et les enregistrer dans la base de données.

La bibliothèque Paging utilise la base de données comme source d'informations pour les données à afficher dans l'interface utilisateur. Lorsque la base arrive à court de données, il faut en demander par le biais du réseau. À cette fin, Paging 3.0 définit la classe abstraite RemoteMediator avec une méthode à implémenter : load(). Cette méthode sera appelée à chaque fois que des données supplémentaires doivent être chargées à partir du réseau. Cette classe renvoie un objet MediatorResult, qui peut être :

  • Error, si une erreur s'est produite lors de la requête de données via réseau ;
  • Success, si les données ont bien été récupérées depuis le réseau. Ici, nous devons également transmettre un signal indiquant si d'autres données peuvent être chargées ou non. Par exemple, si la réponse du réseau a abouti, mais que notre liste de dépôts reste vide, cela signifie qu'il n'y a plus de données à charger.

Dans le package data, nous allons créer une classe appelée GithubRemoteMediator qui prolonge RemoteMediator. Cette classe sera recréée pour chaque nouvelle requête. Ses paramètres seront les suivants :

  • La String de requête.
  • Le GithubService, pour effectuer des requêtes réseau.
  • La RepoDatabase, pour enregistrer les données reçues suite à la requête réseau.
@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 {

   }
}

Pour créer la requête de réseau, la méthode de chargement doit comporter deux paramètres qui fournissent toutes les informations nécessaires :

  • PagingState, qui fournit des informations sur les pages chargées précédemment, sur le dernier indice consulté dans la liste et sur la PagingConfig définie lors de l'initialisation du flux de pagination.
  • LoadType, qui indique si le chargement doit être ajouté à la fin (LoadType.APPEND) ou au début des données (LoadType.PREPEND) chargées précédemment, ou s'il s'agit d'un premier chargement de données (LoadType.REFRESH).

Par exemple, si le type de chargement est LoadType.APPEND, le dernier élément chargé est récupéré à partir de PagingState. Ces informations devraient permettre de connaître le mode de chargement du prochain lot d'objets Repo, en calculant la page suivante à charger.

Dans la section suivante, vous apprendrez à calculer des clés pour le chargement des pages suivantes et précédentes.

Dans le cadre de l'API GitHub, la clé de page que nous utilisons pour demander les pages des dépôts est simplement un index incrémenté lors de l'obtention de la page suivante. Cela signifie que pour un objet Repo, le prochain lot d'objets Repo peut être demandé sur la base d'un indice de page + 1. Le lot précédent d'objets Repo peut être demandé sur la base de l'indice de page - 1. Tous les objets Repo reçus sur une réponse de page donnée auront les mêmes clés suivantes et précédente.

Lorsque nous récupérons le dernier élément chargé à partir de PagingState, il n'existe aucun moyen de connaître son indice de page d'origine. Pour résoudre ce problème, nous pouvons ajouter une autre table, que nous pouvons appeler remote_keys et qui stocke les clés de page suivante et précédente pour chaque Repo. Même si ceci peut être accompli dans la table Repo, créer une table distincte pour les clés distantes suivantes et précédentes associées à un Repo permet une meilleure séparation des responsabilités.

Dans le package db, nous allons créer une classe de données appelée RemoteKeys, l'annoter avec @Entity et ajouter trois propriétés : l'id de dépôt (qui est également la clé primaire) et les clés précédente et suivante (qui peuvent être null s'il n'est pas possible d'ajouter des données en début/fin de liste).

@Entity(tableName = "remote_keys")
data class RemoteKeys(
    @PrimaryKey
    val repoId: Long,
    val prevKey: Int?,
    val nextKey: Int?
)

Commençons par créer une interface RemoteKeysDao. Les capacités suivantes seront nécessaires :

  • Insérer une liste de RemoteKeys, car à chaque fois que des Repos sont obtenus depuis le réseau, nous générerons les clés distantes correspondantes
  • Obtenir une RemoteKey sur la base d'un id de Repo
  • Effacer les RemoteKeys (utilisées à chaque nouvelle requête)
@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()
}

Ajoutons la table RemoteKeys à notre base de données et fournissons l'accès à RemoteKeysDao. Pour ce faire, mettez à jour la RepoDatabase comme suit :

  • Ajoutez RemoteKeys à la liste des entités.
  • Exposez le RemoteKeysDao en tant que fonction abstraite.
@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
}

Maintenant que nous avons enregistré les clés distantes, revenons au GithubRemoteMediator et voyons comment l'utiliser. Cette classe remplacera notre GithubPagingSource. Nous allons copier la déclaration GITHUB_STARTING_PAGE_INDEX de GithubPagingSource dans notre GithubRemoteMediator et supprimer la classe GithubPagingSource.

Voyons comment implémenter la méthode GithubRemoteMediator.load() :

  1. Identifiez la page à charger depuis le réseau, en fonction du LoadType.
  2. Déclenchez la requête réseau.
  3. Une fois la requête réseau traitée, si la liste de dépôts reçue n'est pas vide, effectuez les opérations suivantes :
  4. Calculez les RemoteKeys de chaque Repo.
  5. S'il s'agit d'une nouvelle requête (loadType = REFRESH), effacez la base de données.
  6. Enregistrez les RemoteKeys et les Repos dans la base de données.
  7. Renvoyez MediatorResult.Success(endOfPaginationReached = false).
  8. Si la liste de dépôts est vide, renvoyez MediatorResult.Success(endOfPaginationReached = true). Si une erreur se produit lors de la requête de données, renvoyez MediatorResult.Error.

Voici à quoi ressemble le code dans son ensemble. Nous remplacerons les TODO par la suite.

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

Voyons maintenant comment identifier la page à charger en fonction du LoadType.

Maintenant que nous savons ce qu'il se passe dans la méthode GithubRemoteMediator.load() une fois que nous disposons de la clé de page, voyons comment la calculer. Cela dépend du LoadType.

LoadType.APPEND

Lorsque les données doivent être chargées à la fin de l'ensemble actuel, le paramètre de chargement est LoadType.APPEND. Maintenant, nous devons calculer la clé de la page réseau en fonction du dernier élément dans la base de données.

  1. Nous devons obtenir la clé distante du dernier objet Repo chargé à partir de la base de données, ce que nous ferons dans une fonction séparée :
    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. Si la valeur remoteKeys est nulle, cela signifie que le résultat de l'actualisation ne figure pas encore dans la base de données. Nous pouvons renvoyer "Success" (Réussite) avec endOfPaginationReached = false, car Paging va à nouveau appeler cette méthode si la valeur "RemoteKeys" n'est pas nulle. Si la valeur "remoteKeys" n'est pas null, mais que prevKey est null, cela signifie que nous avons atteint la fin de la pagination pour l'ajout.
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

Lorsque les données doivent être chargées au début de l'ensemble actuel, le paramètre de chargement est LoadType.PREPEND. Nous devons calculer la clé de la page réseau en nous basant sur le premier élément de la base de données.

  1. Nous devons obtenir la clé distante du premier objet Repo chargé à partir de la base de données, ce que nous ferons dans une fonction séparée :
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. Si la valeur remoteKeys est nulle, cela signifie que le résultat de l'actualisation ne figure pas encore dans la base de données. Nous pouvons renvoyer "Success" (Réussite) avec endOfPaginationReached = false, car Paging va à nouveau appeler cette méthode si la valeur "RemoteKeys" n'est pas nulle. Si la valeur "remoteKeys" n'est pas null, mais que prevKey est null, cela signifie que nous avons atteint la fin de la pagination pour l'ajout.
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 est appelé lorsque des données sont chargées pour la première fois ou lorsque la méthode PagingDataAdapter.refresh() est appelée. Le point de référence pour le chargement de nos données devient le state.anchorPosition. S'il s'agit du chargement initial, anchorPosition est null. Lorsque PagingDataAdapter.refresh() est appelé, anchorPosition correspond à la première position visible dans la liste affichée. Nous devons donc charger la page contenant cet élément spécifique.

  1. En nous basant sur la anchorPosition de state, nous pouvons obtenir l'article Repo le plus proche de cette position en appelant state.closestItemToPosition().
  2. En nous basant sur l'élément Repo, nous pouvons récupérer les RemoteKeys dans la base de données.
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. Si remoteKey n'est pas nul, nous pouvons en obtenir la nextKey. Dans l'API GitHub, les clés de page sont incrémentées de manière séquentielle. Ainsi, pour obtenir la page qui contient l'élément actuel, il suffit de soustraire 1 de remoteKey.nextKey.
  2. Si RemoteKey est null (car anchorPosition était null), la page à charger est la première : GITHUB_STARTING_PAGE_INDEX.

Le calcul complet de la page se présente comme suit :

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

Nous avons implémenté le GithubRemoteMediator et la PagingSource dans notre ReposDao. Il faut maintenant mettre à jour GithubRepository.getSearchResultStream pour les utiliser.

Pour ce faire, GithubRepository a besoin d'accéder à la base de données. Nous allons transmettre la base de données en tant que paramètre dans le constructeur. Comme cette classe utilise GithubRemoteMediator :

class GithubRepository(
        private val service: GithubService,
        private val database: RepoDatabase
) { ... }

Mettez à jour le fichier Injection :

  • La méthode provideGithubRepository doit obtenir un contexte en tant que paramètre et appeler RepoDatabase.getInstance dans le constructeur GithubRepository.
  • La méthode provideViewModelFactory doit obtenir un contexte en tant que paramètre et le transmettre à 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))
    }
}

Mettez à jour la méthode SearchRepositoriesActivity.onCreate() et transmettez le contexte à Injection.provideViewModelFactory() :

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

Revenons au GithubRepository. Pour pouvoir rechercher des dépôts par nom, nous devons d'abord ajouter % au début et à la fin de la chaîne de requête. Ensuite, lorsque nous appelons reposDao.reposByName, nous obtenons une PagingSource. Comme la PagingSource est invalidée chaque fois que la base de données est modifiée, nous devons indiquer à Paging comment obtenir une nouvelle instance de PagingSource. Pour cela, il suffit de créer une fonction qui appelle la requête de base de données :

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

Nous pouvons maintenant modifier le compilateur Pager afin d'utiliser un GithubRemoteMediator et la pagingSourceFactory. Pager étant une API expérimentale, nous devons l'annoter avec @OptIn :

@OptIn(ExperimentalPagingApi::class)
return Pager(
        config = PagingConfig(
            pageSize = NETWORK_PAGE_SIZE,
            enablePlaceholders = false
         ),
        remoteMediator = GithubRemoteMediator(
                query,
                service,
                database
        ),
        pagingSourceFactory = pagingSourceFactory
).flow

Et voilà ! Maintenant, exécutons l'application.

Mettre à jour la source d'état de chargement

Pour le moment, notre application charge les données depuis le réseau et les enregistre dans la base de données. Toutefois, pour afficher une icône de chargement pendant le chargement initial de la page (dans SearchRepositoriesActivity.initAdapter), l'application utilise toujours LoadState.source. Ce que nous voulons à présent, c'est afficher une icône de chargement seulement pour les chargements depuis RemoteMediator. Pour cela, nous devons passer de LoadState.source à 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
    }

Vous trouverez le code complet des étapes précédentes dans la branche step13-19_network_and_database.

Tous les composants ont été ajoutés ! Récapitulons :

  • La PagingSource charge les données de manière asynchrone à partir d'une source que vous définissez.
  • Le Pager.flow crée un Flow<PagingData> en fonction d'une configuration et d'une fonction qui définissent l'instanciation de la PagingSource.
  • Le Flow émet de nouvelles PagingData à chaque fois que de nouvelles données sont chargées par la PagingSource.
  • L'interface utilisateur observe la modification des PagingData et utilise un PagingDataAdapter pour actualiser la RecyclerView, qui présente les données.
  • Pour réessayer après un échec de chargement de l'interface utilisateur, utilisez la méthode PagingDataAdapter.retry. En coulisses, la bibliothèque Paging déclenchera la méthode PagingSource.load().
  • Pour ajouter des séparateurs à votre liste, créez un type général avec des séparateurs parmi les types compatibles. Utilisez ensuite la méthode PagingData.insertSeparators() pour implémenter votre logique de génération de séparateurs.
  • Pour afficher l'état de chargement en tant qu'en-tête ou pied de page, utilisez la méthode PagingDataAdapter.withLoadStateHeaderAndFooter() et implémentez un LoadStateAdapter. Pour exécuter d'autres actions en fonction de l'état de chargement, utilisez le rappel PagingDataAdapter.addLoadStateListener().
  • Pour travailler avec un réseau et une base de données, implémentez un RemoteMediator.