Le rôle de l'UI est d'afficher les données de l'application à l'écran. L'UI sert également de point principal d'interaction de l'utilisateur. Chaque fois que les données changent, que ce soit du fait d'une interaction de l'utilisateur (comme appuyer sur un bouton) ou d'une entrée externe (telle qu'une réponse du réseau), l'UI est mise à jour pour refléter ces modifications. En réalité, l'UI est une représentation visuelle de l'état de l'application, tel qu'il est extrait de la couche de données.
Cependant, le format des données d'application obtenues à partir de la couche de données est différent de celui des informations à afficher. Par exemple, vous n'aurez peut-être besoin que d'une partie des données de l'interface utilisateur ou vous devrez fusionner deux sources de données différentes pour présenter des informations pertinentes à l'utilisateur. Quelle que soit la logique que vous appliquez, vous devez transmettre à l'interface utilisateur toutes les informations nécessaires à son affichage complet. La couche d'UI est le pipeline qui convertit les modifications de données d'application dans un formulaire que l'UI peut présenter, puis les affiche.
Une étude de cas basique
Prenons l'exemple d'une application qui récupère les articles d'actualités pour qu'un utilisateur puisse les lire. L'application dispose d'un écran d'articles qui présente des articles disponibles en lecture et permet également aux utilisateurs connectés de placer des articles qui se démarquent vraiment. Étant donné qu'il peut y avoir de nombreux articles à un moment donné, le lecteur doit pouvoir parcourir les articles par catégorie. En résumé, l'application permet aux utilisateurs d'effectuer les opérations suivantes :
- Afficher les articles disponibles en lecture
- Parcourir les articles par catégorie
- Se connecter et ajouter certains articles aux favoris
- Accéder à certaines fonctionnalités premium, le cas échéant
Les sections suivantes utilisent cet exemple comme étude de cas pour présenter les principes du flux de données unidirectionnel, ainsi que pour illustrer les problèmes que ces principes permettent de résoudre dans le contexte de l'architecture de l'application pour la couche d'interface utilisateur.
Architecture de la couche d'interface utilisateur
Le terme UI fait référence aux éléments d'interface utilisateur tels que les conteneurs et les fonctions composables qui affichent les données. Pour créer des UI Android, le kit d'outils recommandé est Jetpack Compose. Étant donné que le rôle de la couche de données est de détenir, gérer et fournir l'accès aux données de l'application, la couche d'UI doit effectuer les étapes suivantes :
- Consommer les données d'application et les transformer en données que l'interface utilisateur peut facilement afficher.
- Consommer les données de l'interface utilisateur et les transformer en éléments d'interface utilisateur pour les présenter à l'utilisateur.
- Consommer les événements d'entrée utilisateur à partir de ces éléments d'UI assemblés et refléter leurs effets dans les données d'UI si nécessaire.
- Répéter les étapes 1 à 3 aussi longtemps que nécessaire.
Le reste de ce guide explique comment mettre en œuvre une couche d'interface utilisateur qui effectue ces étapes. Ce guide traite en particulier des tâches et des concepts suivants :
- Définir l'état de l'UI
- Flux de données unidirectionnel (UDF) comme moyen de produire et de gérer l'état de l'UI
- Comment exposer l'état de l'UI avec des types de données observables conformément aux principes de la fonction définie par l'utilisateur
- Implémenter une UI qui utilise l'état d'UI observable
Le point le plus fondamental concerne la définition de l'état de l'interface utilisateur.
Définir l'état de l'interface utilisateur
Dans l'étude de cas décrite précédemment, l'UI affiche une liste d'articles ainsi que des métadonnées pour chacun d'eux. Ces informations présentées à l'utilisateur correspondent à l'état de l'interface utilisateur.
En d'autres termes, si l'interface utilisateur est visible par l'utilisateur, l'état de l'UI correspond à ce que l'application indique. Comme deux faces d'une même pièce, l'UI est une représentation visuelle de l'état de l'UI. Toute modification de l'état de l'interface utilisateur est immédiatement répercutée dans l'UI.
Reportez-vous à l'étude de cas. Afin de répondre aux exigences de l'application Actualités, les informations requises pour afficher entièrement l'UI peuvent être encapsulées dans une classe de données NewsUiState définie comme suit :
data class NewsUiState(
val isSignedIn: Boolean = false,
val isPremium: Boolean = false,
val newsItems: List<NewsItemUiState> = listOf(),
val userMessages: List<Message> = listOf()
)
data class NewsItemUiState(
val title: String,
val body: String,
val bookmarked: Boolean = false,
...
)
Pour en savoir plus sur l'état de l'UI, consultez États et Jetpack Compose.
Immuabilité
Dans l'exemple précédent, la définition de l'état de l'UI est immuable. L'avantage principal est que les objets immuables fournissent des garanties sur l'état de l'application à un instant T. L'UI peut ainsi se concentrer sur son rôle principal : lire l'état et mettre à jour ses éléments en conséquence. Ne modifiez jamais directement l'état de l'UI, sauf si l'UI elle-même est la seule source de ses données. Si vous ne respectez pas ce principe, alors il existera plusieurs sources de vérité pour une même information, ce qui entraînera des incohérences au niveau des données et des bugs.
Prenons l'exemple de l'étude de cas précédente.
Si l'option bookmarked d'un objet NewsItemUiState de l'état de l'UI est mise à jour dans la classe Activity, elle entre en concurrence avec la couche de données en tant que source du statut d'un article ajouté aux favoris. Les classes de données immuables sont très utiles pour éviter ce type d'incohérence.
Conventions d'attribution de noms dans ce guide
Dans ce guide, les classes d'état de l'interface utilisateur sont nommées en fonction de la fonctionnalité ou de la partie de l'écran qu'elles décrivent. La convention est la suivante :
fonctionnalité + UiState.
Par exemple, l'état d'un écran d'actualités peut s'appeler NewsUiState, et celui d'un élément d'actualité dans une liste d'articles peut être NewsItemUiState.
Gérer l'état avec un flux de données unidirectionnel
Dans la section précédente, nous avons établi que l'état de l'interface utilisateur était un instantané immuable des détails nécessaires à l'affichage de l'interface utilisateur. Cependant, la nature dynamique des données dans les applications signifie que cet état peut changer au fil du temps. Cela peut être dû à une interaction de l'utilisateur ou à d'autres événements modifiant les données sous-jacentes utilisées pour renseigner l'application.
Ces interactions peuvent bénéficier d'un médiateur pour les traiter, en définissant la logique à appliquer à chaque événement et en transformant les sources de données externes afin de créer un état de l'UI. Bien que ces interactions et leur logique puissent être hébergées dans l'UI elle-même, cela peut rapidement devenir pénible lorsque l'UI assume trop de responsabilités. En outre, cela peut affecter la testabilité, car le code obtenu est à couplage fort. À moins que l'état de l'UI ne soit très simple, assurez-vous que la seule responsabilité de l'UI est de consommer et d'afficher l'état de l'UI.
Cette section traite du flux de données unidirectionnel (UDF), un modèle d'architecture permettant d'appliquer cette séparation saine de responsabilités.
Conteneurs d'état
Les conteneurs d'état sont les classes responsables de la production de l'état de l'UI et de la logique nécessaire à cette production. Il existe différentes tailles pour les conteneurs d'états, en fonction de la portée des éléments d'UI correspondants qu'ils gèrent (que ce soit un widget unique, tel qu'une barre d'application inférieure, ou un écran entier ou une destination de navigation).
Dans ce dernier cas, l'implémentation type est une instance d'un objet ViewModel. Toutefois, en fonction des exigences de l'application, une classe simple peut suffire. L'application d'actualités de l'étude de cas, par exemple, utilise une classe NewsViewModel en tant que conteneur d'état pour générer l'état de l'interface utilisateur de l'écran affiché dans cette section.
Il existe de nombreuses façons de modéliser la codépendance entre l'UI et son producteur d'état. Toutefois, étant donné que l'interaction entre l'interface utilisateur et sa classe ViewModel peut être largement assimilée à une entrée d'événement et à sa sortie d'état, la relation comme peut être illustrée comme dans le schéma suivant :
Le modèle où l'état descend et les événements remontent est appelé flux de données unidirectionnel (UDF). Les conséquences de ce modèle pour l'architecture des applications sont les suivantes :
- ViewModel contient et expose l'état à utiliser par l'UI. L'état de l'interface utilisateur correspond aux données d'application transformées par ViewModel.
- L'UI informe le ViewModel des événements utilisateur.
- ViewModel gère les actions de l'utilisateur et met à jour l'état.
- L'état mis à jour est renvoyé à l'interface utilisateur pour être affiché.
- Ce qui précède est répété pour tout événement qui provoque une mutation d'état.
Pour les destinations ou les écrans de navigation, le ViewModel fonctionne avec des dépôts ou des classes de cas d'utilisation pour obtenir des données et les transformer à l'état de l'interface utilisateur tout en incorporant les effets d'événements susceptibles de provoquer des mutations de l'état. L'étude de cas mentionnée précédemment contient une liste d'articles, ayant chacun un titre, une description, une source, le nom de l'auteur, la date de publication et si ces éléments ont été ajoutés aux favoris. L'interface utilisateur de chaque article se présente comme suit :
Un utilisateur demandant à ajouter un article aux favoris est un exemple d'événement pouvant entraîner des mutations d'état. En tant que producteur d'état, il est de la responsabilité du ViewModel de définir toute la logique nécessaire pour remplir tous les champs dans l'état de l'UI et traiter les événements nécessaires à son affichage complet.
Les sections suivantes examinent de plus près les événements qui provoquent des changements d'état, ainsi que la manière dont ils peuvent être traités à l'aide de la fonction définie par l'utilisateur.
Types de logiques
L'ajout d'un article aux favoris est un exemple de logique métier, car il donne de la valeur à votre application. Pour en savoir plus à ce sujet, consultez la page sur la couche de données. Cependant, il existe plusieurs types de logiques qu'il est important de définir :
- La logique métier est la mise en œuvre des exigences produit pour les données d'application. Comme indiqué précédemment, vous pouvez ajouter un article à vos favoris dans l'application de l'étude de cas. La logique métier est généralement placée dans les couches de domaine ou de données, mais jamais dans la couche d'interface utilisateur.
- La logique de comportement de l'interface utilisateur ou la logique d'interface utilisateur indique comment afficher les changements d'état à l'écran. Par exemple, vous pouvez obtenir le texte à afficher à l'écran avec
Resourcessur Android, naviguer vers un écran spécifique lorsque l'utilisateur clique sur un bouton ou afficher un message utilisateur à l'aide d'un toast ou d'un snackbar.
Conservez la logique de l'UI dans l'UI, et non dans le ViewModel, en particulier lorsqu'elle implique des types d'UI tels que Context.
Si l'UI devient plus complexe et que vous souhaitez déléguer la logique de l'UI à une autre classe pour favoriser la testabilité et la séparation des préoccupations, vous pouvez créer une classe simple en tant que conteneur d'état. Les classes simples créées dans l'interface utilisateur peuvent prendre des dépendances du SDK Android, car elles suivent le cycle de vie de l'interface utilisateur. Les objets ViewModel ont une durée de vie plus longue.
Pour en savoir plus sur les conteneurs d'états et la manière dont ils s'inscrivent dans le contexte d'aide à la création de l'interface utilisateur, consultez le guide Jetpack Compose State.
Pourquoi utiliser l'UDF ?
L'UDF modélise le cycle de production de l'état, comme illustré dans la Figure 4. Il sépare également le lieu d'origine des changements d'état, le lieu où ils sont transformés et le lieu où ils sont enfin utilisés. Cette séparation permet à l'interface utilisateur de faire exactement ce que son nom implique : afficher des informations en observant les changements d'état et transmettre l'intent de l'utilisateur en communiquant ces modifications à ViewModel.
En d'autres termes, la fonction définie par l'utilisateur autorise les éléments suivants :
- Cohérence des données. Il n'existe qu'une source d'informations unique pour l'interface utilisateur.
- Testabilité. La source d'état est isolée et donc testable, indépendante de l'UI.
- Facilité de gestion. La mutation d'un état suit un modèle bien défini où les mutations résultent à la fois des événements utilisateur et des sources de données à partir desquelles ils extraient.
Exposer l'état de l'interface utilisateur
Après avoir défini l'état de l'interface utilisateur et déterminé la manière dont vous allez gérer la production de cet état, l'étape suivante consiste à présenter l'état produit à l'interface utilisateur.
Lorsque vous utilisez UDF pour gérer la production de l'état, vous pouvez considérer l'état produit comme un flux. En d'autres termes, plusieurs versions de l'état sont produites au fil du temps. Exposez l'état de l'UI dans un conteneur de données observable, tel que StateFlow. L'UI peut ainsi réagir à toute modification d'état sans avoir à extraire manuellement les données directement à partir de ViewModel. Cela présente également l'avantage de toujours mettre en cache la dernière version de l'état de l'UI, ce qui est utile pour restaurer rapidement l'état après une modification de la configuration.
class NewsViewModel(...) : ViewModel() {
val uiState: NewsUiState = …
}
Pour une présentation des flux Kotlin, consultez Flux Kotlin sur Android.
Pour découvrir comment utiliser StateFlow en tant que conteneur de données observable, consultez l'atelier de programmation État avancé et effets secondaires dans Jetpack Compose.
Dans les cas où les données exposées à l'interface utilisateur sont relativement simples, il est souvent utile de les encapsuler dans un type d'état de l'interface utilisateur, car elles révèlent la relation entre les émissions du conteneur d'état et l'écran ou l'élément d'interface utilisateur qui lui est associé. À mesure que l'élément d'interface utilisateur se complexifie, il est facile d'ajouter à la définition de l'état de l'interface utilisateur pour prendre en compte les informations supplémentaires nécessaires à l'affichage de l'élément.
Une façon courante de créer un flux UiState consiste à exposer une propriété mutableStateOf avec un private set, en conservant l'état modifiable dans le ViewModel, mais en lecture seule pour l'UI.
class NewsViewModel(...) : ViewModel() {
var uiState by mutableStateOf(NewsUiState())
private set
...
}
ViewModel peut alors exposer des méthodes qui modifient en interne l'état en publiant les mises à jour que l'UI doit utiliser. Prenons, par exemple, le cas où vous devez effectuer une action asynchrone.
Vous pouvez lancer une coroutine à l'aide de viewModelScope, puis mettre à jour l'état modifiable une fois l'opération terminée.
class NewsViewModel(
private val repository: NewsRepository,
...
) : ViewModel() {
var uiState by mutableStateOf(NewsUiState())
private set
private var fetchJob: Job? = null
fun fetchArticles(category: String) {
fetchJob?.cancel()
fetchJob = viewModelScope.launch {
try {
val newsItems = repository.newsItemsForCategory(category)
uiState = uiState.copy(newsItems = newsItems)
} catch (ioe: IOException) {
// Handle the error and notify the UI when appropriate.
val messages = getMessagesFromThrowable(ioe)
uiState = uiState.copy(userMessages = messages)
}
}
}
}
Dans l'exemple précédent, la classe NewsViewModel tente de récupérer des articles pour une certaine catégorie, puis reflète le résultat de la tentative, qu'il s'agisse d'une réussite ou d'un échec, dans l'état de l'UI permettant à celle-ci de réagir de manière appropriée.
Pour en savoir plus sur la gestion des erreurs, consultez la section Afficher les erreurs à l'écran.
Informations complémentaires
En plus des conseils précédents, tenez compte des points suivants lorsque vous exposez l'état de l'interface utilisateur :
Utilisez un seul objet d'état de l'UI pour gérer les états associés les uns aux autres. Cela permet de réduire les incohérences et de rendre le code plus facile à comprendre. Si vous affichez la liste des articles d'actualités et le nombre de favoris dans deux flux différents, vous pouvez vous retrouver dans une situation où l'un a été mis à jour et l'autre non. Lorsque vous utilisez un seul flux, les deux éléments sont maintenus à jour. En outre, certaines logiques métier peuvent nécessiter une combinaison de sources. Par exemple, vous pouvez n'afficher un bouton de favori que si l'utilisateur est connecté et qu'il est abonné à un service d'actualités premium. Vous pouvez définir une classe d'état de l'UI comme suit :
data class NewsUiState( val isSignedIn: Boolean = false, val isPremium: Boolean = false, val newsItems: List<NewsItemUiState> = listOf() ) val NewsUiState.canBookmarkNews: Boolean get() = isSignedIn && isPremiumDans cette déclaration, la visibilité du bouton de favori est une propriété dérivée de deux autres propriétés. À mesure que la logique métier se complexifie, il est de plus en plus important d'avoir une classe
UiStateunique où toutes les propriétés sont immédiatement disponibles.États de l'UI : un ou plusieurs flux ? Le principe directeur suivant pour choisir entre l'exposition de l'état de l'UI dans un seul flux ou dans plusieurs flux est la relation entre les éléments émis. Les principaux avantages d'une exposition à un seul flux sont la commodité et la cohérence des données : les consommateurs d'état disposent toujours des informations les plus récentes à tout moment. Toutefois, dans certains cas, des flux d'état distincts de ViewModel peuvent s'avérer appropriés :
Types de données non liés : certains états nécessaires à l'affichage de l'interface utilisateur peuvent être complètement indépendants les uns des autres. Dans de tels cas, les coûts de l'association de ces états disparates peuvent dépasser les avantages, en particulier si l'un de ces états est mis à jour plus fréquemment que l'autre.
Différence
UiState: plus il y a de champs dans un objetUiState, plus il est probable que le flux émette en raison de l'un de ses champs en cours de mise à jour. Comme les éléments d'interface utilisateur n'ont pas de mécanisme de diff pour déterminer si les émissions consécutives sont différentes ou identiques, chaque émission entraîne une mise à jour de l'élément d'interface utilisateur. Cela signifie qu'une atténuation à l'aide des méthodes d'APIFlowtelles quedistinctUntilChanged()peut être nécessaire.
Pour en savoir plus sur le rendu et l'état de l'UI, consultez Cycle de vie des composables.
Utiliser l'état de l'interface utilisateur
Pour utiliser le flux d'objets UiState dans l'UI, utilisez l'opérateur terminal pour le type de données observables que vous utilisez. Par exemple, pour les flux Kotlin, utilisez la méthode collect() ou ses variantes.
Lorsque vous utilisez des conteneurs de données observables dans l'UI, assurez-vous de prendre en compte le cycle de vie de l'interface utilisateur. Ne faites pas en sorte que l'UI observe l'état de l'UI lorsque le composable n'est pas affiché à l'utilisateur. Pour en savoir plus à ce sujet, consultez cet article de blog. Lorsque vous utilisez des flux, il est préférable de gérer les problèmes de cycle de vie avec le champ d'application de coroutine approprié et l'API collectAsStateWithLifecycle :
@Composable private fun ConversationScreen( conversationViewModel: ConversationViewModel = viewModel() ) { val messages by conversationViewModel.messages.collectAsStateWithLifecycle() ConversationScreen( messages = messages, onSendMessage = { message: Message -> conversationViewModel.sendMessage(message) } ) } @Composable private fun ConversationScreen( messages: List<Message>, onSendMessage: (Message) -> Unit ) { MessagesList(messages, onSendMessage) /* ... */ }
Afficher les opérations en cours
Un moyen simple de représenter les états de chargement dans une classe UiState consiste à utiliser un champ booléen :
data class NewsUiState(
val isFetchingArticles: Boolean = false,
...
)
La valeur de cette option représente la présence ou l'absence d'une barre de progression dans l'interface utilisateur.
@Composable
fun LatestNewsScreen(
modifier: Modifier = Modifier,
viewModel: NewsViewModel = viewModel()
) {
Box(modifier.fillMaxSize()) {
if (viewModel.uiState.isFetchingArticles) {
CircularProgressIndicator(Modifier.align(Alignment.Center))
}
// Add other UI elements. For example, the list.
}
}
Afficher les erreurs à l'écran
L'affichage des erreurs dans l'interface utilisateur est semblable à celui des opérations en cours, car elles sont toutes deux facilement représentées par des valeurs booléennes indiquant leur présence ou leur absence. Toutefois, les erreurs peuvent également inclure un message associé permettant de relayer l'erreur à l'utilisateur, ou une action associée à l'utilisateur qui retente l'opération ayant échoué. Par conséquent, lorsqu'une opération en cours est en phase de chargement ou non, il peut être nécessaire de modéliser les états d'erreur avec des classes de données qui hébergent les métadonnées appropriées pour le contexte de l'erreur.
Prenons l'exemple précédent, qui comportait une barre de progression lors de la récupération des articles. Si cette opération entraîne une erreur, vous pouvez afficher un ou plusieurs messages répertoriant les erreurs.
data class Message(val id: Long, val message: String)
data class NewsUiState(
val userMessages: List<Message> = listOf(),
...
)
Vous pouvez ensuite présenter les messages d'erreur à l'utilisateur sous la forme d'éléments d'interface utilisateur tels que des snackbars. Pour en savoir plus sur la production et la consommation des événements d'UI, consultez Événements d'UI.
Exécutions de threads et simultanéité
Assurez-vous que toutes les tâches effectuées dans un ViewModel sont sécurisées, car elles peuvent être appelées à partir du thread principal. Les couches de données et de domaine sont chargées de déplacer le travail vers un autre thread.
Si un ViewModel effectue des opérations de longue durée, il est également responsable de déplacer cette logique vers un thread d'arrière-plan. Les coroutines Kotlin sont un excellent moyen de gérer les opérations simultanées, et les composants d'architecture Jetpack leur sont directement intégrés. Pour en savoir plus sur l'utilisation de coroutines dans les applications Android, consultez Coroutines Kotlin sur Android.
Navigation
Les changements de navigation dans les applications sont souvent dus à des émissions qui ressemblent à celles d'un événement. Par exemple, après la connexion d'une classe SignInViewModel, le champ UiState peut avoir un champ isSignedIn défini sur true. Utilisez les déclencheurs de ce type comme ceux décrits dans la section Utiliser l'état de l'UI ci-dessus, mais reportez la mise en œuvre de la consommation sur le composant Navigation.
Pour en savoir plus sur la navigation dans l'UI, consultez Navigation 3.
Paging
La bibliothèque Paging est utilisée dans l'interface utilisateur avec un type appelé PagingData. Étant donné que PagingData représente et contient des éléments qui peuvent changer au fil du temps (en d'autres termes, il ne s'agit pas d'un type immuable), ne le représentez pas dans un état d'UI immuable.
Vous devez l'exposer à partir du ViewModel indépendamment dans son propre flux.
L'exemple suivant montre l'API Compose de la bibliothèque Paging :
@Composable fun MyScreen(flow: Flow<PagingData<String>>) { val lazyPagingItems = flow.collectAsLazyPagingItems() LazyColumn { items( lazyPagingItems.itemCount, key = lazyPagingItems.itemKey { it } ) { index -> val item = lazyPagingItems[index] Text("Item is $item") } } }
Animations
Pour assurer des transitions de navigation fluides de premier niveau, vous pouvez attendre que le second écran charge des données avant de lancer l'animation.
Pour en savoir plus sur les transitions de navigation, consultez Navigation 3 et Transitions d'éléments partagés dans Compose.
Ressources supplémentaires
Afficher le contenu
Exemples
Les exemples Google suivants illustrent l'utilisation de la couche de l'interface utilisateur. Parcourez-les pour voir ces conseils en pratique :
Recommandations personnalisées
- Remarque : Le texte du lien s'affiche lorsque JavaScript est désactivé
- Production de l'état de l'interface utilisateur
- Conteneurs d'état et état de l'interface utilisateur {:#mad-arch}
- Guide de l'architecture des applications