Como implementar Inyección de Dependencias en Swift

Hasta ahora nos estábamos preocupando más bien poco sobre un tema que si se hace bien nos puede traer grandes beneficios. Este no es otro que aplicar el principio de Inversión de Dependencias.

Si nos vamos a la Wikipedia, en este artículo se explica en detalle y nos dice esto en concreto:

  1. Los módulos de alto nivel no deberían depender de los módulos de bajo nivel. Ambos deberían depender de abstracciones (p.ej., interfaces).
  2. Las abstracciones no deberían depender de los detalles. Los detalles (implementaciones concretas) deben depender de abstracciones.

Así explicado la mayoría nos quedamos con cara de no entender nada. Pero si lo vemos con un ejemplo se entiende mejor.

En nuestra aplicación de ejemplo, en concreto en nuestro HomeViewModel, instanciamos un clase Configuration y otra ApiRestClient. Cuando empezamos a programar, esto suele ser lo más normal, necesitamos una clase que nos ayuda con un determinado tema, creamos una nueva instancia dentro de la clase que necesitamos y seguimos desarrollando.

Esto nos va a traer algunos problemas que a la larga nos pesarán bastante si nuestro código crece y crece (que lo hará).

  1. Imaginaros que tenemos una clase que almacena un dato concreto. Al principio, para no complicar la app, lo que hacemos es almacenar en UserDefaults, en nuestra clase instanciamos UserDefaults directamente y almacenamos. Además en otras clases que necesitamos leer este dato, vamos instanciando y leyendo el dato. Pasado un tiempo, se decide almacenar en base de datos, porque ahora se escriben más datos y en UserDefaults tantos datos son difíciles de manejar. Ahora, todas esas clases donde se estaba leyendo directamente a UserDefaults están fuertemente acopladas a este Framework.
  2. Por otro lado, si intentáis testear una de estas clases, os daréis cuenta de que es difícil hacerlo. No podremos testear por separado la funcionalidad en si de cada clase, vamos a tener siempre a UserDefaults interfiriendo sobre unos tests que se supone, deberían ser unitarios.

Bien, para solucionar todo este embrollo, llega la Inyección de Dependencias que sería la forma de aplicar el principio de Inversión de Dependencias. Siguiendo con el ejemplo anterior, una forma de solucionarlo sería pasando por constructor a todas las clases donde se lee o escribe una actracción.

Podemos tener una interface o protocol que nos diga que tenemos que implementar un método para leer y otro para escribir, pero esto no nos marca o exige como debemos hacerlo. Por otro lado, nos creamos una clase que implementa esta interface y que lee/escribe de UserDefaults, y se la pasamos por constructor a las clases que necesitamos. Cada una de estas clases ahora ya no tiene, ni que instanciar a UserDefaults, ni saber como se lee de este, y cambiar a otro sistema de almacenamiento sería mucho más sencillo, ya que simplemente se puede cambiar en la clase que maneja ahora la lectura/escritura de UserDefaults.

Bueno, dejando de lado la teoría y volviendo a nuestro proyecto. Estoy viendo que a nuestro proyecto le está pasando esto mismo, estamos instanciando dentro de clases otras, y esto va a suponer un problema a futuro. Para resolver en iOS estoy utilizando Resolver, que es una librería que nos ayudará con esto, pero revisando la documentación, me he dado cuenta que el creador va a deprecar la librería, pero tiene una nueva versión Factory, que se basa en la anterior, y básicamente nos permitirá hacer lo mismo.

Vamos con esta última entonces. Lo primero que tendremos que hacer es añadir la librería. Para ello, en File -> Add Packages y en la ventana que aparece, copiamos la url del repositorio https://github.com/hmlongco/Factory

En mi caso, he tenido problemas al añadir la librería, he tenido que añadir y eliminar 2 o 3 veces hasta que XCode la añadió correctamente y reconoció. Si usáis CocoaPods en vuestro proyecto, las instrucciones para instalar las podéis encontrar en el repositorio del autor de la librería.

Una vez añadida la librería, vamos a identificar aquellas clases que se están llamando dentro de otras.

El primer caso es el que comentamos antes, la clase HomeViewModel. Dentro de esta se está instanciando a la clase Configuration y a ApiRestClient.

Crearemos ahora un archivo nuevo que llamaremos AppInjection, en donde definiremos como instanciar estas clases. En este archivo importamos Factory y creamos una extension de Container (es una clase de nuestra librería).

Empezamos definiendo como instanciar Configuration. Creamos una propiedad configurationService y con la ayuda de Factory le decimos como debe instanciar Configuration. En este caso no necesita nada en el constructor.

Llegamos al caso de ApiRestClient. En este caso, necesitamos pasar en el constructor una instancia de Configuration. En este caso llamamos a nuestra propiedad configurationService.

import Factory


extension Container {
    static let configurationService = Factory(scope: .singleton) {
        Configuration()
    }
    
    static let apiRestClientService = Factory(scope: .singleton) {
        ApiRestClient(configuration: configurationService())
    }
}

Fijaros además que le estamos indicando a Factory en el parámetro scope el valor singleton. Con esto lo que conseguiremos es usar siempre la misma instancia de Configuration o ApiRestClient. El comportamiento por defecto es crear siempre una nueva instancia. Si deseamos otro comportamiento diferente se podría, os recomiendo leer la documentación de la librería.

Una vez tenemos esto listo vamos a ir un paso más allá, y añadiremos a Container también los ViewModels. Dentro de la extension Container añadimos:

static let homeViewModel = Factory() {
        HomeViewModel()
    }
    
    static let movieViewModel = Factory() {
        MovieViewModel()
    }

En este caso, no indicamos el scope, ya que queremos una nueva instancia de nuestros viewModel.

Una vez definido como se instancian nuestras clases, vamos a ver como utilizarlo.

Vamos a ViewModel, y vemos que tenemos algo así:

class HomeViewModel: ObservableObject {
    
    private let apiRestClient: ApiRestClient
    private let configuration: Configuration
    
    init() {
        self.configuration = Configuration()
        self.apiRestClient = ApiRestClient(configuration: configuration)
    }

Como veis, estamos instanciando dentro de nuestra clase Configuration y ApiRestClient. Veamos como librarnos de esto.

@Injected(Container.apiRestClientService) private var apiRestClient: ApiRestClient
    
init() {
        
}

Factory nos proporciona un wrapper @Injected, que nos permitirá obtener una instancia de la clase que necesitamos, y de este modo podremos limpiar el constructor.

En el caso de la vista, en lugar de utilizar @Injected, lo haré de la siguiente forma, ya que he detectado que junto a @ObservedObject la librería no funciona bien.

@ObservedObject var viewModel: HomeViewModel = Container.homeViewModel()

Y de este modo Factory nos dará una instancia del ViewModel que necesitamos. Se podría instanciar directamente el ViewModel en este punto, pero bajemos un momento hasta la preview. Y revisemos como cambia.

#if DEBUG
struct HomeView_Previews: PreviewProvider {
    static var previews: some View {
        let _ = Container.homeViewModel.register { MockHomeViewModel() }
        HomeView()
    }
}
#endif

Antes de esto, estábamos instanciando la vista y el viewModel, y asignando el ViewModel a la vista. Ahora lo que se hace es decirle a nuestro gestor de inyección de dependencias que vamos a cambiar nuestro HomeViewModel por MockHomeViewModel cuando mostremos la preview.

Esta misma técnica la podremos aplicar a la hora de hacer tests (que aún no llegamos, pero lo haremos).

Como conclusión, hemos modificado la forma en la que se asignan Configuration, ApiRestClient o los ViewModel, pero no la funcionalidad, aún así, la potencia que nos dará esto a medida que crezca el código será brutal, además de facilitarnos cambiar en nuestro código una pieza por otra con mucha facilidad. Por último, controlar como se instancia un objeto, a partir de ahora, se hará en un único sitio.