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 update comme d'habitude.
  • Lorsque vous utilisez l'état Compose, utilisez Snapshot.withMutableSnapshot pour garantir la mise à jour atomique de l'état dans le contexte simultané.

Par exemple, supposons que, dans l'extrait DiceRollViewModel ci-dessous, SlowRandom.nextInt() soit une fonction suspend qui consomme beaucoup de ressources de calcul et doive être appelée à partir d'une coroutine liée au processeur.

StateFlow

class DiceRollViewModel(
    private val defaultDispatcher: CoroutineScope = Dispatchers.Default
) : ViewModel() {

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

  // Called from the UI
  fun rollDice() {
        viewModelScope.launch() {
            // Other Coroutines that may be called from the current context
            …
            withContext(defaultDispatcher) {
                _uiState.update { currentState ->
                    currentState.copy(
                        firstDieValue = SlowRandom.nextInt(from = 1, until = 7),
                        secondDieValue = SlowRandom.nextInt(from = 1, until = 7),
                        numberOfRolls = currentState.numberOfRolls + 1,
                    )
                }
            }
        }
    }
}

État de Compose

class DiceRollViewModel(
    private val defaultDispatcher: CoroutineScope = Dispatchers.Default
) : ViewModel() {

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

  // Called from the UI
  fun rollDice() {
        viewModelScope.launch() {
            // Other Coroutines that may be called from the current context
            …
            withContext(defaultDispatcher) {
                Snapshot.withMutableSnapshot {
                    _uiState.firstDieValue = SlowRandom.nextInt(from = 1, until = 7)
                    _uiState.secondDieValue = SlowRandom.nextInt(from = 1, until = 7)
                    _uiState.numberOfRolls = _uiState.numberOfRolls + 1
                }
            }
        }
    }
}

API de flux comme sources de changement d'état

Pour les sources de changement d'état qui produisent plusieurs valeurs au fil du temps dans des flux, une approche simple pour produire des états consiste à agréger les sorties de toutes les sources dans un ensemble cohérent.

Lorsque vous utilisez des flux Kotlin, vous pouvez utiliser la fonction combine. Vous trouverez un exemple de cela dans l'exemple "En ce moment sur Android" de InterestsViewModel :

class InterestsViewModel(
    authorsRepository: AuthorsRepository,
    topicsRepository: TopicsRepository
) : ViewModel() {

    val uiState = combine(
        authorsRepository.getAuthorsStream(),
        topicsRepository.getTopicsStream(),
    ) { availableAuthors, availableTopics ->
        InterestsUiState.Interests(
            authors = availableAuthors,
            topics = availableTopics
        )
    }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = InterestsUiState.Loading
    )
}

L'utilisation de l'opérateur stateIn pour créer StateFlows permet à l'interface utilisateur de contrôler plus précisément l'activité du pipeline de production d'état. En effet, il se peut qu'il doive être actif uniquement lorsque l'interface utilisateur est visible.

  • Utilisez SharingStarted.WhileSubscribed() si le pipeline ne doit être actif que lorsque l'interface utilisateur est visible, tout en collectant le flux en tenant compte du cycle de vie.
  • Utilisez SharingStarted.Lazily si le pipeline doit être actif tant que l'utilisateur peut revenir à l'interface utilisateur, c'est-à-dire si celle-ci se trouve sur la pile "Retour" ou dans un autre onglet hors écran.

Dans les cas où l'agrégation de sources d'état basées sur des flux ne s'applique pas, les API de flux telles que les flux Kotlin offrent un ensemble riche de transformations, comme la fusion, l'aplatissement, etc. pour traiter plus facilement les flux dans l'état de l'interface utilisateur.

API ponctuelles et de flux comme sources de changement d'état

Dans le cas où le pipeline de production d'état repose à la fois sur des appels ponctuels et des flux comme sources de changement d'état, les flux constituent la contrainte clé. Par conséquent, convertissez les appels ponctuels en API de flux ou agrégez leur sortie en flux, puis reprenez le traitement comme décrit dans la section "Flux" ci-dessus.

Dans le cas des flux, cela implique généralement de créer une ou plusieurs instances MutableStateFlow de sauvegarde privées pour propager les changements d'état. Vous pouvez également créer des flux d'instantanés à partir de l'état de Compose.

Examinez le TaskDetailViewModel du dépôt architecture-samples ci-dessous :

StateFlow

class TaskDetailViewModel @Inject constructor(
    private val tasksRepository: TasksRepository,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    private val _isTaskDeleted = MutableStateFlow(false)
    private val _task = tasksRepository.getTaskStream(taskId)

    val uiState: StateFlow<TaskDetailUiState> = combine(
        _isTaskDeleted,
        _task
    ) { isTaskDeleted, task ->
        TaskDetailUiState(
            task = taskAsync.data,
            isTaskDeleted = isTaskDeleted
        )
    }
        // Convert the result to the appropriate observable API for the UI
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = TaskDetailUiState()
        )

    fun deleteTask() = viewModelScope.launch {
        tasksRepository.deleteTask(taskId)
        _isTaskDeleted.update { true }
    }
}

État de Compose

class TaskDetailViewModel @Inject constructor(
    private val tasksRepository: TasksRepository,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    private var _isTaskDeleted by mutableStateOf(false)
    private val _task = tasksRepository.getTaskStream(taskId)

    val uiState: StateFlow<TaskDetailUiState> = combine(
        snapshotFlow { _isTaskDeleted },
        _task
    ) { isTaskDeleted, task ->
        TaskDetailUiState(
            task = taskAsync.data,
            isTaskDeleted = isTaskDeleted
        )
    }
        // Convert the result to the appropriate observable API for the UI
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = TaskDetailUiState()
        )

    fun deleteTask() = viewModelScope.launch {
        tasksRepository.deleteTask(taskId)
        _isTaskDeleted = true
    }
}

Types de sorties dans les pipelines de production d'état

Le choix de l'API de sortie pour l'état de l'interface utilisateur et la nature de sa présentation dépendent en grande partie de l'API dont votre application se sert pour afficher l'interface utilisateur. Dans les applications Android, vous pouvez utiliser Views ou Jetpack Compose. Les considérations incluent ici :

Le tableau suivant présente les API à utiliser pour votre pipeline de production d'état pour chaque entrée et consommateur :

Entrée Consommateur Sortie
API ponctuelles Vues StateFlow ou LiveData
API ponctuelles Compose StateFlow ou State Compose
API de flux Vues StateFlow ou LiveData
API de flux Compose StateFlow
API ponctuelles et de flux Vues StateFlow ou LiveData
API ponctuelles et de flux Compose StateFlow

Initialisation du pipeline de production d'état

L'initialisation du pipeline de production d'état implique de définir les conditions initiales d'exécution du pipeline. Cela peut nécessiter de fournir des valeurs d'entrée initiales critiques pour le démarrage du pipeline, par exemple un id pour la vue détaillée d'un article d'actualités, ou de démarrer un chargement asynchrone.

Si possible, vous devez initialiser le pipeline de production d'état en différé afin de conserver les ressources système. Dans la pratique, cela implique souvent d'attendre qu'il y ait un consommateur pour la sortie. Les API Flow autorisent cette opération avec l'argument started dans la méthode stateIn. Dans les cas non applicables, définissez une fonction initialize() idempotente pour démarrer explicitement le pipeline de production d'état, comme indiqué dans l'extrait de code suivant:

class MyViewModel : ViewModel() {

    private var initializeCalled = false

    // This function is idempotent provided it is only called from the UI thread.
    @MainThread
    fun initialize() {
        if(initializeCalled) return
        initializeCalled = true

        viewModelScope.launch {
            // seed the state production pipeline
        }
    }
}

Exemples

Les exemples Google suivants illustrent la génération d'un état dans la couche d'interface utilisateur. Parcourez-les pour voir ces conseils en pratique :