En este artículo, verémos como implementar tu propia libreria de Redux con Kotlin y Rx. Primero, lo primero, pequeña introducción a libreria de Redux:


En esencia Redux es una arquitectura que tiene tres pilares:

  1. Una sola fuente de verdad para tu aplicación, a la cual se llama estado.
  2. Inmutabilidad, este estado no puede ser modificado, solo substituido por otro.
  3. Pureza, una sola función sin efectos secundarios (llamada reducer) que genera estos nuevos estados a partir de acciones.

 

Como implementar tu propia libreria de Redux con Kotlin y Rx

Normalmente el estado está encapsulado en lo que se suele llamara “Store”, al crear el store le diremos cual es el estado inicial de la aplicación y cuál es la función reductora.
A partir de entonces nos podemos suscribir a los cambios de estado y mandarle acciones por tal de modificar el estado de la aplicación.

Pequeño ejemplo de un contador:
El caso de ejemplo para este articulo sera una simple contador, pero esta arquitectura es completamente escalable.

Las acciones serán dos:

sealed class Action {
    object Increment : Action()
    object Decrement : Action()
    class Set(val value: Int) : Action()
}

Gracias a las sealed classes de kotlin esto nos permitirá tener un switch case exhaustivo con el añadido de que las acciones podrán tener distintos parámetros.

Y la función reductora seria:

fun reducer(state: Int, action: Action) : Int = when(action) {
    is Action.Increment -> state+1
    is Action.Decrement -> state-1
    is Action.Set -> action.value
}

Implementacion basica con Rx:
Si rebuscamos un poco en las funciones disponibles en Rx, nos daremos cuenta rápidamente que existe un función reduce que tiene como parámetros una semilla y una función reducer. Así que parece que ya tenemos todo el trabajo hecho:


Creamos un subject para pasar las acciones:

val actions: Subject = PublishSubject.create()

Y le aplicamos la función reduce para tener nuestros estados:

val states = actions.reduce(0, ::reducer)

Ya esta, ahora para realizar una acción simplemente se la pasamos al subject:

actions.onNext(Action.Increment)

El problema es que el método reduce de Rx retorna un Single, no un Observable, así que solo tendremos nuestro estado final si le decimos a las acciones que hemos terminado.

actions.onComplete()

Pero no queremos eso, lo que queremos es que nuestra aplicación reaccione a todos las acciones a medida que se realizan, no cuando terminemos con ella.

Creando el Store:
La solución a este problema se hace evidente si realmente encapsulamos esta lógica en una clase que mantenga en todo momento el estado actual:

class Store<S, A>(initialState: S, private val reducer: (S, A) -> S){

    var currentState = initialState
        private set

    val actions = PublishSubject.create()

    val states= actions.map {
        currentState = reducer(currentState, it)
        currentState
    }
}

Además, al realizar este paso ganamos una nueva funcionalidad, podemos consultar el estado en un momento puntual sin necesidad de suscribirnos a todos los cambios de estado.

Pero si realizamos la siguiente prueba veremos que todavía no hemos acabado:

store.states.subscribe { println("state $it") }
    store.actions.onNext(Action.Increment)
    store.actions.onNext(Action.Increment)
    store.states.subscribe { println("sorry I am late $it") }
    store.actions.onNext(Action.Increment)
    println("final state: ${store.currentState}")

La salida de este código no es la que esperaríamos, nosotros solo hemos realizado tres acciones de incremento, pero al añadirse una nueva suscripción la salida es la siguiente:
state 1
state 2
state 3
sorry I am late 4
final state: 4

Cada vez que llega una nueva acción, esta se ejecuta por cada suscriptor que hay en el observable. Lo cual además significa que si nadie lo está observando nuestras acciones se pierden.
Por suerte este problema lo podemos solucionar en una escueta línea de código:

val states: Observable = actions.map {
        currentState = reducer(currentState, it)
        currentState
    }.publish().apply { disposable = connect() }

Con el publish() creamos un connectable observable que se espera a que haya una conexión para empezar a emitir estados.
Con el ‘.apply { disposable = connect() }’ nos conectamos nada más crearlo, de esta manera todas las acciones serán ejecutadas aunque no tengamos ningún suscriptor. También nos guardamos el disposable de dicha conexión en caso de tener que implementar algún tipo de ciclo de vida para la Store.

Demasiada pureza:

Con una buena función reductora, sin ningún efecto secundario y retornando siempre estados válidos, nos encontramos con un problema:
Modificar el estado a causa de acciones del usuario es sencillo, pero que ocurre cuando queremos mantener una persistencia, hacer logs o coger datos de un backend?
Nuestra función reductora no puede encargarse de esas acciones, especialmente si son asíncronas. Para esto tenemos que introducir un nuevo concepto a la mezcla que nos ayuden a realizar efectos secundarios pero manteniendo la función reductora como una función pura y pudiendo controlar qué efectos secundarios se realizan.

Middlewares:
Un Middleware es un componente de software que nos permitirá introducir los efectos secundarios deseados sin romper con la arquitectura como está planeada.

interface Middleware<S, A> {
    fun dispatch(store: Store<S, A>, next: (A) -> Unit, action: A)
}

Un middleware se compone de una sola función que recibe como parámetros la store en la que está instalado, un función para continuar con el flujo de ejecución y la acción a realizar.
De este modo el middleware tiene toda la información que pueda necesitar así como un mecanismo para continuar la ejecución.

Apium Academy

El siguiente ejemplo seria un middleware que no realiza ninguna acción:

val doNothing = object : Middleware<Int, Action> {
    override fun dispatch(store: Store<Int, Action>, next: (Action) -> Unit, action: Action) {
        next(action)
    }
}

Y este uno que imprima por pantalla el estado actual antes y después ejecutar una acción:

val printLogger = object : Middleware<Int, Action> {
    override fun dispatch(store: Store<Int, Action>, next: (Action) -> Unit, action: Action) {
        println("State is ${store.currentState} before action: $action")
        next(action)
        println("State is ${store.currentState} after action: $action")
    }
}

Como es el propio middleware el que decide en qué punto de su código se ejecuta la acción, tienen flexibilidad para realizar todo tipos de tareas, incluido llamadas asíncronas. Pudiendo capturar la acción que indica que se ha de mandar la petición y mandando una nueva acción cuando lleguen los datos.

Añadimos un vararg a nuestra store para poder añadir cuantos middleware sean necesarios.
Para poder componer de forma sencilla los Middleware vamos a crear una función que los descomponga de manera que podamos pasar los parámetros de uno en uno:

fun Middleware<S, A>.decompose() =
        { s: Store<S, A> ->
            { next: (A) -> Unit ->
                { a: A ->
                    dispatch(s, next, a)
                }
            }
        }

Esta función nos descompone el middleware de manera que podemos pasar primero la store, después la función de continuación y para acabar la acción, esto nos será útil de aquí a un momento.

Ahora definiremos una función que nos permita componer Middlewares:

private fun Middleware<S, A>.combine(other: Middleware<S, A>) = object : Middleware<S, A> {
        override fun dispatch(store: Store<S, A>, next: (A) -> Unit, action: A) =
            this@combine.dispatch(store, other.decompose()(store)(next), action)
    }

Gracias a que hemos podido descomponer el segundo middleware podemos pasarle solo dos de los parámetros y pasar la función resultante como next para el otro.

private inline fun <R, T : R> Array.reduceOrNull(operation: (acc: R, T) -> R) =
        if (this.isEmpty()) null else this.reduce(operation) 
   
private fun Array>.combineAll(): Middleware<S, A>? =
            this.reduceOrNull { acc, middleware -> acc.combine(middleware)}

Con estas dos funciones ahora podemos reducir todo un Array de middlewares de forma sencilla. Con esto ya podemos realizar el último para para terminar la Store.

private val actions = PublishSubject.create()
    fun dispatch(action: A) = middlewares.combineAll()?.dispatch(this, actions::onNext, action) ?: actions.onNext(action)

En lugar de exponer directamente las acciones, añadimos una función que nos combina todos los Middleware y usa el resultado, o en caso de no haber Middlewares, le manda el resultado directamente al Subject de acciones.

Resultado final:

Hemos conseguido escribir una librería de unas 40 líneas que implementa las funcionalidades más importantes para implementar libreria de redux y con soporte incorporado a Rx out of the box:

interface Middleware<S, A> {
    fun dispatch(store: Store<S, A>, next: (A) -> Unit, action: A)

    fun combine(other: Middleware<S, A>) : Middleware<S,A> = object : Middleware<S, A> {
        override fun dispatch(store: Store<S, A>, next: (A) -> kotlin.Unit, action: A) =
            this@Middleware.dispatch(store, other.decompose()(store)(next), action)
    }

     fun decompose() =
        { s: Store<S, A> ->
            { next: (A) -> Unit ->
                { a: A ->
                    dispatch(s, next, a)
                }
            }
        }
}

inline fun <R, T : R> Array.reduceOrNull(operation: (acc: R, T) -> R) = if (this.isEmpty()) null else this.reduce(operation)

fun <S,A> Array>.combineAll() =
    this.reduceOrNull { acc, middleware -> acc.combine(middleware) }

class Store<S, A>(initialState: S, private val reducer: (S, A) -> S, private vararg val middlewares: Middleware<S, A>) {

    var currentState = initialState
        private set

    private val disposable: Disposable

    private val actions = PublishSubject.create()

    val states: Observable = actions.map {
        currentState = reducer(currentState, it)
        currentState
    }.publish().apply { disposable = connect() }


    fun dispatch(action: A) =
        middlewares.combineAll()?.dispatch(this, actions::onNext, action) ?: actions.onNext(action)
}

Y no te olvides de suscribirte a nuestro boletín mensual para recibir más información sobre libreria de redux.