Aller au contenu
Tous les articles

StateFlow vs SharedFlow dans Jetpack Compose : modéliser l'état UI sans le bug de replay

Quand utiliser StateFlow et quand utiliser SharedFlow dans un ViewModel Compose — et le bug d'événement unique qui apparaît quand on les confond.

MFKAPPS 5 min de lecture

Chaque écran Compose que j’ai livré finit par poser la même question : cette information décrit-elle à quoi ressemble l’écran en ce moment, ou décrit-elle quelque chose qui s’est produit une fois ? Se tromper de réponse donne un bug précis et agaçant — un toast qui réapparaît après une rotation, un événement de navigation qui se déclenche deux fois, une célébration de “session terminée” qui rejoue à chaque fois que l’utilisateur remet l’app en arrière-plan puis la rouvre. La solution n’est pas un contournement. C’est choisir le bon type de Flow dès le départ.

Kotlin propose deux hot flows qui semblent interchangeables depuis le site d’appel — tous deux se collectent avec collectAsStateWithLifecycle() ou un LaunchedEffect, tous deux vivent dans un ViewModel, tous deux survivent aux changements de configuration. Ils ne sont pas interchangeables, et la différence correspond exactement à la distinction ci-dessus.

StateFlow : a toujours une valeur, la rejoue toujours

StateFlow est conçu pour l’état — des données qui ont une valeur actuelle à tout instant, que quelqu’un regarde ou non. Les secondes restantes d’un minuteur, les erreurs de validation d’un formulaire, le statut loading/error/success d’une liste. Les nouveaux collecteurs reçoivent immédiatement la dernière valeur, et chaque collecteur voit toujours la même chose.

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

fun start(durationMinutes: Int) {
    _uiState.value = TimerUiState.Running(
        remainingSeconds = durationMinutes * 60,
    )
}

Ce comportement — “toujours rejouer la dernière valeur aux nouveaux collecteurs” — est tout l’intérêt de StateFlow : c’est ce qui rend un écran Compose correct après un changement de configuration. On fait pivoter l’appareil, le Composable se recompose, un nouveau collecteur s’attache, et il affiche immédiatement l’état actuel au lieu d’un écran vide attendant la prochaine émission.

SharedFlow : décrit quelque chose qui s’est produit

Ce même comportement de replay est exactement ce qu’il ne faut pas pour un événement ponctuel. Disons qu’une session Pomodoro se termine et que vous voulez déclencher un son de félicitations et une discrète animation de célébration — quelque chose qui doit se produire une fois, au moment où ça arrive, pas à chaque recomposition de l’écran.

Si cet événement est modélisé comme un StateFlow<Boolean> mis à true à la fin, chaque nouveau collecteur — y compris celui créé après une rotation, ou après que l’utilisateur ait quitté puis rouvert l’app — voit immédiatement true et rejoue la célébration. La solution à laquelle on pense en premier est généralement une remise à zéro manuelle (_sessionComplete.value = false juste après consommation), qui fonctionne jusqu’à ce que deux collecteurs se disputent la lecture, ou que la remise à zéro s’exécute sur le mauvais dispatcher et que le flag ne repasse jamais à temps.

SharedFlow sans replay est le bon outil ici, car il ne conserve aucune “valeur actuelle” — il n’émet que vers les collecteurs activement abonnés au moment de l’émission :

private val _events = MutableSharedFlow<TimerEvent>()
val events: SharedFlow<TimerEvent> = _events.asSharedFlow()

private fun onSessionComplete() {
    viewModelScope.launch {
        _events.emit(TimerEvent.SessionComplete)
    }
}

Un collecteur qui n’écoutait pas au moment de l’événement ne le voit tout simplement jamais — ce qui est correct pour une animation de célébration, et serait faux pour l’état du minuteur. Cette asymétrie est toute la décision : si un abonné tardif doit voir la valeur actuelle, c’est de l’état ; s’il aurait dû la manquer, c’est un événement.

La règle empirique qui tient la route

J’utilise une seule question pour trancher, et elle a tenu bon sur chaque écran construit ainsi : serait-ce un bug qu’un tout nouveau collecteur reçoive immédiatement cette valeur ?

  • État du minuteur, contenu de formulaire, indicateurs de chargement, contenu de liste → non, un nouveau collecteur doit voir la valeur actuelle → StateFlow.
  • “Affiche ce snackbar”, “navigue vers cet écran”, “joue ce son” → oui, le rejouer à un abonné tardif est le bug → SharedFlow(replay = 0).

La seule exception à connaître : SharedFlow(replay = 1) existe et ressemble à un compromis, mais en pratique c’est juste un StateFlow sans la garantie “a toujours une valeur” et sans .value pour les lectures synchrones — il y a rarement une raison d’y recourir plutôt qu’un vrai StateFlow pour modéliser de l’état, ou un SharedFlow(replay = 0) pour modéliser un événement.

Le brancher sur un écran Compose

Les deux se consomment différemment, et confondre ça est la deuxième version la plus courante de ce bug :

@Composable
fun TimerScreen(viewModel: TimerViewModel = viewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    LaunchedEffect(Unit) {
        viewModel.events.collect { event ->
            when (event) {
                TimerEvent.SessionComplete -> playCompletionSound()
            }
        }
    }

    TimerContent(uiState)
}

collectAsStateWithLifecycle() est fait pour StateFlow — il a besoin d’une valeur actuelle à exposer comme état Compose. events se collecte plutôt dans un LaunchedEffect, précisément parce qu’il n’a pas de valeur actuelle à conserver ; c’est un flux auquel réagir, pas une valeur à lire.

En résumé

Le bug que ceci évite est assez subtil pour passer inaperçu au moment du lancement — un toast dupliqué après rotation, une animation redéclenchée après un retour d’arrière-plan — parce que tout semble normal dans le cas courant d’un seul collecteur qui ne part jamais. Il n’apparaît qu’une fois qu’un écran redémarre son collecteur, ce qu’un changement de configuration fait faire à Compose systématiquement. Modélisez l’état avec StateFlow parce que les nouveaux collecteurs sont censés rattraper. Modélisez les événements avec un SharedFlow sans replay parce qu’ils ne le sont pas. J’utilise cette répartition dans le minuteur de Mintly : le compte à rebours en cours est de l’état, que chaque recomposition doit refléter correctement ; la célébration de fin de session est un événement qui doit se déclencher exactement une fois — jamais deux, jamais sur un écran qui vient tout juste de se rouvrir.