Builds rápidos: caché de configuración, build cache, módulos y diagnóstico
Un build lento no es una fatalidad del proyecto ni un problema de máquina: es la suma de causas concretas, cada una medible y cada una con un remedio distinto. Esta lección cierra el nivel poniendo orden en ese arsenal. Primero el método —medir con build scan y con profile antes de tocar nada, porque la intuición sobre dónde se va el tiempo es sistemáticamente errónea—. Después las tres palancas mayores, en orden de rentabilidad: la caché de configuración, que serializa el resultado de la fase que no se puede podar; la caché de builds local y remota, que reutiliza salidas de tareas entre ramas y entre máquinas; y la modularización, que convierte el grafo del proyecto en el límite real de lo que hay que recompilar. Termina con los antipatrones que anulan cualquier caché y con la disciplina de verificarlo en integración continua.
Llega el momento de cobrar todo lo anterior. Sabes que un build tiene tres fases y que solo dos de ellas se pueden podar; sabes que una tarea se salta si sus entradas no cambiaron; sabes que la matriz de variantes multiplica el trabajo de configuración. Con ese modelo en la cabeza, acelerar un build deja de ser una colección de banderas copiadas de un foro y se convierte en un razonamiento: localizar en qué fase se va el tiempo, entender por qué esa fase no se está saltando, y aplicar la palanca que corresponde a esa causa concreta. Lo que sigue son las palancas reales, en orden de rentabilidad, y la advertencia que las acompaña a todas: ninguna funciona si tus scripts hacen trampas, porque las cachés no son magia sino consecuencias de que el trabajo esté bien declarado.
- Medir antes de intervenir, con
--profiley con build scan, y leer el reparto por fases. - Activar y sanear la caché de configuración, entendiendo qué la invalida.
- Distinguir incrementalidad, caché local y caché remota, y saber qué problema resuelve cada una.
- Usar la modularización como límite del trabajo recompilado y evitar los antipatrones que anulan las cachés.
Medir primero: el build scan no negocia
La intuición sobre dónde se va el tiempo de un build es fiable casi nunca. La gente sube la memoria del demonio cuando el problema era la configuración, o modulariza durante un trimestre cuando el problema era una tarea mal declarada que invalidaba la caché entera. Empieza siempre por los datos.
# Reparto por fases, por proyecto y por tarea, en un informe local
./gradlew :app:assembleDebug --profile
# El informe completo, publicado y navegable: la herramienta seria
./gradlew :app:assembleDebug --scan
# Compara dos ejecuciones seguidas sin cambios: la segunda deberia ser casi vacia
./gradlew :app:assembleDebug && ./gradlew :app:assembleDebug --scan
Antes de leer nada conviene fijar el escenario que estás midiendo, porque no hay un único build ni una única cifra. El build limpio de integración continua, la compilación incremental tras cambiar una línea, el cambio de rama y la sincronización del IDE tienen cuellos de botella distintos y se optimizan con palancas distintas. Elige el escenario que más veces al día sufre tu equipo —casi siempre el incremental— y optimiza ese, en lugar de perseguir el número que sale en el informe del servidor de integración, que es el que menos gente padece.
Un build scan responde a las preguntas que importan y que ninguna otra herramienta contesta: cuánto tiempo se fue en configuración y cuánto en ejecución, qué tareas fueron las más caras, cuáles se ejecutaron pudiendo estar actualizadas y por qué —qué entrada concreta cambió—, si la caché de configuración se reutilizó o se descartó, y qué porcentaje de tareas vino de la caché. Ese último dato, el ratio de aciertos, es el mejor indicador de salud de un build: si compilas dos veces seguidas sin cambiar nada y la segunda ejecución no es casi instantánea, tienes una tarea con entradas no deterministas y ninguna otra optimización te va a salvar.
Antes de cualquier otra cosa, ejecuta el mismo build dos veces sin tocar el proyecto. Si la segunda tarda una fracción de la primera, tu incrementalidad funciona y el trabajo está en otra parte. Si tarda casi lo mismo, deja de mirar el resto: hay una tarea que se ejecuta siempre. Búscala con --info y su mensaje de por qué no está actualizada. Es el diagnóstico más barato y el más ignorado.
Caché de configuración: pagar la fase cara una sola vez
De la primera lección quedó pendiente esta pieza. La fase de configuración se paga íntegra en cada invocación porque Gradle necesita evaluar los scripts para saber qué tareas existen. La caché de configuración rompe esa fatalidad: serializa el grafo de tareas ya configurado y, si nada relevante ha cambiado, lo recupera del disco y salta directamente a la ejecución. En un proyecto grande la diferencia entre configurar y deserializar es de un orden de magnitud.
# gradle.properties
org.gradle.configuration-cache=true
org.gradle.configuration-cache.problems=warn # quitalo cuando este limpio
org.gradle.caching=true
org.gradle.parallel=true
El precio es que tus scripts deben respetar reglas estrictas, y son exactamente las que ya conoces. Durante la ejecución no se puede acceder al objeto Project; no se pueden leer variables de entorno ni propiedades del sistema con las APIs de Java; no se pueden capturar objetos vivos dentro de las acciones de tarea. Todo eso se sustituye por la API de Provider, que declara la dependencia sobre el valor sin resolverla, de modo que Gradle sepa cuándo la caché deja de ser válida.
// Rompe la cache de configuracion: lee el entorno directamente y captura project
tasks.register("informe") {
doLast {
val rama = System.getenv("GIT_BRANCH")
println("$rama en ${project.name}")
}
}
// Compatible: los valores se declaran fuera y viajan como Provider
val rama = providers.environmentVariable("GIT_BRANCH").orElse("desconocida")
val nombre = project.name
tasks.register("informe") {
val ramaCapturada = rama
doLast { println("${ramaCapturada.get()} en $nombre") }
}
La caché se invalida cuando cambia cualquier script de compilación, el catálogo de versiones, un fichero de propiedades o el valor de un Provider declarado como entrada. Es decir: se invalida cuando debe, y el build scan te dice cuál fue la causa. Si un proyecto la descarta constantemente, la causa suele ser una lectura de entorno no declarada o una tarea que captura estado mutable.
Activar la caché de configuración en un proyecto con años encima produce un informe largo, y la reacción habitual es desactivarla. No lo hagas: cada problema listado es un sitio donde tu build hace trabajo no declarado, es decir, un sitio donde también estaba comprometida la corrección. Empieza con el modo de advertencia, arregla lo tuyo, actualiza los plugins de terceros que aún no sean compatibles y solo entonces pásalo a error. La caché de configuración es, de paso, el mejor auditor de higiene de un build que existe.
Incrementalidad, caché local y caché remota
Tres mecanismos distintos que se confunden con frecuencia y que resuelven problemas diferentes.
flowchart TD A[Se pide una tarea] --> B[Coinciden las entradas con la ejecucion anterior aqui] B -- Si --> C[UP-TO-DATE, no se ejecuta nada] B -- No --> D[Existe la huella en la cache local] D -- Si --> E[FROM-CACHE, se copian las salidas] D -- No --> F[Existe la huella en la cache remota] F -- Si --> G[FROM-CACHE, se descargan las salidas] F -- No --> H[Se ejecuta la tarea y se publica su resultado en la cache] style C fill:#a6e3a1,color:#11111b style E fill:#89b4fa,color:#11111b style G fill:#cba6f7,color:#11111b style H fill:#f38ba8,color:#11111b
Incrementalidad
Compara las entradas con la última ejecución en este mismo directorio. Es gratis y siempre está activa, pero se pierde en cuanto haces clean, cambias de rama o compilas en otra máquina.
Caché local
Guarda las salidas indexadas por la huella de las entradas en ~/.gradle/caches/build-cache-1. Sobrevive al clean y a los cambios de rama: si vuelves a un estado ya compilado, las salidas se copian en lugar de recalcularse.
Caché remota
La misma idea compartida por todo el equipo y por la integración continua. Cuando alguien compila algo que tú necesitas, tú te lo descargas. Es la palanca de mayor impacto en equipos medianos y grandes.
El demonio
No es una caché de salidas sino de proceso: mantiene viva la JVM con sus clases cargadas y su compilador calentado entre invocaciones. Por eso la primera compilación del día siempre miente sobre lo que tardan las demás.
La caché de builds solo funciona con tareas cacheables, y para serlo una tarea debe declarar entradas y salidas de forma completa y ser reproducible: mismas entradas, mismas salidas, siempre. Las tareas del plugin de Android y de Kotlin lo son casi todas. Las tuyas lo serán si evitas los tres venenos habituales: rutas absolutas entre las entradas, marcas de tiempo o identificadores aleatorios entre las salidas, y lecturas del sistema sin declarar. Una única tarea no reproducible en una posición temprana del grafo puede anular en la práctica toda la caché aguas abajo, porque su salida es entrada de las demás.
Módulos: recompilar menos, no recompilar más rápido
Cuando la incrementalidad y las cachés ya funcionan, el techo lo pone la topología del proyecto. Un módulo es la unidad de invalidación: si cambias una línea de un módulo, hay que recompilarlo entero y, además, todo lo que dependa de él. Un monolito de un solo módulo recompila todo ante cualquier cambio, por definición. Modularizar es reducir el alcance de esa onda expansiva.
// El modulo de datos expone su API pero no filtra sus dependencias
dependencies {
api(project(":core:modelo")) // visible para quien dependa de mi
implementation(project(":core:red")) // detalle interno, no se propaga
}
La distinción entre api e implementation es la palanca más barata y la peor usada. Con api, tu dependencia entra en el classpath de compilación de todos tus consumidores, así que cualquier cambio en ella los invalida en cascada. Con implementation, se queda contigo: los consumidores solo se recompilan si cambia tu propia API pública. En un proyecto donde todo se declaró como api —el valor por omisión mental de mucha gente— la modularización no acelera nada, porque el grafo de invalidación sigue siendo completo aunque el de directorios esté troceado.
Hay un segundo mecanismo, menos conocido, que multiplica el efecto de implementation: la evitación de compilación. Cuando cambias el cuerpo de una función sin tocar su firma, el ABI del módulo no cambia, y Gradle puede saltarse la recompilación de sus consumidores aunque el módulo sí se haya recompilado. Ese ahorro desaparece en cuanto tocas una API pública, y desaparece por completo si todo se expone. De ahí una regla operativa útil: mantén pequeñas y estables las superficies públicas de tus módulos, porque cada símbolo público es una entrada en el contrato que, al cambiar, invalida a todos los que dependen de ti.
Dos criterios más para trocear con cabeza. El primero es que el grafo debe ser ancho y poco profundo: muchos módulos hermanos que dependan de un núcleo común paralelizan bien, mientras que una cadena larga de módulos que dependen unos de otros serializa la compilación y anula la ventaja. El segundo es que las fronteras deben seguir a la funcionalidad y no a las capas técnicas: partir en ui, dominio y datos produce tres módulos gigantes que se invalidan juntos ante casi cualquier cambio, mientras que partir por funcionalidad —cada una con sus tres capas dentro— produce módulos que de verdad cambian por separado.
Cuatro costumbres frecuentes que hacen inútil todo lo anterior: ejecutar clean por rutina, que tira la incrementalidad a la basura por una superstición; inyectar la fecha o el hash del commit en BuildConfig en las builds de desarrollo, lo que cambia una entrada en cada compilación; declarar api por omisión, que propaga cada invalidación al proyecto entero; y aplicar plugins pesados a todos los módulos con subprojects en lugar de aplicarlos donde hacen falta. Ninguna se arregla con una bandera: se arreglan quitándolas.
Repasa lo que ha pasado en este nivel y verás que todas las técnicas que has estudiado son variaciones de un mismo movimiento, y que ese movimiento no es hacer el trabajo más deprisa sino demostrar que el trabajo no hace falta. La incrementalidad no acelera la compilación: prueba que las entradas no cambiaron y la omite. La caché de builds no acelera nada tampoco: prueba que ese resultado ya se calculó en algún sitio y lo copia. La caché de configuración no configura más rápido: prueba que la configuración anterior sigue siendo válida. Modularizar no compila mejor: reduce el conjunto de cosas que hay que probar de nuevo. Y fíjate en la condición que comparten las cuatro, porque es la idea que de verdad te llevas: todas exigen que el trabajo esté declarado. Gradle no puede saltarse una tarea cuyas entradas desconoce, no puede cachear una salida que no es reproducible, no puede reutilizar una configuración que lee el entorno a escondidas, no puede acotar el alcance de un cambio si todo el mundo depende de todo. La velocidad, aquí, no es una propiedad que se añade con banderas: es lo que emerge cuando el sistema puede razonar sobre lo que le pides. Ese es también el motivo por el que las optimizaciones de este nivel se refuerzan entre sí en lugar de sumarse —un build honesto acumula aciertos de caché en cada capa— y por el que un build deshonesto no mejora aunque le eches máquinas: no hay hardware que compense la imposibilidad de deducir que algo ya estaba hecho. Sal de aquí con la pregunta correcta grabada. No preguntes cuánto tarda tu build; pregunta cuánto de lo que tarda es trabajo que el sistema no ha podido demostrar que era innecesario. Esa cifra, y no la del reloj, es la que mide de verdad la salud de un proyecto, y es la única que sigue diciendo la verdad cuando alguien compra ordenadores más rápidos.
- Ejecuta el mismo build dos veces seguidas sin cambios y publica un build scan de la segunda. Anota el ratio de tareas actualizadas y desde caché, y localiza cualquiera que se ejecute.
- Activa
org.gradle.configuration-cacheen modo advertencia y cataloga los problemas que aparezcan. Arregla al menos uno sustituyendo un acceso directo al entorno porproviders. - Activa la caché de builds, ejecuta
cleany vuelve a compilar. Compara el tiempo con el de uncleansin caché y explica de dónde salió la diferencia. - Revisa las declaraciones de dependencias entre tus módulos y convierte a
implementationtodas las que no formen parte de la API pública. Mide el tiempo de una compilación incremental antes y después de tocar un módulo profundo. - Introduce a propósito una entrada no determinista —el instante actual en un
buildConfigField— y observa en el build scan cómo se propaga la invalidación por el grafo. Quítala y explica la lección con tus palabras.