Où hisser l'état

Dans une application Compose, l'endroit où vous hissez l'état de l'UI varie selon que c'est la logique d'UI ou la logique métier qui l'exige. Ce document présente ces deux scénarios principaux.

Bonne pratique

Vous devez hisser l'état de l'UI sur le plus petit ancêtre commun entre tous les composables qui le lisent et l'écrivent. Vous devez garder l'état le plus près possible de l'endroit où il est utilisé. À partir du propriétaire de l'état, présentez aux consommateurs l'état immuable et les événements pour le modifier.

Le plus petit ancêtre commun peut également se trouver en dehors de la composition. C'est le cas, par exemple, lors du hissage d'état (hoisting) dans un ViewModel, car la logique métier est impliquée.

Cette page présente cette bonne pratique en détail et contient une mise en garde dont vous devez tenir compte.

Types d'état et de logique d'UI

Vous trouverez ci-dessous les définitions des types d'état et de logique d'UI utilisés tout au long de ce document.

État de l'UI

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

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.

Logique d'UI

Lorsque la logique d'UI doit lire ou écrire un état, vous devez limiter l'état à l'UI, suivant son cycle de vie. Pour ce faire, vous devez hisser l'état au niveau approprié dans une fonction modulable. Vous pouvez également effectuer cette opération dans une classe de conteneur d'état simple, également limitée au cycle de vie de l'UI.

Vous trouverez ci-dessous une description des deux solutions, ainsi qu'une explication pour savoir quand les utiliser.

Composables en tant que propriétaire d'état

Il est recommandé d'avoir une logique d'UI et un état d'élément d'UI dans les composables si l'état et la logique sont simples. Vous pouvez laisser votre état interne à un composable ou le hisser si nécessaire.

Aucun hissage d'état nécessaire

Le hissage d'état n'est pas toujours obligatoire. L'état peut être conservé en interne dans un composable si aucun autre composable n'a besoin de le contrôler. Cet extrait contient un composable qui est développé et qui est réduit lorsque l'utilisateur appuie dessus :

@Composable
fun ChatBubble(
    message: Message
) {
    var showDetails by rememberSaveable { mutableStateOf(false) } // Define the UI element expanded state

    ClickableText(
        text = AnnotatedString(message.content),
        onClick = { showDetails = !showDetails } // Apply simple UI logic
    )

    if (showDetails) {
        Text(message.timestamp)
    }
}

La variable showDetails est l'état interne de cet élément d'interface utilisateur. Il n'est lu et modifié que dans ce composable, et la logique qui lui est appliquée est très simple. Hisser l'état dans ce cas n'apporterait donc aucun avantage. Vous pouvez donc le laisser en interne. Ce composable devient ainsi le propriétaire et la source d'informations unique de l'état développé.

Hisser dans des composables

Si vous devez partager l'état de votre élément d'interface utilisateur avec d'autres composables et y appliquer la logique d'UI à différents endroits, vous pouvez le hisser plus haut dans la hiérarchie de l'UI. Cela facilitera également la réutilisation et les tests de vos composables.

L'exemple suivant est une application de chat qui implémente deux fonctionnalités :

  • Le bouton JumpToBottom fait défiler la liste des messages jusqu'en bas. Le bouton effectue la logique d'UI sur l'état de la liste.
  • La liste MessagesList défile vers le bas après que l'utilisateur a envoyé de nouveaux messages. UserInput effectue la logique d'UI sur l'état de la liste.
Application de chat avec un bouton JumpToBottom et défilement vers le bas pour les nouveaux messages
Image 1. Application Chat avec un bouton JumpToBottom et défilement vers le bas pour les nouveaux messages

La hiérarchie de composables est la suivante :

Arborescence des composables du chat
Figure 2 : Arborescence des composables du chat

L'état LazyColumn est hissé dans l'écran de conversation afin que l'application puisse effectuer la logique d'UI et lire l'état de tous les composables qui en ont besoin :

Hisser l'état LazyColumn de LazyColumn vers ConversationScreen
Figure 3. Hisser l'état de LazyColumn de LazyColumn vers ConversationScreen

Les composables sont donc les suivants :

Arborescence des composables du chat avec LazyListState hissé sur ConversationScreen
Figure 4. Arborescence des composables Chat avec LazyListState hissé vers ConversationScreen

Le code est le suivant :

@Composable
private fun ConversationScreen(/*...*/) {
    val scope = rememberCoroutineScope()

    val lazyListState = rememberLazyListState() // State hoisted to the ConversationScreen

    MessagesList(messages, lazyListState) // Reuse same state in MessageList

    UserInput(
        onMessageSent = { // Apply UI logic to lazyListState
            scope.launch {
                lazyListState.scrollToItem(0)
            }
        },
    )
}

@Composable
private fun MessagesList(
    messages: List<Message>,
    lazyListState: LazyListState = rememberLazyListState() // LazyListState has a default value
) {

    LazyColumn(
        state = lazyListState // Pass hoisted state to LazyColumn
    ) {
        items(messages, key = { message -> message.id }) { item ->
            Message(/*...*/)
        }
    }

    val scope = rememberCoroutineScope()

    JumpToBottom(onClicked = {
        scope.launch {
            lazyListState.scrollToItem(0) // UI logic being applied to lazyListState
        }
    })
}

LazyListState est hissé jusqu'au niveau requis pour la logique d'UI qui doit être appliquée. Étant donné qu'il est initialisé dans une fonction modulable, il est stocké dans la composition, suivant son cycle de vie.

Notez que lazyListState est défini dans la méthode MessagesList, avec la valeur par défaut rememberLazyListState(). Il s'agit d'un modèle courant dans Compose. Cela rend les composables plus flexibles et plus faciles à réutiliser. Vous pouvez ensuite utiliser le composable dans différentes parties de l'application qui ne doivent peut-être pas contrôler l'état. C'est généralement le cas lorsque vous testez ou prévisualisez un composable. C’est exactement ainsi LazyColumn définit son état.

Le plus petit ancêtre commun de LazyListState est ConversationScreen.
Figure 5. Le plus petit ancêtre commun de LazyListState est ConversationScreen

Classe de conteneur d'état simple en tant que propriétaire d'état

Lorsqu'un composable contient une logique d'UI complexe impliquant un ou plusieurs champs d'état d'un élément d'interface utilisateur, il doit déléguer cette responsabilité à des conteneurs d'état, tels qu'une classe de conteneur d'état simple. Cela permet de tester plus facilement la logique du composable de manière isolée et réduit la complexité du composable. Cette approche favorise le principe de séparation des préoccupations : le composable est responsable de l'émission des éléments d'interface utilisateur, tandis que le conteneur d'état inclut la logique d'UI et l'état des éléments d'interface utilisateur.

Les classes de conteneur d'état simples fournissent des fonctions pratiques aux appelants de votre fonction modulable. Ainsi, ils n'ont pas à écrire cette logique eux-mêmes.

Ces classes simples sont créées et mémorisées dans la composition. Étant donné qu'elles suivent le cycle de vie du composable, elles peuvent prendre les types fournis par la bibliothèque Compose, tels que rememberNavController() ou rememberLazyListState().

La classe de conteneur d'état simple LazyListState en est un bon exemple. Elle est implémentée dans Compose pour contrôler la complexité d'UI de LazyColumn ou LazyRow.

// LazyListState.kt

@Stable
class LazyListState constructor(
    firstVisibleItemIndex: Int = 0,
    firstVisibleItemScrollOffset: Int = 0
) : ScrollableState {
    /**
     *   The holder class for the current scroll position.
     */
    private val scrollPosition = LazyListScrollPosition(
        firstVisibleItemIndex, firstVisibleItemScrollOffset
    )

    suspend fun scrollToItem(/*...*/) { /*...*/ }

    override suspend fun scroll() { /*...*/ }

    suspend fun animateScrollToItem() { /*...*/ }
}

LazyListState encapsule l'état de la LazyColumn qui stocke le scrollPosition pour cet élément d'interface utilisateur. Il présente également des méthodes permettant de modifier la position de défilement, par exemple en faisant défiler la page jusqu'à un élément donné.

Comme vous pouvez le constater, l'augmentation des responsabilités d'un composable rend nécessaire le recours à un conteneur d'état. Les responsabilités peuvent être liées à la logique d'UI ou simplement à la quantité d'états à suivre.

Un autre modèle courant consiste à utiliser une classe de conteneur d'état simple pour gérer la complexité des fonctions modulables racines dans l'application. Vous pouvez utiliser une telle classe pour encapsuler l'état au niveau de l'application, comme l'état de navigation et le dimensionnement de l'écran. Vous trouverez une description complète de ce processus sur la page Logique et conteneur d'état de l'interface utilisateur.

Logique métier

Si des classes de conteneurs d'état simples et des composables sont responsables de la logique d'UI et de l'état des éléments d'interface utilisateur, un conteneur d'état au niveau de l'écran prend en charge les tâches suivantes :

  • Fournir un accès à la logique métier de l'application, qui est généralement placée dans d'autres couches de la hiérarchie, comme les couches métier et de données.
  • Préparer les données d'application pour la présentation dans un écran particulier, qui devient l'état d'UI de l'écran.

ViewModels en tant que propriétaire d'état

Compte tenu de leurs avantages pour le développement sur Android, les ViewModels AAC 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.

Lorsque vous hissez l'état de l'UI dans ViewModel, vous le déplacez en dehors de la composition.

L&#39;état hissé dans ViewModel est stocké en dehors de la composition.
Figure 6 : L'état hissé dans ViewModel est stocké en dehors de la composition.

Les ViewModels ne sont pas stockés dans la composition. Ils sont fournis par le framework et limités à un ViewModelStoreOwner, qui peut être une activité, un fragment, un graphique de navigation ou la destination d'un graphique de navigation. Pour en savoir plus sur les champs d'application ViewModel, consultez la documentation.

Ainsi, ViewModel est la source de référence et le plus petit ancêtre commun pour l'état d'UI.

État d'UI de l'écran

Conformément aux définitions ci-dessus, l'état d'UI de l'écran est obtenu en appliquant des règles métier. Étant donné que le conteneur d'état au niveau de l'écran en est responsable, cela signifie que l'état d'UI de l'écran est généralement hissé dans ce conteneur qui, dans ce cas, est un ViewModel.

Examinons le ConversationViewModel d'une application de chat, et la façon dont il expose l'état d'UI de l'écran et les événements pour y apporter des modifications :

class ConversationViewModel(
    channelId: String,
    messagesRepository: MessagesRepository
) : ViewModel() {

    val messages = messagesRepository
        .getLatestMessages(channelId)
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = emptyList()
        )

    // Business logic
    fun sendMessage(message: Message) { /* ... */ }
}

Les composables consomment l'état d'UI de l'écran hissé dans le ViewModel. Vous devez injecter l'instance ViewModel dans vos composables au niveau de l'écran pour fournir un accès à la logique métier.

Voici un exemple de ViewModel utilisé dans un composable au niveau de l'écran. Dans ce cas, le composable ConversationScreen() utilise l'état d'UI de l'écran hissé dans ViewModel :

@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)
    /* ... */
}

Exploration de propriétés

L'exploration de propriétés désigne la transmission de données via plusieurs composants enfants imbriqués jusqu'à l'emplacement où ils sont lus.

Dans Compose, cela peut se produire, par exemple, lorsque vous injectez le conteneur d'état au niveau de l'écran au niveau supérieur, et transmettez l'état et les événements aux composables enfants. Cela peut également générer une surcharge de signatures de fonctions modulables.

Bien qu'exposer des événements en tant que paramètres lambda individuels puisse surcharger la signature de la fonction, cela permet d'optimiser la visibilité des responsabilités de la fonction modulable. Vous pouvez voir un aperçu de son fonctionnement.

Il est préférable d'utiliser l'exploration de propriétés plutôt que de créer des classes wrapper pour encapsuler l'état et les événements au même endroit, car cela réduit la visibilité des responsabilités des composables. En l'absence de classes wrapper, vous aurez également plus de chances de ne transmettre aux composables que les paramètres dont ils ont besoin, ce qui constitue une bonne pratique.

La même bonne pratique s'applique s'il s'agit d'événements de navigation. Pour en savoir plus à ce sujet, consultez la documentation sur la navigation.

Si vous avez identifié un problème de performances, vous pouvez également choisir de différer la lecture de l'état. Pour en savoir plus, consultez la documentation sur les performances.

État de l'élément d'interface utilisateur

Vous pouvez hisser l'état de l'élément d'interface utilisateur dans le conteneur d'état au niveau de l'écran si une logique métier doit le lire ou l'écrire.

Reprenons l'exemple de l'application de chat. Cette application affiche des suggestions d'utilisateurs dans un chat de groupe lorsque l'utilisateur saisit @ et un indice. Ces suggestions proviennent de la couche de données, et la logique permettant de calculer une liste de suggestions d'utilisateurs est considérée comme une logique métier. Cette fonctionnalité se présente comme suit :

Fonctionnalité qui affiche des suggestions d&#39;utilisateurs dans un chat de groupe lorsque l&#39;utilisateur saisit &quot;@&quot; et un indice
Figure 7 : Fonctionnalité qui affiche des suggestions d'utilisateurs dans un chat de groupe lorsque l'utilisateur saisit @ et un indice

Le ViewModel qui implémente cette fonctionnalité se présenterait comme suit :

class ConversationViewModel(/*...*/) : ViewModel() {

    // Hoisted state
    var inputMessage by mutableStateOf("")
        private set

    val suggestions: StateFlow<List<Suggestion>> =
        snapshotFlow { inputMessage }
            .filter { hasSocialHandleHint(it) }
            .mapLatest { getHandle(it) }
            .mapLatest { repository.getSuggestions(it) }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = emptyList()
            )

    fun updateInput(newInput: String) {
        inputMessage = newInput
    }
}

inputMessage est une variable qui stocke l'état TextField. Chaque fois que l'utilisateur saisit une nouvelle entrée, l'application appelle la logique métier pour produire suggestions.

suggestions est l'état d'UI de l'écran. Cette variable est utilisée à partir de l'interface utilisateur de Compose en collectant des données depuis StateFlow.

Avertissement

Pour certains états d'élément d'interface utilisateur de Compose, le hissage vers ViewModel peut nécessiter une attention particulière. Par exemple, certains conteneurs d'état d'éléments d'UI Compose exposent des méthodes pour modifier l'état. Certaines d'entre elles peuvent être des fonctions de suspension qui déclenchent des animations. Ces fonctions de suspension peuvent générer des exceptions si vous les appelez à partir d'un CoroutineScope qui ne s'applique pas à la composition.

Supposons que le contenu du panneau des applications soit dynamique, et que vous deviez le récupérer et l'actualiser à partir de la couche de données après sa fermeture. Vous devez hisser l'état du panneau vers ViewModel afin de pouvoir appeler à la fois la logique métier et d'UI sur cet élément à partir du propriétaire d'état.

Toutefois, appeler la méthode close() de DrawerState à l'aide de viewModelScope à partir de l'UI de Compose génère une exception d'exécution de type IllegalStateException avec le message "Un MonotonicFrameClock n'est pas disponible dans ce CoroutineContext”".

Pour résoudre ce problème, utilisez un CoroutineScope limité à la composition. Il fournit un MonotonicFrameClock dans le CoroutineContext nécessaire au fonctionnement des fonctions de suspension.

Pour corriger ce problème, remplacez le CoroutineContext de la coroutine dans ViewModel par celui qui est limité à la composition. Cela peut se présenter comme suit :

class ConversationViewModel(/*...*/) : ViewModel() {

    val drawerState = DrawerState(initialValue = DrawerValue.Closed)

    private val _drawerContent = MutableStateFlow(DrawerContent.Empty)
    val drawerContent: StateFlow<DrawerContent> = _drawerContent.asStateFlow()

    fun closeDrawer(uiScope: CoroutineScope) {
        viewModelScope.launch {
            withContext(uiScope.coroutineContext) { // Use instead of the default context
                drawerState.close()
            }
            // Fetch drawer content and update state
            _drawerContent.update { content }
        }
    }
}

// in Compose
@Composable
private fun ConversationScreen(
    conversationViewModel: ConversationViewModel = viewModel()
) {
    val scope = rememberCoroutineScope()

    ConversationScreen(onCloseDrawer = { conversationViewModel.closeDrawer(uiScope = scope) })
}

En savoir plus

Pour en savoir plus sur l'état et Jetpack Compose, consultez les ressources supplémentaires suivantes.

Exemples

Ateliers de programmation

Vidéos