Ir al contenido

Blog


Creación de varias aplicaciones iOS de marca distinta a partir de una única base de código

2 de marzo de 2021

|

David Phan

Una de las decisiones tecnológicas clave que tuvimos que tomar cuando DoorDash adquirió Caviar en 2019 consistió en integrar la app Caviar iOS con la infraestructura y plataforma móvil existente de DoorDash. Mantener una pila tecnológica independiente para Caviar no era escalable, ni habría sido eficiente. Sin embargo, también necesitábamos mantener la experiencia de Caviar para su base de clientes.

Queríamos cambiar la infraestructura y la plataforma subyacentes de la aplicación Caviar sin alterar la forma en que los clientes utilizaban la aplicación. Ya habíamos realizado un proyecto similar para nuestra experiencia web y necesitábamos repetirlo para nuestras experiencias móviles.

Nuestra solución requería reconstruir la aplicación para iOS de DoorDash en una plataforma de nueva arquitectura que también pudiera soportar la aplicación para iOS de Caviar. Aunque se trataba de una estrategia intensiva, daría lugar a una plataforma móvil escalable capaz de admitir otras marcas de aplicaciones en el futuro.

Creación de las aplicaciones iOS de Caviar y DoorDash a partir del mismo código base

Dada la decisión de reconstruir la aplicación Caviar iOS para integrarla con DoorDash, lo primero que teníamos que hacer era construir una nueva arquitectura debajo de la aplicación actual de DoorDash que pudiera soportar ambas marcas. El objetivo de esta nueva arquitectura era:

  1. Obtén la posibilidad de crear binarios independientes para cada una de las aplicaciones.
  2. Compartir tanto código como sea posible sin dejar de ser capaces de crear características y experiencias distintas.
  3. Minimizar la carga de ingeniería del equipo actual de iOS para consumidores de DoorDash.

Con todo esto en mente, se nos ocurrieron tres opciones que podrían ajustarse a nuestras necesidades: 

Objetivos de compilación separados

De las tres opciones, los objetivos de compilación separados eran, con diferencia, los más fáciles de configurar. Con este enfoque, duplicaríamos el objetivo actual de la aplicación DoorDash en un objetivo de aplicación Caviar y, a continuación, repetiríamos la operación para los objetivos de prueba.

Este enfoque tenía dos características principales: era fácil de implementar y también requería cambios mínimos en el lado de DoorDash. Teníamos un calendario muy apretado para la integración de DoorDash y Caviar, por lo que una configuración sencilla era un aspecto atractivo de esta solución. Además, la cantidad mínima de cambios en la base de código de DoorDash para facilitarla también fue tranquilizadora.

Sin embargo, esta velocidad de desarrollo inicial se produce a expensas de la capacidad de mantenimiento y la velocidad futura. Tener objetivos duplicados significaba que al añadir archivos, los ingenieros tendrían que hacer las selecciones apropiadas para las membresías de objetivos para Caviar, DoorDash, o ambos. Configurar las afiliaciones de archivos es bastante sencillo, pero también es muy fácil equivocarse. Y aunque estos errores son bastante fáciles de solucionar, podrían dar lugar a errores de compilación, lo que ralentiza el desarrollo, especialmente cuando fallan en la máquina de integración continua/entrega continua (CI/CD). 

Al final, decidimos no seguir este camino. La velocidad de desarrollo inicial no compensaba el impacto en la capacidad de mantenimiento y la velocidad.

Configuraciones de compilación independientes

Otra posible solución es utilizar archivos de configuración de Xcode (xcconfig) separados para Caviar y DoorDash. Para este enfoque, cambiaríamos el nombre de nuestros archivos xcconfig actuales de versión y depuración a DoorDash-Release y DoorDash-Debug, haciendo esas configuraciones específicas para la aplicación DoorDash. Luego duplicaríamos esos archivos para crear archivos xcconfig separados para Caviar debug y release. A partir de ahí podríamos crear diferentes esquemas de construcción que utilizan diferentes archivos xcconfig para DoorDash y Caviar. Este método nos permitiría tener un objetivo de aplicación que podría ser configurado para construir ya sea Caviar o DoorDash basado en el archivo de configuración que proporcionamos en el esquema de construcción. 

Con este enfoque seríamos capaces de mantener un único objetivo de compilación para ambas aplicaciones, y hacerlo simplificaría muchas cosas. Para empezar, un único objetivo significaría que no tendríamos que preocuparnos por la pertenencia de los archivos de destino como lo habríamos hecho en el enfoque anterior. Además, significaría cambios mínimos en el entorno actual de DoorDash. Para personalizar Caviar y DoorDash simplemente añadiríamos o modificaríamos las distintas variables de tiempo de compilación según fuera necesario.

Sin embargo, este enfoque seguía sin ajustarse completamente a nuestras necesidades. Ampliar los archivos de configuración para poder personalizar Caviar y DoorDash al nivel que queríamos habría sido un proceso tedioso y minucioso. Tendríamos que definir variables para todas las diferencias entre las dos aplicaciones y luego asignarlas a las dependencias correctas. Como seguimos ampliando cada experiencia, esto podría aumentar fácilmente a más variables de las que podríamos mantener de forma realista. Además, el uso de variables de compilación en los archivos de configuración habría sido una forma bastante indirecta de personalizar las aplicaciones.

Envoltorios de aplicaciones independientes en torno a una biblioteca de aplicaciones común

La solución que elegimos fue extraer la aplicación actual de DoorDash para iOS en una biblioteca estática y, a continuación, crear dos objetivos separados para las aplicaciones de Caviar y DoorDash que dependerían de la biblioteca para todo el código compartido de la aplicación. 

Tomamos el proyecto DoorDash existente, eliminamos todas las piezas específicas de la aplicación, como AppDelegate, xcassets, xcconfig y los derechos de la aplicación, y lo agrupamos todo en una biblioteca estática que llamamos CommonApp. A partir de ahí, hemos creado dos nuevos objetivos de aplicación, uno para Caviar y otro para DoorDash, que actúan como envoltorios específicos de la aplicación en torno al código compartido que se encuentra en CommonApp. Estos objetivos envolventes de aplicaciones incluyen todo el código y la lógica que son mutuamente exclusivos de cada aplicación. Aquí incluimos elementos como AppDelegate, xcassets, xcconfig, derechos de aplicación y archivos de implementación exclusivos de cada experiencia. 

Este enfoque nos dio la posibilidad de personalizar fácilmente cada experiencia con un impacto mínimo en la otra. Para los casos en los que queríamos tener diferentes implementaciones de características entre las experiencias, simplemente podíamos crear esas implementaciones en cada una de las envolturas de aplicaciones y hacer que se utilizaran en CommonApp a través de la inyección de dependencias. Con este enfoque, había una clara separación del código que reflejaba muy bien la realidad. El código compartido vivía en CommonApp y el código específico de la aplicación vivía en los destinos de aplicación correspondientes.

En general, el único inconveniente de este enfoque fue la cantidad de esfuerzo necesario en la configuración inicial para extraer todo el código compartido a la biblioteca estática, CommonApp, y la configuración de los dos nuevos objetivos ligeros para inyectar las dependencias adecuadas a CommonApp. Sin embargo, la capacidad de mantenimiento y la escalabilidad de este enfoque merecen la pena en comparación con el tiempo de configuración adicional.

Conservar el aspecto y el tacto de Caviar

Ahora que teníamos una forma de crear las aplicaciones, teníamos que encontrar una forma limpia y escalable de dar estilo a cada una de ellas. Teníamos dos objetivos en mente: queríamos poder personalizar completamente la tematización entre la aplicación Caviar y DoorDash, así como la capacidad de desarrollar fácilmente para ambas experiencias. Este último objetivo significaría poder establecer diferentes valores de tematización en función de la experiencia sin tener que recurrir a un montón de sentencias if-else u operadores ternarios (véase el fragmento de código siguiente).

Nuestra solución consistió en crear un conjunto de semántica de interfaz de usuario (IU) para colores, iconografía y tipografía que abstrajera los valores subyacentes y nos ofreciera una forma de proporcionar diferentes conjuntos de valores para cada aplicación sin cambiar ningún código en los sitios de llamada. Por suerte, nuestro equipo de infraestructura de diseño ya había creado un sistema de lenguaje de diseño (DLS) que proporcionaba los elementos de interfaz de usuario que necesitábamos.

Para los colores y los iconos, nuestro DLS amplió las implementaciones UIColor y UIImage del marco de interfaz iOS de Apple con métodos estáticos para todos nuestros casos semánticos y de uso. Estos métodos asignarían los valores subyacentes correspondientes proporcionados por el tema de cada aplicación almacenado en los xcassets(catálogos de activos de Xcode) de cada aplicación. Del mismo modo, para la tipografía, asignaba la semántica correspondiente a las fuentes subyacentes correctas proporcionadas por cada aplicación con los atributos adicionales adecuados aplicados. 

El siguiente fragmento de código muestra cómo extendemos UIColor para incluir enumeraciones (enums) para las distintas semánticas de color (casos de uso) que tenemos en toda la aplicación. Estas enumeraciones se pueden utilizar para obtener el valor subyacente al que se asignan en los xcassets de cada aplicación.

typealias Color = UIColor
extension Color {   
     enum Border: String, CaseValueIterable {       
          case primary = "border/primary"       
          case secondary = "border/secondary"   
     }

     static func border(_ color: Border) -> Color {       
          return ColorAssets(name: color.rawValue).color   
     }
}
pantalla de construcción que muestra los activos de color
Figura 1: Todos los colores definidos en los xcassets se asignan a una semántica definida en código que puede utilizarse para obtener el color de los xcassets.

Fijar valores implícitamente permite una mayor flexibilidad

El DLS nos permite sustituir el código que establece valores explícitos por una semántica que establece valores implícitos que pueden configurarse con valores diferentes en función de la experiencia. 

En el fragmento de código que aparece a continuación vemos cómo definiríamos explícitamente los colores en función de la experiencia (colores no semánticos) frente a cómo lo haríamos implícitamente (colores semánticos). En la versión no semántica, cada vez que definimos un color tenemos que comprobar en qué experiencia estamos. Aunque este método funciona, es tedioso, desordenado y propenso a errores. En la versión semántica, tanto si estamos implementando para Caviar como para DoorDash podemos utilizar la misma sintaxis y el mismo lenguaje. El DLS proporciona el color apropiado a través de los xcassets de cada aplicación. 

// Non-semantic colors
var borderColor:UIColor? = isCaviar ? .darkGray : .black
var backgroundColor:UIColor? = isCaviar ? .orange : .red
var foregroundColor:UIColor? = isCaviar ? .white : .lightGray

// Semantic colors via DLS
let borderColor:UIColor? = .border(.secondary)
let backgroundColor: UIColor = .button(.primary(.background))
let foregroundColor: UIColor = .button(.primary(.foreground))
// Non-semantic icons
button.setImage(UIImage(named: "arrow-gray-right"), for: .normal)

// Semantic icons
button.setImage(.small(.arrow(.right)), for: .normal)

En cuanto a la tipografía, el DLS enumeró cada caso de uso, abstrayendo la fuente y el estilo específicos de la base de código, como se ve en el fragmento de código siguiente. Como tal, esta misma arquitectura puede soportar las aplicaciones iOS de DoorDash y Caviar, con diferentes estilos aplicados a cada una.  

extension DLSKit.Typography {
    public enum Default: String, TextStyle, CaseValueIterable {
        case MajorPageTitle
        case PageTitle
        case PageDescriptionBody
        case PageSubtext
        case TextFieldLabel
        case TextFieldText
        case TextFieldPlaceholder
        case AlertTitle
        case AlertText
        case AlertAction
        case PrimaryButtonText
    ......
    }
}

Al igual que con la tipografía, aplicamos atributos definidos en la DLS, como la negrita y el tamaño de fuente, que pueden verse en el fragmento de código siguiente. 

public func attributes(overrides: TextAttributes = [:]) -> TextAttributes {
            let paragraphStyle = NSMutableParagraphStyle()
            let textAttributes: TextAttributes = {
                switch self {
                case .MajorPageTitle:
                    return DLSKit.Typography.Base.Bold32.attributes(overrides: [:])                   
                case .PageTitle:
                    return DLSKit.Typography.Base.Bold24.attributes(overrides: [:])                   
                case .PageDescriptionBody:
                    paragraphStyle.lineHeightMultiple = Default.bodyLineHeight
                    return DLSKit.Typography.Base.Medium16.attributes(overrides: [
                        .paragraphStyle: paragraphStyle,
                        .foregroundColor: Default.subtextColor
                        ])                   
                case .PageSubtext:
.....
}

El siguiente fragmento de código muestra las fuentes mapeadas definidas en los xcassets de cada aplicación.

    enum Medium: String, CaseValueIterable {
        case TTNorms = "medium/TTNorms"
    }
    enum Regular: String, CaseValueIterable {
        case TTNorms = "regular/TTNorms"
    }
pantalla de construcción que muestra el activo de fuente
Figura 2: Al igual que ocurre con los colores, definimos la semántica de los tipos de letra utilizados en una aplicación, lo que aporta una gran flexibilidad al aspecto que podemos dar a cualquier aplicación creada a partir de esta arquitectura.

Esta arquitectura permitía establecer fuentes dentro de la base de código compartida sin que tuviéramos que realizar ningún trabajo adicional para que se renderizaran correctamente entre experiencias.

// Correct underlying font provided by each apps xcassets, so engineers can develop without 
// having to worry about picking the correct font for each experience.
textLabel?.font = DLSKit.Typography.Default.ListRowTitle.font()

Construir nuestras aplicaciones iOS con esta nueva arquitectura ofrece una serie de ventajas prácticas. Al asignar la semántica a los valores, las compilaciones de la aplicación se tematizan automáticamente, lo que garantiza la coherencia entre versiones. Al crear nuevas funciones u otras actualizaciones de la aplicación, los ingenieros ya no tienen que buscar los valores de los elementos de la interfaz de usuario y establecerlos en DoorDash o Caviar. Basta con utilizar la semántica. Las futuras actualizaciones de la interfaz de usuario o de la marca, o incluso las aplicaciones que den soporte a nuevas líneas de negocio, serán mucho más fáciles de crear.

Reconstruir la experiencia Caviar

Aprovechar el DLS nos permitió crear dos aplicaciones distintas a partir de la misma base de código, con temáticas diferentes pero esencialmente iguales. La última pieza del proyecto consistió en personalizar la aplicación iOS de Caviar para que su experiencia coincidiera con su marca. 

Una vez que determinamos qué partes de la aplicación debían diferir entre experiencias, definimos protocolos para estos componentes de marca. La identificación de estas piezas nos permitió sustituir las implementaciones de clases concretas en CommonApp por definiciones abstractas que no tuvieran en cuenta la marca. A continuación, con el uso de la inyección de dependencias, cada una de las aplicaciones podría proporcionar sus propias implementaciones de estos componentes de marca que personalizarían la experiencia en consecuencia. 

Por ejemplo, echemos un vistazo al controlador de la vista de aterrizaje. Cada aplicación debe tener su propia vista de aterrizaje, la pantalla que los usuarios ven por primera vez cuando abren la aplicación, que coincida con la experiencia de marca. 

En el siguiente fragmento de código, definimos protocolos, incluido el de la vista de aterrizaje, para vistas específicas de marca. 

public protocol LandingViewControllerProtocol: UIViewController {
    var landingRouter: LandingRouterProtocol { get }
}

También incluimos un protocolo de fábrica para definir las vistas específicas de la marca, como se muestra en este fragmento de código.

public protocol BrandedViewFactoryProtocol {
    /// Use Case: Splash
    func makePreSilentSignInMigrationService() -> PreSilentSignInMigrationServiceProtocol?
    func makeLaunchView() -> UIView
    func makeStaticSplashView() -> StaticSplashViewProtocol
   
    /// Use Case: Landing
    func makeLandingViewController() -> LandingViewControllerProtocol
   
    func makeStorePageHeaderView() -> StorePageHeaderView
   
    func makeLoginBannerView() -> UIView?
    func makeLoginHeaderView(isSignIn: Bool) -> LoginHeaderViewProtocol
   
    func makeVerifyEmailModule(email: String) -> VerifyEmailModuleProtocol?
   
    func makeVerifyEmailSuccessModal() -> VerifyEmailSuccessModalProtocol?
}

Ahora en CommonApp podemos sustituir las instancias de estas clases concretas por protocolos que se pueden inyectar con implementaciones específicas de la marca. 

    @ResolverInjected private var brandedViewFactory: BrandedViewFactoryProtocol

....

    func tableView(_ tableView: UITableView, headerFor storeViewModel: StoreHeaderViewModelV2) -> UIView {
        let view = brandedViewFactory.makeStorePageHeaderView()
        let presenter = StoreHeaderPresenter(view: view)
        view.delegate = self
        presenter.present(with: storeViewModel)
        storeHeaderPresenter = presenter
        return view
    }

Con nuestros protocolos definidos, somos capaces de proporcionar dos implementaciones separadas de la cabecera de la página de la tienda que se personalizan de manera diferente entre las aplicaciones Caviar y DoorDash iOS.

Figura 3: A pesar de estar construidas con el mismo código, somos capaces de proporcionar dos experiencias completamente diferentes para nuestras aplicaciones Caviar y DoorDash iOS proporcionando implementaciones separadas para las áreas de la aplicación que deben diferir entre las dos.

Definimos los protocolos de las vistas de aterrizaje utilizando el código mostrado anteriormente. El siguiente fragmento de código muestra cómo iniciamos las diferentes vistas de aterrizaje que se muestran en la Figura 4, a continuación, en las diferentes instancias de nuestras aplicaciones iOS.

class LandingModule {
    @ResolverInjected private var brandedViewFactory: BrandedViewFactoryProtocol
   
    let viewController: UINavigationController
   
    init() {
        self.viewController = UINavigationController()
       
        viewController.viewControllers = [brandedViewFactory.makeLandingViewController()]
    }
}

Del mismo modo, podemos proporcionar dos implementaciones separadas de la pantalla de aterrizaje con pocas modificaciones en el código compartido.

Figura 4: Con pocas modificaciones en la base de código subyacente, podemos mostrar dos pantallas de aterrizaje diferentes en nuestras aplicaciones Caviar y DoorDash para iOS.

Este enfoque nos permite personalizar selectivamente partes de la base de código sin afectar al código circundante, lo que reduce la posibilidad de introducir errores. La capacidad de personalizar la experiencia del usuario final entre las dos aplicaciones nos da una gran flexibilidad para dar soporte a las líneas de negocio de DoorDash.

Conclusión

Aunque la adición de Caviar a DoorDash nos obligó a rediseñar nuestras aplicaciones iOS, terminamos con una solución global mucho más escalable. Una vez completado el trabajo descrito anteriormente, ahora tenemos una única base de código que podemos utilizar para crear las aplicaciones de DoorDash y Caviar. Estas aplicaciones utilizan temas y marcas distintos, pero comparten el 90% de su código. Nuestro equipo móvil puede personalizar aún más cada aplicación sin enturbiar el código compartido, lo que aumenta la fiabilidad. Esa base de código compartida también significa que podemos hacer mejoras generales y añadir funciones para ambas aplicaciones al mismo tiempo.

No es infrecuente que una empresa se lance con una única aplicación y luego crezca hasta el punto de necesitar dar soporte a nuevas líneas de negocio con nuevas aplicaciones. En la fase de lanzamiento, la creación de un DLS y la arquitectura para múltiples objetivos de compilación para dar soporte a una única aplicación puede no tener sentido. Sin embargo, el rápido crecimiento puede dificultar la inversión de tiempo en la creación de una arquitectura escalable. Establecer una arquitectura de este tipo al principio puede aliviar muchos problemas posteriores.

Fotografía del encabezado por Dil en Unsplash.

About the Author

Trabajos relacionados

Ubicación
Oakland, CA; San Francisco, CA
Departamento
Ingeniería
Ubicación
Oakland, CA; San Francisco, CA
Departamento
Ingeniería
Job ID: 2980899
Ubicación
San Francisco, CA; Sunnyvale, CA
Departamento
Ingeniería
Ubicación
Pune, India
Departamento
Ingeniería
Ubicación
Pune, India
Departamento
Ingeniería