ViewModel: Antipatrones de eventos únicos

Los eventos de ViewModel son acciones provenientes de ViewModel que la IU debe realizar. Por ejemplo, mostrar un mensaje informativo al usuario o navegar a una pantalla diferente cuando cambia el estado de la app.

Nuestra guía sobre los eventos de ViewModel está sesgada de dos formas diferentes:

  1. Siempre que se origine un evento único en el ViewModel, este debe controlarlo de inmediato, lo que provoca una actualización de estado. El ViewModel solo debe exponer el estado de la app. La exposición de eventos que no se redujeron a un estado de ViewModel implica que el ViewModel no es la fuente de información principal para el estado derivado de esos eventos. El flujo unidireccional de datos (UDF) describe las ventajas de enviar eventos solo a los consumidores que duran más tiempo que los productores.
  2. Se debe exponer el estado mediante un tipo de soporte de datos observable.
El estado sigue el UDF y fluye hacia abajo del ViewModel a la IU, y los eventos suben desde la IU hasta el ViewModel

Es posible que en tu app expongas eventos de ViewModel a la IU con canales de Kotlin u otras transmisiones reactivas, como SharedFlow, o bien quizás se trate de un patrón que observaste en otros proyectos. Cuando el productor (el ViewModel) dura más tiempo que el consumidor (IU, Compose o Views), que puede ser el caso de los eventos de ViewModel, estas API no garantizan el envío y procesamiento de esos eventos. Esto puede dar lugar a errores y problemas futuros para el desarrollador y constituye una experiencia de usuario inaceptable para la mayoría de las apps.

Debes controlar los eventos de ViewModel de inmediato, lo que provocará una actualización del estado de la IU. Intentar exponer los eventos como un objeto mediante otras soluciones reactivas, como Channel o SharedFlow, no garantiza el envío y procesamiento de los eventos.

Caso de éxito

A continuación, se muestra un ejemplo de implementación de un ViewModel en el flujo de pagos típico de una app. En los siguientes fragmentos de código, el MakePaymentViewModel directamente indica a la IU que deberá dirigirse a la pantalla de resultados de pago cuando regrese el resultado de la solicitud de pago. Usaremos este ejemplo para explorar por qué el control de eventos puntuales de ViewModel como este causa problemas y mayores costos de ingeniería.

La IU consume este evento y navega en consecuencia:

La implementación de navigateToPaymentResultScreen que se muestra arriba tiene varios defectos de diseño.

Antipatrón n.º 1: Se puede perder el estado de la finalización del pago

Un canal no garantiza el envío y procesamiento de los eventos. Por lo tanto, los eventos pueden perderse, lo que provoca un estado incoherente en la IU. Un ejemplo de esto podría ocurrir cuando la IU (consumidor) pasa a segundo plano y detiene la recopilación del Channel justo después de que el ViewModel (productor) envía el evento. Lo mismo puede decirse de otras API que no son un tipo de soporte de datos observable, como SharedFlow, que podrían emitir eventos aunque no haya consumidores escuchando.

Este es un antipatrón porque el estado del resultado de pago modelado en la capa de la IU no es durable ni atómico si pensamos en una transacción ACID. Puede que el pago haya tenido éxito en lo que respecta al depósito, pero nunca pasamos a la siguiente pantalla adecuada.

Nota: Este antipatrón se podría mitigar con Dispatchers.Main.immediate cuando se envían y reciben eventos. Sin embargo, si esto no se aplica mediante una comprobación de lint, esta solución podría ser propensa a errores, ya que los desarrolladores podrían olvidarla con facilidad.

Antipatrón n.º 2: Indicarle a la IU que realice una acción

Para una app que admite varios tamaños de pantalla, la acción de IU que se realiza dado un evento ViewModel podría ser diferente según el tamaño de la pantalla. Por ejemplo, la app de casos de éxito debe ir a la pantalla de resultados de pago cuando se ejecuta en un teléfono celular; pero si se ejecuta en una tablet, la acción podría mostrar el resultado en una parte diferente de la misma pantalla.

El ViewModel debe indicar a la IU cuál es el estado de la app y la IU debe determinar cómo reflejarlo. El ViewModel no debería indicar a la IU las acciones que debe realizar.

Antipatrón n.º 3: No controlar el evento único de inmediato

Modelar el evento como algo que se envía y se olvida es lo que causa problemas. Cumplir con las propiedades ACID es más difícil, por lo que no se puede garantizar la máxima fiabilidad e integridad de los datos. Según el estado, suceden eventos. Cuanto más tiempo pase sin controlarse un evento, más difícil será el problema. Para los eventos de ViewModel, procesa el evento tan pronto como sea posible y genera un nuevo estado de IU a partir de él.

En el caso de éxito, creamos un objeto para el evento (representado como un Boolean) y lo expusimos con un Channel:

// Create Channel with the event modeled as a Boolean
val _navigateToPaymentResultScreen = Channel<Boolean>()
// Trigger event
_navigateToPaymentResultScreen.send(isPaymentSuccessful)

Una vez que lo hagas, habrás asumido la responsabilidad de garantizar cuestiones como el envío y control exactos. Si por alguna razón debes modelar un evento como un objeto, limita su tiempo de vida para que sea lo más corto posible y no tenga oportunidad de perderse.

El control de un evento puntual en ViewModel se suele reducir a una llamada de método; por ejemplo, la actualización del estado de la IU. Una vez que se llama a ese método, se sabe si se completó con éxito o si hubo una excepción, y se sabe que ocurrió exactamente una vez.

Mejoras en el caso de éxito

Si te encuentras en una de estas situaciones, vuelve a considerar lo que en verdad significa ese evento único del ViewModel para tu IU. Contrólalos de inmediato y redúcelos al estado de la IU que se expone con un soporte de datos observable, como StateFlow o mutableStateOf.

El estado de la IU representa mejor la IU en un momento dado, ofrece más garantías de envío y procesamiento, suele ser más fácil de probar y se integra de forma coherente con el resto de la app.

Si te cuesta encontrar una forma de reducir los eventos puntuales de ViewModel a un estado, vuelve a considerar lo que en verdad significa ese evento para tu IU.

En el ejemplo anterior, el ViewModel debe exponer lo que son los datos reales de la app (los datos de pago, en este caso) en lugar de indicarle a la IU la acción que debe realizar. La siguiente es una mejor representación de ese evento del ViewModel controlado y reducido al estado, y expuesto con un tipo de soporte de datos observable.

En el código anterior, el evento se controla de inmediato cuando se llama a _uiState.update (#L28) con los nuevos datos de paymentResult (#L31). Así, no hay forma de que el evento se pierda. El evento se redujo a estado y el campo paymentResult en MakePaymentUiState refleja los datos de la solicitud del resultado del pago.

Así, la IU reaccionaría ante los cambios en el paymentResult y actuaría en consecuencia.

Nota: En tu caso de uso, la Actividad no finish() y se guarda en la pila de atrás, el ViewModel necesitaría exponer una función para borrar el paymentResult del UiState (es decir, fijando el campo en null) que se llamará después de que la Actividad inicie la otra. Puedes encontrar un ejemplo de esto en la sección Cómo los eventos de consumo pueden activar actualizaciones de estado de la documentación.

Como se mencionó en la sección Consideraciones adicionales de la capa de la IU, puedes exponer el estado de la IU de tu pantalla con varias transmisiones, si eso es lo que requiere tu caso de uso. Lo importante es que esas transmisiones sean tipos de soporte de datos observables. En el ejemplo anterior, se expone una transmisión de estado de IU única porque la marca isLoading y la propiedad paymentResult están muy relacionadas. Si se separan, podría haber incoherencias en la IU; por ejemplo, si isLoading es true y paymentResult no es null. Si están juntas en la misma clase de UiState, podemos diferenciar con mayor claridad los campos que conforman el estado de la IU de la pantalla, lo que causa menos errores.

Esperamos que esta entrada de blog te haya ayudado a entender las razones por las que recomendamos 1) controlar de inmediato los eventos puntuales de ViewModel y reducirlos al estado, y 2) exponer el estado con un tipo de soporte de datos observable. Creemos que este enfoque te ofrece más garantías de envío y procesamiento, suele ser más fácil de probar y se integra de forma coherente con el resto de tu app.

Declinación de garantías: Al igual que el resto de nuestras orientaciones sobre arquitectura, considera esta entrada como una guía y adáptala según tus necesidades.

Para obtener más información acerca de este tema, consulta la documentación sobre eventos de la IU.

Agradecemos especialmente a Adam Powell por los interminables debates, conocimientos y aportes que sumó a esta entrada de blog. También agradecemos a Ale Stamato y a Jose Alcérreca por sus revisiones minuciosas.

Source: Google Dev

Deja un comentario