Problèmes de superposition de référentiels et de cohérence des états
Ce qui est vraiment difficile à gérer, c'est que le cache local, l'état de la mémoire, le retour des paquets distants et l'état dérivé de l'interface utilisateur écrivent tous secrètement la « vérité ».
Lorsque de nombreux projets Android sont dans un état de confusion, la première réaction est de continuer à ajouter des couches.
ViewModel -> UseCase -> Repository -> LocalDataSource -> RemoteDataSource Lorsque cette chaîne est présentée, le code semble plus soigné. Le problème est que la propreté et la cohérence ne sont pas la même chose. Many teams are making the Repository more and more like a “unified portal”, but in the end they find that the page status is more difficult to infer: the list and details are inconsistent, the collection status jumps back and forth, the UI does not change after the request is successful, and a set of old data appears after the process is rebuilt.
Mon jugement est le suivant : **La valeur de la superposition du référentiel réside dans des sources d’état claires et des limites d’écriture. Tant que le cache mémoire, la base de données locale, le retour de paquet distant et l’état dérivé de l’interface utilisateur peuvent tous modifier leurs valeurs, plus la superposition est soignée, plus il est difficile de maintenir la cohérence de l’état. **
Le vrai problème est qu’il y a plus d’une vérité
Une situation courante est que le référentiel peut « unifier la gestion des données », ce qui n’est qu’à moitié correct.
Of course, Repository can package the network, local cache, and disk persistence, but if you don’t continue to ask “who is the source of the truth,” Repository just wraps multiple states into the same class name.
Le chemin incontrôlable le plus courant est le suivant :
- La page lit d’abord la base de données locale et affiche immédiatement l’ancienne valeur ;
- Lancer une requête à distance en même temps et mettre à jour le cache mémoire après avoir renvoyé le paquet ;
- Afin de poursuivre une interaction fluide, modifiez d’abord directement l’état de l’interface utilisateur et effectuez une mise à jour optimiste ;
- Une autre page lit une autre valeur du champ singleton du Repository ;
- Enfin, le téléchargement asynchrone de la base de données est terminé, et l’ancienne page est repoussée.
À ce stade, ce que vous voyez en surface, c’est que « l’architecture est hiérarchiquement complète ». En fait, il existe déjà quatre groupes d’États dans le système qui se disputent le droit d’interpréter.
Ils répondent respectivement à différentes questions :
- La base de données veut répondre « Peut-elle être restaurée au prochain démarrage ? » ;
- La mémoire cache veut répondre « Cet accès est-il rapide ? » ;
- L’extrémité distante renvoie le paquet et souhaite répondre “Qu’est-ce que le serveur vient de dire ?” ;
- L’état de l’interface utilisateur veut répondre “comment l’interface doit-elle être rendue à ce moment”.
Ces choses sont toutes importantes, mais le fait d’être importantes ne signifie pas qu’elles peuvent toutes être la source de la vérité.
If there is no clear definition of “who is responsible for persistent truth values, who is only responsible for derived presentation, and who can only read but not write”, the Repository will slowly degenerate into a state transfer station. Il capture toute la complexité mais n’en élimine rien.
Le référentiel est plus facilement abusé en tant que coordinateur qui “peut tout changer”
Le problème avec de nombreux codes n’est pas que le référentiel est trop mince, mais qu’il est trop puissant.
Un référentiel typique fait souvent ces choses en même temps :
- Faire des requêtes réseau;
- Salle de lecture et d’écriture ;
- Maintenir la carte mémoire ; -Champs nécessaires pour assembler l’interface utilisateur ;
- Annulation de la mise à jour optimiste en cas d’échec ;
- Envoyez facilement des événements pour avertir les autres modules de les actualiser.
It seems very concentrated, but in fact it is the “data access layer”, “state coordination layer”, “caching strategy layer” and “domain rule layer” rolled into a ball.
Once Repository is responsible for both “read aggregation” and “multi-source write coordination”, it will naturally enter an awkward state: anyone can change data through it, but no one can quickly tell which observers a change will ultimately affect and which writeback path will be triggered.
Par exemple, pour une opération de collecte, de nombreuses implémentations ressemblent à ceci :
suspend fun toggleFavorite(id: String) {
memory[id] = !(memory[id] ?: false)
dao.updateFavorite(id, memory[id]!!)
api.toggleFavorite(id)
}
Ce code est court, mais il mélange trois niveaux de sémantique :
- L’interface utilisateur souhaite donner un retour immédiat, alors changez d’abord la mémoire ;
- Je souhaite conserver la cohérence locale, j’écris donc la bibliothèque immédiatement ;
- Le serveur est le véritable arbitre, mais les résultats sont renvoyés à la fin.
Le problème n’est pas que « changer localement d’abord » doit être erroné, mais que la sémantique de l’échec n’est pas définie.
Comment converger si l’interface expire mais que le serveur réussit réellement ? Si l’écriture locale réussit mais que l’écriture distante échoue, qui annulera ? Si deux pages sont sélectionnées en même temps comme favorites, laquelle prévaudra à la fin ?
Une fois que ces problèmes ne sont pas explicitement conçus, le référentiel masque simplement la condition de concurrence critique dans une méthode apparemment propre.
Flow peut propager un état, ce qui ne signifie pas qu’il garantit automatiquement la cohérence.
In recent years, Android has been fond of connecting Flow, StateFlow, and SharedFlow to the Repository, and then exposing a “responsive data source” to the upstream. This is certainly better than calling back everywhere, but it often creates the illusion that as long as I stream the data, the consistency problem will disappear.
Ne le fera pas.
Le flux réactif résout la façon dont les changements sont propagés, et non qui détermine les changements.
Le modèle suivant est très courant :
val userFlow = combine(
dao.observeUser(id),
memoryStateFlow,
remoteRefreshStateFlow
) { local, memory, remote ->
mergeUser(local, memory, remote)
}
Le plus grand risque de ce code n’est pas qu’il soit laid à l’écrit, mais que mergeUser() introduit souvent discrètement des décisions commerciales :
- Le nom est basé sur l’extrémité distante ;
- Le fait qu’il soit en ligne ou non dépend de la mémoire ;
- Le fait qu’il ait été lu sera déterminé localement ;
- L’état de chargement est également suspendu à l’interface utilisateur.
Ce qu’il faut en fin de compte, c’est un “résultat d’assemblage qui peut à peine restituer la page à ce moment-là”.
This type of splicing is very convenient on the read path, but it can easily get out of control on the write path, because it is already difficult to answer:
- À quel niveau un certain champ doit-il être modifié ?
- Après le changement d’un calque, les autres calques doivent-ils être synchronisés ?
- Une fois le processus reconstruit, quels champs peuvent encore être reconstruits ;
- Quels champs seront remplacés par de nouvelles valeurs lors de la récupération hors ligne.
Le phénomène étrange dans de nombreux projets est donc le suivant : plus le flux de données est joliment écrit, plus les bogues de statut deviennent métaphysiques. La cause fondamentale est qu’il n’existe pas de source unique d’État responsable dans le système.
Ce qui devrait vraiment être contrôlé, c’est la limite d’écriture
La contrainte la plus importante dans la conception d’un référentiel est “l’endroit où il existe une autorisation d’écriture”.
If a business object can be modified by UI optimistic update, modified by Repository memory cache, pushed back by database observer, and overwritten by interface return packets, sooner or later it will encounter order inconsistency problems.
Plutôt que de continuer à ajouter des abstractions, je recommande de clarifier d’abord les limites d’écriture :
1. Choisissez d’abord la source de la vérité
Tous les scénarios n’exigent pas que « la base de données locale soit la seule source de vérité », mais une source primaire doit être sélectionnée.
- Dans les scénarios où la priorité hors ligne et la récupération de liste sont possibles, la base de données locale doit généralement être utilisée ;
- Dans les scénarios où le temps réel est fort et où les anciennes valeurs ne peuvent pas être acceptées, les résultats à distance peuvent être utilisés ;
- Les états d’interaction d’interface pure, tels que l’expansion, la sélection et la saisie, doivent être explicitement laissés dans l’état de l’interface utilisateur et ne pas être réinjectés dans le référentiel.
L’essentiel n’est pas de s’appuyer sur la base de données pour déterminer la moitié des champs, l’autre moitié des champs doit être déterminée en mémoire, puis de s’appuyer sur l’interface utilisateur pour combler le trou lorsqu’une erreur se produit.
2. Séparez « état dérivé » et « état persistant »
Une grande partie de la confusion vient de la réécriture de l’état d’affichage temporaire dans la couche de persistance.
Par exemple :
isLoadingisRefreshingisExpandedpendingRetryCount
Ces états peuvent déterminer la manière dont l’interface utilisateur est dessinée, mais ils ne doivent pas être mélangés avec des valeurs de vérité commerciale au sein de la même entité et diffusés.
Once the derived state is put into the public model of the Repository, it will be mistakenly reused between different pages and different life cycles. Au final, il n’est même pas clair que “ce champ conserve toujours la valeur de la dernière page”.
3. Rendre le chemin d’écriture inférieur au chemin de lecture
Les lectures peuvent être regroupées et les écritures peuvent être fermées.
Vous pouvez regrouper les signaux de base de données, de mémoire et de rafraîchissement à distance lors de la lecture pour donner à la page un modèle suffisant ; mais lors de l’écriture, il est préférable de n’emprunter qu’un seul chemin contrôlé et de le laisser décider :
- S’il faut d’abord écrire localement ;
- Si une compensation est requise ; -S’il est permis d’écraser les anciennes versions ;
- S’il faut inclure un numéro de version ou un horodatage ;
- Quelle sémantique l’interface utilisateur devrait voir après un échec.
Plus le système autorise d’entrées en écriture, plus la cohérence dépend du « ne pas faire d’erreurs ». Ce n’est pas du design, c’est de la chance.
Un contre-exemple courant : afin de “faire l’expérience d’une douceur soyeuse”, effectuez d’abord des modifications avant d’en parler
Le moyen le plus simple d’écrire une cohérence de mauvais état est la petite décision de “cette interaction est très simple, modifions-la d’abord localement”.
Par exemple, les likes, les collections, les suivis et les lectures sont trop faciles à considérer comme « changez d’abord l’interface utilisateur, puis échouez ». Le problème est qu’une fois qu’ils traversent des pages, des listes et des niveaux de mise en cache, ce ne sont plus de petites décisions.
Les cas d’échec ressemblent généralement à ceci :
- Cliquez sur Favoris sur la page de détails et le bouton s’allumera immédiatement ;
- La page de liste surveille également le même état de la mémoire du référentiel, elle s’allume donc de manière synchrone ;
- L’interface expire et le Repository déclenche un rollback ;
- But the list page has already obtained the old value because of the database observer, and the rollback order is different from the details page;
- L’utilisateur revient au niveau précédent et constate que le statut des deux pages est incohérent ;
- Une fois le processus de mise à mort redémarré, il revient au troisième résultat.
The most annoying thing about this kind of problem is that it doesn’t always recur, so the team can easily attribute it to “Flow timing issues”, “Compose reorganization issues” or “sporadic network fluctuations”.
En fait, la cause première est plus simple : ** permet à plusieurs couches d’avoir la qualification nécessaire pour écrire le résultat final en même temps. **
Ce service est structuré pour des raisons de responsabilité et non de propreté formelle
Je ne suis pas contre la superposition du référentiel. Sans Repository, de nombreux projets Android seraient encore plus compliqués.
Mais ce que Repository devrait vraiment fournir, c’est :
- Pouvez-vous expliquer d’où vient le parcours de lecture ? -Écrivez les décisions par lesquelles le chemin est passé et si la responsabilité peut être tenue ;
- Qui prévaudra lorsqu’une erreur se produit et si elle peut être récupérée ;
- Ce qui est partagé entre les pages est la véritable valeur commerciale ou l’état d’affichage temporaire, et s’il peut être séparé.
S’il n’est pas possible de répondre à ces questions, aussi belle que soit la superposition, ce ne sera qu’un ordre visuel.
Cela fait ressembler le code davantage à un diagramme d’architecture, mais cela ne fait pas nécessairement ressembler l’État à un système.
Limites applicables
Cet article se concentre principalement sur :
- Avoir un cache local ou une salle ;
- Statut de partage de plusieurs pages ;
- Recherchez simultanément la vitesse du premier écran, la récupération hors ligne et les commentaires interactifs instantanés ;
- Utilisez Repository + Flow/StateFlow pour organiser la lecture et l’écriture des données.
Si l’application est très légère, les données sont presque toujours une requête unique, la page est prête à être utilisée et il n’y a pas besoin de synchronisation entre les pages, alors même si le référentiel est écrit simplement et grossièrement, le problème de cohérence de l’état ne sera pas particulièrement important.
Le vrai problème concerne les projets qui sont « de taille moyenne à grande mais pas encore assez grands pour être complètement mis en plateforme » : il y a de plus en plus de fonctions et les sources de données deviennent de plus en plus complexes, mais l’équipe utilise toujours la première méthode consistant à « emballer d’abord une couche de référentiel, puis en parler » pour le prendre en charge. À ce stade, des systèmes de structure soignée et de comportement chaotique sont les plus susceptibles d’apparaître.
Résumé
Le malentendu le plus courant concernant la superposition de référentiels dans Android est de confondre « entrée d’accès unifiée » avec « état naturel unifié ».
Les entrées unifiées ne peuvent que réduire la confusion sur la surface d’appel ; ce n’est qu’en éliminant la source de la vérité, en fermant la frontière d’écriture et en définissant à l’avance la sémantique de l’échec que nous pourrons véritablement réduire les conflits d’État.
Autrement, il faudrait un ensemble de choses bien structurées et plus difficiles à responsabiliser.
What to read next
Want more posts about Android?
Posts in the same category are usually the best next step for reading more on this topic.
View same categoryWant to keep following #State Management?
Tags are useful for related tools, specific problems, and similar troubleshooting notes.
View same tagWant to explore another direction?
If you are not sure what to read next, return to the homepage and start from categories, topics, or latest updates.
Back home