wandres.dev
MODULARIZAR · paquetes SPM

Tiempos de compilación: compilar y previsualizar una sola feature

El argumento más citado a favor de modularizar es el tiempo de compilación, y casi siempre se cita mal. Esta lección explica por qué un objetivo monolítico se degrada de forma superlineal, qué invalida exactamente el compilador cuando cambias una línea, y por qué el corte entre interfaz e implementación es la palanca que más recorta el bucle. Enseña a medir con `swift build` y con las banderas de diagnóstico en vez de creer, a construir y previsualizar un solo objetivo con `--target`, y termina donde casi nadie mira: el punto en que trocear más deja de pagar porque el coste fijo por módulo y el enlazado se comen la ganancia.

⏱ 19 min

Hay una asimetría curiosa en cómo se discute el tiempo de compilación: todo el mundo lo sufre y casi nadie lo mide. Se repite que modularizar acelera el build como si fuera un axioma, sin distinguir la compilación limpia de la incremental, sin saber qué invalida un cambio concreto y sin comprobar después si el número mejoró. Esta lección trata el bucle de edición como lo que es —la magnitud que determina cómo trabajas, qué pruebas y cuántas veces al día te atreves a experimentar— y lo hace con la disciplina que se le exige a cualquier optimización: primero entender el mecanismo, después medir, después cambiar una cosa, después volver a medir.

🎯 Al terminar esta lección sabrás
  • Explicar por qué el coste de un objetivo monolítico crece más deprisa que su tamaño y qué invalida exactamente un cambio de una línea.
  • Distinguir un cambio que altera la interfaz emitida de uno que solo toca implementación, y por qué el segundo no cruza fronteras de módulo.
  • Medir el bucle real con swift build, con los avisos de expresiones lentas y con los resúmenes de tiempo de compilación, en lugar de estimar de memoria.
  • Reconocer el punto de rendimientos decrecientes: coste fijo por objetivo, tope de paralelismo y efecto del enlazado dinámico frente al estático.

Por qué un objetivo grande se degrada más rápido que su tamaño

En una compilación incremental el sistema necesita responder a una pregunta: dado este cambio, qué archivos hay que rehacer. La respuesta se calcula a partir de un grafo de dependencias entre declaraciones que el compilador construye por objetivo. Dentro de un objetivo, ese grafo es denso por construcción, porque cualquier archivo puede referirse a cualquier otro sin declararlo: es lo que significa que todo sea internal. Cuanto más denso el grafo, mayor el cierre transitivo de un cambio, y por eso el número de archivos rehechos crece más deprisa que el número de archivos añadidos.

A eso se suma el coste de inferencia de tipos, que no es lineal. La comprobación de una sola expresión con literales, operadores sobrecargados y genéricos puede disparar una búsqueda combinatoria; en la práctica, un puñado de expresiones patológicas explican una fracción desproporcionada del tiempo total. Estas expresiones no se reparten uniformemente: viven en las vistas y en los constructores de resultados, exactamente donde más se edita.

La frontera de módulo corta las dos cosas a la vez. Reduce la densidad del grafo, porque un objetivo solo puede referirse a lo que declara, y acota la explosión de inferencia dentro de un conjunto pequeño de archivos. Y añade un tercer efecto que es el más valioso y el peor entendido: la invalidación entre objetivos no depende de que hayas tocado el módulo, sino de que hayas tocado su interfaz.

// Cambia el cuerpo: los dependientes NO se recompilan
func normalizar(_ texto: String) -> String {
  texto.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}

// Cambia la firma pública: TODOS los dependientes se recompilan
public func normalizar(_ texto: String, agresivo: Bool) -> String { ... }

De ahí la regla operativa: las capas bajas y muy compartidas —modelos, interfaces de clientes— deben tener API estable y cambiar poco, porque cada cambio suyo se propaga a todo el grafo. Las capas altas pueden cambiar cuanto quieran, porque nadie depende de ellas.

Hay un matiz que conviene tener presente y que TCA agrava más que otras arquitecturas: las macros. Expandir @Reducer, @ObservableState o @DependencyClient es trabajo del compilador que ocurre antes de la comprobación de tipos, y en un objetivo que declara treinta reducers ese trabajo se nota. No es un argumento contra las macros —el código que generan habría que escribirlo igual, y a mano sería peor— pero sí explica por qué el reparto en módulos pequeños ayuda especialmente aquí: la expansión se rehace por objetivo, no por proyecto, y un objetivo con dos reducers expande dos.

Medir antes de creer

Todo lo anterior es una hipótesis hasta que hay un número. El paquete se puede construir sin abrir Xcode, y eso ya elimina una variable enorme de ruido.

# Construir todo el paquete y cronometrarlo de verdad
time swift build

# Construir un unico objetivo y su subgrafo
time swift build --target PerfilFeature

# Limpio, para comparar peras con peras
swift package clean && time swift build --target PerfilFeature

# Cazar expresiones y funciones patologicas
swift build -Xswiftc -warn-long-expression-type-checking=150 \
            -Xswiftc -warn-long-function-bodies=150

Los dos últimos avisos son el mejor rendimiento por minuto invertido de toda esta lección: emiten una advertencia por cada expresión o función cuya comprobación de tipos supere el umbral en milisegundos. Casi siempre aparecen tres o cuatro culpables, casi siempre son concatenaciones o aritmética mezclada dentro de un cuerpo de vista, y casi siempre se arreglan anotando el tipo o partiendo la expresión en dos. En Xcode, el resumen de tiempos por fase permite ver además cuánto se va en compilar y cuánto en enlazar, que son problemas distintos con soluciones distintas.

Escenario Qué mide Cómo se mueve al modularizar
Limpio del paquete entero Coste total del troceo Empeora un poco por sobrecarga por objetivo
Tocar el cuerpo de una feature Tu bucle diario real Mejora de forma radical
Tocar la firma de un cliente Coste de mover una capa baja Mejora poco, y por eso hay que estabilizarla
💡
Compara siempre el mismo escenario

Un número de compilación sin escenario no significa nada. Fija los tres de la tabla y anótalos por separado antes de tocar la estructura, porque son la única forma de saber si un cambio ayudó. Modularizar suele empeorar levemente el primero y mejorar radicalmente el segundo; si solo mides el primero, concluirás exactamente lo contrario de lo que está pasando.

Compilar y previsualizar solo lo que estás tocando

El efecto práctico llega cuando el objetivo que construyes es el que estás editando. Con --target construyes esa feature y su subgrafo, y nada más: ni las otras features, ni las implementaciones vivas, ni el objetivo de app. Lo mismo vale para los tests, que se pueden filtrar por objetivo y por nombre.

swift build --target PerfilFeature
swift test --filter PerfilFeatureTests

Las previews se benefician del mismo mecanismo sin que hagas nada especial: el canvas necesita construir el módulo que contiene la vista y sus dependencias, de modo que si PerfilFeature depende de una interfaz liviana y no de la implementación viva, el canvas no compila la capa de red en ningún momento. Aquí se ve por qué el corte entre interfaz e implementación de la lección anterior no era una sutileza de pureza: es la decisión que determina si tu preview tarda cinco segundos o cincuenta.

El caso extremo del mismo razonamiento son los SDK de terceros pesados —analítica, pagos, mapas— que ninguna feature necesita para dibujarse. Además de esconderlos detrás de una interfaz propia, conviene consumirlos como artefactos binarios ya compilados cuando el proveedor los ofrece, para que no participen en tu compilación en absoluto.

.binaryTarget(name: "SDKPesado", path: "Vendor/SDKPesado.xcframework"),
.target(name: "AnaliticaLive", dependencies: ["Analitica", "SDKPesado"]),

El grafo resultante tiene una propiedad agradable: la única ruta desde una feature hasta el SDK pasa por la interfaz Analitica, que no lo conoce, y por AnaliticaLive, del que solo depende la raíz. Ninguna feature compila ni enlaza el SDK, y su preview arranca como si no existiera.

🧊

Capas bajas estables

Modelos e interfaces cambian poco. Cada cambio en su API pública se propaga a todo el grafo.

🔌

Implementación fuera del camino

Ninguna feature depende de la capa viva, así que editarla no recompila ninguna feature.

📐

Expresiones anotadas

Los avisos de comprobación lenta señalan tres culpables. Anotar el tipo suele bastar.

🎯

Construir el objetivo, no la app

El bucle diario es swift build --target, no un build completo del proyecto.

El límite: cuando trocear deja de pagar

Modularizar no es monótonamente bueno y conviene decirlo con la misma claridad con la que se defiende. Cada objetivo tiene un coste fijo: emitir su interfaz, escribir sus artefactos, resolver su parte del grafo. Con módulos muy pequeños, ese coste fijo domina y una compilación limpia empeora respecto al monolito. El paralelismo tampoco es infinito: los objetivos independientes se compilan a la vez hasta saturar los núcleos, y a partir de ahí añadir más módulos no compra concurrencia sino sobrecarga.

Hay un segundo límite en el enlazado. Muchos módulos enlazados dinámicamente alargan el arranque de la app, porque cada imagen hay que cargarla y resolverla en tiempo de ejecución. La regla sensata es enlazar estático salvo que exista una razón concreta para no hacerlo, y recordar que el objetivo de app debe quedarse delgado. Un tercer límite, más humano, es la profundidad: un grafo con cadenas muy largas hace que un cambio en la base pase por muchos eslabones antes de llegar arriba, y ahí la ganancia se diluye.

El criterio de fusión que mejor funciona no es el tamaño sino la correlación de cambios: si dos objetivos aparecen juntos en casi todos los commits, la frontera entre ellos no está separando nada y solo cobra su coste fijo. El criterio simétrico para dividir es la asimetría de ritmo: cuando dentro de un objetivo conviven una parte que cambia a diario y otra que no se toca desde hace meses, partirlo es rentable aunque el objetivo sea pequeño, porque libera a la parte estable de recompilarse por culpa de la volátil.

Merece la pena, por último, ser explícito sobre lo que la modularización no arregla. No hace que una expresión mal tipada se compruebe más rápido, solo confina el daño. No convierte una compilación limpia de veinte minutos en una de dos, porque el trabajo total es aproximadamente el mismo y lo que cambia es cuánto de él hay que rehacer. Y no compensa un grafo mal orientado: si tu capa base cambia cada semana, el bucle seguirá siendo largo por mucho que multipliques objetivos, porque cada cambio suyo se propagará hacia arriba. Esa observación es justamente la que motiva la última lección del nivel.

flowchart LR
E[Editas una linea] --> Q[Cambia la interfaz publica]
Q -- no --> S[Solo recompila su objetivo]
Q -- si --> T[Recompila el objetivo y sus dependientes]
S --> R[Bucle corto]
T --> U[Bucle largo]
U --> V[Estabiliza la API de las capas bajas]
style R fill:#a6e3a1,color:#11111b
style U fill:#f38ba8,color:#11111b
La duración del bucle no acelera tu trabajo: decide qué clase de trabajo eres capaz de hacer

Es tentador tratar el tiempo de compilación como una molestia cuantitativa, treinta segundos aquí, tres minutos allá, y calcular la ganancia multiplicando por el número de compilaciones diarias. Ese cálculo subestima el fenómeno porque asume que el trabajo es el mismo y solo cambia su velocidad, cuando lo que ocurre es que la duración del bucle selecciona qué prácticas son viables. Con un bucle de tres minutos, probar una hipótesis cuesta lo bastante como para que compense pensar mucho antes de probar, lo que suena virtuoso hasta que se advierte la consecuencia real: se prueban menos hipótesis, se prefieren los cambios grandes a los pequeños porque amortizan mejor la espera, y el desarrollo guiado por tests se vuelve una ceremonia que nadie sostiene. Con un bucle de diez segundos ocurre lo contrario: el cambio pequeño es barato, el experimento es gratis, y refactorizar deja de ser un proyecto para convertirse en un gesto. La modularización actúa exactamente sobre esta variable, y por eso su efecto es cualitativo y no aritmético. Hay una segunda consecuencia que rara vez se nombra: un bucle corto es también un bucle honesto, porque cuando compilar es caro la gente compila menos y acumula cambios sin verificar, de modo que los errores se descubren en lotes grandes donde ya no se sabe cuál los causó. Un objetivo que compila en segundos permite que cada paso quede comprobado y, cuando algo se rompe, el conjunto de sospechosos es diminuto. La modularización no te hace más rápido escribiendo el mismo código: te permite escribir código de una manera que con un monolito lento sencillamente no está disponible.

⚔️ Instrumenta tu bucle y decide con números
  1. Anota los tres escenarios de referencia: compilación limpia del paquete, incremental tras tocar un cuerpo de feature e incremental tras tocar la firma pública de un cliente.
  2. Ejecuta el build con los avisos de comprobación de tipos lenta y umbral de ciento cincuenta milisegundos. Arregla los tres peores casos y vuelve a medir el incremental.
  3. Comprueba si alguna feature depende de una implementación viva. Corta esa dependencia, deja solo la interfaz y mide de nuevo el arranque de su preview.
  4. Compara swift build completo con swift build --target de la feature en la que trabajas hoy. Si la diferencia no es grande, tu grafo tiene una capa baja que cambia demasiado.
  5. Cuenta objetivos y núcleos disponibles. Si tienes muchos más módulos que núcleos y la compilación limpia empeoró, fusiona los objetivos más pequeños que siempre cambian juntos.