Problemas de coherencia de estado y capas del repositorio
Lo que es realmente difícil de administrar es que el caché local, el estado de la memoria, el retorno de paquetes remotos y el estado derivado de la interfaz de usuario escriben en secreto la "verdad".
Cuando muchos proyectos de Android están en estado de confusión, la primera reacción es seguir agregando capas.
ViewModel -> UseCase -> Repository -> LocalDataSource -> RemoteDataSource Cuando se distribuye esta cadena, el código se ve más ordenado. El problema es que la pulcritud y la coherencia no son lo mismo. Muchos equipos están haciendo que el Repositorio se parezca cada vez más a un “portal unificado”, pero al final descubren que el estado de la página es más difícil de inferir: la lista y los detalles son inconsistentes, el estado de la colección salta hacia adelante y hacia atrás, la interfaz de usuario no cambia después de que la solicitud es exitosa y aparece un conjunto de datos antiguos después de que se reconstruye el proceso.
Mi opinión es: **El valor de las capas del Repositorio reside en fuentes de estado claras y límites de escritura. Siempre que la memoria caché, la base de datos local, el retorno remoto de paquetes y el estado derivado de la interfaz de usuario puedan cambiar sus valores, cuanto más ordenadas sean las capas, más difícil será mantener la coherencia del estado. **
El verdadero problema es que hay más de una verdad.
Una situación común es que el Repositorio puede “unificar la gestión de datos”, lo cual es sólo la mitad de correcto.
Por supuesto, el Repositorio puede empaquetar la red, el caché local y la persistencia del disco, pero si no continúa preguntando “quién es la fuente de la verdad”, el Repositorio simplemente encapsula múltiples estados en el mismo nombre de clase.
El camino más común fuera de control es este:
- La página lee primero la base de datos local y muestra el valor anterior inmediatamente;
- Iniciar una solicitud remota al mismo tiempo y actualizar la memoria caché después de devolver el paquete;
- Para lograr una interacción fluida, primero cambie directamente el estado de la interfaz de usuario y realice una actualización optimista;
- Otra página lee otro valor del campo singleton del Repositorio; -Finalmente, se completa la descarga asincrónica de la base de datos y la página anterior se retrasa.
En este momento, lo que se ve en la superficie es que “la arquitectura está jerárquicamente completa”. De hecho, ya hay cuatro conjuntos de estados en el sistema que compiten por el derecho a interpretar.
Responden diferentes preguntas respectivamente:
- La base de datos quiere responder “¿Se puede restaurar la próxima vez que se inicie?”;
- La memoria caché quiere responder “¿Es este acceso rápido?”;
- El extremo remoto devuelve el paquete y quiere responder “¿Qué acaba de decir el servidor?”;
- El estado de la interfaz de usuario quiere responder “¿cómo debería representarse la interfaz en este momento?”.
Todas estas cosas son importantes, pero ser importantes no significa que todas puedan ser la fuente de la verdad.
Si no hay una definición clara de “quién es responsable de los valores de verdad persistentes, quién es sólo responsable de la presentación derivada y quién sólo puede leer pero no escribir”, el Repositorio degenerará lentamente en una estación de transferencia de estados. Capta toda la complejidad pero no elimina nada de ella.
Se abusa más fácilmente del repositorio como coordinador que “puede cambiar cualquier cosa”
El problema con muchos códigos no es que el Repositorio sea demasiado delgado, sino que es demasiado poderoso.
Un repositorio típico suele hacer estas cosas al mismo tiempo:
- Realizar solicitudes de red;
- Sala de lectura y escritura;
- Mantener el mapa de memoria; -Campos necesarios para ensamblar la UI;
- Revertir la actualización optimista en caso de falla;
- Envíe eventos cómodamente para notificar a otros módulos que se actualicen.
Parece muy concentrado, pero en realidad es la “capa de acceso a datos”, la “capa de coordinación de estado”, la “capa de estrategia de almacenamiento en caché” y la “capa de reglas de dominio” formando una bola.
Una vez que el Repositorio sea responsable tanto de la “agregación de lectura” como de la “coordinación de escritura de múltiples fuentes”, naturalmente entrará en un estado incómodo: cualquiera puede cambiar datos a través de él, pero nadie puede saber rápidamente a qué observadores afectará en última instancia un cambio y qué ruta de reescritura se activará.
Por ejemplo, una operación de recopilación, muchas implementaciones son así:
suspend fun toggleFavorite(id: String) {
memory[id] = !(memory[id] ?: false)
dao.updateFavorite(id, memory[id]!!)
api.toggleFavorite(id)
}
Este código es convenientemente corto, pero combina tres niveles de semántica:
- La interfaz de usuario desea brindar información inmediata, así que primero cambie la memoria;
- Quiero mantener la coherencia local, así que escribo la biblioteca inmediatamente;
- El servidor es el verdadero árbitro, pero los resultados se devuelven al final.
El problema no es que “cambiar primero lo local” deba ser incorrecto, sino que la semántica del error no está definida.
¿Cómo converger si la interfaz se agota pero el servidor realmente tiene éxito? Si la escritura local tiene éxito pero la escritura remota falla, ¿quién retrocederá? Si se hace clic en dos páginas como favoritas al mismo tiempo, ¿cuál prevalecerá al final?
Una vez que estos problemas no se diseñan explícitamente, el Repositorio simplemente oculta la condición de carrera en un método aparentemente limpio.
El flujo puede propagar el estado, lo que no significa que garantice automáticamente la coherencia.
En los últimos años, a Android le ha gustado conectar Flow, StateFlow y SharedFlow al Repositorio y luego exponer una “fuente de datos receptiva” al flujo ascendente. Sin duda, esto es mejor que volver a llamar a todas partes, pero a menudo crea la ilusión de que mientras transmita los datos, el problema de coherencia desaparecerá.
No.
El flujo responsivo resuelve cómo se propagan los cambios, no quién determina los cambios.
El siguiente patrón es muy común:
val userFlow = combine(
dao.observeUser(id),
memoryStateFlow,
remoteRefreshStateFlow
) { local, memory, remote ->
mergeUser(local, memory, remote)
}
El mayor riesgo de este código no es que sea feo por escrito, sino que mergeUser() a menudo introduce silenciosamente decisiones comerciales:
- El nombre se basa en el extremo remoto;
- Si está en línea o no depende de la memoria;
- La lectura se determinará localmente;
- El estado de carga también se cuelga en la interfaz de usuario.
Lo que se necesita al final es un “resultado de unión que apenas pueda representar la página en este momento”.
Este tipo de empalme es muy conveniente en la ruta de lectura, pero puede salirse de control fácilmente en la ruta de escritura, porque ya es difícil de responder:
- ¿A qué nivel se debe cambiar un determinado campo?
- Después de que cambia una capa, ¿es necesario sincronizar otras capas?
- Después de reconstruir el proceso, qué campos aún se pueden reconstruir;
- Qué campos se sobrescribirán con nuevos valores durante la recuperación sin conexión.
Entonces, el fenómeno extraño en muchos proyectos es: cuanto más bellamente escrito está el flujo de datos, más metafísicos se vuelven los errores de estado. La causa fundamental es que no existe una única fuente estatal responsable en el sistema.
Lo que realmente debería controlarse es el límite de escritura.
La restricción más importante en el diseño del Repositorio es “dónde hay permiso de escritura”.
Si un objeto comercial puede modificarse mediante una actualización optimista de la interfaz de usuario, modificarse mediante la memoria caché del repositorio, rechazarse mediante el observador de la base de datos y sobrescribirse mediante paquetes de retorno de la interfaz, tarde o temprano encontrará problemas de inconsistencia de orden.
En lugar de seguir añadiendo abstracciones, recomiendo aclarar primero los límites de la escritura:
1. Elige primero la fuente de la verdad
No todos los escenarios requieren que “la base de datos local sea la única fuente de verdad”, pero se debe seleccionar una fuente primaria.
- En escenarios donde la prioridad fuera de línea y la recuperación de listas son posibles, generalmente se debe utilizar la base de datos local;
- En escenarios donde el tiempo real es fuerte y no se pueden aceptar valores antiguos, se pueden utilizar resultados remotos;
- Los estados de interacción pura de la interfaz, como expansión, selección y entrada, deben dejarse explícitamente en el estado de la interfaz de usuario y no volver a volcarse al Repositorio.
La clave es no depender de la base de datos para determinar la mitad de los campos, la otra mitad de los campos se determinará en la memoria y luego confiar en la interfaz de usuario para llenar el vacío cuando ocurre un error.
2. Separe el “estado derivado” y el “estado persistente”
Gran parte de la confusión proviene de escribir el estado de visualización temporal en la capa de persistencia.
Por ejemplo:
isLoadingisRefreshingisExpandedpendingRetryCount
Estos estados pueden determinar cómo se dibuja la interfaz de usuario, pero no deben mezclarse con valores de verdad empresarial en la misma entidad ni distribuirse.
Una vez que el estado derivado se coloca en el modelo público del Repositorio, se reutilizará por error entre diferentes páginas y diferentes ciclos de vida. Al final, ni siquiera está claro que “este campo aún conserva el valor de la última página”.
3. Haga que la ruta de escritura sea menor que la ruta de lectura
Las lecturas se pueden agregar y las escrituras se pueden cerrar.
Puede juntar la base de datos, la memoria y las señales de actualización remota al leer para darle a la página un modelo suficiente; pero al escribir, es mejor tomar sólo un camino controlado y dejar que él decida:
- Si escribir localmente primero;
- si se requiere compensación; -Si está permitido sobrescribir versiones antiguas;
- Si se debe incluir un número de versión o una marca de tiempo;
- Qué semántica debería ver la interfaz de usuario después de un error.
Cuantas más entradas de escritura permita el sistema, más coherencia dependerá de “no cometer errores”. No es diseño, es suerte.
Un contraejemplo común: para “experimentar una suavidad sedosa”, primero haga cambios antes de hablar de ello.
La forma más sencilla de escribir una coherencia de estado incorrecta es la pequeña decisión de “esta interacción es muy simple, cambiémosla localmente primero”.
Por ejemplo, los me gusta, las colecciones, los seguidores y las lecturas son demasiado fáciles de considerar como “cambiar la interfaz de usuario primero y luego fallar”. El problema es que una vez que cruzan páginas, listas y niveles de almacenamiento en caché, ya no son decisiones pequeñas.
Los casos de falla suelen verse así:
- Haga clic en Favorito en la página de detalles y el botón se iluminará inmediatamente;
- La página de lista también monitorea el mismo estado de la memoria del Repositorio, por lo que se ilumina sincrónicamente;
- La interfaz expira y el Repositorio activa la reversión;
- Pero la página de lista ya obtuvo el valor anterior debido al observador de la base de datos, y el orden de reversión es diferente al de la página de detalles;
- El usuario vuelve al nivel anterior y ve que el estado de las dos páginas es inconsistente;
- Después de reiniciar el proceso de eliminación, se vuelve al tercer resultado.
Lo más molesto de este tipo de problema es que no siempre se repite, por lo que el equipo puede atribuirlo fácilmente a “problemas de sincronización del flujo”, “problemas de reorganización de redacción” o “fluctuaciones esporádicas de la red”.
De hecho, la causa raíz es más simple: ** permite que varias capas tengan la calificación para escribir el resultado final al mismo tiempo. **
Este servicio tiene niveles de responsabilidad, no de pulcritud formal.
No estoy en contra de las capas del repositorio. Sin el Repositorio, muchos proyectos de Android serían aún más complicados.
Pero lo que realmente debería proporcionar el Repositorio es:
- ¿Puedes explicar de dónde viene el camino de la lectura? -Escribir qué sentencias atravesó el camino y si se puede exigir responsabilidad;
- Quién prevalecerá cuando ocurra un error y si éste podrá recuperarse;
- Lo que se comparte entre páginas es el valor real del negocio o el estado de visualización temporal, y si se puede separar.
Si estas preguntas no pueden responderse, no importa cuán hermosas sean las capas, será solo un orden visual.
Hace que el código se parezca más a un diagrama de arquitectura, pero no necesariamente hace que el estado se parezca más a un sistema.
Límites aplicables
Este artículo se centra principalmente en:
- Tener caché o sala local;
- Estado de uso compartido de varias páginas;
- Busque simultáneamente la velocidad de la primera pantalla, la recuperación fuera de línea y la retroalimentación interactiva instantánea;
- Utilice Repository + Flow/StateFlow para organizar la lectura y escritura de datos.
Si la aplicación es muy liviana, los datos son casi siempre una solicitud única, la página está lista para usar y no hay necesidad de sincronización entre páginas, entonces, incluso si el Repositorio está escrito de manera simple y tosca, el problema de coherencia del estado no será particularmente prominente.
El verdadero problema es para proyectos que son “medianos a grandes, pero aún no lo suficientemente grandes como para tener una plataforma completa”: cada vez hay más funciones y las fuentes de datos se vuelven cada vez más complejas, pero el equipo todavía está usando el método inicial de “empaquetar una capa de Repositorio primero y luego hablar sobre ello” para respaldarlo. En esta etapa, es más probable que aparezcan sistemas que tienen una estructura ordenada y un comportamiento caótico.
Resumen
El malentendido más común sobre las capas del repositorio en Android es confundir “entrada de acceso unificado” con “estado natural unificado”.
Las entradas unificadas sólo pueden reducir la confusión en la superficie de llamada; Sólo aclarando la fuente de la verdad, cerrando los límites de escritura y definiendo la semántica del fracaso de antemano podremos realmente reducir las luchas estatales.
De lo contrario, lo que se necesita es un conjunto ordenado de elementos que sean más difíciles de responsabilizar.
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