Atelier de programmation avancé sur Android Paging

1. Introduction

Points abordés

  • Principaux composants de Paging 3.0
  • Ajout de Paging 3.0 à votre projet
  • Ajout d'un en-tête ou d'un pied de page à votre liste avec l'API Paging 3.0
  • Ajout de séparateurs de listes avec l'API Paging 3.0
  • Pagination à partir d'un réseau et d'une 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
  • Accepter les bases de données pour la pagination depuis un réseau ou une base de données

Voici à quoi ressemblera l'application à la fin de l'atelier :

23643514cb9cf43e.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.

2. Configurer votre environnement

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 la dernière version de Paging à notre 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 :

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

89af884fa2d4e709.png

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

50d1d2aa6e79e473.png

3. Présentation du projet

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. ViewModel expose les éléments suivants :

  1. LiveData<UiState>
  2. Une fonction (UiAction) -> Unit

Le UiState est une représentation de tous les éléments nécessaires pour afficher l'UI de l'application, différents champs correspondant à différents composants de l'UI. C'est un objet immuable, c'est-à-dire qu'il ne peut pas être modifié. Toutefois, de nouvelles versions peuvent être produites et observées par l'interface utilisateur. Dans notre cas, les nouvelles versions sont générées suite à une action de l'utilisateur (nouvelle requête de recherche ou défilement de la liste pour récupérer d'autres requêtes, par exemple).

Le type UiAction représente bien les actions de l'utilisateur. Inclure l'API pour les interactions avec ViewModel dans un seul type présente les avantages suivants :

  • Surface d'API réduite : vous pouvez ajouter, supprimer ou modifier des actions, mais la signature de la méthode ViewModel ne change jamais. De ce fait, la refactorisation est locale et moins susceptible de provoquer des fuites d'abstractions ou d'implémentations de l'interface.
  • Gestion simplifiée de la simultanéité : comme nous le verrons plus tard dans l'atelier de programmation, il est important de s'assurer que l'ordre d'exécution de certaines requêtes est respecté. En saisissant l'API avec UiAction, nous pouvons écrire un code avec des exigences strictes concernant quelles opérations peuvent se produire et à quel moment.

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é.
  • La liste revient toujours au début lorsque l'orientation est modifiée ou le processus arrêté.

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.

4. Composants de la bibliothèque Paging

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.

5. Définir la source de données

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

}

6. Créer et configurer PagingData

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. Ici, nous allons créer une GithubPagingSource 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 {
        const val NETWORK_PAGE_SIZE = 50
    }
}

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

7. Requérir et mettre en cache PagingData dans le ViewModel

Avant de corriger les erreurs de compilation, examinons les types dans le ViewModel :

sealed class UiAction {
    data class Search(val query: String) : UiAction()
    data class Scroll(
        val visibleItemCount: Int,
        val lastVisibleItemPosition: Int,
        val totalItemCount: Int
    ) : UiAction()
}

data class UiState(
    val query: String,
    val searchResult: RepoSearchResult
)

Dans notre UiState, nous exposons un searchResult. searchResult sert de cache en mémoire pour les recherches de résultats et survit aux modifications de la configuration. Avec Paging 3.0, il n'est plus nécessaire de convertir notre Flow en LiveData. À la place, SearchRepositoriesViewModel expose à présent un StateFlow<UiState>. De plus, nous abandonnons complètement la valeur searchResult, et exposons à la place un Flow<PagingData<Repo>> distinct ayant le même objectif que searchResult.

PagingData est un type autonome qui contient un flux modifiable de mises à jour des données à afficher dans le RecyclerView. Chaque émission de PagingData est totalement indépendante, et plusieurs PagingData peuvent être émises pour une même requête. De ce fait, les Flows de PagingData doivent être exposés indépendamment des autres Flows.

Autre avantage pour l'expérience utilisateur : pour chaque nouvelle requête saisie, nous voulons remonter au début de la liste afin d'afficher le premier résultat de recherche. Toutefois, comme les données de pagination peuvent être émises plusieurs fois, nous ne voulons remonter au début de la liste que si l'utilisateur n'a pas commencé à faire défiler la liste.

Pour ce faire, mettons à jour UiState et ajoutons des champs pour lastQueryScrolled et hasNotScrolledForCurrentSearch. Ces indicateurs nous empêchent de revenir au début de la liste quand il ne faut pas :

data class UiState(
    val query: String = DEFAULT_QUERY,
    val lastQueryScrolled: String = DEFAULT_QUERY,
    val hasNotScrolledForCurrentSearch: Boolean = false
)

Revoyons l'architecture. Comme toutes les requêtes adressées à ViewModel passent par un seul point d'entrée (le champ accept défini en tant que (UiAction) -> Unit), nous devons effectuer les opérations suivantes :

  • Convertir ce point d'entrée en flux contenant les types qui nous intéressent
  • Transformer ces flux
  • Recombiner les flux dans un StateFlow<UiState>

En termes plus fonctionnels, nous allons reduce les émissions de UiAction en UiState. C'est un peu comme une chaîne de montage : les types UiAction sont les matières premières en entrée, ils provoquent des effets (parfois appelés mutations) et le UiState est le produit fini prêt à être associé à l'interface utilisateur. Ce processus revient à faire de l'UI une fonction de UiState.

Réécrivons le ViewModel pour gérer chaque type UiAction en deux flux différents, puis transformons-les en un StateFlow<UiState> à l'aide de quelques opérateurs Flow de Kotlin.

Tout d'abord, mettons à jour les définitions du state dans le ViewModel pour utiliser un StateFlow au lieu de LiveData. Ajoutons également un champ pour exposer un Flow de PagingData :

   /**
     * Stream of immutable states representative of the UI.
     */
    val state: StateFlow<UiState>

    val pagingDataFlow: Flow<PagingData<Repo>>

Ensuite, mettons à jour la définition de la sous-classe UiAction.Scroll :

sealed class UiAction {
    ...
    data class Scroll(val currentQuery: String) : UiAction()
}

Notez que nous avons supprimé tous les champs de la classe de données UiAction.Scroll et que nous les avons remplacés par la chaîne currentQuery. Nous pouvons ainsi associer une action de défilement à une requête particulière. Supprimons également l'extension shouldFetchMore, devenue inutile. Cette opération doit également être restaurée après l'arrêt du processus. Nous mettons donc à jour la méthode onCleared() dans SearchRepositoriesViewModel :

class SearchRepositoriesViewModel{
  ...
   override fun onCleared() {
        savedStateHandle[LAST_SEARCH_QUERY] = state.value.query
        savedStateHandle[LAST_QUERY_SCROLLED] = state.value.lastQueryScrolled
        super.onCleared()
    }
}

// This is outside the ViewModel class, but in the same file
private const val LAST_QUERY_SCROLLED: String = "last_query_scrolled"

À présent, découvrons la méthode pour créer le Flow de pagingData à partir de GithubRepository :

class SearchRepositoriesViewModel(
    ...
) : ViewModel() {

    override fun onCleared() {
        ...
    }

    private fun searchRepo(queryString: String): Flow<PagingData<Repo>> =
        repository.getSearchResultStream(queryString)
}

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 pouvons à présent commencer à convertir le champ accept du ViewModel en flux UiAction. Remplacez le bloc init de SearchRepositoriesViewModel par le code suivant :

class SearchRepositoriesViewModel(
    ...
) : ViewModel() {
    ...
    init {
        val initialQuery: String = savedStateHandle.get(LAST_SEARCH_QUERY) ?: DEFAULT_QUERY
        val lastQueryScrolled: String = savedStateHandle.get(LAST_QUERY_SCROLLED) ?: DEFAULT_QUERY
        val actionStateFlow = MutableSharedFlow<UiAction>()
        val searches = actionStateFlow
            .filterIsInstance<UiAction.Search>()
            .distinctUntilChanged()
            .onStart { emit(UiAction.Search(query = initialQuery)) }
        val queriesScrolled = actionStateFlow
            .filterIsInstance<UiAction.Scroll>()
            .distinctUntilChanged()
            // This is shared to keep the flow "hot" while caching the last query scrolled,
            // otherwise each flatMapLatest invocation would lose the last query scrolled,
            .shareIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000),
                replay = 1
            )
            .onStart { emit(UiAction.Scroll(currentQuery = lastQueryScrolled)) }
   }
}

Examinons l'extrait de code ci-dessus. Nous commençons avec deux éléments : la initialQuery String, extraite de l'état enregistré ou une valeur par défaut, et lastQueryScrolled, une String représentant le dernier terme recherché pour lequel l'utilisateur a interagi avec la liste. Ensuite, scindons le Flow en plusieurs types UiAction spécifiques :

  1. UiAction.Search pour chaque fois que l'utilisateur saisit une requête particulière
  2. UiAction.Scroll pour chaque fois que l'utilisateur fait défiler la liste avec une requête spécifique sélectionnée

Des transformations supplémentaires sont également appliquées au UiAction.Scroll Flow. Les voici :

  1. shareIn : cette transformation est nécessaire, car lorsque ce Flow est consommé, il l'est avec un opérateur flatmapLatest. À chaque émission en amont, flatmapLatest annule le dernier Flow sur lequel il s'exécutait et commence à s'exécuter en fonction du nouveau flux qui lui a été fourni. Dans le cas présent, nous perdrions la valeur de la dernière requête que l'utilisateur a fait défiler. Nous utilisons donc l'opérateur Flow avec une valeur replay de 1 pour mettre en cache la dernière valeur, afin qu'elle ne soit pas perdue lorsqu'une nouvelle requête est reçue.
  2. onStart : également utilisé pour la mise en cache. Si l'utilisateur ferme l'application, mais qu'il a déjà fait défiler une requête, il ne faut pas remonter au début de la liste pour ne pas lui faire perdre son résultat.

Des erreurs de compilation devraient encore se produire, car les champs state, pagingDataFlow et accept ne sont pas encore définis. Nous allons résoudre ce problème. Nous pouvons utiliser les transformations appliquées à chaque UiAction pour créer des flux pour PagingData et UiState.

init {
        ...
        pagingDataFlow = searches
            .flatMapLatest { searchRepo(queryString = it.query) }
            .cachedIn(viewModelScope)

        state = combine(
            searches,
            queriesScrolled,
            ::Pair
        ).map { (search, scroll) ->
            UiState(
                query = search.query,
                lastQueryScrolled = scroll.currentQuery,
                // If the search query matches the scroll query, the user has scrolled
                hasNotScrolledForCurrentSearch = search.query != scroll.currentQuery
            )
        }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000),
                initialValue = UiState()
            )

        accept = { action ->
            viewModelScope.launch { actionStateFlow.emit(action) }
        }
    }
}

Nous utilisons l'opérateur flatmapLatest dans le flux searches, car chaque nouvelle requête de recherche nécessite la création d'un Pager. Ensuite, appliquons l'opérateur cachedIn au flux PagingData pour le maintenir actif dans le viewModelScope et attribuons le résultat au champ pagingDataFlow. Côté UiState, nous utilisons l'opérateur de combinaison pour renseigner les champs obligatoires UiState et attribuer le Flow obtenu au champ state exposé. Définissons également accept comme un lambda qui lance une fonction de suspension alimentant notre machine d'état.

Et voilà ! Nous avons à présent un ViewModel fonctionnel tant d'un point de vue programmatique littéral que réactif.

8. Faire fonctionner l'adaptateur avec PagingData

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 apporté de nombreuses modifications. Il ne reste plus qu'une étape avant de pouvoir exécuter l'application : connecter l'interface utilisateur !

9. Déclencher les mises à jour par le réseau

Remplacer LiveData par Flow

Modifions SearchRepositoriesActivity pour qu'elle fonctionne 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.

Heureusement, nous n'avons pas beaucoup de modifications à apporter. Plutôt que d'utiliser la fonction observe() pour observer les LiveData, nous allons utiliser la fonction launch() pour lancer une coroutine et la fonction collect() pour collecter un Flow. Le UiState sera combiné au Flow LoadState de PagingAdapter pour garantir que nous ne reviendrons pas au début de la liste avec les nouvelles émissions de PagingData si l'utilisateur a déjà fait défiler la liste.

Tout d'abord, comme nous renvoyons désormais l'état en tant que StateFlow au lieu de LiveData, toutes les références aux LiveData dans l'Activity doivent être remplacées par un StateFlow, en veillant à ajouter également un argument pour le Flow de pagingData. Le premier endroit pour cela est la méthode bindState :

   private fun ActivitySearchRepositoriesBinding.bindState(
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        uiActions: (UiAction) -> Unit
    ) {
        ...
    }

Cette modification entraîne des modifications en cascade. Nous devons en effet à présent mettre à jour bindSearch() et bindList(). Comme les modifications de bindSearch() sont minimales, commençons par cet élément :

   private fun ActivitySearchRepositoriesBinding.bindSearch(
        uiState: StateFlow<UiState>,
        onQueryChanged: (UiAction.Search) -> Unit
    ) {
        searchRepo.setOnEditorActionListener {...}
        searchRepo.setOnKeyListener {...}

        lifecycleScope.launch {
            uiState
                .map { it.query }
                .distinctUntilChanged()
                .collect(searchRepo::setText)
        }
    }

La principale modification concerne la nécessité de lancer une coroutine et de collecter la modification de requête depuis le Flow UiState.

Résoudre le problème de défilement et associer les données

Passons maintenant au défilement. Tout d'abord, comme pour les deux dernières modifications, nous remplaçons LiveData par StateFlow et ajoutons un argument pour le Flow pagingData. Ensuite, passons à l'écouteur de défilement. Notez qu'auparavant nous avons utilisé un OnScrollListener rattaché à une RecyclerView pour déterminer le moment où déclencher la récupération de données supplémentaires. La bibliothèque Paging gère le défilement de la liste à notre place. Toutefois, nous avons encore besoin de OnScrollListener pour savoir si l'utilisateur a fait défiler la liste pour la requête actuelle. Dans la méthode bindList(), remplaçons setupScrollListener() par un RecyclerView.OnScrollListener intégré. Supprimons également la méthode setupScrollListener().

   private fun ActivitySearchRepositoriesBinding.bindList(
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        list.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                if (dy != 0) onScrollChanged(UiAction.Scroll(currentQuery = uiState.value.query))
            }
        })
        // the rest of the code is unchanged
    }

Ensuite, configurons le pipeline pour créer un indicateur booléen shouldScrollToTop. Une fois cela fait, nous nous retrouvons avec deux flux que nous pouvons utiliser pour collect des données : le Flow PagingData et le Flow shouldScrollToTop.

    private fun ActivitySearchRepositoriesBinding.bindList(
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        list.addOnScrollListener(...)
        val notLoading = repoAdapter.loadStateFlow
            // Only emit when REFRESH LoadState for the paging source changes.
            .distinctUntilChangedBy { it.source.refresh }
            // Only react to cases where REFRESH completes i.e., NotLoading.
            .map { it.source.refresh is LoadState.NotLoading }

        val hasNotScrolledForCurrentSearch = uiState
            .map { it.hasNotScrolledForCurrentSearch }
            .distinctUntilChanged()

        val shouldScrollToTop = combine(
            notLoading,
            hasNotScrolledForCurrentSearch,
            Boolean::and
        )
            .distinctUntilChanged()

        lifecycleScope.launch {
            pagingData.collectLatest(repoAdapter::submitData)
        }

        lifecycleScope.launch {
            shouldScrollToTop.collect { shouldScroll ->
                if (shouldScroll) list.scrollToPosition(0)
            }
        }
    }

Dans le code ci-dessus, nous utilisons collectLatest sur le Flow pagingData pour pouvoir annuler la collecte sur les émissions pagingData précédentes sur les nouvelles émissions de pagingData. Pour l'indicateur shouldScrollToTop, les émissions de PagingDataAdapter.loadStateFlow sont synchronisées avec les éléments affichés dans l'interface utilisateur. Vous pouvez donc appeler list.scrollToPosition(0) dès que l'indicateur booléen émis est vrai.

Le type de LoadStateFlow est 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).

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

Une fois cela terminé, votre activité devrait se présenter comme suit :

class SearchRepositoriesActivity : AppCompatActivity() {

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

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

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

        // bind the state
        binding.bindState(
            uiState = viewModel.state,
            pagingData = viewModel.pagingDataFlow,
            uiActions = viewModel.accept
        )
    }

    /**
     * Binds the [UiState] provided  by the [SearchRepositoriesViewModel] to the UI,
     * and allows the UI to feed back user actions to it.
     */
    private fun ActivitySearchRepositoriesBinding.bindState(
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        uiActions: (UiAction) -> Unit
    ) {
        val repoAdapter = ReposAdapter()
        list.adapter = repoAdapter

        bindSearch(
            uiState = uiState,
            onQueryChanged = uiActions
        )
        bindList(
            repoAdapter = repoAdapter,
            uiState = uiState,
            pagingData = pagingData,
            onScrollChanged = uiActions
        )
    }

    private fun ActivitySearchRepositoriesBinding.bindSearch(
        uiState: StateFlow<UiState>,
        onQueryChanged: (UiAction.Search) -> Unit
    ) {
        searchRepo.setOnEditorActionListener { _, actionId, _ ->
            if (actionId == EditorInfo.IME_ACTION_GO) {
                updateRepoListFromInput(onQueryChanged)
                true
            } else {
                false
            }
        }
        searchRepo.setOnKeyListener { _, keyCode, event ->
            if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_ENTER) {
                updateRepoListFromInput(onQueryChanged)
                true
            } else {
                false
            }
        }

        lifecycleScope.launch {
            uiState
                .map { it.query }
                .distinctUntilChanged()
                .collect(searchRepo::setText)
        }
    }

    private fun ActivitySearchRepositoriesBinding.updateRepoListFromInput(onQueryChanged: (UiAction.Search) -> Unit) {
        searchRepo.text.trim().let {
            if (it.isNotEmpty()) {
                list.scrollToPosition(0)
                onQueryChanged(UiAction.Search(query = it.toString()))
            }
        }
    }

    private fun ActivitySearchRepositoriesBinding.bindList(
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        list.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                if (dy != 0) onScrollChanged(UiAction.Scroll(currentQuery = uiState.value.query))
            }
        })
        val notLoading = repoAdapter.loadStateFlow
            // Only emit when REFRESH LoadState for the paging source changes.
            .distinctUntilChangedBy { it.source.refresh }
            // Only react to cases where REFRESH completes i.e., NotLoading.
            .map { it.source.refresh is LoadState.NotLoading }

        val hasNotScrolledForCurrentSearch = uiState
            .map { it.hasNotScrolledForCurrentSearch }
            .distinctUntilChanged()

        val shouldScrollToTop = combine(
            notLoading,
            hasNotScrolledForCurrentSearch,
            Boolean::and
        )
            .distinctUntilChanged()

        lifecycleScope.launch {
            pagingData.collectLatest(repoAdapter::submitData)
        }

        lifecycleScope.launch {
            shouldScrollToTop.collect { shouldScroll ->
                if (shouldScroll) list.scrollToPosition(0)
            }
        }
    }
}

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.

10. Afficher l'état de chargement dans un pied de page

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 du LoadState de Paging.
  • 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)
    }
}

Rattacher l'adaptateur de pied de page à la liste

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 ActivitySearchRepositoriesBinding.bindState(), puis appelez withLoadStateHeaderAndFooter() sur l'adaptateur. Nous pouvons appeler adapter.retry() en tant que fonction "réessayer".

   private fun ActivitySearchRepositoriesBinding.bindState(
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        uiActions: (UiAction) -> Unit
    ) {
        val repoAdapter = ReposAdapter()
        list.adapter = repoAdapter.withLoadStateHeaderAndFooter(
            header = ReposLoadStateAdapter { repoAdapter.retry() },
            footer = ReposLoadStateAdapter { repoAdapter.retry() }
        )
        ...
    }

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.

11. Afficher l'état de chargement dans "Activity"

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

  • Pendant la migration vers 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 chargement 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 celle-ci est vide. Pour savoir quand la liste a été chargée, nous allons utiliser la propriété PagingDataAdapter.loadStateFlow. Ce Flow émet à 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 (plus d'informations à ce sujet ultérieurement).

Dans SearchRepositoriesActivity.bindList(), nous collectons directement les données depuis loadStateFlow. Cette liste est vide lorsque l'état refresh de CombinedLoadStates est NotLoading et adapter.itemCount == 0. Ensuite, nous modifions la visibilité de emptyList et list, respectivement :

private fun ActivitySearchRepositoriesBinding.bindList(
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        ...
        lifecycleScope.launch {
            repoAdapter.loadStateFlow.collect { loadState ->
                val isListEmpty = loadState.refresh is LoadState.NotLoading && repoAdapter.itemCount == 0
                // show empty list
                emptyList.isVisible = isListEmpty
                // Only show the list if refresh succeeds.
                list.isVisible = !isListEmpty
                }
            }
        }
    }

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

   private fun ActivitySearchRepositoriesBinding.bindList(
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        retryButton.setOnClickListener { repoAdapter.retry() }
        ...
}

Ensuite, réagissons aux modifications de l'état de chargement dans SearchRepositoriesActivity.bindList. Comme nous ne voulons afficher la barre de progression que lors de nouvelles requêtes, nous devons utiliser le type de chargement de notre source de pagination (plus précisément, CombinedLoadStates.source.refresh et sur le LoadState: Loading ou Error). De plus, une fonctionnalité que nous avions mise 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 ActivitySearchRepositoriesBinding.bindList dans SearchRepositoriesActivity pour obtenir cette fonctionnalité :

private fun ActivitySearchRepositoriesBinding.bindList(
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        ...
        lifecycleScope.launch {
            repoAdapter.loadStateFlow.collect { loadState ->
                val isListEmpty = loadState.refresh is LoadState.NotLoading && repoAdapter.itemCount == 0
                // show empty list
                emptyList.isVisible = isListEmpty
                // Only show the list if refresh succeeds.
                list.isVisible = !isListEmpty
                // Show loading spinner during initial load or refresh.
                progressBar.isVisible = loadState.source.refresh is LoadState.Loading
                // Show the retry state if initial load or refresh fails.
                retryButton.isVisible = loadState.source.refresh is LoadState.Error

                // Toast on any error, regardless of whether it came from RemoteMediator or PagingSource
                val errorState = loadState.source.append as? LoadState.Error
                    ?: loadState.source.prepend as? LoadState.Error
                    ?: loadState.append as? LoadState.Error
                    ?: loadState.prepend as? LoadState.Error
                errorState?.let {
                    Toast.makeText(
                        this@SearchRepositoriesActivity,
                        "\uD83D\uDE28 Wooops ${it.error}",
                        Toast.LENGTH_LONG
                    ).show()
                }
            }
        }
    }

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.

12. Ajouter des séparateurs de liste

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.

573969750b4c719c.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>>.

class SearchRepositoriesViewModel(
    private val repository: GithubRepository,
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {

    ...

    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 :

   private fun searchRepo(queryString: String): Flow<PagingData<UiModel>> =
        repository.getSearchResultStream(queryString)
            .map { pagingData -> pagingData.map { UiModel.RepoItem(it) } }
            .map {
                it.insertSeparators { before, after ->
                    if (after == null) {
                        // we're at the end of the list
                        return@insertSeparators null
                    }

                    if (before == null) {
                        // we're at the beginning of the list
                        return@insertSeparators UiModel.SeparatorItem("${after.roundedStarCount}0.000+ stars")
                    }
                    // check between 2 items
                    if (before.roundedStarCount > after.roundedStarCount) {
                        if (after.roundedStarCount >= 1) {
                            UiModel.SeparatorItem("${after.roundedStarCount}0.000+ stars")
                        } else {
                            UiModel.SeparatorItem("< 10.000+ stars")
                        }
                    } else {
                        // no separator
                        null
                    }
                }
            }

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.

13. Pagination depuis le réseau et la base de données

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 !

14. Définir la base de données, la table et l'objet d'accès aux données Room

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 demander des données au réseau et les enregistrer dans la base de données.

15. Demander et enregistrer des données : présentation

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 au réseau. Pour cela, 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.

16. Calculer et enregistrer des clés de page distante

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** (opération effectuée à 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
}

17. Demander et enregistrer des données : implémentation

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.

18. Obtenir la page 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 nextKey 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 nextKey is null, that means we've reached
        // the end of pagination for append.
        val nextKey = remoteKeys?.nextKey
        if (nextKey == null) {
            return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
        }
        nextKey
    }
      ...
  }

LoadType.PREPEND

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 au début.
val page = when (loadType) {
    LoadType.PREPEND -> {
        val remoteKeys = getRemoteKeyForFirstItem(state)
        // If remoteKeys is null, that means the refresh result is not in the database yet.
        val prevKey = remoteKeys?.prevKey
        if (prevKey == null) {
            return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
        }
        prevKey
    }

      ...
  }

LoadType.REFRESH

LoadType.REFRESH 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
    }
}

19. Mettre à jour le flux de pagination

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

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

       // get the view model
        val viewModel = ViewModelProvider(
            this, Injection.provideViewModelFactory(
                context = this,
                owner = 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.

Réagir aux états de chargement lors de l'utilisation d'un RemoteMediator

Jusqu'à présent, lorsque nous lisions depuis CombinedLoadStates, nous le faisions toujours depuis CombinedLoadStates.source. Toutefois, si vous utilisez un RemoteMediator, vous ne pouvez obtenir des informations de chargement précises qu'en vérifiant à la fois CombinedLoadStates.source et CombinedLoadStates.mediator. En particulier, nous déclenchons un retour au début de la liste pour les nouvelles requêtes lorsque le LoadState de source est NotLoading. Nous devons également nous assurer que le LoadState du RemoteMediator que nous venons d'ajouter est NotLoading.

Pour cela, nous définissons une énumération qui récapitule les états de présentation de notre liste tels qu'ils sont récupérés par Pager :

enum class RemotePresentationState {
    INITIAL, REMOTE_LOADING, SOURCE_LOADING, PRESENTED
}

Avec la définition ci-dessus, nous pouvons comparer les émissions consécutives de CombinedLoadStates et les utiliser pour déterminer l'état exact des éléments de la liste.

@OptIn(ExperimentalCoroutinesApi::class)
fun Flow<CombinedLoadStates>.asRemotePresentationState(): Flow<RemotePresentationState> =
    scan(RemotePresentationState.INITIAL) { state, loadState ->
        when (state) {
            RemotePresentationState.PRESENTED -> when (loadState.mediator?.refresh) {
                is LoadState.Loading -> RemotePresentationState.REMOTE_LOADING
                else -> state
            }
            RemotePresentationState.INITIAL -> when (loadState.mediator?.refresh) {
                is LoadState.Loading -> RemotePresentationState.REMOTE_LOADING
                else -> state
            }
            RemotePresentationState.REMOTE_LOADING -> when (loadState.source.refresh) {
                is LoadState.Loading -> RemotePresentationState.SOURCE_LOADING
                else -> state
            }
            RemotePresentationState.SOURCE_LOADING -> when (loadState.source.refresh) {
                is LoadState.NotLoading -> RemotePresentationState.PRESENTED
                else -> state
            }
        }
    }
        .distinctUntilChanged()

Le code ci-dessus nous permet de mettre à jour la définition du Flow notLoading que nous utilisons pour vérifier si nous pouvons faire défiler la liste jusqu'en haut :

       val notLoading = repoAdapter.loadStateFlow
            .asRemotePresentationState()
            .map { it == RemotePresentationState.PRESENTED }

De même, pour afficher une icône de chargement pendant le chargement initial de la page (dans l'extension bindList de SearchRepositoriesActivity) 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. Les autres éléments de l'interface utilisateur dont la visibilité dépend des LoadStates présentent également ce problème. Mettons donc à jour la liaison des LoadStates avec les éléments de l'interface utilisateur comme suit :

private fun ActivitySearchRepositoriesBinding.bindList(
        header: ReposLoadStateAdapter,
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        ...

        lifecycleScope.launch {
            repoAdapter.loadStateFlow.collect { loadState ->
                ...
                val isListEmpty = loadState.refresh is LoadState.NotLoading && repoAdapter.itemCount == 0
                // show empty list
                emptyList.isVisible = isListEmpty
                // Only show the list if refresh succeeds, either from the the local db or the remote.
                list.isVisible =  loadState.source.refresh is LoadState.NotLoading || loadState.mediator?.refresh is LoadState.NotLoading
                // Show loading spinner during initial load or refresh.
                progressBar.isVisible = loadState.mediator?.refresh is LoadState.Loading
                // Show the retry state if initial load or refresh fails.
                retryButton.isVisible = loadState.mediator?.refresh is LoadState.Error && repoAdapter.itemCount == 0
                }
            }
        }
    }

De plus, comme la base de données est notre seule source d'informations, il est possible de lancer l'application dans un état où des données sont présentes dans la base de données, mais où l'actualisation avec le RemoteMediator échoue. C'est un problème intéressant que nous pouvons facilement résoudre. Pour cela, nous pouvons conserver une référence à l'en-tête LoadStateAdapter et remplacer son LoadState par celui de RemoteMediator, si et seulement si son état d'actualisation comporte une erreur. Sinon, nous utilisons la valeur par défaut.

private fun ActivitySearchRepositoriesBinding.bindList(
        header: ReposLoadStateAdapter,
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        ...

        lifecycleScope.launch {
            repoAdapter.loadStateFlow.collect { loadState ->
                // Show a retry header if there was an error refreshing, and items were previously
                // cached OR default to the default prepend state
                header.loadState = loadState.mediator
                    ?.refresh
                    ?.takeIf { it is LoadState.Error && repoAdapter.itemCount > 0 }
                    ?: loadState.prepend
                ...
            }
        }
    }

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

20. Conclusion

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.
  • Ajouter un RemoteMediator entraîne la mise à jour du champ mediator dans le LoadStatesFlow.