Inyección de dependencia nativa en Swift

Compartir esta publicación

En este artículo vamos a repasar un par de soluciones a un problema común en el desarrollo de aplicaciones móviles, y es la Inyección de Dependencias.

Todos hemos visto un proyecto que llamaba a clases singleton desde cualquier parte del código sin ninguna seguridad a la hora de escribir datos en ellas, luchando con condiciones de carrera y acoplando implementaciones a todos estos problemas. También se pueden encontrar muchas librerías de terceros que pueden ayudar a resolver o al menos gestionar estos problemas, pero muchas veces no queremos o no podemos añadir dependencias extra, en este caso una buena aproximación es implementar la solución que se ajuste a tus necesidades de forma básica pero suficiente y con código nativo.

class AViewModel {
    let serviceA = ServiceA.shared
}

Esto no nos da ninguna garantía de que en el momento en que llamamos al servicio, la instancia no ha sido utilizada por algún otro código que podría implicar condiciones de carrera o comportamientos no deseados si no se maneja. También vale la pena mencionar que esta clase sería imposible de probar desacoplada de la instancia singleton de ServiceA. Para mejorar esto un poco podríamos ir con algo como esto: 

protocol ServiceAProtocol {
    func execute()
}

class AViewModel {
    let serviceA = ServiceAProtocol
    init(serviceA: ServiceAProtocol) {
        self.serviceA = ServiceA
    }
}

Ahora podemos crear una clase mocked que implemente ServiceAProtocol y podemos probar AViewModel sin depender de la instacioaServiceA.shared.

Para solucionar esto podemos primero implementar este pequeño sistema con protocolos y typealiases que nos permitirán definir qué dependencias necesitamos para una determinada clase y contener esa definición en ella.Si adaptamos el AViewModel para manejar esto tendríamos algo así:

protocol ServiceAContainer {
    var serviceA: ServiceAProtocol { get }
}

class AViewModel {
    typealias Dependencies = ServiceAContainer
    let dependencies: Dependencies
    init(dependencies: Dependencies) {
        self.dependencies = dependencies
    }
}

Dentro de AViewModel podemos acceder a serviceA directamente desde nuestra propiedad dependencies.

Tal vez todavía no veas el beneficio de esta abstracción, pero lo verás en un rato. Por ahora vamos a ver cómo podríamos utilizar esta implementación con el servicio tal y como estaba antes, esto podría hacerse así:

extension AViewModel {
    struct ADependencies: Dependencies {
        let serviceA: ServiceAProtocol = ServiceA.shared
    }

    convenience init() {
        self.init(dependencies: ADependencies()) 
    }
}

Así que en este punto, si manejamos cuando AViewModel es instanciado y estamos seguros de que estaría bien si se instanciara ServiceA después de que cualquier otra clase lo haya instanciado, estamos listos. Imagina que no sólo tenemos ServiceA pero también ServiceB y probablemente muchas más que serán usadas en toda nuestra aplicación, y recuerda que en el último párrafo hicimos la suposición de que no estamos corriendo contra ninguna condición de carrera por usar instancias compartidas. Si queremos tener el control de todas estas clases, necesitamos centralizarlas en algún contenedor, que vamos a llamar DependencyContainer.

protocol ServiceBContainer {
    var serviceB: ServiceBProtocol { get }
}
typealias DependencyContainers = ServiceAContainer
    & ServiceBContainer

struct DependencyContainer: DependencyContainers {
    var serviceA: ServiceAProtocol
    var serviceB: ServiceBProtocol
}

Con este pequeño código podemos tener una estructura que tenga una referencia a implementaciones específicas de los servicios deseados sólo conociéndolos por el protocolo y lo mejor de esto es que DependencyContainer se ajusta a un typeAlias que nos garantiza que la clase tiene esas referencias.

  Gestiona componentes React de forma eficiente con Storybook.js

En este punto, tenemos la posibilidad de crear una instancia de este contenedor, y acceder a sus servicios así:

let container = DependencyContainer(serviceA: ServiceA.shared, 
                                    serviceB: ServiceB.shared)

Y comoDependencyContainer se ajusta a ServiceAContainer porque está definido en DependencyContainers podemos seguir adelante y tratar de adaptar nuestro AViewModel para hacer uso de esto:

extension AViewModel {
    convenience init() {
        let container = DependencyContainer(serviceA: ServiceA.shared, 
                                            serviceB: ServiceB.shared)
        self.init(dependencies: container)    
    }
}

Así que si inicializamos el contenedor al principio de la aplicación, nos aseguramos de que todas nuestras instancias estarán listas cuando cualquier otra clase las necesite después del lanzamiento de la aplicación. Y, por ejemplo, si ahora queremos crear una nueva clase que utilice ServiceB sería realmente sencillo:

class BViewModel {
    typealias Dependencies = ServiceBContainer
    let dependencies: Dependencies
    init(dependencies: Dependencies) {
        self.dependencies = dependencies
    }
}

extension BViewModel {
    convenience init() {
        let container = DependencyContainer(serviceA: ServiceA.shared, 
                                            serviceB: ServiceB.shared)
        self.init(dependencies: container)    
    }
}

Y si queremos tener más de una dependencia es tan fácil como añadirlas al typealias específico de cada clase y mientras el contenedor principal implemente este contenedor, ya está listo.

Esta implementación servirá para tener un sistema DI sólido sin necesidad de código externo y es lo suficientemente simple como para tenerlo controlado, pero vamos a darle un pequeño giro más para hacer uso de los property wrappers para simplificar un poco el código boilerplate que necesitamos para implementar cada inyección de dependencia.

Primero tenemos que saber qué es un WritableKeyPath en Swift, en resumen los Key Paths son expresiones para acceder a propiedades de forma dinámica. En el caso de los WritableKeyPath estos te permiten leer y escribir desde el valor al que estás accediendo, por lo que si queremos un wrapper de propiedades que nos proporcione una dependencia que esté dentro de un contenedor, significa que tendremos un valor almacenado en DependencyContainer al que podremos acceder dinámicamente con un WritableKeyPathdeterminado. Una vez que lo hagamos, se verá así en nuestro AViewModel:

@Dependency(\.aService) var aService

Como puedes ver no hay necesidad de añadir ninguna otra propiedad ni inicializar nada en el punto de la integración, lo cual es muy conveniente. Pero veamos el código involucrado para llegar a este resultado, y para ello necesitamos implementar un tipo genérico para almacenar las dependencias en el contenedor, también necesitamos manejar el caso nil para poder establecer las dependencias después de la creación del contenedor. Así que vamos con esto:

/// A protocol to define an injected dependency.
public protocol DependencyKey {
    /// Representing the type of the injected dependency.
    associatedtype Value

    /// The current value of the injected dependency.
    static var currentValue: Self.Value { get set }
}

/// A protocol to define an injected dependency whose initial value is set lazy.
public protocol LazyDependencyKey {
    /// Representing the type of the injected dependency.
    associatedtype Value

    /// The current value of the injected dependency.
    static var currentValue: Self.Value? { get set }
}

extension LazyDependencyKey {
    /// The unwrapped value of the injected dependency. Fails if the actual value has not been set before access.
    static var value: Self.Value {
        get {
            guard let currentValue = currentValue else {
                preconditionFailure("A value must be set before accessing the property.")
            }

            return currentValue
        }
        set {
            currentValue = newValue
        }
    }
}

Estos protocolos tienen un tipo Value asociado y un currentValue que es una instancia de ese tipo Value. Con esto en su lugar, ahora podemos definir nuestra clase DependencyContainer como sigue:

public class DependencyContainer {
    /// Singleton instance used to be accessed by the computed properties.
    private static var current = DependencyContainer()

    /// Access the dependency with the specified `key`.
    /// - Parameter key: Implementation type of `DependencyKey`.
    public static subscript<K>(key: K.Type) -> K.Value where K: DependencyKey {
        get { key.currentValue }
        set { key.currentValue = newValue }
    }

    /// Accesses the dependency with the specified `keyPath`.
    /// - Parameter keyPath: The key path of the computed property.
    public static subscript<T>(_ keyPath: WritableKeyPath<DependencyContainer, T>) -> T {
        get { current[keyPath: keyPath] }
        set { current[keyPath: keyPath] = newValue }
    }

    /// Set the initial value for the specified `key`. This method has to be called before the property is injected or accessed anywhere.
    /// - Parameter initialValue: The initial value that is injected wherever it is used.
    /// - Parameter key: The key to set the value for.
    public static func set<K>(initialValue: K.Value, key: K.Type) where K: LazyDependencyKey {
        key.currentValue = initialValue
    }
}

Aquí podemos ver que el contenedor sólo almacena un valor o lo devuelve para una ruta clave específica y eso es más o menos todo. Ahora tenemos que definir la envoltura de la propiedad e incluirla en nuestra clase original.Se vería algo así:

/// A property wrapper type that reflects a dependency injected using `DependencyContainer`.
@propertyWrapper
public struct Dependency<T> {
    private let keyPath: WritableKeyPath<DependencyContainer, T>

    public var wrappedValue: T {
        get { DependencyContainer[keyPath] }
        set { DependencyContainer[keyPath] = newValue }
    }

    public init(_ keyPath: WritableKeyPath<DependencyContainer, T>) {
        self.keyPath = keyPath
    }
}

Ahora bien, si por ejemplo queremos utilizar BService enBViewModel primero debemos crear la clave para el servicio, y establecerla en el contenedor, también necesitamos definir una propiedad para acceder a ella directamente en el DependencyContainer.

public struct BServiceDependencyKey: LazyDependencyKey {
    public static var currentValue:BService?
}

extension DependencyContainer {
    public var bService: BService {
        get { Self[BServiceDependencyKey.self] }
        set { Self[BServiceDependencyKey.self] = newValue }
    }
}
...
// Somewhere on your app launch code
    setupDependencies()
}
	
// MARK: - Dependencies
private extension AppDelegate {
    func setupDependencies() {
        DependencyContainer.set(initialValue: BService.shared, key: BServiceDependencyKey.self)
        DependencyContainer.set(initialValue: AService.shared, key: AServiceDependencyKey.self)
    }
}
class BViewModel {
    @Dependency(\.bService) var bService
}

class AViewModel {
    @Dependency(\.aService) var aService
}

Y ya está. Tenemos una inyección de dependencia de una línea con control total de las instancias que tenemos en el momento de lanzar la app e instanciar cualquier clase que las necesite. 

  Migración a Swift 6: La estricta concurrencia que debes adoptar

Author

  • Felipe Ferrari scaled

    iOS Developer working in the software development industry with agile methodologies. Skilled in Swift, objective-C, Python, PostgreSQL, SQL, PHP, and C++. More than 8 years of experience working as iOs Developer, following the best practices.

    Ver todas las entradas

Leave a Reply

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>

Suscríbete a nuestro boletín de noticias

Recibe actualizaciones de los últimos descubrimientos tecnológicos

¿Tienes un proyecto desafiante?

Podemos trabajar juntos

apiumhub software development projects barcelona
Secured By miniOrange