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](https://developer.android.com/static/images/topic/architecture/ui-layer/events-vs-state.png?authuser=9&hl=fr)
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
surDrawerState
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.
- 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
- 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](https://developer.android.com/static/images/topic/architecture/ui-layer/state-production-pipeline.png?authuser=9&hl=fr)
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éthodeupdate
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 combiner . 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 :
- La lecture de l'état en tenant compte du cycle de vie
- La nécessité ou non d'afficher l'état dans un ou plusieurs champs à partir du conteneur d'état
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
le permettent avec le
Argument started
dans stateIn
. Dans les cas où cela n'est pas applicable,
définir un idempotent ;
La fonction initialize()
pour démarrer explicitement le pipeline de production d'état
comme indiqué dans l'extrait 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 :
Recommandations personnalisées
- Remarque : Le texte du lien s'affiche lorsque JavaScript est désactivé
- Couche d'interface utilisateur
- Créer une application orientée hors connexion
- Conteneurs d'état et état de l'interface utilisateur {:#mad-arch}