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 :
Prérequis
- Android Studio Arctic Fox
- Bonne connaissance des composants d'architecture LiveData, ViewModel et View Binding, ainsi que de l'architecture suggérée dans le Guide de l'architecture des applications
- Bonne connaissance des coroutines et de Kotlin Flow
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 :
- Décompressez le code, puis ouvrez le projet dans Android Studio.
- Exécutez la configuration d'exécution
app
sur un appareil ou un émulateur.
L'application s'exécute et affiche une liste de dépôts GitHub semblables à celui-ci :
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, etRepoSearchResult
, 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 uneRecyclerView
.
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 :
LiveData<UiState>
- 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 enLiveData
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 employerLiveData
. - 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
oufilter
dans la liste affichée, indépendamment de l'utilisation deFlow
,LiveData
ouFlowable
/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 à unPagingData
distinct.PagingSource
– unePagingSource
est la classe de base permettant de charger des instantanés de données dans un flux dePagingData
.Pager.flow
– crée unFlow<PagingData>
basé sur unePagingConfig
et sur une fonction définissant la construction de laPagingSource
implémentée.PagingDataAdapter
– unRecyclerView.Adapter
qui présente lesPagingData
dans uneRecyclerView
. LePagingDataAdapter
peut être connecté à unFlow
Kotlin, desLiveData
, un élément RxJavaFlowable
ou un élément RxJavaObservable
. LePagingDataAdapter
suit les événements de chargementPagingData
internes lors du chargement des pages et utiliseDiffUtil
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 objetsPagingData
.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 auGithubService
.
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
seranull
. Dans ce cas, vous devez définir la clé de page initiale. Pour ce projet, la constanteGITHUB_STARTING_PAGE_INDEX
devrait être déplacée deGithubRepository
vers votre implémentationPagingSource
, 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 unFlow
Kotlin. - Utilisez
Pager.liveData
avecLiveData
. - Utilisez
Pager.flowable
avec un élément RxJavaFlowable
. - Utilisez
Pager.observable
avec un élément RxJavaObservable
.
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'unePagingSource
(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ètremaxSize
dansPagingConfig
. 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 configurationenablePlaceholders
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 transmettantenablePlaceholders = false
.- Une fonction qui définit la façon de créer la
PagingSource
. Ici, nous allons créer uneGithubPagingSource
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 :
UiAction.Search
pour chaque fois que l'utilisateur saisit une requête particulièreUiAction.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 :
shareIn
: cette transformation est nécessaire, car lorsque ceFlow
est consommé, il l'est avec un opérateurflatmapLatest
. À chaque émission en amont,flatmapLatest
annule le dernierFlow
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érateurFlow
avec une valeurreplay
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.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émenteListAdapter
. Remplacez cela par l'implémentation dePagingDataAdapter
. 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 desPagingData
.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".
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 duLoadState
de Paging. - Le fichier adaptateur, qui définit comment créer et rattacher le
ViewHolder
. Au lieu d'étendre unRecyclerView.Adapter
, nous étendrons unLoadStateAdapter
à 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).
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
.
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ètrePagingDataAdapter
parUiModel
. - Implémentez un comparateur
UiModel
et remplacez leREPO_COMPARATOR
par celui-ci. - Créez la
SeparatorViewHolder
et rattachez-la à la description deUiModel.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
duonBindViewHolder
.
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 :
- 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 objetsRepo
. - 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. - Créez un
Pager
basé sur la table de dépôts en tant que source de données, et leRemoteMediator
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 objetsRepo
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>
, renvoyerPagingSource<Int, Repo>
. Ainsi, la tablerepos
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 étendRoomDatabase
. - Annotez la classe avec
@Database
, définissez la liste des entités pour contenir la classeRepo
, 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 uncompanion object
qui crée l'objetRepoDatabase
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 laPagingConfig
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 desRepos
sont obtenus depuis le réseau, nous génèrerons les clés distantes correspondantes - Obtenir une **
RemoteKey
** sur la base d'unid
deRepo
. - 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()
:
- Identifiez la page à charger depuis le réseau, en fonction du
LoadType
. - Déclenchez la requête réseau.
- 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 :
- Calculez les
RemoteKeys
de chaqueRepo
. - S'il s'agit d'une nouvelle requête (
loadType = REFRESH
), effacez la base de données. - Enregistrez les
RemoteKeys
et lesRepos
dans la base de données. - Renvoyez
MediatorResult.Success(endOfPaginationReached = false)
. - 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, renvoyezMediatorResult.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.
- 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)
}
}
- 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) avecendOfPaginationReached = false
, car Paging va à nouveau appeler cette méthode si la valeur "RemoteKeys" n'est pas nulle. Si la valeur "remoteKeys" n'est pasnull
, mais quenextKey
estnull
, 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.
- 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)
}
}
- 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) avecendOfPaginationReached = false
, car Paging va à nouveau appeler cette méthode si la valeur "RemoteKeys" n'est pas nulle. Si la valeur "remoteKeys" n'est pasnull
, mais queprevKey
estnull
, 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.
- En nous basant sur la
anchorPosition
destate
, nous pouvons obtenir l'articleRepo
le plus proche de cette position en appelantstate.closestItemToPosition()
. - En nous basant sur l'élément
Repo
, nous pouvons récupérer lesRemoteKeys
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)
}
}
}
- Si
remoteKey
n'est pas nul, nous pouvons en obtenir lanextKey
. 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 deremoteKey.nextKey
. - Si
RemoteKey
estnull
(caranchorPosition
étaitnull
), 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 appelerRepoDatabase.getInstance
dans le constructeurGithubRepository
. - 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 unFlow<PagingData>
en fonction d'une configuration et d'une fonction qui définissent l'instanciation de laPagingSource
. - Le
Flow
émet de nouvellesPagingData
à chaque fois que de nouvelles données sont chargées par laPagingSource
. - L'interface utilisateur observe la modification des
PagingData
et utilise unPagingDataAdapter
pour actualiser laRecyclerView
, 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éthodePagingSource.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 unLoadStateAdapter
. Pour exécuter d'autres actions en fonction de l'état de chargement, utilisez le rappelPagingDataAdapter.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 champmediator
dans leLoadStatesFlow
.