Production de l'état de l'interface utilisateur

Les interfaces utilisateur modernes sont rarement statiques. L'état de l'interface utilisateur change lorsque l'utilisateur interagit avec ou lorsque l'application doit afficher de nouvelles données.

Ce document présente diverses consignes concernant la production et la gestion de l'état de l'interface utilisateur. À la fin de ce module :

  • vous saurez quelles API utiliser pour produire l'état de l'interface utilisateur (varie selon la nature des sources de changement d'état disponibles dans vos conteneurs d'état, conformément aux principes du flux de données unidirectionnel) ;
  • vous saurez comment déterminer la portée de la production de l'état de l'interface utilisateur pour tenir compte des ressources système ;
  • saurez comment exposer l'état de l'interface utilisateur utilisé par l'UI.

Fondamentalement, la production d'états consiste en l'application progressive de ces modifications à l'état de l'interface utilisateur. L'état existe toujours et change en fonction des événements. Le tableau ci-dessous récapitule les différences entre les événements et les états :

Événements État
Temporaire, imprévisible et existe pour une durée limitée. Existe toujours.
Entrées de la production d'état. Résultat de la production d'état.
Produit de l'interface utilisateur ou d'autres sources. Est utilisée par l'UI.

Pour résumer, un état est, un événement se produit. Le schéma ci-dessous permet de visualiser la façon dont un état change à mesure que des événements se produisent. Chaque événement est traité par le conteneur d'état approprié et entraîne un changement d'état :

Événements et état
Figure 1 : Les événements provoquent un changement d'état

Les événements peuvent provenir des sources suivantes :

  • Utilisateurs : lorsqu'ils interagissent avec l'interface utilisateur de l'application.
  • Autres sources de changement d'état : API qui présentent des données d'application à partir des couches de l'interface utilisateur, de domaine ou de données, comme les événements de délai d'inactivité de la snackbar, les cas d'utilisation ou les dépôts, respectivement.

Pipeline de production de l'état de l'interface utilisateur

La production d'état dans les applications Android peut être vue comme un pipeline de traitement comprenant les éléments suivants :

  • Entrées : les sources du changement d'état. Elles peuvent être :
    • Locales, au niveau de l'interface utilisateur : il peut s'agir d'événements utilisateur, par exemple la saisie par l'utilisateur d'un titre pour une tâche à effectuer dans une application de gestion des tâches, ou d'API donnant accès à une logique d'interface utilisateur qui entraînent des changements de l'état de l'interface utilisateur. C'est par exemple le cas de l'appel de la méthode open sur DrawerState dans Jetpack Compose.
    • Externes à la couche d'interface utilisateur : il s'agit des sources provenant de la couche de domaine ou de données qui entraînent des changements de l'état de l'interface utilisateur. Par exemple, des actualités qui ont fini de se charger à partir d'un NewsRepository ou d'autres événements.
    • Un mélange de ces sources.
  • Conteneurs d'état : types qui appliquent une logique métier et/ou une logique d'interface utilisateur aux sources de changement d'état et traitent les événements utilisateur pour générer l'état de l'interface utilisateur.
  • Résultat : état de l'interface utilisateur que l'application peut afficher pour fournir aux utilisateurs les informations dont ils ont besoin.
Pipeline de production d'état
Figure 2 : Pipeline de production d'état
.

API de production d'état

Deux API principales sont utilisées pour la production d'état, en fonction de l'étape du pipeline :

Étape du pipeline API
Entrée Vous devez utiliser des API asynchrones pour effectuer des tâches en dehors du thread UI pour que l'interface utilisateur ne présente pas d'à-coups (coroutines ou flux en Kotlin, et RxJava ou rappels en Java, par exemple).
Sortie Vous devez utiliser des API de conteneurs de données observables pour invalider et réafficher l'interface utilisateur lorsque l'état change (StateFlow, état Compose ou LiveData, par exemple). Les conteneurs de données observables permettent de s'assurer que l'UI a toujours un état à afficher à l'écran.

Des deux, choisir l'API asynchrone pour les entrées impacte davantage la nature du pipeline de production d'état que choisir l'API observable pour la sortie. En effet, les entrées dictent le type de traitement qui peut être appliqué au pipeline.

Assemblage du pipeline de production d'état

Les sections suivantes présentent les techniques de production d'état les plus adaptées à différentes entrées et les API de sortie correspondantes. Chaque pipeline de production d'état est une combinaison d'entrées et de sorties et doit être :

  • Sensible au cycle de vie : dans le cas où l'interface utilisateur n'est pas visible ou active, le pipeline de production d'état ne doit consommer aucune ressource, sauf si cela est explicitement requis.
  • Facile à utiliser : l'interface utilisateur doit pouvoir facilement afficher l'état de l'interface utilisateur produit. Les considérations liées à la sortie du pipeline de production d'état varient selon les API d'affichage telles que le système View ou Jetpack Compose.

Entrées des pipelines de production d'état

Les entrées d'un pipeline de production d'état peuvent fournir leurs sources de changement d'état via :

  • des opérations ponctuelles, qui peuvent être synchrones ou asynchrones, par exemple des appels aux fonctions suspend ;
  • des API de flux, par exemple Flows ;
  • un mélange de ces différentes méthodes.

Les sections suivantes expliquent comment assembler un pipeline de production d'état pour chacune des entrées ci-dessus.

API ponctuelles comme sources de changement d'état

Utilisez l'API MutableStateFlow comme conteneur d'état observable et modifiable. Dans les applications Jetpack Compose, vous pouvez également envisager d'utiliser mutableStateOf, en particulier lorsque vous utilisez les API textuelles de Compose. Les deux API proposent des méthodes permettant de mettre à jour de manière sécurisée et atomique les valeurs qu'elles hébergent, de façon synchrone ou asynchrone.

Prenons l'exemple de mises à jour d'état dans une application simple de lancer de dés. Chaque lancer de dés de l'utilisateur appelle la méthode synchrone Random.nextInt(), et le résultat est écrit dans l'état de l'interface utilisateur.

StateFlow

data class DiceUiState(
    val firstDieValue: Int? = null,
    val secondDieValue: Int? = null,
    val numberOfRolls: Int = 0,
)

class DiceRollViewModel : ViewModel() {

    private val _uiState = MutableStateFlow(DiceUiState())
    val uiState: StateFlow<DiceUiState> = _uiState.asStateFlow()

    // Called from the UI
    fun rollDice() {
        _uiState.update { currentState ->
            currentState.copy(
            firstDieValue = Random.nextInt(from = 1, until = 7),
            secondDieValue = Random.nextInt(from = 1, until = 7),
            numberOfRolls = currentState.numberOfRolls + 1,
            )
        }
    }
}

État de Compose

@Stable
interface DiceUiState {
    val firstDieValue: Int?
    val secondDieValue: Int?
    val numberOfRolls: Int?
}

private class MutableDiceUiState: DiceUiState {
    override var firstDieValue: Int? by mutableStateOf(null)
    override var secondDieValue: Int? by mutableStateOf(null)
    override var numberOfRolls: Int by mutableStateOf(0)
}

class DiceRollViewModel : ViewModel() {

    private val _uiState = MutableDiceUiState()
    val uiState: DiceUiState = _uiState

    // Called from the UI
    fun rollDice() {
        _uiState.firstDieValue = Random.nextInt(from = 1, until = 7)
        _uiState.secondDieValue = Random.nextInt(from = 1, until = 7)
        _uiState.numberOfRolls = _uiState.numberOfRolls + 1
    }
}

Modification de l'état de l'interface utilisateur à partir d'appels asynchrones

Pour les changements d'état qui nécessitent un résultat asynchrone, lancez une coroutine dans le CoroutineScope approprié. L'application peut ainsi supprimer le travail lorsque le CoroutineScope est annulé. Le conteneur d'état écrit ensuite le résultat de l'appel de méthode de suspension dans l'API observable utilisée pour fournir l'état de l'interface utilisateur.

Prenons l'exemple de AddEditTaskViewModel dans l'exemple d'architecture. Lorsque la méthode de suspension saveTask() enregistre une tâche de manière asynchrone, la méthode update sur MutableStateFlow propage le changement d'état à l'état de l'interface utilisateur.

StateFlow

data class AddEditTaskUiState(
    val title: String = "",
    val description: String = "",
    val isTaskCompleted: Boolean = false,
    val isLoading: Boolean = false,
    val userMessage: String? = null,
    val isTaskSaved: Boolean = false
)

class AddEditTaskViewModel(...) : ViewModel() {

   private val _uiState = MutableStateFlow(AddEditTaskUiState())
   val uiState: StateFlow<AddEditTaskUiState> = _uiState.asStateFlow()

   private fun createNewTask() {
        viewModelScope.launch {
            val newTask = Task(uiState.value.title, uiState.value.description)
            try {
                tasksRepository.saveTask(newTask)
                // Write data into the UI state.
                _uiState.update {
                    it.copy(isTaskSaved = true)
                }
            }
            catch(cancellationException: CancellationException) {
                throw cancellationException
            }
            catch(exception: Exception) {
                _uiState.update {
                    it.copy(userMessage = getErrorMessage(exception))
                }
            }
        }
    }
}

État de Compose

@Stable
interface AddEditTaskUiState {
    val title: String
    val description: String
    val isTaskCompleted: Boolean
    val isLoading: Boolean
    val userMessage: String?
    val isTaskSaved: Boolean
}

private class MutableAddEditTaskUiState : AddEditTaskUiState() {
    override var title: String by mutableStateOf("")
    override var description: String by mutableStateOf("")
    override var isTaskCompleted: Boolean by mutableStateOf(false)
    override var isLoading: Boolean by mutableStateOf(false)
    override var userMessage: String? by mutableStateOf<String?>(null)
    override var isTaskSaved: Boolean by mutableStateOf(false)
}

class AddEditTaskViewModel(...) : ViewModel() {

   private val _uiState = MutableAddEditTaskUiState()
   val uiState: AddEditTaskUiState = _uiState

   private fun createNewTask() {
        viewModelScope.launch {
            val newTask = Task(uiState.value.title, uiState.value.description)
            try {
                tasksRepository.saveTask(newTask)
                // Write data into the UI state.
                _uiState.isTaskSaved = true
            }
            catch(cancellationException: CancellationException) {
                throw cancellationException
            }
            catch(exception: Exception) {
                _uiState.userMessage = getErrorMessage(exception))
            }
        }
    }
}

Modification de l'état de l'interface utilisateur à partir de threads en arrière-plan

Il est préférable de lancer des coroutines sur le coordinateur principal pour la production de l'état de l'interface utilisateur. Autrement dit, en dehors du bloc withContext dans les extraits de code ci-dessous. Toutefois, si vous devez mettre à jour l'état de l'interface utilisateur dans un autre contexte d'arrière-plan, vous pouvez le faire à l'aide des API suivantes :

  • Utilisez la méthode withContext pour exécuter des coroutines dans un autre contexte simultané.
  • Lorsque vous utilisez MutableStateFlow, utilisez la méthode