Modularización de las dependencias de iOS con ‘integraciones’

La arquitectura del proyecto Clean Xcode es una cebolla que se puede pelar de un millón de formas diferentes. Y una cosa que la mayoría de los ingenieros de iOS están de acuerdo es que Model-View-Controller (MVC) es solo una pequeña parte de la historia arquitectónica.

Un problema que surge para la mayoría de los proyectos es el desafío de integrar dependencias, ya sean marcos de Apple, SDK de terceros o API HTTP simples.

No me refiero a la gestión de dependencias en la línea de Swift Package Manager o CocoaPods. Estoy hablando de cómo organizar e integrar el código que escribe que hace uso de esas dependencias.

Utilizo lo que llamo un patrón de «integraciones» para conectar y desconectar dependencias de la forma más ordenada posible. Así es como funciona.

Integraciones vs alternativas

Este patrón de «integraciones» no es un sistema arquitectónico completo. Más bien, es simplemente una forma de tomar una postura firme en separar el código de terceros de las entrañas de su aplicación. Todo el código de terceros, junto con el código que escribe que utiliza dicho código, existe en su propio silo. Entonces, cuando inevitablemente se encuentra cambiando una dependencia por otra, sus modelos y vistas no necesitan ser tocados.

Puede hacer que este patrón funcione en una variedad de sistemas arquitectónicos. Se puede usar con modelos de vista o no, y es especialmente útil para patrones que aún no tienen en cuenta las dependencias de terceros, como la enésima versión de MVC que ve en las bases de código existentes.

Claro, voy a hacer referencia a conceptos arquitectónicos que utilizo para hacer que esto funcione (por ejemplo, cómo configuro modelos y los vinculo a mis integraciones). Pero te animo a que los tomes como guías y te permitas interiorizar los conceptos de alto nivel que luego se pueden adaptar de otras formas que se adapten a tus necesidades.

Después de todo, no inventé la idea de modularizar las dependencias. Lo que propongo aquí es un sabor que encontré después de aprender mucho de otros en la comunidad de iOS. Y me gusta por su flexibilidad y la estética arquitectónica que hace posible.

Modelos del deseo de vuestro corazón

La mejor parte de usar integraciones es hacer lo que quieras con tus modelos. Swift es uno de los lenguajes de programación más legibles jamás creados. Tiene todo tipo de consideraciones atractivas que sirven a su primera y principal pauta de API: claridad en el punto de uso.

Así que, maldita sea, quiero usar estas funciones. Quiero usar estructuras cuando tengan sentido. Quiero emplear genéricos y extensiones para reducir la reutilización del código. Quiero usar todas las funciones ingeniosas que vienen con enumeraciones. Y quiero hacer esto con total libertad, sin disculpas.

Lo que no quiero es estar limitado por los matices específicos de cualquier marco de persistencia que esté empleando. No quiero ver @objcensuciar mi código de modelo, junto con nombres incómodos de colecciones que a veces pueden venir con marcos como CoreData. No digo que nunca tenga sentido apoyarse en algo como CoreData. Hay una gran cantidad de características y beneficios avanzados que pueden justificar permitir que sus tentáculos lleguen a su código. Solo digo que me gusta resistirme a hacerlo a menos que sea realmente necesario.

Quiero que mis modelos sean representaciones nativas de mis datos sexys, precisas, con nombres simples, nítidos y totalmente Swift que utilicen las funciones más recientes y mejores que mi objetivo de implementación permite.

Así que eso es lo que hago, lo mejor que puedo. Diseño mis modelos de una manera específica de la aplicación al contenido de mi corazón. Y luego dejo la responsabilidad de hablar con el código de terceros a la implementación de la integración.

Servicios como puente

Por supuesto, sus modelos son puramente una representación del estado de la aplicación. Entonces necesitamos algo encima de esta capa para buscar y mutar este estado. Y queremos que esto se defina en términos de nuestros modelos de aplicaciones, y completamente (o al menos tanto como sea posible) sin hacer referencia a cómo se implementa realmente.

Una «capa de servicio» es un patrón común que puede tomar algunas formas diferentes. Me gusta definir mis servicios como protocolos que se pueden implementar concretamente dentro de sus integraciones. Por ejemplo, digamos que nuestra aplicación necesita buscar Postobjetos como parte de un feed de redes sociales. Es posible que tenga un PostServiceque maneje la recuperación:

/// A service that fetches posts
protocol PostService {
    
    /// The result of fetching posts
    typealias FetchPostsResult = Result<[Post], Error>
    
    /// The completion handler called after an attempt to fetch posts
    typealias FetchPostsCompletion = (FetchPostsResult) -> Void
    
    /// Fetch posts for the user
    func fetchPosts(for user: User, _ completion: FetchPostsCompletion)
}

Y puede implementar este servicio como desee. Si está utilizando una API HTTP simple, podría verse así:

/// An HTTP API implementation of a post service
struct MyBackendPostService: PostService {
    
    func fetchPosts(for user: User, _ completion: (FetchPostsResult) -> Void) {
        // TODO: Implement URL request for fetching posts
    }
}

O podrías usar algo como Firebase:

/// A firebase implementation of a post service
struct FirebasePostService: PostService {
    
    func fetchPosts(for user: User, _ completion: (FetchPostsResult) -> Void) {
        // TODO: Implement Firebase query for fetching posts
    }
}

No importa, porque todo lo que necesita el código de vista es alguna instancia de PostService.

Y para pasarlo a su código de vista, crea un ServiceProviderobjeto como parte de su capa de Servicios, que es un puente desde su capa de Integraciones a su capa de Servicios:

/// The object that provides concrete implementation of al services
struct ServiceProvider {
    
    static let postService: PostService = FirebasePostService()
}

De esta manera, si decide cambiar la implementación, solo necesita editar la ServiceProviderestructura que a su vez es utilizada por sus vistas o almacenar objetos. ¡Tus puntos de vista permanecen sin cambios!

Las integraciones son completas

Se pretende que las integraciones sean completas. Es decir, todo el código que escriba, de cualquier tipo, que interactúe directamente con una dependencia de terceros determinada, vive dentro de la integración.

Como tal, me gusta agregar un grupo (carpeta) «Integraciones» junto con todas mis otras capas así:

Luego agrego un grupo para cada integración, cada uno de los cuales es un pequeño universo en sí mismo con todo lo necesario para que la integración funcione. En este caso, integro el SDK de Facebook, el SDK de Firebase, la API de Airtable para una de mis «bases» de Airtable y una API HTTP específica para mi aplicación:

Observe la integración de Firebase. Hay una clase de administrador que maneja toda la inicialización y el código de alto nivel necesarios para que se ejecute el SDK de Firebase (p. Ej., Configuración de claves, llamadas en respuesta a eventos del ciclo de vida de la aplicación, etc.). Hay un grupo para todos los servicios específicos de la aplicación que implementa la integración (ver arriba), y también hay un grupo para los modelos específicos de integración de Firebase y los controladores de vista. Veremos por qué los necesitaría más adelante, pero el punto es que cada integración tiene un grupo para cada uno de los tipos de elementos necesarios para que esa integración funcione.

Finalmente, hay una IntegrationsManagerclase de alto nivel que maneja todos los eventos de configuración y ciclo de vida para todas las integraciones. No hay nada que deteste más que ensuciar al delegado de la aplicación con la configuración de dependencia, por lo que todo eso se delega a esta clase.

Las integraciones también son para vistas

Las integraciones no se tratan únicamente de abstraer cosas como los marcos de persistencia. Se trata de abstraer cualquier tipo de código de terceros que pueda imaginar, incluso vistas.

Por ejemplo, Firebase de Google proporciona un flujo de autenticación completamente desarrollado que puede colocar directamente en su aplicación, lo que puede suponer un gran ahorro de tiempo para las empresas emergentes con equipos pequeños y presupuestos limitados. No es hermoso, pero está bien para un MVP.

La cuestión es que no me interesa ver la vista de Firebase y las clases de controlador de vista mezcladas con el código de vista específico de mi aplicación. ¡Así que las integraciones vuelven al rescate!

Como parte de la integración, creo una FirebaseAuthenticationViewControllerque es una subclase UIKit de FUIAuthPickerViewController de Firebase que maneja toda la personalización específica de mi aplicación del componente de Firebase. Y como estoy usando SwiftUI, creo una UIViewControllerRepresentableinstancia llamada FirebaseAuthenticationViewque maneja la traducción de UIKit.

Esto es genial porque mantiene todo el código de mi vista que usa el SDK de Firebase con todos los demás códigos de integración, separados de las entrañas de mi aplicación. Pero tampoco quiero dejar caer mi FirebaseAuthenticationViewcódigo en mi vista, porque esa es solo una forma más indirecta de ensuciar el código específico de mi aplicación con dependencias de terceros.

Entonces, ¿cómo puedo solucionar esto?

Idealmente, queremos nuestra propia vista SwiftUI llamada AuthenticationViewque podamos agregar a nuestra jerarquía de vista según lo desee. Nuestra vista acepta todas las dependencias en su inicializador como mejor nos parezca, y es la estructura que presentamos cada vez que queremos ingresar a nuestro flujo de autenticación.

Pero dado que estamos decidiendo que su implementación se diferirá a un marco de terceros, es perfectamente razonable requerir la implementación como un parámetro inicializador para nuestra vista nativa. Y aquí es nuevamente donde entran los servicios.

Si no estuviéramos usando una interfaz de usuario personalizada, es casi seguro que tendríamos algo como un AuthenticationServiceque tiene funciones de inicio de sesión y registro como tal:

/// A service that managers user authentication
protocol AuthenticationService {
    
    /// The result of a login attempt
    typealias LoginResult = Result<User, Error>

    /// Login with the provided email and password
    func login(email: String, password: String, _ completion: LoginResult)
}

Pero como estamos delegando todo nuestro flujo de autenticación a Firebase, no es descabellado pedirle al servicio que proporcione esa dependencia de esta manera:

/// A service that managers user authentication
protocol AuthenticationService {
    
        /// The view that manages authentication
    var authenticationView: AnyView { get }
}

Luego, implementamos este servicio nuevamente en nuestra integración y devolvemos una instancia de nuestro FirebaseAuthenticationViewlike so:

/// A Firebase implementation of the authentication service
struct FirebaseAuthenticationService: AuthenticationService {
    
    var authenticationView: AnyView {
        return FirebaseAuthenticationView()
    }
}

De esta manera, si decidiéramos utilizar el siguiente y mejor BAAS que proporciona un flujo de inicio de sesión completo, solo tendríamos que proporcionar una implementación alternativa de AuthenticationViewy nada más.

Ahora, me doy cuenta de que algunos de nosotros podemos sentir que este no es realmente un uso apropiado de un «servicio». Personalmente, no tengo ningún problema con eso si solo pensamos en un servicio como un proveedor de dependencia. Pero también estoy abierto a usar algo como un objeto AuthenticationViewProvider y mantener los servicios orientados a los datos. Cómo lo hagas depende de ti. Sin embargo, el punto es que no es tan difícil abstraer incluso ver los detalles y designarlos como dependencias que se inyectarán a través de algunos objetos pasados ​​cuya API es específica de la aplicación. Y si en algún momento decidimos que queremos lanzar nuestra propia solución, simplemente expandimos el servicio para incluir la funcionalidad que queremos y eliminamos la vista.

¿Alguien quiere modelos específicos de integración?

Otro grupo / carpeta que frecuentemente he asociado con una integración es una carpeta de «modelos». Así es, ocasionalmente decidiré definir estructuras de modelo para usarlas únicamente dentro de una integración, incluso si un modelo dado ya está representado por un modelo que ya he definido para la aplicación.

Eso podría hacer que te preguntes si en realidad soy una persona cuerda. Los modelos son lo suficientemente tediosos como para configurarlos como están. ¿Por qué iba alguien alguna vez definir un modelo más de una vez?

Hay razones, así que escúchame.

Recuerde, mi más profundo deseo de que los modelos utilizados en toda la aplicación sean diseñados de tal manera que tengan las siguientes características:

  1. Aprovechan al máximo las últimas y mejores funciones de lenguaje Swift.
  2. Están diseñados en torno a la «claridad en el punto de uso» dentro de la aplicación.
  3. No conocen los detalles de implementación de los servicios de los que dependen.

El problema es que el uso de modelos a nivel de aplicación en una integración a veces puede violar uno o más de estos principios, permitiendo que los tentáculos de las dependencias lleguen a lugares donde no deberían.

Por ejemplo, es probable que su API JSON de backend deba Codableserializar y deserializar los modelos obtenidos y enviados a través de HTTP. El problema es que la forma en que se almacenan y diseñan sus modelos en esa API no es necesariamente una coincidencia uno a uno con la forma en que podría optimizar sus modelos en el contexto de su aplicación con las últimas funciones de Swift. Sus implementaciones de Codablepueden ser sencillas como agregar la declaración y sintetizar automáticamente la implementación, o pueden requerir una lógica específica de la API para traducir el JSON obtenido en las estructuras de su modelo local.

Esto puede ser un problema o no. Si sus modelos solo interactúan con una API, entonces podría estar bien implementar EncodableDecodablecomo extensiones de la estructura del modelo, pero dentro del grupo de integración. De esta manera, si cambia las API, puede reemplazar las implementaciones según sea necesario sin requerir cambios en el alcance que afecten a toda la aplicación.

Pero, ¿qué sucede si su aplicación interactúa con varias API y SDK que necesitan conocer sus modelos? ¿Y si cada uno de estos representa los datos de manera un poco diferente o solo interactúa con una parte de cada modelo?

Por ejemplo, digamos que tiene un Videomodelo que se usa en su aplicación para reproducir videos en tiempo real. Los usuarios pueden cargar videos para que se almacenen en Firebase como parte de los datos del usuario, pero su aplicación también utiliza un servicio de terceros para el alojamiento y la transmisión de videos de alto rendimiento como JWPlayer. La cuestión es que la forma en que se modela cada video en este servicio de terceros es diferente de Firebase. No solo incluye menos datos sobre cada video, sino que los datos se modelan de manera diferente en JWPlayer debido a las limitaciones de la API.

En este caso, ¿dónde implementaría las extensiones EncodableDecodable? ¿En la capa de modelos a nivel de aplicación? ¿En el nivel superior de la capa de integraciones? Ninguno se siente bien. Y de cualquier manera, tendría que tener toda esta lógica dentro de esa implementación para acomodar cada caso específico que puede conducir a un caos ilegible que es imposible de mantener.

En casos como este, encuentro valioso separar las preocupaciones por completo. Firebase obtiene su propia estructura llamada FirebaseVideoque está diseñada en torno a cómo se representa un video en Firebase. Del mismo modo, el JWPlayer obtiene su propia estructura llamada JWPlayerVideo. Cada uno está modelado de tal manera que las implementaciones Encodabley las personalizaciones Decodableson sencillas para la API. Luego, cualquier lógica necesaria para traducir de nuevo al modelo específico de la aplicación Videose realiza como una extensión en la Videoestructura:

extension Video {
    
    init(_ video: FirebaseVideo) {
        self.id = video.id
        ...
    }
}

extension Video {
    
    init(_ video: JWPlayerVideo) {
        self.id = video.id
        ...
    }
}

Es mucho más fácil y menos propenso a errores razonar a través de los detalles del mapeo de los mismos datos del modelo representados de diferentes maneras en el nivel de estructuras e inicializadores Swift que hacerlo en el contexto de los protocolos de serialización.

Claro, es un poco más tedioso tener múltiples definiciones, Codableimplementaciones e inicializadores de modelos, y no estoy diciendo que tenerlos siempre esté justificado. Pero en una situación como esta, el tedio vale el dolor de cabeza mucho peor de razonar a través de casos extremos, sin mencionar que todo este código llega a vivir exclusivamente dentro de la integración para la que es relevante.

Subscríbete y recibe nuevas noticias

devnow

Autor desde: agosto 12, 2020

Deje su comentario

EN ES