Conteneurs d'état et état de l'interface utilisateur

Le guide consacré à la couche de l'interface utilisateur (UI) montre comment le flux de données unidirectionnel permet de générer et de gérer l'état de l'UI pour la couche de l'UI.

Les données circulent dans une seule direction, de la couche de données vers l'UI.
Figure 1 : Flux de données unidirectionnel

Il souligne également les avantages de déléguer la gestion des flux de données unidirectionnels à une classe spéciale appelée "conteneur d'état", que vous pouvez implémenter avec une classe ViewModel ou une classe simple. Ce document examine de plus près ces conteneurs d'état et leur rôle dans la couche de l'UI.

Après avoir lu ce document, vous devriez comprendre comment l'état de l'application est géré dans la couche de l'UI, autrement dit le pipeline qui génère l'état de l'UI. Vous devriez être en mesure de :

  • comprendre les types d'états qui existent dans la couche de l'UI ;
  • comprendre les types de logiques qui agissent sur ces états dans la couche de l'UI ;
  • choisir l'implémentation appropriée d'un conteneur d'état, par exemple ViewModel ou une classe simple.

Éléments du pipeline qui génère l'état de l'UI

L'état de l'UI et la logique qui le génère définissent la couche de l'UI.

État de l'interface utilisateur

L'état de l'UI est la propriété qui décrit l'UI. Il existe deux types d'états d'UI.

  • L'état de l'UI de l'écran correspond à ce qu'il faut afficher à l'écran. Par exemple, une classe NewsUiState peut contenir des articles d'actualité et d'autres informations nécessaires pour afficher l'UI. Cet état est généralement associé à d'autres couches de la hiérarchie, car il contient les données de l'application.
  • L'état de l'élément d'UI fait référence aux propriétés intrinsèques des éléments d'UI qui influent sur la façon dont ils sont rendus. Un élément d'UI peut être affiché ou masqué, et peut avoir une police, ou une taille ou couleur de police spécifiques. Dans les affichages Android, l'affichage gère lui-même cet état, car il s'agit d'un élément avec état par essence, lequel est modifié ou interrogé par des méthodes. Les méthodes get et set de la classe TextView en sont des exemples. Dans Jetpack Compose, l'état est externe au composable. Vous pouvez même le hisser à proximité du composable dans la fonction modulable appelante ou un conteneur d'état. Par exemple, ScaffoldState pour le composable Scaffold.

Logique

L'état de l'UI n'est pas une propriété statique, car les données de l'application et les événements utilisateur le font changer au fil du temps. La logique détermine les spécificités de la modification, y compris quelles parties de l'état de l'UI ont changé, pourquoi et quand ce changement doit avoir lieu.

La logique génère l'état de l'interface utilisateur
Figure 2 : La logique génère l'état de l'UI

La logique d'une application peut être soit une logique métier, soit une logique d'UI :

  • La logique métier correspond à l'implémentation des exigences produit pour les données de l'application. Par exemple, ajouter un article aux favoris dans une application de lecture d'actualités lorsque l'utilisateur appuie sur le bouton. Cette logique permet d'enregistrer un favori dans un fichier ou une base de données. Elle est généralement placée dans les couches de domaine ou de données. Le conteneur d'état délègue généralement cette logique à ces couches en appelant les méthodes qu'elles exposent.
  • La logique de l'UI se rapporte à la façon dont l'état de l'UI s'affiche à l'écran. Par exemple, afficher le bon indicateur de barre de recherche lorsque l'utilisateur a sélectionné une catégorie, faire défiler jusqu'à l'élément d'une liste ou naviguer vers un écran particulier lorsque l'utilisateur clique sur un bouton.

Cycle de vie d'Android, et types d'état et de logique de l'interface utilisateur

La couche de l'UI se compose de deux parties : une partie dépendante et une autre partie indépendante du cycle de vie de l'UI. Cette séparation détermine les sources de données disponibles pour chaque partie, et nécessite donc différents types d'état et de logique d'UI.

  • Partie indépendante du cycle de vie de l'UI : cette partie de la couche de l'UI se rapporte aux couches qui génèrent les données de l'application (couches de données ou de domaine). Elle est définie par une logique métier. Le cycle de vie, les modifications de configuration et la recréation de l'Activity dans l'UI peuvent avoir une incidence si le pipeline qui génère l'état de l'UI est actif, mais n'ont pas d'incidence sur la validité des données produites.
  • Partie dépendante du cycle de vie de l'UI : cette partie de la couche de l'UI se rapporte à la logique de l'UI. Elle est directement influencée par les modifications apportées au cycle de vie ou à la configuration. Ces modifications affectent directement la validité des sources de données lues dans celle-ci. Par conséquent, son état ne peut changer que lorsque son cycle de vie est actif. Les autorisations d'exécution et l'obtention de ressources dépendantes d'éléments de configuration tels que les chaînes localisées en sont des exemples.

Cette explication peut être résumée ainsi :

Partie indépendante du cycle de vie de l'UI Partie dépendante du cycle de vie de l'UI
Logique métier Logique d'UI
État de l'UI de l'écran

Pipeline qui génère l'état de l'UI

Le pipeline qui génère l'état de l'UI correspond aux étapes nécessaires pour produire cet état. Ces étapes comprennent l'application des types de logique définis précédemment et dépendent complètement des besoins de votre UI. Certaines UI peuvent bénéficier des deux types de parties du cycle de vie de l'UI, d'une seule partie, ou d'aucune.

Autrement dit, les permutations suivantes du pipeline de couche de l'UI sont valides :

  • État de l'UI produit et géré par la propre UI. Voici un exemple de compteur de base simple et réutilisable :

    @Composable
    fun Counter() {
        // The UI state is managed by the UI itself
        var count by remember { mutableStateOf(0) }
        Row {
            Button(onClick = { ++count }) {
                Text(text = "Increment")
            }
            Button(onClick = { --count }) {
                Text(text = "Decrement")
            }
        }
    }
    
  • Logique d'UI → UI. Par exemple, afficher ou masquer un bouton permettant à un utilisateur d'accéder à la partie supérieure d'une liste.

    @Composable
    fun ContactsList(contacts: List<Contact>) {
        val listState = rememberLazyListState()
        val isAtTopOfList by remember {
            derivedStateOf {
                listState.firstVisibleItemIndex < 3
            }
        }
    
        // Create the LazyColumn with the lazyListState
        ...
    
        // Show or hide the button (UI logic) based on the list scroll position
        AnimatedVisibility(visible = !isAtTopOfList) {
            ScrollToTopButton()
        }
    }
    
  • Logique métier → UI Élément d'UI affichant la photo de l'utilisateur actuel à l'écran.

    @Composable
    fun UserProfileScreen(viewModel: UserProfileViewModel = hiltViewModel()) {
        // Read screen UI state from the business logic state holder
        val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    
        // Call on the UserAvatar Composable to display the photo
        UserAvatar(picture = uiState.profilePicture)
    }
    
  • Logique métier → Logique d'UI → UI Élément d'UI qui défile pour afficher les bonnes informations à l'écran pour un certain état d'UI.

    @Composable
    fun ContactsList(viewModel: ContactsViewModel = hiltViewModel()) {
        // Read screen UI state from the business logic state holder
        val uiState by viewModel.uiState.collectAsStateWithLifecycle()
        val contacts = uiState.contacts
        val deepLinkedContact = uiState.deepLinkedContact
    
        val listState = rememberLazyListState()
    
        // Create the LazyColumn with the lazyListState
        ...
    
        // Perform UI logic that depends on information from business logic
        if (deepLinkedContact != null && contacts.isNotEmpty()) {
            LaunchedEffect(listState, deepLinkedContact, contacts) {
                val deepLinkedContactIndex = contacts.indexOf(deepLinkedContact)
                if (deepLinkedContactIndex >= 0) {
                  // Scroll to deep linked item
                  listState.animateScrollToItem(deepLinkedContactIndex)
                }
            }
        }
    }
    

Dans les cas où les deux types de logique sont appliqués au pipeline qui génère l'état de l'UI, la logique métier doit toujours être appliquée avant la logique de l'UI. Si vous essayez d'appliquer la logique métier après la logique de l'UI, cela impliquerait que la première dépend de la seconde. Les sections suivantes expliquent pourquoi cela est problématique dans une présentation détaillée des différents types de logique et de leurs conteneurs d'état.

Les données circulent de la couche qui génère les données vers l&#39;interface utilisateur
Figure 3 : Application de la logique dans la couche de l'UI

Les conteneurs d'état et leurs responsabilités

La responsabilité d'un conteneur d'état est de stocker un état pour que l'application puisse le lire. Dans les cas où la logique est nécessaire, elle sert d'intermédiaire et fournit un accès aux sources de données qui hébergent la logique requise. De cette manière, le conteneur d'état délègue la logique à la source de données appropriée.

Avantages :

  • UI simples : l'UI se contente de lier son état.
  • Facilité de gestion : la logique définie dans le conteneur d'état peut être itérée sans modifier l'UI elle-même.
  • Testabilité : l'UI et sa logique de production d'état peuvent être testées indépendamment.
  • Lisibilité : les lecteurs du code peuvent clairement voir les différences entre le code de présentation de l'UI et le code de production de l'état de l'UI.

Quels que soient leur taille ou leur champ d'application, chaque élément d'UI présente une relation individuelle avec le conteneur d'état correspondant. De plus, un conteneur d'état doit être en mesure d'accepter et de traiter toute action de l'utilisateur pouvant entraîner un changement d'état de l'UI et doit générer le changement qui s'ensuit.

Types de conteneurs d'état

À l'instar des types d'états et de logiques d'UI, il existe deux types de conteneurs d'état dans la couche d'UI définis par leur relation au cycle de vie de l'UI :

  • Conteneur d'état de logique métier
  • Conteneur d'état de logique d'UI

Les sections suivantes examinent de plus près les types de conteneurs d'état, en commençant par le conteneur d'état de la logique métier.

La logique métier et son conteneur d'état

Les conteneurs d'état de la logique métier traitent les événements utilisateur et transforment les données des couches de données ou de domaine en états d'UI. Afin d'offrir une expérience utilisateur optimale lors de la modification du cycle de vie et de la configuration de l'application Android, les conteneurs d'état qui utilisent la logique métier doivent présenter les propriétés suivantes :

Propriété Détails
Génère l'état de l'UI Les conteneurs d'état de la logique métier sont responsables de la production de l'état de leurs UI. Cet état de l'UI est souvent dû au traitement des événements utilisateur et à la lecture des données du domaine et des couches de données.
Subsiste à la recréation d'activité Les conteneurs d'état de la logique métier conservent leur état et leurs pipelines de traitement de l'état lors de la recréation d'Activity, ce qui permet d'offrir une expérience utilisateur fluide. Dans les cas où le conteneur d'état ne peut pas être conservé et est recréé (généralement une fois le processus terminé), il doit pouvoir recréer facilement son dernier état pour garantir une expérience utilisateur cohérente.
Présente un état de longue durée Les conteneurs d'état de logique métier sont souvent utilisés pour gérer l'état des destinations de navigation. Par conséquent, ils conservent souvent leur état lors des changements de navigation jusqu'à ce qu'ils soient supprimés du graphique de navigation.
Est propre à son UI et n'est pas réutilisable En général, les conteneurs d'état de logique métier génèrent un état pour une fonction spécifique de l'application, par exemple TaskEditViewModel ou TaskListViewModel, et ne s'appliquent donc qu'à cette fonction. Un même conteneur d'état peut prendre en charge ces fonctions d'application selon différents facteurs de forme. Par exemple, les versions pour mobiles, télévisions et tablettes de l'application peuvent réutiliser le même conteneur d'état de la logique métier.

Prenons l'exemple de la destination de navigation de l'auteur dans l'application Now in Android :

L&#39;application Now in Android montre comment une destination de navigation représentant une fonction d&#39;application majeure devrait avoir son propre conteneur d&#39;état de logique métier unique.
Figure 4 : Application Now in Android

Dans ce cas, en opérant en tant que conteneur d'état de logique métier, AuthorViewModel génère l'état de l'UI :

@HiltViewModel
class AuthorViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val authorsRepository: AuthorsRepository,
    newsRepository: NewsRepository
) : ViewModel() {

    val uiState: StateFlow<AuthorScreenUiState> = …

    // Business logic
    fun followAuthor(followed: Boolean) {
      …
    }
}

Notez que les attributs décrits précédemment pour AuthorViewModel sont les suivants :

Propriété Détails
Génère AuthorScreenUiState AuthorViewModel lit les données de AuthorsRepository et de NewsRepository et les utilise pour générer AuthorScreenUiState. La propriété applique également la logique métier lorsque l'utilisateur souhaite suivre ou ne plus suivre un Author en déléguant l'accès à AuthorsRepository.
A accès à la couche de données Une instance de AuthorsRepository et de NewsRepository lui est transmise dans son constructeur, ce qui lui permet d'implémenter la logique métier consistant à suivre un Author.
Survit à la recréation d'Activity Comme il est implémenté avec ViewModel, il sera conservé lors de la recréation rapide de Activity. Si le processus se termine, l'objet SavedStateHandle peut être lu pour fournir la quantité minimale d'informations requise pour restaurer l'état de l'UI à partir de la couche de données.
Présente un état de longue durée L'élément ViewModel est limité au graphique de navigation. Par conséquent, à moins que la destination de l'auteur ne soit supprimée du graphique de navigation, l'état de l'UI dans uiState StateFlow reste en mémoire. L'utilisation de StateFlow permet également de rendre l'application de la logique métier générant l'état inactive, car l'état n'est généré que s'il existe un collecteur d'état de l'UI.
Est propre à son UI AuthorViewModel ne s'applique qu'à la destination de navigation de l'auteur et ne peut pas être réutilisé ailleurs. Si une logique métier est réutilisée dans plusieurs destinations de navigation, elle doit être encapsulée dans un composant dont la portée est définie au niveau des données ou de la couche du domaine.

ViewModel en tant que conteneur d'état de logique métier

Utilisés pour le développement sur Android, les ViewModels sont adaptés pour fournir un accès à la logique métier et préparer les données de l'application pour une présentation à l'écran. Voici quelques-uns de leurs avantages :

  • Les opérations déclenchées par les ViewModels survivent aux modifications de configuration.
  • Intégration avec la navigation :
    • La navigation met en cache les ViewModels lorsque l'écran se trouve sur la pile "Retour". Il est important que vos données précédemment chargées soient immédiatement disponibles lorsque vous revenez à votre destination. Cette opération est plus difficile à effectuer avec un conteneur d'état qui suit le cycle de vie de l'écran composable.
    • Le ViewModel est également effacé lorsque la destination se détache de la pile "Retour", ce qui assure le nettoyage automatique de votre état. Cela diffère de l'écoute de la suppression du composable, qui peut se produire pour plusieurs raisons, telles que l'accès à un nouvel écran, un changement de configuration ou pour d'autres motifs.
  • Intégration à d'autres bibliothèques Jetpack telles que Hilt.

La logique de l'UI et son conteneur d'état

La logique de l'UI agit sur les données fournies par l'UI elle-même. Elle peut agir sur l'état des éléments de l'UI, ou sur des sources de données de l'UI telles que l'API des autorisations ou Resources. Les conteneurs d'état qui utilisent la logique de l'UI présentent généralement les propriétés suivantes :

  • Génèrent un état d'UI et gèrent l'état des éléments de l'UI.
  • Ne survivent pas à la recréation d'Activity : les conteneurs d'état hébergés dans la logique de l'UI dépendent souvent de sources de données de l'UI. Tenter de conserver ces informations lors des modifications de configuration provoque souvent une fuite de mémoire. Si les conteneurs d'état ont besoin de données pour persister suite aux modifications de configuration, ils doivent déléguer à un autre composant pouvant survivre à la recréation de Activity. Dans Jetpack Compose, par exemple, les états d'élément d'UI composable créés avec les fonctions remembered délèguent souvent à rememberSaveable pour conserver l'état lors de la recréation d'Activity (par exemple, rememberScaffoldState() et rememberLazyListState()).
  • Comportent des références à des sources de données limitées à l'UI : les sources de données telles que les API et les ressources de cycle de vie peuvent être référencées et lues en toute sécurité, car le conteneur d'état de la logique de l'UI a le même cycle de vie que l'UI.
  • Sont réutilisables dans plusieurs UI : différentes instances du même conteneur d'état de logique d'UI peuvent être réutilisées dans différentes parties de l'application. Par exemple, un conteneur d'état pour la gestion des événements d'entrée utilisateur pour un groupe d'icônes peut être utilisé sur une page de recherche pour les icônes de filtre, ainsi que pour le champ "À" pour les destinataires d'un e-mail.

Le conteneur d'état de la logique d'UI est généralement implémenté avec une classe simple. En effet, l'UI est elle-même responsable de la création du conteneur d'état de la logique d'UI, qui possède le même cycle de vie que la propre UI. Dans Jetpack Compose, par exemple, le conteneur d'état fait partie de la composition et suit le cycle de vie de la composition.

Cette capture d'écran de l'application Now in Android illustre la précédente explication :

Now in Android utilise un conteneur d&#39;état de classe simple pour gérer la logique de l&#39;UI
Figure 5 : Application exemple Now in Android

Dans l'exemple Now in Android, selon la taille de son écran, l'utilisateur peut naviguer à l'aide d'une barre d'application ou d'un rail de navigation situé en bas de l'écran. Les écrans plus petits utilisent la barre d'application inférieure et les grands écrans le rail de navigation.

Étant donné que la logique permettant de choisir l'élément d'UI de navigation approprié utilisé dans la fonction modulable NiaApp ne dépend pas de la logique métier, elle peut être gérée par un conteneur d'état de classe simple appelé NiaAppState :

@Stable
class NiaAppState(
    val navController: NavHostController,
    val windowSizeClass: WindowSizeClass
) {

    // UI logic
    val shouldShowBottomBar: Boolean
        get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact ||
            windowSizeClass.heightSizeClass == WindowHeightSizeClass.Compact

    // UI logic
    val shouldShowNavRail: Boolean
        get() = !shouldShowBottomBar

   // UI State
    val currentDestination: NavDestination?
        @Composable get() = navController
            .currentBackStackEntryAsState().value?.destination

    // UI logic
    fun navigate(destination: NiaNavigationDestination, route: String? = null) { /* ... */ }

     /* ... */
}

L'exemple ci-dessus contient des informations clés concernant NiaAppState :

  • Ne survit pas à la recréation d'Activity : NiaAppState est mémorisé (remembered) dans la composition en la créant avec une fonction modulable rememberNiaAppState en respectant les conventions de dénomination de Compose. Une fois Activity recréée, l'instance précédente est perdue et une nouvelle instance est créée. Les dépendances adaptées à la nouvelle configuration de l'instance Activity sont également transmises. Il peut s'agir de nouvelles dépendances, ou de dépendances restaurées à partir de la configuration précédente. Par exemple, rememberNavController() est utilisé dans le constructeur NiaAppState et délègue à rememberSaveable pour conserver l'état lors de la recréation d'Activity.
  • Comporte des références à des sources de données limitées à l'UI : les références à navigationController, Resources et à d'autres types limités au cycle de vie peuvent être conservées en toute sécurité dans NiaAppState, car elles partagent le même champ d'application du cycle de vie.

Choisir entre une classe ViewModel et une classe simple pour un conteneur d'état

Dans les sections ci-dessus, le choix entre un conteneur d'état de classe ViewModel ou simple dépend de la logique appliquée à l'état de l'UI et des sources de données sur lesquelles la logique agit.

Le diagramme ci-dessous résume l'emplacement des conteneurs d'état dans le pipeline qui génère l'état de l'UI :

Les données circulent de la couche qui génère les données vers la couche de l&#39;interface utilisateur
Figure 6 : Conteneurs d'état dans le pipeline qui génère l'état de l'UI. Les flèches représentent le flux des données.

Au final, vous devez générer l'état de l'interface utilisateur à l'aide des conteneurs d'état les plus proches de l'endroit où elle est utilisée. De manière moins formelle, vous devez maintenir l'état le plus bas possible tout en conservant la propriété appropriée. Si vous avez besoin d'accéder à la logique métier et que l'état de l'UI persiste jusqu'à ce qu'il soit possible d'accéder à un écran, même lors de la recréation d'Activity, ViewModel est un choix idéal pour implémenter le conteneur d'état de la logique métier. Pour un état et une logique d'UI à courte durée de vie, une classe simple dont le cycle de vie dépend uniquement de l'UI devrait suffire.

Combinaisons de conteneurs d'état

Les conteneurs d'état peuvent dépendre d'autres conteneurs d'état, à condition que ces dépendances aient une durée de vie égale ou inférieure. Voici quelques exemples :

  • Un conteneur d'état de logique d'UI peut dépendre d'un autre conteneur d'état de logique d'UI.
  • Un conteneur d'état de niveau écran peut dépendre d'un conteneur d'état de logique d'UI.

L'extrait de code suivant montre comment le DrawerState de Compose dépend d'un autre conteneur d'état interne, SwipeableState, et comment le conteneur d'état de la logique d'UI d'une application peut dépendre de DrawerState :

@Stable
class DrawerState(/* ... */) {
  internal val swipeableState = SwipeableState(/* ... */)
  // ...
}

@Stable
class MyAppState(
  private val drawerState: DrawerState,
  private val navController: NavHostController
) { /* ... */ }

@Composable
fun rememberMyAppState(
  drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed),
  navController: NavHostController = rememberNavController()
): MyAppState = remember(drawerState, navController) {
  MyAppState(drawerState, navController)
}

Un exemple de dépendance survivant à un conteneur d'état pourrait être le cas d'un conteneur d'état de logique d'UI qui dépendrait d'un conteneur d'état de niveau écran. Cela réduirait la réutilisabilité du conteneur d'état à courte durée de vie, et lui donnerait accès à plus de logique et d'états que nécessaire.

Si le conteneur d'état à courte durée de vie a besoin d'informations provenant d'un conteneur d'état plus étendu, transmettez uniquement les informations dont il a besoin en tant que paramètres, plutôt que de transmettre l'instance du conteneur d'état. Par exemple, dans l'extrait de code suivant, la classe du conteneur d'état de la logique d'UI reçoit uniquement ce dont elle a besoin en tant que paramètres de ViewModel, au lieu de transmettre l'intégralité de l'instance ViewModel en tant que dépendance.

class MyScreenViewModel(/* ... */) {
  val uiState: StateFlow<MyScreenUiState> = /* ... */
  fun doSomething() { /* ... */ }
  fun doAnotherThing() { /* ... */ }
  // ...
}

@Stable
class MyScreenState(
  // DO NOT pass a ViewModel instance to a plain state holder class
  // private val viewModel: MyScreenViewModel,

  // Instead, pass only what it needs as a dependency
  private val someState: StateFlow<SomeState>,
  private val doSomething: () -> Unit,

  // Other UI-scoped types
  private val scaffoldState: ScaffoldState
) {
  /* ... */
}

@Composable
fun rememberMyScreenState(
  someState: StateFlow<SomeState>,
  doSomething: () -> Unit,
  scaffoldState: ScaffoldState = rememberScaffoldState()
): MyScreenState = remember(someState, doSomething, scaffoldState) {
  MyScreenState(someState, doSomething, scaffoldState)
}

@Composable
fun MyScreen(
  modifier: Modifier = Modifier,
  viewModel: MyScreenViewModel = viewModel(),
  state: MyScreenState = rememberMyScreenState(
    someState = viewModel.uiState.map { it.toSomeState() },
    doSomething = viewModel::doSomething
  ),
  // ...
) {
  /* ... */
}

Le diagramme suivant représente les dépendances entre l'interface utilisateur et les différents conteneurs d'état de l'extrait de code précédent :

UI dépendant à la fois du conteneur d&#39;état de logique d&#39;UI et du conteneur d&#39;état de niveau écran
Figure 7 : UI dépendant de différents conteneurs d'état. Les flèches indiquent les dépendances.

Exemples

Les exemples Google suivants illustrent l'utilisation de conteneurs d'état dans la couche de l'interface utilisateur. Parcourez-les pour voir ces conseils en pratique :