wandres.dev
GRADLE PARA ANDROID · KTS, catálogos y variantes

El modelo de Gradle: proyectos, tareas y fases

Gradle no es un lanzador de comandos sino un motor que construye un grafo dirigido acíclico de tareas y lo ejecuta con el mínimo trabajo posible. Esta lección desmonta ese modelo pieza por pieza: qué es un proyecto y qué es un subproyecto, por qué una tarea se define por sus entradas y salidas declaradas y no por su cuerpo, y sobre todo cuáles son las tres fases —inicialización, configuración y ejecución— que atraviesa cada invocación. La distinción entre configurar y ejecutar es la que explica casi todo lo que sorprende de Gradle: por qué tus scripts corren enteros aunque solo pidas una tarea, por qué un bloque inocente en el sitio equivocado multiplica el tiempo de cada compilación y por qué la configuración lenta es el impuesto que pagas en cada sincronización de Android Studio, en cada build incremental y en cada trabajo de integración continua.

⏱ 20 min

Gradle no es un lanzador de comandos. Es un motor que primero construye un grafo dirigido acíclico de unidades de trabajo y solo después lo recorre, saltándose todo lo que puede demostrar que ya está hecho. Esa distinción, que suena académica, explica casi todo lo que te desconcertará de él: por qué tu build.gradle.kts se evalúa entero aunque solo pidas una tarea, por qué un bucle inocente en el sitio equivocado encarece cada compilación del día, y por qué la documentación de Android repite hasta el cansancio que no hagas trabajo durante la configuración. Lo que sigue no es una lista de recetas: es el modelo del que todas esas recetas son consecuencia. Interiorízalo y el resto del nivel se leerá solo.

🎯 Al terminar esta lección sabrás
  • Distinguir proyecto, subproyecto y tarea, y leer un build como un grafo dirigido acíclico.
  • Separar con precisión las fases de inicialización, configuración y ejecución.
  • Entender la incrementalidad como una función de las entradas y salidas declaradas.
  • Reconocer y medir los patrones que inflan la fase de configuración.

El build es un grafo, no un guion

La intuición más dañina que traes de otras herramientas es la del guion secuencial: en make o en los scripts de package.json escribes órdenes y se ejecutan de arriba abajo. Gradle no funciona así. Un build de Gradle es una jerarquía de objetos Project —uno raíz y uno por cada subproyecto declarado en settings.gradle.kts— y cada Project contiene un contenedor de tareas. Una tarea es un nodo; las relaciones de dependencia entre tareas son las aristas. Cuando invocas ./gradlew :app:assembleDebug no estás pidiendo que se ejecute un procedimiento, estás nombrando un nodo del grafo y pidiendo que se calcule su clausura transitiva de dependencias y se recorra en orden topológico.

Esa jerarquía de proyectos merece una precisión: el proyecto raíz de un build Android no suele contener código. Su papel es declarar en settings.gradle.kts qué módulos participan, dónde se buscan los plugins y dónde se buscan las dependencias. Los módulos son los subproyectos, se nombran con la ruta de dos puntos —:app, :core:datos— y cada uno aporta su propio build.gradle.kts. Cuando omites el prefijo e invocas ./gradlew assembleDebug sin nombrar módulo, estás pidiendo esa tarea en todos los proyectos que la tengan, que es la razón por la que el mismo comando tarda muy distinto según desde dónde lo lances.

En un proyecto Android típico ese grafo lo puebla el plugin de Android, no tú. assembleDebug depende de empaquetar el APK, que depende de dexar las clases, que depende de compilar Kotlin y Java, que depende de procesar los recursos y generar R, que depende de resolver las configuraciones de dependencias. Nada de eso lo escribes; lo declara el plugin al aplicarse. Tu papel habitual no es crear tareas sino configurar las que ya existen, y de ahí que el noventa por ciento de un build.gradle.kts de Android sea configuración declarativa y no imperativa.

# Ver el grafo sin ejecutarlo: imprime las tareas en orden topologico
./gradlew :app:assembleDebug --dry-run

# Que tareas ofrece un subproyecto y de donde salen
./gradlew :app:tasks --all

Que el modelo sea un grafo y no una secuencia tiene tres consecuencias inmediatas. La primera es que el orden de las líneas de tu script no determina el orden de ejecución: puedes configurar la tarea B antes que la A y aun así A correrá primero si B depende de ella. La segunda es que Gradle puede paralelizar: dos ramas del grafo sin aristas entre sí son candidatas a ejecutarse a la vez, y eso es lo que habilita --parallel en proyectos multi módulo. La tercera es que el grafo debe ser acíclico: una dependencia circular entre tareas —o entre subproyectos— no es un error de estilo, es un build que Gradle se niega a ordenar.

Conviene además distinguir dos relaciones que la gente confunde. dependsOn declara una dependencia real: A necesita que B haya corrido, y pedir A arrastra a B al grafo. mustRunAfter declara solo una restricción de orden: si ambas están en el grafo, se respetará la secuencia, pero pedir una no arrastra a la otra. Hay además finalizedBy, que engancha una tarea al final de otra ocurra lo que ocurra, y que es el mecanismo con el que los informes de pruebas se generan aunque las pruebas fallen. Elegir la relación equivocada produce dos patologías clásicas: dependencias de más, que hinchan cada build con trabajo que nadie pidió, y dependencias de menos, que funcionan por casualidad hasta el día en que la paralelización reordena el recorrido y el build empieza a fallar de forma intermitente.

ℹ️
El envoltorio no es un detalle burocratico

Se invoca ./gradlew y no gradle por una razón que toca directamente a la reproducibilidad. El envoltorio fija en el repositorio la versión exacta de Gradle que el proyecto necesita y la descarga si no está, de modo que tu máquina, la del resto del equipo y la de integración continua ejecutan literalmente el mismo motor. Un proyecto que depende de la versión instalada en cada portátil no tiene un build, tiene tantos builds como desarrolladores. Y el fichero de verificación del envoltorio, que comprueba la firma del distribuible descargado, es una defensa de cadena de suministro que no cuesta nada mantener.

Tres fases y un solo error caro

Cada invocación de Gradle atraviesa exactamente tres fases, y confundirlas es la fuente número uno de builds lentos.

flowchart TD
A[Fase 1 Inicializacion - se evalua settings punto gradle punto kts y se decide que subproyectos participan] --> B[Fase 2 Configuracion - se evaluan TODOS los build scripts y se crean y cablean los objetos Task]
B --> C[Fase 3 Ejecucion - solo corren las acciones de las tareas seleccionadas y no actualizadas]
B -.-> D[Aqui vive el impuesto - esta fase corre siempre, pidas lo que pidas]
style A fill:#89b4fa,color:#11111b
style B fill:#f38ba8,color:#11111b
style C fill:#a6e3a1,color:#11111b
style D fill:#f9e2af,color:#11111b

En inicialización Gradle lee settings.gradle.kts, descubre qué subproyectos existen y crea la instancia de Project de cada uno. En configuración evalúa el script de compilación de todos los proyectos que participan, de arriba abajo, aplicando plugins y registrando tareas. En ejecución recorre el subgrafo pedido y ejecuta las acciones de las tareas que no puede saltarse. La asimetría crucial es esta: la ejecución se puede podar —si nada cambió, no se ejecuta casi nada— pero la configuración, salvo que la caché de configuración intervenga, se paga íntegra en cada invocación.

De ahí nace la regla que ordena todo el resto: el cuerpo de un script se ejecuta durante la configuración; solo lo que está dentro de una acción de tarea se ejecuta durante la ejecución. Un println suelto imprime en cada ./gradlew tasks. Una llamada a git rev-parse o una lectura de fichero fuera de una acción se paga en cada sincronización de Android Studio. Ese es el error caro, y casi siempre se cuela disfrazado de utilidad inofensiva.

// MAL: el comando se ejecuta en cada configuracion, siempre
val sha = providers.exec {
    commandLine("git", "rev-parse", "--short", "HEAD")
}.standardOutput.asText.get()          // .get() fuerza el valor aqui y ahora

// BIEN: el valor queda envuelto en un Provider y solo se resuelve
// cuando alguna tarea que lo necesita se va a ejecutar de verdad
val shaProvider = providers.exec {
    commandLine("git", "rev-parse", "--short", "HEAD")
}.standardOutput.asText.map { it.trim() }

tasks.register("imprimirSha") {
    val valor = shaProvider              // se captura el Provider, no el String
    doLast { println(valor.get()) }      // doLast corre en la fase de ejecucion
}

La API de Provider que aparece en el ejemplo no es un rodeo burocrático: es el mecanismo con el que Gradle traslada valores de la configuración a la ejecución sin calcularlos antes de tiempo. Un Provider es una promesa de valor con una cadena de transformaciones asociada mediante map o flatMap, y solo se resuelve cuando alguien lo pide de verdad. Su hermano Property es un Provider al que además se le puede asignar, y es el tipo con el que se declaran las entradas configurables de una tarea. Adoptarlos convierte una regla de estilo en una garantía comprobable: si un valor viaja envuelto, es estructuralmente imposible que su cálculo se cuele en la fase de configuración.

⚠️
tasks.create frente a tasks.register

tasks.create construye y configura la tarea inmediatamente, durante la configuración, participe o no en el build que has pedido. tasks.register la registra de forma perezosa y solo la materializa si alguien la necesita. En un proyecto con cientos de tareas la diferencia es medible. La misma lógica gobierna tasks.named frente a tasks.getByName y withType.configureEach frente a withType.all: elige siempre la variante perezosa. Es lo que la documentación llama configuration avoidance, y no es una micro optimización sino un cambio de régimen.

Entradas, salidas y el arte de no hacer nada

Una tarea de Gradle no se define por su cuerpo sino por su contrato: qué ficheros y propiedades consume y qué ficheros produce. Todo lo demás —la incrementalidad, la caché, la paralelización segura— se deriva de que ese contrato esté bien declarado. Antes de ejecutar una tarea, Gradle calcula una huella con el hash de cada entrada y la compara con la que guardó la última vez. Si coincide y las salidas siguen ahí, marca la tarea como actualizada y no ejecuta ni una línea de su acción.

UP-TO-DATE

Las entradas y las salidas coinciden con la ejecución anterior en este mismo directorio. Gradle salta la tarea entera. Es el resultado que quieres ver en la inmensa mayoría de un build incremental.

📦

FROM-CACHE

Las entradas no coinciden con lo local, pero su huella ya existe en la caché de builds. Gradle no ejecuta la tarea: descarga o copia sus salidas. Es la incrementalidad viajando entre máquinas y entre ramas.

⏭️

SKIPPED / NO-SOURCE

La tarea no tenía nada que hacer porque su conjunto de fuentes está vacío o una condición onlyIf la desactivó. Distinto de estar actualizada: aquí sencillamente no había trabajo.

🔥

Ejecutada

Algo cambió, o la tarea no declaró bien sus entradas y salidas. Si ves ejecutarse tareas que deberían estar actualizadas, sospecha de una entrada no determinista: una marca de tiempo, una ruta absoluta, un valor aleatorio.

Conviene ver el mecanismo con precisión, porque de él se deriva todo lo demás. Antes de ejecutar, Gradle recopila el conjunto de entradas de la tarea —ficheros, directorios, propiedades escalares, la propia versión de la herramienta y la ruta de clases que la implementa— y calcula un hash combinado. Si ese hash coincide con el de la última ejecución y las salidas siguen presentes e intactas, la tarea se declara actualizada. Nada de esto lo decide la tarea por sí misma: lo decide el motor a partir de lo que la tarea declaró. Una tarea que lee un fichero sin haberlo declarado como entrada no es una tarea rápida, es una tarea que miente, y el motor la creerá.

La consecuencia práctica es que una tarea mal declarada envenena todo lo que hay aguas abajo. Si tu tarea personalizada no declara una entrada, Gradle la considerará actualizada cuando no lo está y compilarás con datos viejos; si declara una entrada de más —el directorio del proyecto entero, por ejemplo— nunca estará actualizada y arrastrará consigo a cada tarea que dependa de ella. Ese es el motivo por el que la costumbre de invocar clean antes de cada build es, en Gradle, una confesión: significa que el proyecto tiene un contrato roto en alguna parte y que la respuesta ha sido renunciar a la incrementalidad en lugar de arreglarlo.

💡
Diagnostica por que una tarea no esta actualizada

Ejecuta ./gradlew :app:assembleDebug --info y busca las líneas que empiezan por Task ... is not up-to-date because — Gradle te dice exactamente qué entrada cambió. Para un análisis serio, --scan publica un informe donde cada tarea muestra su huella y la razón de su resultado. No adivines: la incrementalidad es determinista y siempre hay una causa concreta.

Por qué la configuración lenta te arruina el día

Ahora se puede cerrar el argumento. La fase de ejecución escala con lo que cambiaste: si tocas una línea de un módulo hoja, se recompila esa línea. La fase de configuración, en cambio, escala con el tamaño del proyecto y se paga entera en cada invocación, cambies lo que cambies. Y no la pagas solo cuando compilas: la pagas cuando Android Studio sincroniza el proyecto, cuando pides ./gradlew tasks, cuando el IDE reindexa tras un cambio de rama, cuando integración continua arranca cualquier trabajo. Multiplica diez segundos de configuración por las decenas de veces al día que Gradle arranca y tendrás la medida real del daño.

# Cuanto tarda cada fase, con desglose por proyecto y por tarea
./gradlew :app:assembleDebug --profile

# La misma informacion, mucho mejor presentada, en un build scan
./gradlew :app:assembleDebug --scan

Los sospechosos habituales son siempre los mismos: resolución de dependencias forzada durante la configuración, lectura de ficheros o llamadas a procesos externos fuera de una acción de tarea, tasks.create en bucles, lógica repetida en allprojects o subprojects, y plugins que hacen trabajo pesado al aplicarse.

Merece detenerse en el penúltimo, porque es el más extendido y el menos reconocido. Poner configuración común en un bloque allprojects o subprojects del fichero raíz parece la forma natural de no repetirse, pero acopla todos los módulos al raíz, obliga a evaluarlo entero antes que a ninguno y bloquea optimizaciones que Gradle sí puede aplicar cuando los proyectos son independientes. La alternativa correcta es un plugin de convención: un plugin propio, definido en un módulo incluido, que encapsula esa configuración común y que cada módulo aplica explícitamente en su bloque plugins. El resultado se lee mejor, se compila una sola vez y no obliga a evaluar el proyecto raíz para saber cómo se configura una hoja.

Una advertencia final sobre las mediciones. El demonio de Gradle mantiene viva una JVM entre invocaciones con sus clases cargadas y su compilador ya optimizado, así que la primera ejecución tras arrancar la máquina siempre es sensiblemente más lenta que las siguientes y no representa tu experiencia real. Mide siempre con el demonio caliente y repite varias veces; y si necesitas comparar dos configuraciones, cambia una sola cosa entre ellas. La optimización de builds está llena de conclusiones falsas extraídas de una única medición ruidosa. La caché de configuración —que estudiarás en la última lección de este nivel— existe precisamente para amortizar esta fase serializando su resultado, pero solo funciona si tus scripts respetan las reglas que acabas de ver. Escribir un build compatible con la caché de configuración no es un trabajo adicional: es la misma disciplina de separar configuración de ejecución, ahora con un verificador automático.

La fase que no ves es la que te cobra

Casi todo el sufrimiento con Gradle procede de mirar la parte equivocada del reloj. El desarrollador que se queja de que su build tarda mira la barra de progreso de la compilación, optimiza el compilador de Kotlin, sube la memoria del demonio y no consigue mover la aguja, porque el tiempo no se le va donde cree. Se le va antes: en una fase que no produce ningún artefacto, que no aparece en el resultado y que sin embargo se ejecuta íntegra cada vez que el IDE parpadea. Ahí está la asimetría que define esta herramienta y que conviene grabar a fuego: la ejecución se puede podar, la configuración no. Gradle puede demostrar que una tarea no necesita ejecutarse comparando hashes de entradas, pero no puede adivinar que tu script no necesita evaluarse, porque el script es precisamente lo que le dice qué tareas existen. Por eso cada línea que escribes fuera de una acción de tarea es una línea que se ejecutará miles de veces sin que nadie te lo pida, y por eso las buenas prácticas de Gradle —registrar en vez de crear, envolver valores en Provider en vez de resolverlos, declarar entradas y salidas en vez de leer el disco a mano— no son manías de purista: son la única forma de mantener acotada la única parte del build que no se puede saltar. Cuando entiendas esto dejarás de tratar el fichero de compilación como un script auxiliar donde cabe cualquier cosa y empezarás a tratarlo como lo que es: la declaración de un grafo que el motor necesita leer entero, muy deprisa, antes de poder decidir que no hay nada que hacer. Los proyectos que tardan un minuto en sincronizar no llegaron ahí por un error grande, sino por doscientas conveniencias pequeñas que alguien puso en la fase equivocada.

⚔️ Mide tu propio grafo
  1. Ejecuta ./gradlew :app:assembleDebug --dry-run en un proyecto real y dibuja a mano las cinco o seis tareas principales del grafo, con sus aristas.
  2. Añade un println en el cuerpo de tu build.gradle.kts y ejecuta ./gradlew tasks, ./gradlew help y una sincronización del IDE. Cuenta cuántas veces se imprime y explica por qué.
  3. Mueve ese println dentro de un doLast de una tarea registrada con tasks.register y repite el experimento. Justifica la diferencia en términos de fases.
  4. Lanza el mismo build dos veces seguidas sin tocar nada y clasifica cada tarea del informe según su resultado: actualizada, desde caché, saltada o ejecutada. Investiga con --info cualquiera que se ejecute la segunda vez.
  5. Corre ./gradlew :app:assembleDebug --profile, abre el informe y anota el reparto entre configuración y ejecución. Si la configuración pasa de dos o tres segundos, localiza al menos un culpable concreto.