wandres.dev
MÁS ALLÁ DEL IPHONE · iPad, Mac, watch, visionOS

Una base, muchas pantallas: compartir código con criterio

Compartir código entre iPhone, iPad, Mac, reloj y visor es una buena idea llevada hasta un mal final con una frecuencia notable. Esta lección traza la frontera con precisión: qué comparte todo el mundo sin discutirlo, dónde deja de compartirse y por qué, cuánto cuesta realmente cada condicional de plataforma, y qué arquitectura hace que lo correcto sea también lo cómodo.

⏱ 20 min

La promesa de escribir una vez y ejecutar en todas partes es tan antigua como seductora, y en la familia de plataformas de Apple ha llegado más cerca de cumplirse que en ningún otro sitio: un mismo lenguaje, una misma biblioteca de interfaz, un mismo sistema de paquetes. Precisamente por eso el error que domina esta etapa no es compartir poco sino compartir demasiado. Una base común bien trazada multiplica el trabajo de un equipo pequeño; una base común extendida un paso más allá de donde debía producir un código lleno de condicionales, con una vista que intenta ser cinco vistas y satisface a ninguna, y con un coste de cambio que crece con cada plataforma añadida en lugar de repartirse entre ellas. La pregunta profesional no es cuánto se puede compartir, sino dónde deja de convenir.

🎯 Al terminar esta lección sabrás
  • Separar el núcleo compartible de la interfaz específica con un criterio explícito.
  • Reconocer las señales que indican que una abstracción compartida ha ido demasiado lejos.
  • Estructurar el proyecto en paquetes que hagan barato lo correcto y visible lo dudoso.
  • Justificar cada condicional de plataforma o eliminarlo en favor de una inyección.

Lo que se comparte sin discusión

Hay una capa cuya pertenencia al tronco común no admite debate razonable, y conviene nombrarla con precisión para que la discusión se concentre después en la frontera. Son los tipos del dominio, las reglas de negocio, la validación, el acceso a red y su decodificación, la persistencia y su esquema, la sincronización, la criptografía, el formateo de valores y todo el vocabulario con el que la app habla de su problema. Una tarea vencida es una tarea vencida en el teléfono, en el reloj y en el visor; que la regla se duplique es un defecto en cualquier plataforma, porque garantiza que algún día divergirá.

// Paquete Nucleo: sin dependencias de interfaz ni de plataforma
public struct Tarea: Identifiable, Codable, Sendable {
    public let id: UUID
    public var titulo: String
    public var vence: Date?
    public var completada: Bool

    public var estaVencida: Bool {
        guard let vence, !completada else { return false }
        return vence < .now                       // una regla, un lugar
    }
}

La disciplina que sostiene esa capa es negativa y muy simple: el paquete del núcleo no importa ninguna biblioteca de interfaz. Si un tipo del dominio necesita un color, un icono o un tamaño, ese tipo ha dejado de pertenecer al dominio. La prueba mecánica es que el núcleo debe compilar para todas las plataformas de la familia sin un solo condicional, y esa prueba se puede automatizar y ejecutar en integración continua, que es donde las buenas intenciones dejan de bastar.

💡
El núcleo se prueba sin arrancar ninguna app

Un beneficio derivado y a menudo mayor que el propio ahorro de código es que un núcleo sin interfaz se prueba en milisegundos, sin simulador, sin renderizado y sin flaqueza. Cuando el equipo descubre que la suite de pruebas del dominio tarda dos segundos, la frontera empieza a defenderse sola, porque nadie quiere devolver una regla al lado lento.

Dónde deja de convenir

La frontera se traza con una pregunta que funciona sorprendentemente bien: esto que voy a compartir, ¿es una afirmación sobre el problema o una decisión sobre la experiencia? Las afirmaciones sobre el problema se comparten siempre. Las decisiones sobre la experiencia son propiedad de cada plataforma, porque nacen de expectativas distintas, de presupuestos de atención distintos y de dispositivos de entrada distintos.

🧠

Compartir

Modelo, reglas, red, persistencia, sincronización, formateo y errores del dominio. Todo lo que sería un defecto duplicar.

🎨

No compartir

Disposición, navegación, densidad, gestos, tipografía y jerarquía visual. Todo lo que responde a expectativas de la plataforma.

🤝

Compartir con cuidado

Componentes pequeños y sin opinión de disposición: una insignia de estado, un formateador, una paleta semántica.

🧪

Nunca compartir

Una vista con condicionales por plataforma en su interior. Es el síntoma clásico de una frontera trazada un nivel demasiado arriba.

El caso de la navegación merece un párrafo propio porque es donde más equipos se equivocan. Es tentador compartir el estado de navegación, ya que parece pertenecer al modelo: hay una selección, hay una ruta, hay un modal abierto. Pero la navegación es exactamente donde las plataformas divergen más —una pila en el teléfono, columnas en la tableta, ventanas en el Mac, una pantalla plana en el reloj, un espacio en el visor— y forzar una representación común obliga a que cada plataforma traduzca desde un vocabulario que no es el suyo. Lo que sí comparte bien es la identidad de lo que se está mirando: un identificador de tarea es común, y que ese identificador se convierta en una pantalla apilada, en una columna o en una ventana es asunto de cada plataforma.

flowchart TB
a[Paquete Nucleo: dominio y datos] --> b[App iOS: pila y gestos]
a --> c[App iPadOS: columnas y teclado]
a --> d[App macOS: ventanas y menus]
a --> e[App watchOS: complicacion y corona]
a --> f[App visionOS: ventana y volumen]
a --> g[Pruebas rapidas sin interfaz]
b --> h[Cada plataforma decide su experiencia]
c --> h
d --> h
e --> h
f --> h

El coste real de compartir de más

Conviene cuantificar el daño en lugar de invocarlo, porque a favor de compartir siempre hay un argumento visible —menos líneas— y en contra solo argumentos que se manifiestan meses después. El primer coste es combinatorio: cada condicional de plataforma dentro de una vista multiplica los caminos que hay que razonar y probar, y tres condicionales en el mismo archivo producen ocho comportamientos posibles de los que nadie ha visto más de dos.

// Sintoma inconfundible de frontera mal trazada
var body: some View {
    #if os(watchOS)
    ListaCompacta()
    #elseif os(macOS)
    TablaConMenus()
    #else
    ListaEstandar()
    #endif
}

El segundo coste es de acoplamiento temporal: cuando cinco plataformas comparten una vista, cualquier mejora en una obliga a validar las otras cuatro, de modo que el ritmo de todas queda determinado por el equipo más lento y por la plataforma más restrictiva. Esto convierte cada mejora en una negociación, y las negociaciones caras se posponen. El tercer coste es el más insidioso y el que rara vez se atribuye a su causa: la interfaz compartida tiende al mínimo común denominador, porque lo que no existe en el reloj no puede usarse y lo que solo existe en el Mac queda fuera. El resultado es un conjunto de apps sin defectos evidentes y sin ninguna virtud propia, que es exactamente el fracaso que este nivel entero intenta evitar.

⚠️
La regla de los tres usos, aplicada a plataformas

No extraigas un componente compartido cuando dos plataformas parezcan necesitar lo mismo. Escríbelo dos veces, deja que ambas versiones evolucionen unas semanas y observa si convergen o divergen. Si divergen, acabas de ahorrarte una abstracción equivocada. Si convergen y aparece un tercer uso idéntico, la extracción será obvia y el diseño del componente saldrá de tres casos reales en vez de dos suposiciones.

Una arquitectura que hace barato lo correcto

La frontera no se sostiene con disciplina sino con estructura, porque la disciplina se agota los viernes por la tarde. La forma que mejor funciona consiste en un paquete local con el núcleo, uno o varios paquetes de características que dependen de él, y un objetivo de aplicación por plataforma cuya única responsabilidad es componer escenas y decidir la experiencia. La dependencia apunta siempre hacia dentro, y el sistema de compilación se encarga de que un intento de importar interfaz desde el núcleo falle en vez de discutirse.

// Package.swift del nucleo compartido
let package = Package(
    name: "Nucleo",
    platforms: [.iOS(.v17), .macOS(.v14), .watchOS(.v10), .visionOS(.v1)],
    products: [.library(name: "Nucleo", targets: ["Nucleo"])],
    targets: [
        .target(name: "Nucleo"),
        .testTarget(name: "NucleoTests", dependencies: ["Nucleo"])
    ]
)

Declarar las cinco plataformas en el paquete tiene un efecto que va más allá de lo administrativo: convierte la portabilidad en un error de compilación. En cuanto alguien use en el núcleo una función que no existe en watchOS, la compilación falla en su máquina y no en la de otra persona seis semanas después. Es la misma idea que atraviesa todo el nivel llevada a la organización del proyecto: hacer que las decisiones importantes sean explícitas y verificables en lugar de confiar en que se recuerden.

Para las diferencias legítimas que sí atraviesan la frontera —dónde se guardan los ficheros, cómo se notifica, qué capacidades de sensor hay— la herramienta correcta no es el condicional sino la inyección. El núcleo declara qué necesita en forma de protocolo y cada aplicación aporta su implementación, con lo cual el condicional desaparece del código compartido y se convierte en una elección hecha una sola vez, en el sitio donde se construye la app.

public protocol Avisador: Sendable {
    func avisar(_ mensaje: String) async     // el nucleo declara la necesidad
}
// iOS notifica; macOS usa el centro de notificaciones; watchOS vibra.
// Ninguna de esas decisiones vive en el nucleo.
ℹ️
Cómo saber si la frontera está bien puesta

Tres preguntas rápidas la evalúan. ¿Puede el núcleo compilar y pasar sus pruebas sin ningún condicional de plataforma? ¿Puede añadirse una plataforma nueva sin tocar el núcleo? ¿Puede rediseñarse por completo la app del Mac sin abrir un solo fichero compartido? Tres síes describen una base sana; cada no señala con precisión qué se filtró al lado equivocado.

Compartir no es un ahorro de escritura: es una apuesta sobre qué va a permanecer igual

La forma habitual de razonar sobre el código compartido lo trata como una cuestión de eficiencia, y por eso conduce sistemáticamente a compartir de más: si escribir dos veces es desperdicio, cualquier duplicación parece un defecto y toda unificación parece un progreso. Pero la duplicación no es el eje real de la decisión. Cuando colocas algo en el tronco común estás afirmando que ese algo va a permanecer idéntico en cinco plataformas a lo largo de los próximos años, y esa afirmación es una predicción, con la incertidumbre de cualquier predicción. Sobre el dominio la predicción es sólida, porque una tarea vencida seguirá siéndolo mientras el producto exista y ninguna plataforma futura cambiará esa aritmética. Sobre la experiencia la predicción es frágil hasta el punto de ser casi siempre falsa, porque las plataformas divergen por diseño y su divergencia es el motivo de que existan por separado. Visto así, el código compartido deja de medirse en líneas ahorradas y pasa a medirse en riesgo asumido, y la pregunta correcta al extraer cualquier cosa al tronco es qué tendría que ocurrir en el mundo para que esta unificación se volviera un estorbo, y con cuánta probabilidad ocurrirá. Ese cambio de marco es lo que separa una base común que sigue acelerando al equipo en el tercer año de una que empezó ahorrando semanas y terminó cobrándolas con intereses, y también es la razón por la que las cuatro lecciones anteriores importaban: no se puede decidir qué merece compartirse sin conocer de verdad aquello que se está intentando unificar.

📝
Lo esencial

Se comparte el dominio: tipos, reglas, red, persistencia, sincronización y errores, en un paquete sin ninguna dependencia de interfaz y con pruebas que corren en segundos. No se comparte la experiencia: disposición, navegación, densidad, gestos y jerarquía visual pertenecen a cada plataforma. La identidad de lo que se mira sí es común; su traducción a pila, columna, ventana o volumen no lo es. Los condicionales dentro de una vista compartida delatan una frontera mal puesta, y las diferencias legítimas se resuelven inyectando protocolos, no ramificando el tronco.

⚔️ Auditar tu frontera
  1. Extrae tu dominio a un paquete local que declare las cinco plataformas y comprueba que compila y pasa sus pruebas sin un solo condicional.
  2. Busca todos los condicionales de plataforma del proyecto, clasifícalos en legítimos y sintomáticos, y convierte al menos uno sintomático en una inyección por protocolo.
  3. Toma la vista más compartida que tengas y escríbela por separado para dos plataformas; compara ambas versiones tras una semana y decide si convergen.
  4. Comprueba si podrías rediseñar la app del Mac sin abrir ningún fichero compartido, y anota exactamente qué te lo impide.
  5. Escribe para cada elemento del tronco común la predicción que estás haciendo sobre él y estima cuánto tiempo esperas que siga siendo cierta.