Au début, sur Android, on avait les LiveData. C’était simple, un peu trop même, mais ça faisait le job pour l’époque. Puis les Coroutines et la sainte trinité des Flux (Flow, StateFlow, SharedFlow) sont arrivés. La documentation nous a dit : « C’est le futur, remplacez tout ! ».
On s’est exécutés. Le problème ? L’API est tellement riche et subtile qu’on finit souvent par utiliser le mauvais outil au mauvais endroit. Résultat en production : des écrans de détails qui s’ouvrent deux fois sans raison, des messages d’erreur qui disparaissent au moindre coup de vent (ou rotation d’écran), et des fuites de mémoire invisibles.
Si vous en avez marre de coder des comportements asynchrones « au pif » en espérant que ça passe les tests QA, posons les choses à plat.
1. Pourquoi StateFlow est votre pire ennemi pour les événements One-Shot
C’est le piège le plus classique du monde. Vous êtes en MVI ou MVVM, et vous voulez ouvrir l’écran « Détails » après un clic sur un bouton. Logiquement, vous ajoutez un état dans votre StateFlow :
Kotlin
data class ScreenState(
val isLoading: Boolean = false,
val navigateToDetails: Boolean = false // <- Le début des problèmes
)Dans votre Composable, vous écoutez le flux. Si MapsToDetails passe à true, vous déclenchez navController.navigate(). Tout fonctionne pendant vos tests.
Le bug de l’enfer : L’utilisateur arrive sur l’écran de détails, puis il tourne son téléphone (changement de configuration). L’activité est recréée, le Composable se réabonne au StateFlow. Et devinez quoi ? La dernière valeur du StateFlow est toujours MapsToDetails = false ? Non, elle est toujours à true. L’application relit l’état et réouvre l’écran de détails une deuxième fois. L’utilisateur est bloqué dans une boucle infinie de redirection.
La solution de cowboy (à éviter) :
Remettre manuellement MapsToDetails = false dans le ViewModel juste après la navigation. C’est moche, ça crée des race conditions, et ça pollue votre logique.
La vraie solution : Les Channels
La navigation, un Toast, ou une SnackBar ne sont pas des états (un fait permanent), ce sont des événements (une action unique dans le temps). Pour ça, on utilise un Channel. Un Channel transmet l’information, et dès qu’elle est lue, elle est consommée et détruite.
Kotlin
class MyViewModel : ViewModel() {
// Un Channel configuré pour ne pas stocker les anciens événements
private val _navigationEvent = Channel<NavigationTarget>(Channel.BUFFERED)
val navigationEvent = _navigationEvent.receiveAsFlow() // Exposé en flux froid
fun onButtonClicked() {
viewModelScope.launch {
_navigationEvent.send(NavigationTarget.Details)
}
}
}2. StateFlow vs SharedFlow : Le match de catch des flux chauds
Si vous devez exposer de la donnée en continu, vous devez choisir entre un StateFlow et un SharedFlow. Pour faire simple, retenez cette image :
StateFlowest une boîte aux lettres : Elle contient toujours une lettre (une valeur actuelle). Si vous allez voir la boîte à 14h, vous lisez la lettre. Si vous y retournez à 15h et que rien n’a changé, c’est toujours la même lettre. Il a obligatoirement besoin d’une valeur initiale(MutableStateFlow(InitialState)).SharedFlowest un mégaphone (ou un flux Twitch) : Il diffuse du son en direct. Si vous allumez votre radio à 14h05, vous entendez ce qui se dit à 14h05. Tout ce qui s’est dit à 14h00 est perdu à jamais pour vous.
Le cas concret en Prod : Vous faites une application de chat en temps réel.
- Pour afficher la liste des messages à l’écran :
StateFlow. L’UI a besoin de connaître la liste actuelle à n’importe quel moment, même si l’écran vient de pivoter. - Pour déclencher une vibration ou une notification sonore à la réception d’un nouveau message :
SharedFlow. Vous ne voulez surtout pas que le téléphone re-vibre à chaque fois que l’utilisateur tourne son écran parce que le « State » de la vibration est resté actif.
Kotlin
// Idéal pour l'état de l'UI
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
// Idéal pour des actions/notifications instantanées en arrière-plan
val globalEvents: SharedFlow<AppEvent> = _globalEvents.asSharedFlow()3. La règle d’or des Dispatchers : Votre code doit être « Main-Safe »
Combien de fois avez-vous vu (ou écrit) ce genre de code dans un ViewModel ?
Kotlin
fun loadHeavyData() {
viewModelScope.launch {
// On est sur Dispatchers.Main par défaut ici !
val result = myRepository.parseHugeJsonFiles() // <- Freeze de l'UI de 500ms
_uiState.value = UiState.Success(result)
}
}Par défaut, viewModelScope.launch démarre sur le Thread Principal (Dispatchers.Main). Si votre fonction dans le Repository fait du calcul lourd (parsing de gros JSON, requêtes SQL complexes, filtrage de listes de 10 000 items), vous allez faire sauter des frames à votre interface graphique.
La bonne pratique (La responsabilité inversée) :
Ce n’est pas au ViewModel de deviner qu’une fonction va être lourde et de s’isoler sur un autre thread. C’est de la responsabilité du Repository/UseCase d’être « Main-Safe ». Une fonction suspendue ne devrait jamais bloquer le thread principal, peu importe d’où elle est appelée.
Dans votre Repository, forcez le changement de contexte :
Kotlin
class DataRepository {
suspend fun parseHugeJsonFiles(): List<Data> {
// On force explicitement le passage sur le pool de threads dédié aux Entrées/Sorties
return withContext(Dispatchers.IO) {
// Votre logique lourde ici
val jsonString = readFromDisk()
gson.fromJson(jsonString, MyDataType::class.java)
}
}
}Maintenant, votre ViewModel peut appeler myRepository.parseHugeJsonFiles() dans son viewModelScope.launch standard sans se poser de questions : l’UI restera fluide comme du beurre.
En résumé : Votre Cheat Sheet pour la prod
Pour arrêter de jouer à la roulette russe avec vos flux de données, appliquez cette grille de décision simple :
| Besoin | Outil à utiliser | Pourquoi ? |
| Représenter l’état d’un écran | StateFlow | Garde en mémoire la dernière valeur pour l’UI, gère les rotations d’écran. |
| Événements de navigation / Toasts | Channel | Événement à consommation unique (One-shot). Pas de re-déclenchement au scroll ou à la rotation. |
| Événements globaux / Multi- abonnés | SharedFlow | Comme un bus d’événements. Idéal pour notifier plusieurs composants en même temps en direct. |
| Calcul lourd / Accès disque | withContext(Dispatchers.IO | À encapsuler directement dans vos couches basses (Repositories) pour garantir la fluidité de l’UI. |
Besoin d’expertise sur l’architecture asynchrone de vos applications ?
États incohérents, événements dupliqués, UI qui freeze : ces symptômes révèlent souvent une architecture asynchrone à consolider. Chez Atecna, notre équipe accompagne les entreprises sur l’ensemble du cycle de vie de leurs projets mobiles : architecture, performance, accessibilité et maintenance.