En 2019, hicimos un meetup en el que expusimos nuestro approach a la arquitectura en el desarrollo mobile. Aquí podéis ver la charla: https://youtu.be/nV2L2Ql_690

A modo de resumen intentaré explicar primero el concepto a alto nivel. La idea de este enfoque es llevar un paso más allá la inversión de dependencias y hacer que tanto la vista como los modelos se expongan mediante interfaces. Estas interfaces son conectadas mediante un “true presenter” o “binder” cuya única función es vincular las entradas y salidas de vista y modelo entre ellas. El resultado de esto es que tanto la vista como el modelo se desconocen, haciendo que a la hora de crear tests para el modelo puedas ignorar la vista por completo y lo mismo cuando creas test para la vista.

Implementación de la arquitectura

En aquel entonces usábamos una combinación de Rx y herencia para conseguir el patrón expuesto. Después del meetup nos propusimos mejorar este aspecto para hacer más fácil implementar esta arquitectura, así como explicarla a las nuevas incorporaciones al equipo.

Para poder llevar a cabo más fácilmente esta arquitectura, hemos implementado una librería en Kotlin. El core está hecho sin usar código java ni android, para que en un futuro se pueda utilizar en proyectos multiplataforma. El resto del artículo, así como los ejemplos, estarán enfocados en Android, que es la plataforma donde hemos podido usar la librería de una forma más extensa.

Para empezar vamos a introducir los conceptos básicos que vamos a manejar con la librería para después ver cómo con ellos podemos construir una aplicación.

  • Binder: es el encargado de juntar las entradas y salidas de vista y modelo.
  • Event: es una acción o mensaje que la vista o modelo lanzan hacia fuera.
  • Receiver: es el encargado de recibir y gestionar los eventos
  • Bindable: interfaz que define que una clase puede ser bindeada con otra así como los métodos para hacerlo.

Tipos de Binders

Los binders son en su concepto más simple y puro, una única función que junta Events con Receivers. A estos binders se accede extendiendo las siguientes interfaces (Bindables) que proporciona la librería. Ahora mismo hay tres tipos de Bindables capaces de proporcionar estos binders:

  • GlobalBind: Es un binder global sin scope, su principal función es bindear instancias que no están vinculadas a un ciclo de vida. Principalmente bindear servicios con otros servicios.
  • CoBindable: Es un alias de CoroutineScope, proporciona un binder vinculado al ciclo de vida del scope de corutinas.
  • ViewBindable: Es un alias de LifeCycleOwner de Android, proporciona un binder vinculado a dicho ciclo de vida.

Ejemplo:

fun ViewBindable.bindDataList(view: DataListView, service: DataListService) = bind {
    view.displayList via service.dataListReceived
    view.onError via service.errorReceived
    view.onLoading via service.startedFetching
    view.requestList via service.loadData
}

Definir Binders

Como se puede ver, definimos el binder como una función que extiende el Bindable “ViewBindable”, al extender esta interfaz tenemos a nuestra disposición el método bind, que nos proporciona como receiver a un Binder. El método “vía” es un método que proporcionan los binders, es el encargado de unir Events y Receivers del mismo tipo entre ellos.

Hablando de Events y Receivers. Así es como definimos las interfaces que recibe el Binder anterior:

interface DataListView {
    val requestList: Event
    val onError: Receiver
    val onLoading: ReceiverU
    val displayList: Receiver<List>
}

interface DataListService {
    val loadData: Receiver
    val errorReceived: Event
    val startedFetching: EventU
    val dataListReceived: Event<List>
}

Tanto Receivers como Eventos tienen un parámetro genérico, este el tipo que reciben o envían respectivamente. Para poder bindear un evento con un receiver estos deben tener el mismo tipo de parámetro. EventU y ReceiverU, se proporcionan por comodidad en caso de no necesitar enviar datos con el evento.

Implementación de interfaz 

Hasta ahora hemos visto cómo definir las interfaces de nuestras vista y nuestro modelo, y también cómo conectar sus entradas y salidas entre ellas. Vamos ahora a ver cómo podemos implementar estas interfaces y como efectivamente sus instancias no se van a conocer entre ellas.

class DataListFragment : Fragment(R.layout.fragment_main_list), DataListView {
   override val requestList: Event = event()

   override val onError: Receiver = receiver {
       ...
   }

   override val onLoading: ReceiverU = receiver {
       ...
   }
   override val displayList: Receiver<List> = receiver {
       ...
   }
}

Como se puede ver, la implementación de un evento es muy sencilla. Tan solo hay que llamar a la función “event”. Luego en nuestro código podremos llamar al evento como si de un método se tratara para lanzarlo y que todos los receiver que tiene vinculados sean ejecutados.

Los receivers tienen un poco más de interés, cuando llamamos al método “receiver” debemos pasar una lambda que tiene como argumentos un solo parámetro del mismo tipo que el receiver. Este es el código que se llamará cuando uno de sus eventos vinculados sea lanzado.

Si le damos un vistazo a cómo implementar el servicio veremos un par de detalles interesantes:

class NetworkDataListService(repository: NetworkDataRepository) : DataListService, Bindable {
    override val errorReceived: Event = event(retainValue = false)
    override val startedFetching: EventU = event(retainValue = false)
    override val dataListReceived: Event<List> = event()
    override val loadData: Receiver = suspendReceiver {
        startedFetching()
        runCatching { repository.getList(it) }.fold({
            dataListReceived(it)
        }, {
            errorReceived(Error(it))
        })
    }
}

Primero de todo, nuestro servicio debe implementar la interfaz Bindable, para simplificar las implementaciones acostumbramos a hacer que las interfaces que no son para vistas la implementen directamente:

interface DataDetailsService : Bindable {
   (...)
}

Así no hay que preocuparse de que quien implemente el servicio se tenga que acordar de añadir la interfaz en las implementaciones. Pero para de cara a este artículo es interesante ver todos los detalles.

Podemos ver dos conceptos más aparte de este detalle:

  • Por un lado en el evento le pasamos el parámetro retainValue = false  esto significa que este evento no retiene valores. Por defecto los eventos se comportan de forma similar a un  BehaviourSubject de Rx o un StateFlow de Kotlin, es decir, cuando un receiver se conecta a un evento el receiver recibe el último valor enviado por el evento. Cuando pasamos retainValue = false, el evento no retiene el último valor enviado y cuando este sea vinculado a un receiver este no recibirá ningún valor hasta que el evento sea lanzado de nuevo.
  • El otro detalle es que en lugar de receiver como en la vista aquí hemos usado suspendReceiver esta librería viene con soporte para corutinas y dentro de esta lambda, además del valor que nos mande el evento tenemos acceso al scope del binder, por lo que podemos lanzar corutinas o llamar a funciones suspendibles sin problema.

En este último ejemplo también podemos ver que desde dentro de la lambda de loadData podemos llamar a los eventos del propio servicio para mandar información a quien la esté escuchando.

Como se puede ver, ni la vista ni el servicio tienen visibilidad el uno del otro. Si quisiéramos testear de forma unitaria este servicio solo habría que mockear el repositorio de red (que es el que realmente nos interesa) y comprobar que los eventos de salida apropiados se lanzan en cada caso. Y por parte de la vista solo habría que comprobar que cuando llega un dato se pinta la vista que toca, o que cuando el usuario realiza una acción, esta desencadena un evento.

Con este sistema también podemos conectar servicios entre sí, por ejemplo bindeando un ErrorLogger a los eventos de error de otros servicios.

interface ErrorLogger : Bindable {
   val onError: Receiver
}

Cuando bindeamos servicios sin ciclo de vida entre sí, lo habitual es hacerlo usando el GlobalBinder, pues normalmente queremos que corran durante todo el tiempo de ejecución de la aplicación.

GlobalBind.bindDataListErrors(get(), get())
GlobalBind.bindDataDetailsErrors(get(), get())

Ya solo nos quedan dos cosas: Quién llama al binder y cómo escribir los tests. La librería es agnóstica al framework de inyección de dependencias que se use, pero hay que tener en cuenta las peculiaridad de Android: no podemos crear nosotros las instancias de las vistas. ¿Cómo lo solucionamos? El método “bind” que proporcionan los bindables devuelve la instancia del binder, de este modo lo que podemos hacer es inyectar el binder en el fragment. En nuestro caso usamos koin así que lo voy a usar como ejemplo:

private val binds = module {
   koinBind { dataList ->
       dataList.bindDataList(dataList, get())
   }
}

Definimos un módulo binds en el que definiremos nuestros binders que dependan de una vista de android, que la recibirán como parámetro. 

koinBind es un método helper para hacer más fácil la tarea de definir los módulos.

inline fun  Module.koinBind(noinline block: Scope.(T) -> Binder) {
   scope {
       scoped(named()) { (bindable: T) ->
           block(bindable)
       }
   }
}

Al haber definido los binders del fragment en un scope e instancia que llevan su nombre podemos recoger los binders fácilmente tal que así:

import org.koin.androidx.scope.lifecycleScope as koinScope
inline fun  T.applyBinds() = koinScope.get(named()) { parametersOf(this) }

Lo único que queda es llamar al método en el fragment y lo tendremos todo vinculado:

init {
   applyBinds()
}

Todo esto está muy bien, pero si no pudiéramos hacer los tests con la misma o más facilidad no serviría de nada. Por este motivo hemos creado en la librería un módulo con un pequeño framework de test para poder crearlos con la mayor facilidad posible:

@Test
    fun `load data dispatches fetch and error events if request fails`() {
        val error = Error()
        coEvery { repository.getList(any()) } throws error

        sut.loadData withParameter request shouldDispatch {
            sut.startedFetching withParameter Unit
            sut.errorReceived withType Error::class
            sut.errorReceived assertOverParameter {
                assertEquals(error, it.cause)
            }
        }
    }

Como se puede ver incluye varios métodos con los que poder escribir tests de una forma bastante semántica y con muy poco boilerplate. A parte de los aquí mostrados hay otros como: withAny Paramenter, withType o never Dispatched.

Esta librería está aún en progreso y hace muy poquito que la hemos abierto para que quien quiera pueda colaborar o experimentar con ella. Aquí esta el link al repositorio de Github donde además se incluye un sample donde se puede ver todo en funcionamiento: https://github.com/apiumhub/event-binder