Build types y product flavors: la matriz de variantes
Un módulo de Android no produce un artefacto sino una matriz de ellos, y entender cómo se genera esa matriz es entender el plugin entero. Los build types describen cómo se construye la app —depuración frente a publicación, ofuscación, firma, optimización— y los product flavors describen qué app se construye: por entorno, por cliente, por nivel de funcionalidad. El producto cartesiano de ambos ejes produce las variantes, y cada variante trae su propio conjunto de fuentes, su propio grafo de tareas y su propia configuración de dependencias. Esta lección recorre las dos dimensiones, explica el mecanismo de fusión de conjuntos de fuentes y de manifiestos, muestra cómo inyectar diferencias con BuildConfig y resValue sin duplicar código, y advierte del coste real de una matriz que crece por multiplicación y no por suma.
La pregunta que ordena esta lección es engañosamente simple: cuando ejecutas ./gradlew assemble, ¿cuántas aplicaciones estás construyendo? La respuesta casi nunca es una. El plugin de Android modela la construcción como una matriz de dos ejes independientes: los build types, que responden a cómo se construye —con depuración o sin ella, ofuscado o legible, firmado con qué clave—, y los product flavors, que responden a qué se construye —contra qué servidor, con qué marca, con qué conjunto de funcionalidades—. La combinación de ambos genera las variantes, y cada variante es un artefacto completo con su propio código fuente, sus propios recursos, sus propias dependencias y su propia rama del grafo de tareas. Quien no ve la matriz duplica ficheros; quien la ve, la configura.
- Distinguir con precisión el eje del build type del eje del product flavor.
- Modelar sabores en varias dimensiones y calcular las variantes resultantes.
- Entender la fusión de conjuntos de fuentes, recursos y manifiestos por variante.
- Inyectar diferencias con
BuildConfig,resValuey firma sin duplicar código ni secretos.
Build types: cómo se construye
Todo módulo de Android trae dos build types de fábrica, debug y release, y no son intercambiables ni simétricos. debug está pensado para el ciclo de desarrollo: se firma automáticamente con una clave de depuración compartida, activa la depurabilidad, desactiva la reducción de código y prioriza la velocidad de compilación por encima de todo lo demás. release está pensado para el usuario final: se firma con tu clave real, se optimiza y se reduce, y su compilación es deliberadamente más lenta porque hace trabajo que en desarrollo sería absurdo.
android {
buildTypes {
debug {
applicationIdSuffix = ".debug" // convive con la de produccion
versionNameSuffix = "-dev"
isMinifyEnabled = false
buildConfigField("boolean", "REGISTRO_VERBOSO", "true")
}
release {
isMinifyEnabled = true // R8 elimina codigo no alcanzable
isShrinkResources = true // y recursos no referenciados
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
signingConfig = signingConfigs.getByName("publicacion")
buildConfigField("boolean", "REGISTRO_VERBOSO", "false")
}
create("benchmark") { // un tercer tipo, propio
initWith(buildTypes.getByName("release"))
signingConfig = signingConfigs.getByName("debug")
isDebuggable = false
matchingFallbacks += listOf("release")
}
}
}
Tres detalles con consecuencias. applicationIdSuffix en debug permite tener instaladas a la vez la versión de desarrollo y la de producción, algo que se agradece a diario y que además evita el desastre de que un tester sobrescriba su app real. El build type benchmark es el patrón canónico para medir rendimiento: hereda de release con initWith para reproducir sus optimizaciones, pero se firma con la clave de depuración para poder instalarlo sin ceremonia. Y matchingFallbacks es la respuesta a un problema que aparece en cuanto tienes módulos de biblioteca: si un módulo no define benchmark, hay que decirle a Gradle contra qué variante suya debe enlazar.
signingConfig es exactamente el sitio donde más credenciales se han filtrado en la historia de Android. La contraseña del almacén y la del alias no van en build.gradle.kts, que está en el control de versiones. Léelas con providers.gradleProperty desde un gradle.properties local ignorado por Git, desde variables de entorno en integración continua, o delega en la firma gestionada por Google Play. La regla simple: si un secreto aparece en un git diff, ya está comprometido.
Product flavors y dimensiones
Un product flavor no describe cómo se compila sino qué producto sale. Los casos canónicos son el entorno —desarrollo, preproducción, producción— y el cliente, en las aplicaciones que se publican con varias marcas sobre una misma base. La pieza que mucha gente descubre tarde es que los sabores viven en dimensiones, y que las dimensiones se multiplican entre sí.
android {
flavorDimensions += listOf("entorno", "distribucion")
productFlavors {
create("dev") {
dimension = "entorno"
applicationIdSuffix = ".dev"
buildConfigField("String", "URL_API", "\"https://api.dev.ejemplo.com\"")
resValue("string", "app_name", "Ejemplo Dev")
}
create("prod") {
dimension = "entorno"
buildConfigField("String", "URL_API", "\"https://api.ejemplo.com\"")
resValue("string", "app_name", "Ejemplo")
}
create("play") {
dimension = "distribucion"
// usa los servicios de Google
}
create("libre") {
dimension = "distribucion"
applicationIdSuffix = ".libre" // variante sin servicios propietarios
}
}
}
Con dos dimensiones de dos sabores cada una y dos build types, este módulo produce ocho variantes: devPlayDebug, devPlayRelease, devLibreDebug, y así hasta completar la matriz. El nombre de cada variante se forma concatenando los sabores en el orden en que se declararon las dimensiones y rematando con el build type, y ese nombre es también el de la tarea: ./gradlew assembleDevPlayDebug. Nada de esto lo escribes tú; lo genera el plugin al cerrar la configuración.
flowchart LR E1[dev] --> M[Producto cartesiano] E2[prod] --> M D1[play] --> M D2[libre] --> M B1[debug] --> M B2[release] --> M M --> V[Ocho variantes cada una con sus tareas fuentes y dependencias] style M fill:#f38ba8,color:#11111b style V fill:#f9e2af,color:#11111b
Una matriz completa rara vez es útil: nadie necesita prodLibreDebug firmado a diario. Usa androidComponents.beforeVariants para desactivar las combinaciones sin sentido y recuperarás tiempo de sincronización y de compilación, porque cada variante viva añade tareas al grafo. Podar la matriz es la optimización más rentable y menos practicada de todo este apartado.
Conjuntos de fuentes: la fusión que evita duplicar
Cada variante no solo hereda configuración: hereda código y recursos. El plugin define un conjunto de fuentes por sabor, por build type y por combinación, y los fusiona en un orden de prioridad conocido. La consecuencia práctica es que puedes tener implementaciones distintas de una misma clase por sabor sin un solo if en el código común.
src/main
El tronco común: el código y los recursos que comparten todas las variantes. Es donde vive el noventa y tantos por ciento del proyecto y donde debe seguir viviendo.
src/dev, src/prod
Un conjunto por sabor. Aquí van las clases, los recursos y el manifiesto específicos de ese producto. Una clase declarada aquí sustituye a la de main en esa variante.
src/debug, src/release
Un conjunto por build type. El sitio natural para herramientas de desarrollo, pantallas de diagnóstico o inicializadores que no deben viajar al artefacto publicado.
src/devPlayDebug
Un conjunto por combinación completa, con la prioridad más alta. Útil, pero si empiezas a necesitarlo con frecuencia es señal de que tu matriz está modelando dos productos distintos.
La regla de fusión tiene dos caras y conviene no confundirlas. Para código fuente no hay fusión: una misma clase no puede estar en main y en dev a la vez, o el compilador verá duplicados. El patrón correcto es declarar la clase o la interfaz solo en los conjuntos de sabor, uno por cada uno, de modo que cada variante encuentre exactamente una implementación. Para recursos y manifiestos sí hay fusión con prioridad: el recurso del conjunto más específico gana sobre el de main, y los manifiestos se combinan atributo a atributo según las reglas del fusionador. Por eso resValue y un strings.xml por sabor son intercambiables, y por eso un permiso declarado en src/dev/AndroidManifest.xml no aparece en la variante de producción.
// dependencies acepta configuraciones por variante, generadas por el plugin
dependencies {
implementation(libs.androidx.core.ktx) // todas las variantes
debugImplementation(libs.leakcanary) // solo el build type debug
"playImplementation"(libs.play.services.location) // solo el sabor play
"devPlayDebugImplementation"(libs.herramienta.diagnostico)
}
El coste de la matriz y cómo contenerlo
Las variantes se multiplican, y la multiplicación es implacable: tres dimensiones de tres sabores con tres build types son veintisiete variantes, cada una con su cadena de compilación, su empaquetado y su firma. El impacto no se limita al tiempo de compilación de lo que pides; se nota antes, en la fase de configuración, porque el plugin debe crear y cablear las tareas de todas las variantes vivas aunque solo vayas a construir una. Esa es la conexión directa con la primera lección de este nivel: la matriz es uno de los mayores multiplicadores del coste de configuración en proyectos Android.
Contenerlo exige criterio de modelado más que trucos. Antes de añadir una dimensión, pregúntate si la diferencia que quieres expresar es realmente de artefacto o es de configuración en tiempo de ejecución. Un servidor distinto por entorno puede resolverse con un sabor, sí, pero en muchos equipos se resuelve mejor con una pantalla de ajustes oculta en las builds internas, que produce un solo artefacto y elimina una dimensión entera. Un cliente con su marca sí justifica un sabor, porque cambia recursos, identidad de aplicación y firma. La pregunta que discrimina: si dos combinaciones producen artefactos que jamás se distribuyen por separado, no eran variantes, eran una opción.
Aquí hay una lección de diseño que excede a Gradle y que casi todo equipo aprende por las malas. Cuando modelas la variabilidad de un producto como ejes independientes, estás eligiendo un espacio cuyo tamaño es el producto de sus dimensiones, no la suma. Añadir un tercer sabor a una dimensión de dos parece un cambio pequeño —una entrada más en un fichero— pero si tienes otra dimensión de dos y dos build types, acabas de pasar de ocho variantes a doce, y con ellas doce cadenas de compilación, doce artefactos que alguien debería probar y doce combinaciones donde puede esconderse un fallo. Nadie percibe el salto porque el cambio se ve pequeño en el diff, y ese desajuste entre el tamaño del cambio y el tamaño de su consecuencia es precisamente lo que hace peligroso al mecanismo. Observa además el efecto sobre la verificación: tu integración continua casi con seguridad prueba una o dos variantes, así que la mayor parte de ese espacio no se compila nunca hasta el día en que alguien lo necesita y descubre que llevaba meses roto. Una matriz sin probar no es una capacidad, es una deuda con intereses. La disciplina que salva es empezar preguntando si la variabilidad pertenece de verdad al artefacto. Lo que cambia con el usuario o con la sesión es configuración en tiempo de ejecución y debe resolverse con datos. Lo que cambia con el entorno suele ser configuración también, aunque duela admitirlo. Solo lo que cambia la identidad de lo que se distribuye —la firma, el identificador, la marca, el conjunto de servicios propietarios— merece un eje. Los proyectos que terminan con veinte sabores no llegaron ahí por una decisión de arquitectura: llegaron porque veinte veces resultó más rápido añadir un sabor que preguntarse si aquello era, en realidad, una casilla de ajustes.
- Declara dos dimensiones de sabores y un tercer build type. Ejecuta
./gradlew tasks --ally cuenta cuántas tareas de ensamblado aparecen. Comprueba que el número coincide con el producto cartesiano. - Crea
src/devysrc/prodcon la misma clase en cada uno y ninguna enmain. Compila las dos variantes y verifica que cada una toma la suya. - Declara un permiso solo en el manifiesto de
src/devy comprueba con el manifiesto fusionado de cada variante que no se cuela en producción. - Inyecta la URL de la API con
buildConfigFieldy el nombre visible conresValue, y explica cuándo conviene cada mecanismo. - Desactiva con
androidComponents.beforeVariantslas combinaciones que nunca vas a publicar y mide el tiempo de configuración antes y después con--profile.