Concepts et implémentation de Jetpack Compose
Les événements de l'UI sont des actions qui doivent être gérées dans la couche d'UI, que ce soit par l'UI ou par le ViewModel. Les événements les plus courants sont des événements utilisateur. L'utilisateur produit des événements utilisateur en interagissant avec l'application, par exemple en appuyant sur l'écran ou en faisant des gestes. L'UI exploite ensuite ces événements à l'aide de rappels comme les écouteurs onClick().
ViewModel gère en principe la logique métier d'un événement utilisateur spécifique (par exemple, l'utilisateur qui clique sur un bouton pour actualiser certaines données). En général, ViewModel gère cette opération en proposant des fonctions que l'UI peut appeler. Les événements utilisateur peuvent également avoir une logique de comportement d'UI que l'interface utilisateur peut gérer directement, comme la navigation vers un autre écran ou l'affichage d'un Snackbar.
Bien que la logique métier reste identique pour la même application sur différents plates-formes mobiles ou facteurs de forme, la logique de comportement de l'UI est un détail d'intégration qui peut varier selon les cas. La page de la couche de l'UI définit ces types de logiques comme suit :
- La logique métier fait référence à la procédure à suivre en cas de changement d'état (par exemple, effectuer un paiement ou stocker des préférences utilisateur). En général, le domaine et les couches de données gèrent cette logique. Dans ce guide, la classe ViewModel des composants de l'architecture est utilisée comme solution catégorique pour les classes qui gèrent la logique métier.
- La logique de comportement de l'UI, ou logique de l'UI, désigne la manière d'afficher les changements d'état (par exemple, la logique de navigation ou la manière de montrer des messages à l'utilisateur). L'UI gère cette logique.
Gérer les événements utilisateur
L'UI peut directement gérer les événements utilisateur si ceux-ci concernent la modification de l'état d'un élément de l'UI (par exemple, l'état d'un élément extensible). Si l'événement nécessite une logique métier, telle que l'actualisation des données à l'écran, il doit être traité par ViewModel.
L'exemple suivant montre comment différents boutons sont utilisés pour développer un élément d'interface utilisateur (logique d'UI) et pour actualiser les données à l'écran (logique métier) :
class LatestNewsActivity : AppCompatActivity() {
private lateinit var binding: ActivityLatestNewsBinding
private val viewModel: LatestNewsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
// The expand details event is processed by the UI that
// modifies a View's internal state.
binding.expandButton.setOnClickListener {
binding.expandedSection.visibility = View.VISIBLE
}
// The refresh event is processed by the ViewModel that is in charge
// of the business logic.
binding.refreshButton.setOnClickListener {
viewModel.refreshNews()
}
}
}
Événements utilisateur dans RecyclerViews
Si l'action est produite plus bas dans l'arborescence de l'UI, comme dans un élément RecyclerView ou un élément View personnalisé, ViewModel doit toujours être celui qui gère les événements utilisateur.
Par exemple, supposons que tous les articles d'actualité de NewsActivity contiennent un bouton de favori. ViewModel doit connaître l'identifiant de l'article ajouté aux favoris. Lorsque l'utilisateur ajoute un article à ses favoris, l'adaptateur RecyclerView n'appelle pas la fonction addBookmark(newsId) présentée à partir du ViewModel, ce qui nécessiterait une dépendance sur ViewModel. Au lieu de cela, ViewModel expose un objet d'état appelé NewsItemUiState qui contient l'intégration permettant de gérer l'événement :
data class NewsItemUiState(
val title: String,
val body: String,
val bookmarked: Boolean = false,
val publicationDate: String,
val onBookmark: () -> Unit
)
class LatestNewsViewModel(
private val formatDateUseCase: FormatDateUseCase,
private val repository: NewsRepository
)
val newsListUiItems = repository.latestNews.map { news ->
NewsItemUiState(
title = news.title,
body = news.body,
bookmarked = news.bookmarked,
publicationDate = formatDateUseCase(news.publicationDate),
// Business logic is passed as a lambda function that the
// UI calls on click events.
onBookmark = {
repository.addBookmark(news.id)
}
)
}
}
Ainsi, l'adaptateur RecyclerView ne fonctionne qu'avec les données dont il a besoin, à savoir la liste d'objets NewsItemUiState. L'adaptateur n'a pas accès à l'intégralité du ViewModel, ce qui réduit les risques d'abus des fonctionnalités présentées par le ViewModel. Lorsque vous autorisez uniquement la classe d'activité à utiliser le ViewModel, vous devez séparer les responsabilités. Cela garantit que des objets spécifiques à l'UI, comme les vues ou les adaptateurs RecyclerView, n'interagissent pas directement avec le ViewModel.
Conventions de dénomination pour les fonctions d'événements utilisateur
Dans ce guide, les fonctions ViewModel qui gèrent les événements utilisateur sont nommées à partir d'un verbe en fonction de l'action qu'elles traitent, par exemple : addBookmark(id) ou logIn(username, password).
Gérer les événements ViewModel
Les actions de l'UI qui proviennent de ViewModel, à savoir les événements ViewModel, doivent toujours entraîner une mise à jour de l'état de l'UI. Cela est conforme aux principes de flux de données unidirectionnel. Il permet de reproduire les événements après les changements de configuration et garantit que les actions de l'UI ne sont pas perdues. Si vous utilisez le module d'état enregistré, vous pouvez également rendre les événements reproductibles après l'arrêt du processus.
Mapper les actions de l'UI avec l'état de l'UI n'est pas toujours simple, mais permet de simplifier la logique. Par exemple, votre réflexion ne doit pas se cantonner à déterminer la façon dont l'UI doit accéder à un écran particulier. Vous devez réfléchir plus avant, en imaginant une façon de représenter ce parcours utilisateur dans votre état de l'UI. En d'autres termes, ne réfléchissez pas aux actions que l'UI doit effectuer, mais plutôt à l'effet de ces actions sur son état.
Prenons l'exemple de la navigation vers l'écran d'accueil lorsque l'utilisateur est connecté sur l'écran de connexion. Vous pouvez modéliser cela dans l'état de l'UI comme suit :
data class LoginUiState(
val isLoading: Boolean = false,
val errorMessage: String? = null,
val isUserLoggedIn: Boolean = false
)
Cette interface utilisateur réagit aux changements de l'état isUserLoggedIn et accède à la destination appropriée si nécessaire :
class LoginViewModel : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
/* ... */
}
class LoginActivity : AppCompatActivity() {
private val viewModel: LoginViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { uiState ->
if (uiState.isUserLoggedIn) {
// Navigate to the Home screen.
}
...
}
}
}
}
}
L'utilisation d'événements peut déclencher des mises à jour de l'état
L'utilisation de certains événements ViewModel dans l'UI peut entraîner la mise à jour d'autres états de celle-ci. Par exemple, lors de l'affichage de messages transitoires à l'écran pour informer l'utilisateur qu'un événement s'est produit, l'UI doit avertir l'élément ViewModel afin de déclencher une autre mise à jour de l'état lorsque le message s'est affiché à l'écran. L'événement qui se produit lorsque le message est "consommé" par l'utilisateur (en le fermant ou après un délai d'inactivité) peut être considéré comme une "entrée utilisateur", dont ViewModel doit alors en tenir compte. Dans ce cas, l'état de l'UI peut être modélisé comme suit :
// Models the UI state for the Latest news screen.
data class LatestNewsUiState(
val news: List<News> = emptyList(),
val isLoading: Boolean = false,
val userMessage: String? = null
)
ViewModel met à jour l'état de l'UI comme suit lorsque la logique métier nécessite l'affichage d'un nouveau message transitoire à l'utilisateur :
class LatestNewsViewModel(/* ... */) : ViewModel() {
private val _uiState = MutableStateFlow(LatestNewsUiState(isLoading = true))
val uiState: StateFlow<LatestNewsUiState> = _uiState
fun refreshNews() {
viewModelScope.launch {
// If there isn't internet connection, show a new message on the screen.
if (!internetConnection()) {
_uiState.update { currentUiState ->
currentUiState.copy(userMessage = "No Internet connection")
}
return@launch
}
// Do something else.
}
}
fun userMessageShown() {
_uiState.update { currentUiState ->
currentUiState.copy(userMessage = null)
}
}
}
ViewModel n'a pas besoin de savoir comment l'interface utilisateur affiche le message à l'écran, car il sait qu'un message doit être affiché. Une fois le message transitoire affiché, l'UI doit en informer le ViewModel, ce qui entraîne la mise à jour de son état afin d'effacer la propriété userMessage :
class LatestNewsActivity : AppCompatActivity() {
private val viewModel: LatestNewsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { uiState ->
uiState.userMessage?.let {
// TODO: Show Snackbar with userMessage.
// Once the message is displayed and
// dismissed, notify the ViewModel.
viewModel.userMessageShown()
}
...
}
}
}
}
}
Même si le message est temporaire, l'état de l'interface utilisateur est une représentation fidèle de ce qui est affiché à l'écran à chaque instant. Soit le message utilisateur s'affiche, soit il ne s'affiche pas.
Événements de navigation
La section L'utilisation d'événements peut déclencher des mises à jour de l'état présente en détail comment vous pouvez utiliser l'état de l'UI pour afficher à l'écran des messages destinés aux utilisateurs. Les événements de navigation sont également un type d'événement courant dans une application Android.
Si l'événement est déclenché dans l'UI, car l'utilisateur a appuyé sur un bouton, l'UI s'en charge en appelant le contrôleur de navigation.
class LoginActivity : AppCompatActivity() {
private lateinit var binding: ActivityLoginBinding
private val viewModel: LoginViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
binding.helpButton.setOnClickListener {
navController.navigate(...) // Open help screen
}
}
}
Si la saisie de données nécessite une validation de la logique métier avant la navigation, le ViewModel doit exposer cet état à l'UI. L'UI réagira à ce changement d'état et naviguera en conséquence. La section Gérer les événements ViewModel présente ce cas d'utilisation. Voici un code similaire :
class LoginActivity : AppCompatActivity() {
private val viewModel: LoginViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { uiState ->
if (uiState.isUserLoggedIn) {
// Navigate to the Home screen.
}
...
}
}
}
}
}
Dans l'exemple ci-dessus, l'application fonctionne comme prévu, car la destination actuelle (Login) ne serait pas conservée dans la pile "Retour". Les utilisateurs ne peuvent pas y revenir s'ils appuient dessus. Toutefois, si cela se produit, la solution nécessite une logique supplémentaire.
Événements de navigation lorsque la destination est conservée dans la pile "Retour"
Lorsqu'un ViewModel définit un état qui génère un événement de navigation de l'écran A vers l'écran B et que l'écran A est conservé dans la pile "Retour" de Navigation, vous avez peut-être besoin d'une logique supplémentaire pour ne pas passer automatiquement à l'écran B. Pour cela, vous devez disposer d'un état supplémentaire indiquant si l'interface utilisateur doit déterminer s'il faut passer à l'autre écran. Normalement, cet état est conservé dans l'UI, car la logique de navigation concerne l'UI et non le ViewModel. Pour illustrer cela, prenons le cas d'utilisation suivant.
Supposons qu'un utilisateur se trouve dans le flux d'inscription de votre application. Sur l'écran de validation de la date de naissance, lorsqu'il saisit une date, celle-ci est validée par le ViewModel lorsqu'il appuie sur le bouton "Continuer". ViewModel délègue la logique de validation à la couche de données. Si la date est valide, l'utilisateur passe à l'écran suivant. En tant que fonctionnalité supplémentaire, les utilisateurs peuvent passer d'un écran d'inscription à l'autre s'ils souhaitent modifier certaines données. Par conséquent, toutes les destinations du flux d'inscription sont conservées dans la même pile "Retour". Compte tenu de ces exigences, vous pouvez implémenter cet écran comme suit :
// Key that identifies the `validationInProgress` state in the Bundle
private const val DOB_VALIDATION_KEY = "dobValidationKey"
class DobValidationFragment : Fragment() {
private var validationInProgress: Boolean = false
private val viewModel: DobValidationViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val binding = // ...
validationInProgress = savedInstanceState?.getBoolean(DOB_VALIDATION_KEY) ?: false
binding.continueButton.setOnClickListener {
viewModel.validateDob()
validationInProgress = true
}
viewLifecycleOwner.lifecycleScope.launch {
viewModel.uiState
.flowWithLifecycle(viewLifecycleOwner.lifecycle)
.collect { uiState ->
// Update other parts of the UI ...
// If the input is valid and the user wants
// to navigate, navigate to the next screen
// and reset `validationInProgress` flag
if (uiState.isDobValid && validationInProgress) {
validationInProgress = false
navController.navigate(...) // Navigate to next screen
}
}
}
return binding
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putBoolean(DOB_VALIDATION_KEY, validationInProgress)
}
}
La validation de la date de naissance est une logique métier qui relève du ViewModel. La plupart du temps, le ViewModel délègue cette logique à la couche de données. La logique de navigation déterminant si l'utilisateur passe à l'écran suivant est une logique d'UI, car ces exigences peuvent varier en fonction de la configuration de l'interface utilisateur. Par exemple, vous ne souhaitez peut-être pas passer automatiquement à un autre écran sur une tablette si vous affichez plusieurs étapes d'inscription en même temps. La variable validationInProgress du code ci-dessus implémente cette fonctionnalité et détermine si l'interface utilisateur passer automatiquement à l'écran suivant lorsque la date de naissance est valide et que l'utilisateur souhaite passer à l'étape d'inscription suivante.
Recommandations personnalisées
- Remarque : Le texte du lien s'affiche lorsque JavaScript est désactivé
- Couche d'interface utilisateur
- Conteneurs d'état et état de l'interface utilisateur {:#mad-arch}
- Guide de l'architecture des applications