Hay algunos conceptos que están empezando a resonar muy fuerte en Android, y Unidirectional Data Flow es uno de ellos.
Como puede que sepas si estás apuntado a la newsletter, me he planteado escribir una serie de artículos sobre MVI (Model View Intent), y me he topado con que el tema es más complejo de lo que esperaba.
Así que en este primer artículo del mini-curso sobre MVI te quiero hablar sobre un paso anterior: qué significa Unidirectional Data Flow (o Flujo Unidireccional de Datos), y cómo puedes aplicarlo a tus proyectos.
Un dato curioso: es muy posible que ya lo estés usando y no lo sepas 😄
¿Qué es Unidirectional Data Flow?
En sí el concepto es realmente sencillo.
Piensa que una App al final no es más que una serie de acciones que el usuario realiza sobre la interfaz de usuario.
Estas acciones pueden ser activas, como pulsar un botón, o más bien pasivas, como que se abra una nueva pantalla.
¿Y qué ocurre cuando se realizan estas acciones? Normalmente esto producirá una actualización del estado: por ejemplo que se añada un nuevo elemento a una base de datos, o que se pidan ciertos datos a un servidor que nos recuperen más información.
El siguiente paso natural es, con esos datos, irnos actualizar la pantalla para pintar los cambios.
Pues ya está, si esto lo generalizas para cualquier interacción que el usuario pueda realizar, ya estás cumpliendo Unidirectional Data Flow.
Unidirectional data Flow implica que los datos tienen un único camino por el que pueden transferirse a otras partes de la aplicación
El flujo de datos sigue un único camino
Es posible que te hayas encontrado en situaciones como la siguiente:
Tienes un formulario en el que al pulsar un botón, ciertas partes de la UI se tienen que quedar inactivas hasta que ocurra algo. Por ejemplo, que recibas un resultado del servidor.
Así que en el evento del botón, lo desactivas, desactivas algunos campos, incluso cambias el mensaje y haces la petición. Por el camino ocurren otras cosas y de repente hay un caso en el que se te ha olvidado que te deja algún campo bloqueado.
Obviamente, en un caso tan simple como este es difícil que pase, pero cuando ya hay múltiples casuísticas que hacen que desde muchos puntos se pueda actualizar la interfaz y los datos, se producen muchas condiciones que se nos van a escapar.
¿Qué ocurre en Unidirectional Data Flow? Que esto no debe ocurrir.
La interfaz solo se encarga de lanzar una acción, la acción modifica el estado que representa la interfaz, y esta es actualizada en base a ese estado.
Unidirectional Data Flow es más fácil de testear
Toda la casuística de la interfaz de usuario queda modelada en un estado, y testear la UI es “tan fácil” como comprobar que tras una acción el modelo queda en el estado que esperas.
Si has desarrollador interfaces, ya sabes que esto no es tan simple, pero al menos sabes que el modelo representa el estado. Otra cosa es que la vista no sea capaz de interpretarlo correctamente.
¿Y cómo se implementa Unidirectional Data Flow en Android?
Bueno, como ves es un concepto muy flexible, que en principio no da detalles de implementación, pero es muy posible que ya estés haciendo algo parecido sin habértelo planteado.
Si ya usas los ViewModel de los architecture components y tienes un LiveData o un Flow que devuelve una entidad que representa el estado de la UI, pues ya lo tienes.
Cualquier acción en la UI llamará a una función del ViewModel. Esta función hará lo que necesite (llamar a un caso de uso, o un repositorio, actualizar una base de datos, hacer una petición a un servidor), y actualizará el modelo.
Al usar un LiveData, cualquier cambio en ese modelo se propagará a la vista, que actualizará la interfaz.
Unidirectional Data Flow está muy atado a la Programación Reactiva
La realidad es que no es estrictamente necesario para cumplir las reglas, pero no es casualidad que el concepto se popularizara con React.
Como la UI depende de los cambios de un modelo, el actuar reactivamente a esos cambios te va a ayudar mucho. Puedes echarle un vistazo a mi curso gratuito de Programación Reactiva en Android con Kotlin si te interesa profundizar en el tema.
¿Y por qué ahora esta popularidad de Unidirectional Data Flow?
Es una idea que encaja muy bien con el concepto de interfaces declarativas.
Desde la aparición de React, y luego con React Native, Swift UI, Flutter y ahora Jetpack Compose, nos estamos encontrando con que la interfaz lo que hace es actualizarse cada vez que un estado cambia.
Y esto encaja perfectamente con el concepto que estamos viendo aquí.
No es necesario utilizar una interfaz declarativa para implementarlo, y todo lo que vamos a ver aquí aún utilizará las vistas clásicas de Android. Pero allí encajará de forma aún más natural si cabe.
Implementando Unidirectional Data Flow en Android con Kotlin
Lo primero que quiero aclarar es que, como ya hablamos al principio, este concepto es muy flexible, y por tanto hay muchas formas de implementarlo.
Puedes encontrar montones de formas por ahí en función de quien lo haya implementado, normalmente siguiendo conceptos como Redux, MVI o similares.
Aquí vamos a lo más básico que cumpla con esta regla. Voy a utilizar los ViewModel de los Architecture Components por simplicidad.
Para ello, vamos a crear una pantalla de Login que tiene dos campos de usuario y contraseña, un botón, un ProgressBar
para mostrar el progreso y un TextView
para mostrar un mensaje de error.
Cuando se pulse al botón, éste se bloqueará (para que no se pueda seguir haciendo click) y se mostrará el progreso hasta que obtengamos una respuesta.
El estado (ViewState)
Necesitamos representar las diferentes situaciones posibles de la interfaz en un modelo, para que cada vez que cambie se actualice la UI en consecuencia.
En nuestro caso tenemos algo sencillo:
data class LoginViewState( val loading: Boolean = false, val error: String? = null )
Por ir usando nombres similares a los que veremos en MVI, a los modelos los vamos a llamar ViewState
.
El ViewModel
Es el que se encargará de modificar el ViewState
y el que nos permitirá también suscribirnos a las modificaciones del mismo.
Primero vemos los segundo. Para suscribirnos a esas modificaciones podemos usar un LiveData
, pero en esta ocasión vamos a usar StateFlow
como ya vimos.
Creo que es el futuro de Android, así que mejor irnos acostumbrando a usarlo:
class LoginViewModel : ViewModel() { private val _viewState = MutableStateFlow(LoginViewState()) val viewState: StateFlow<LoginViewState> get() = _viewState ... }
Ahora solo nos queda poder lanzar las acciones de la vista par que modifiquen el LoginViewState
. De momento, lo vamos a modelar con simples funciones:
class LoginViewModel : ViewModel() { ... fun loginClicked(email: String, pass: String) { viewModelScope.launch { _viewState.value = LoginViewState(true, null) delay(2000) _viewState.value = LoginViewState(false, "Test Error") } } }
Estoy usando las Corrutinas de Kotlin para poder simular un delay sin bloquear el hilo principal.
Simplemente vamos a lanzar un error para mostrarlo en pantalla. La navegación la dejaremos para siguientes artículos.
La vista
Lo primero de todo es recuperar el ViewModel
. Con las extensiones de KTX es muy sencillo:
private val vm by viewModels<LoginViewModel>()
Luego hacemos el binding con ViewBinding, y en el click del botón llamamos al ViewModel para que valide el login:
binding = ActivityMainBinding.inflate(layoutInflater).apply { setContentView(root) login.setOnClickListener { vm.loginClicked( username.text.toString(), password.text.toString() ) } }
Para actualizar la interfaz cada vez que se modifique el estado, tenemos que suscribirnos al Flow. Lo haré con las últimas funciones de la librería de que vimos en este artículo:
addRepeatingJob(Lifecycle.State.STARTED) { vm.viewState.collect(::render) }
La función render
simplemente convierte el estado en modificaciones de la UI:
private fun render(loginViewState: LoginViewState) = with(binding) { loading.visible = loginViewState.loading login.isEnabled = !loginViewState.loading message.isVisible = loginViewState.error != null loginViewState.error?.let(message::setText) }
Las acciones también se pueden modelar con clases
Una cosa muy chula que se puede hacer, y que además te puede permitir extraer clases genéricas que te hagan la mayor parte del trabjo, es modelar estas acciones y que el ViewModel solo tenga un evento para ejecutar una acción.
sealed class LoginEvent { class DoLogin(val email: String, val pass: String) : LoginEvent() }
Por no liarlo con otro concepto que veremos más adelante, lo vamos a llamar Event
.
Ahora mismo solo tenemos un tipo de evento, pero esto es normal que crezca y que en una misma pantalla tengamos varias interacciones por parte del usuario.
Ahora necesitamos una función que admita y procese estos eventos en el ViewModel
:
fun process(event: LoginEvent) { when(event){ is LoginEvent.DoLogin -> doLogin(event.email, event.pass) } }
Aunque aquí haría falta una función por cada evento (que tampoco es algo grave), piensa que en una Clean Architecture al uso estos eventos se convertirían en llamadas a casos de uso.
Por tanto, tan solo con esa función y un mapeo de los eventos a los casos de uso, el ViewModel
estaría solucionado.
Conclusión
Esta ha sido una primera toma de contacto con los patrones unidireccionales, para que veas que seguramente lo que ya estabas haciendo de antes te sirve para muy fácilmente adaptarte a las nuevas reglas.
En el próximo artículo vamos a ver en qué consiste MVI, que ya con esta base nos va a ser mucho más sencillo de comprender.
0 comentarios