Développement Mobile

ViewModel et StateFlow avec Jetpack Compose : architecture moderne

11 min de lecture

Construire une UI Compose statique est satisfaisant pendant deux jours. Vient ensuite le moment où il faut séparer la logique métier de l’affichage : on veut un état qui survit aux rotations, des appels asynchrones qui ne plantent pas l’écran, et un code testable indépendamment de l’UI. La réponse standard en 2026 s’appelle ViewModel + StateFlow + collectAsStateWithLifecycle, et constitue la fondation de toute architecture Android moderne. Ce tutoriel détaille la mise en place complète, des dépendances Gradle au flux unidirectionnel observable, avec un exemple concret de liste d’articles chargée depuis un repository.

Prérequis

  • Projet Compose fonctionnel avec écrans simples (cf. Premiers écrans Compose)
  • Notions de coroutines Kotlin (suspend, scope, launch)
  • Android Studio Otter 3, Kotlin 2.3.21, Compose BOM 2026.05.00
  • Temps estimé : 75 minutes

Étape 1 — Comprendre pourquoi un ViewModel

L’Activity et le Composable ne sont pas faits pour héberger l’état métier. Une Activity est détruite et recréée à chaque rotation. Un Composable peut être recomposé plusieurs fois par seconde. Si l’état vit là, chaque rotation ré-exécute la requête réseau, chaque recomposition risque de la relancer, et le code devient infestable. Le ViewModel résout ces deux problèmes : il survit aux changements de configuration et fournit un scope coroutines (viewModelScope) qui s’annule automatiquement à la destruction définitive.

Un ViewModel ne connaît jamais Android. Il ne reçoit pas de Context, ne dépend pas de Compose. Il expose un état observable et reçoit des intents (clics, saisies) sous forme de méthodes. Cette indépendance le rend trivialement testable : on instancie le ViewModel dans un test JVM pur, on appelle les méthodes, on vérifie l’état exposé. Aucun émulateur, aucun framework Android requis.

Étape 2 — Ajouter les dépendances Gradle

Dans libs.versions.toml, ajoutez les versions et déclarations Lifecycle ViewModel Compose. La version 2.10.0 est l’état stable du printemps 2026, alignée avec Lifecycle 2.10 qui apporte les helpers collectAsStateWithLifecycle stabilisés depuis 2024.

[versions]
lifecycleRuntimeKtx = "2.10.0"
lifecycleViewmodelCompose = "2.10.0"

[libraries]
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }

Dans app/build.gradle.kts, déclarez les deux dépendances dans le bloc dependencies { } : implementation(libs.androidx.lifecycle.viewmodel.compose) et implementation(libs.androidx.lifecycle.runtime.compose). Synchronisez. La première fournit viewModel() côté composable, la seconde fournit collectAsStateWithLifecycle(). Ces deux briques suffisent pour 99 % des cas.

Étape 3 — Modéliser un état d’UI immutable

L’UI d’un écran réel a plusieurs facettes : chargement initial, données affichées, erreur réseau, état de rafraîchissement. Au lieu d’éparpiller des booléens, on regroupe tout dans une seule data class immutable. Cette pratique s’appelle UI State Holder et simplifie radicalement la lecture du code.

data class ListeArticlesUiState(
    val articles: List<Article> = emptyList(),
    val isLoading: Boolean = false,
    val erreur: String? = null
)

Chaque champ a un défaut, ce qui rend l’instanciation initiale triviale. L’avantage du data class immutable : Compose détecte instantanément qu’un nouvel objet est arrivé et recompose. Pas besoin d’annotations @Stable explicites (les data class avec membres stables sont stables par déduction du compilateur Compose).

La règle d’or : un seul StateFlow<UiState> par écran. Si vous avez besoin d’un dialogue ou d’un Snackbar, ajoutez un champ dialogue: Dialogue? = null au UiState plutôt que de créer plusieurs Flows séparés. Une seule source de vérité, recomposition cohérente.

Étape 4 — Écrire le ViewModel avec MutableStateFlow

Le ViewModel expose un StateFlow<UiState> en lecture et conserve un MutableStateFlow en privé pour les écritures. Cette séparation interdit aux composables de modifier l’état directement — ils doivent passer par des méthodes du ViewModel. Voici une implémentation complète qui charge une liste d’articles depuis un repository.

class ListeArticlesViewModel(
    private val repository: ArticlesRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow(ListeArticlesUiState(isLoading = true))
    val uiState: StateFlow<ListeArticlesUiState> = _uiState.asStateFlow()

    init {
        charger()
    }

    fun charger() {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true, erreur = null) }
            try {
                val articles = repository.listerArticles()
                _uiState.update {
                    it.copy(articles = articles, isLoading = false)
                }
            } catch (e: Exception) {
                _uiState.update {
                    it.copy(isLoading = false, erreur = e.message ?: "Erreur inconnue")
                }
            }
        }
    }
}

Cinq points à comprendre. private val _uiState = MutableStateFlow(...) conserve l’écriture côté ViewModel. val uiState: StateFlow<...> = _uiState.asStateFlow() expose en lecture seule. viewModelScope est un CoroutineScope lié au cycle de vie du ViewModel ; il annule toutes ses coroutines à la destruction. _uiState.update { it.copy(...) } applique une mise à jour atomique sans risque de race condition. Le try/catch capture les erreurs réseau pour qu’elles atteignent l’UI sous forme d’état, pas de crash.

Étape 5 — Brancher le ViewModel à un Composable

Côté Compose, on instancie le ViewModel avec viewModel() ou hiltViewModel() si on utilise Hilt. On observe le StateFlow avec collectAsStateWithLifecycle(), qui suspend la collection quand l’écran n’est plus visible et la reprend automatiquement. C’est la version moderne de collectAsState(), recommandée par Google depuis 2023.

@Composable
fun EcranListeArticles(
    viewModel: ListeArticlesViewModel = viewModel()
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    when {
        uiState.isLoading -> {
            Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                CircularProgressIndicator()
            }
        }
        uiState.erreur != null -> {
            Column(
                modifier = Modifier.fillMaxSize().padding(24.dp),
                verticalArrangement = Arrangement.Center
            ) {
                Text("Une erreur est survenue : " + uiState.erreur)
                Spacer(Modifier.height(12.dp))
                Button(onClick = { viewModel.charger() }) { Text("Réessayer") }
            }
        }
        else -> {
            ListeArticles(articles = uiState.articles, onArticleClick = {})
        }
    }
}

Le when hiérarchique fait office de machine à états déclarative. Quand le ViewModel passe de isLoading=true à articles=[...], le composable bascule automatiquement du spinner vers la liste. L’écran d’erreur expose un bouton « Réessayer » qui appelle viewModel.charger() et redéclenche le flux. Tout reste type-safe et testable.

Étape 6 — Gérer les événements ponctuels (Snackbar, navigation)

Un état réactif convient pour ce qui doit être visible à tout moment. Mais pour les événements ponctuels (afficher un Snackbar « article supprimé », naviguer après un succès), un StateFlow n’est pas idéal : si on bascule un booléen showSnackbar=true puis on le repasse à false, et que l’utilisateur tourne le téléphone entre les deux, on peut louper l’événement ou le rejouer.

La solution moderne consiste à exposer un Channel ou un SharedFlow pour les événements one-shot, distinct du StateFlow principal. Le composable les collecte dans un LaunchedEffect(Unit) { viewModel.evenements.collect { ... } }. Pour les écrans simples, garder une liste de Snackbars dans le UiState et laisser le composable les pop après affichage suffit largement.

// Variante simple : événements dans le UiState
data class UiState(
    val articles: List<Article> = emptyList(),
    val messages: List<String> = emptyList()
)

fun afficherMessage(message: String) {
    _uiState.update { it.copy(messages = it.messages + message) }
}

fun messageAffiche(message: String) {
    _uiState.update { it.copy(messages = it.messages - message) }
}

Cette approche fonctionne pour des messages ponctuels. Côté composable, on déclenche un LaunchedEffect(uiState.messages) qui pop le premier message et appelle messageAffiche après que le Snackbar a disparu. Simple, sans bibliothèque tierce.

Étape 7 — Injecter le repository avec un constructeur

Le ViewModel reçoit son ArticlesRepository par paramètre de constructeur. Pour qu’viewModel() sache instancier ce ViewModel avec son repo, deux options. La voie courte pour un petit projet : un ViewModelProvider.Factory explicite. La voie moderne et durable : Hilt avec @HiltViewModel.

@HiltViewModel
class ListeArticlesViewModel @Inject constructor(
    private val repository: ArticlesRepository
) : ViewModel() {
    // ... même code que précédemment
}

@Composable
fun EcranListeArticles(
    viewModel: ListeArticlesViewModel = hiltViewModel()
) { /* ... */ }

Hilt génère automatiquement la Factory à la compilation via KSP. Vous devez juste ajouter hilt-android, hilt-compiler (KSP), hilt-navigation-compose aux dépendances, annoter votre Application avec @HiltAndroidApp, et chaque Activity avec @AndroidEntryPoint. Pour un projet de plus de cinq écrans, c’est un investissement qui paie dès la deuxième semaine.

Erreurs fréquentes

Symptôme Cause Solution
État qui ne se met pas à jour dans l’UI Lecture via .value au lieu de collectAsStateWithLifecycle() Toujours observer le Flow, jamais lire .value dans un composable
Requête réseau qui se relance à chaque rotation Logique dans le composable au lieu du ViewModel Déplacer dans init ou viewModelScope.launch
Crash après destruction Coroutine dans GlobalScope Toujours viewModelScope.launch
Snackbar qui s’affiche en double après rotation Événement stocké dans un boolean dans UiState Utiliser une liste de messages avec messageAffiche ou un Channel
NoSuchMethodError viewModel() Manque lifecycle-viewmodel-compose Ajouter la dépendance Gradle
UiState pas reconnu comme stable Champ List<X> mutable Utiliser persistentListOf (kotlinx.collections.immutable) ou se contenter de List immutable

Tester son ViewModel

L’intérêt majeur du pattern : on teste sans Android. Créez un module :app avec un test JVM dans src/test/java. Injectez un faux ArticlesRepository qui renvoie une liste fixe, instanciez le ViewModel, et observez le uiState via turbine (bibliothèque Cash App pour tester les Flows).

@Test
fun chargementReussiExposeListeArticles() = runTest {
    val fakeRepo = FakeArticlesRepository(listOf(Article(1, "Test", "Résumé")))
    val viewModel = ListeArticlesViewModel(fakeRepo)

    viewModel.uiState.test {
        assertEquals(true, awaitItem().isLoading)
        val final = awaitItem()
        assertEquals(false, final.isLoading)
        assertEquals(1, final.articles.size)
    }
}

Ce test tourne en moins de 50 ms, sans émulateur, sans Compose. Vous pouvez en écrire des dizaines pour couvrir tous les cas (succès, erreur, état vide, rafraîchissement). C’est la principale raison qui justifie d’investir dans le pattern ViewModel + StateFlow : un code testable dès le premier jour, qui le restera quand l’application grandira.

Étape 8 — Combiner plusieurs StateFlow avec combine

Un écran réel a souvent plusieurs sources d’état : une liste d’articles, un filtre actif, une recherche en cours. Combiner ces flux en un seul UiState dérivé évite la cascade de if côté composable. L’opérateur combine fait exactement ça : il combine plusieurs Flows en un nouveau Flow qui ré-émet à chaque changement d’une des sources.

private val filtre = MutableStateFlow<Categorie?>(null)
private val recherche = MutableStateFlow("")
private val articles = repository.observerArticles()

val uiState: StateFlow<ListeArticlesUiState> = combine(
    articles, filtre, recherche
) { liste, cat, q ->
    val filtrees = liste
        .filter { cat == null || it.categorie == cat }
        .filter { q.isBlank() || it.titre.contains(q, ignoreCase = true) }
    ListeArticlesUiState(articles = filtrees, isLoading = false)
}.stateIn(
    scope = viewModelScope,
    started = SharingStarted.WhileSubscribed(5000),
    initialValue = ListeArticlesUiState(isLoading = true)
)

L’opérateur stateIn convertit un Flow froid en StateFlow chaud, avec une valeur initiale et une politique de partage. WhileSubscribed(5000) garde le Flow actif tant qu’au moins un collector est présent, et coupe 5 secondes après le dernier départ — un compromis qui survit aux brèves rotations sans recharger les données. C’est le pattern recommandé pour la majorité des écrans.

Foire aux questions

StateFlow ou LiveData ?
StateFlow systématiquement pour tout nouveau code. LiveData reste utile pour maintenir d’anciennes bases mais ne devrait plus être introduit dans un projet neuf en 2026.

Faut-il un ViewModel par écran ou par fonctionnalité ?
Par écran par défaut. Si plusieurs écrans partagent un état (typiquement un wizard à plusieurs étapes), un ViewModel partagé scopé à la NavGraph via hiltViewModel est la bonne solution.

Hilt ou Koin ?
Hilt pour les projets professionnels (intégration officielle Google, compile-time safety). Koin pour les petits projets ou prototypes (configuration plus rapide, runtime-based).

Comment partager du state entre composables sans ViewModel ?
Pour de l’état local d’écran : state hoisting vers le composable parent. Pour de l’état global éphémère : CompositionLocal. Mais dès qu’il y a logique métier, ViewModel.

Comment annuler une coroutine longue ?
viewModelScope.launch s’annule automatiquement à la destruction du ViewModel. Pour annuler manuellement avant : garder une référence au Job et appeler job.cancel().

Quelle différence entre StateFlow et SharedFlow ?
StateFlow a toujours une valeur courante et conflate (un nouveau collector reçoit immédiatement la valeur actuelle). SharedFlow est conçu pour des événements éphémères, sans valeur initiale, et peut diffuser à plusieurs collectors. Pour l’UI state, StateFlow ; pour les événements ponctuels, SharedFlow ou Channel.

Faut-il un ViewModel pour un écran sans état ?
Non. Un écran purement statique (page À propos, écran de chargement) n’a pas besoin de ViewModel. Le pattern s’applique dès qu’il y a logique métier ou requête asynchrone.

Pour aller plus loin

Le ViewModel et son UiState sont en place. L’étape suivante consiste à brancher le repository à une vraie source : une API REST. Le tutoriel Consommer une API REST avec Retrofit reprend la mise en place complète de Retrofit 3, kotlinx.serialization, OkHttp et l’intégration aux coroutines. Pour la vue d’ensemble de la stack, voir le guide principal Kotlin et Jetpack Compose.

Ressources et références

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité