Producción en Android
Al terminar: Firmar, ofuscar y probar la release en un dispositivo antes de que la pruebe un usuario.
Hasta ahora todo lo que has instalado era una versión de depuración (debug): sin optimizar y con una firma que Android Studio inventa por ti. Esta lección produce el binario que se publicaría (la versión de publicación, release) y lo prueba en el emulador, que no es lo mismo que compilarlo.
Al terminar tendrás un APK firmado con tu clave, que pasa de unos 17 MB a unos 7 MB al encender el optimizador, con la versión escrita en un solo sitio de Android y una pantalla de arranque propia. Se instala una librería (core-splashscreen) y se toca el build.gradle.kts del módulo, el catálogo, el manifiesto y MainActivity. Cuenta con unos 40 minutos, de los que varios son de espera: cada assembleRelease con el optimizador encendido tarda de dos a siete minutos.
Paso 1 · Una versión, en un solo sitio
Qué es. Una app publicada lleva dos números: versionName (el que lee la gente, 1.0.0) y versionCode (un entero que la tienda compara y que solo puede subir). Los dos viven hoy como números sueltos en build.gradle.kts. Los llevas al catálogo, que es donde se escribe cada número de la app, para que no haya dos sitios de Android donde cambiarlos. (iOS guarda los suyos en otro fichero y los dos se igualan a mano: lo verás en la lección siguiente.)
1 · En [versions]. Abre Gradle Scripts → libs.versions.toml (Version Catalog "libs") y añade al principio de la sección [versions]:
appVersionName = "1.0.0"
appVersionCode = "1"No son librerías: en el catálogo también caben valores sueltos, y se leen con libs.versions.appVersionName.get().
2 · En el módulo. Abre Gradle Scripts → build.gradle.kts (Module :androidApp). Dentro de android { defaultConfig { … } } sustituye las dos líneas versionCode y versionName que ya trae (con los números que sean) por:
// Del catálogo, que es el único sitio donde se escribe un número.
// `versionCode` es un entero; el catálogo guarda texto, de ahí `toInt()`.
versionCode = libs.versions.appVersionCode.get().toInt()
versionName = libs.versions.appVersionName.get()Pulsa Sync Now; si sale en rojo, repasa la caja «Si Sync sale en rojo» de Los ficheros de Gradle de tu proyecto. El error típico es escribir libs.versions.appVersionName sin el .get(): Gradle dice que espera un String y recibe un Provider.
Paso 2 · Tu clave de firma, fuera del proyecto
El concepto. Android solo instala una actualización si viene firmada con la misma clave que la versión instalada. La clave es la identidad de tu app: si la pierdes no puedes volver a actualizarla con el mismo nombre, y si se la das a alguien, puede publicar «actualizaciones» tuyas. Vive en un fichero llamado almacén de claves (.jks, de Java KeyStore).
Dónde guardarla. Fuera de la carpeta del proyecto. Así no viaja con él si algún día lo comprimes para enviarlo, lo copias a otro disco o lo subes a algún sitio. Crea una carpeta tuya y genera la clave dentro, con la Terminal de Android Studio:
mkdir -p ~/claves
keytool -genkeypair -v \
-keystore ~/claves/pokedex.jks -alias pokedex \
-keyalg RSA -keysize 4096 -validity 10000keytool viene con el JDK. Te pide una contraseña (dos veces) y unos datos (nombre, organización, ciudad…): escribe lo que quieras en los datos, y elige una contraseña tuya y apúntala en un gestor de contraseñas; si la olvidas, la clave no se recupera. Al final pregunta si los datos son correctos: responde yes (o sí si te pregunta en español). La contraseña de la clave es la misma que la del almacén: en el formato actual (PKCS12) keytool no pide una segunda. Los 10.000 días son unos 27 años: Play exige que la clave caduque después de 2033, y una validez corta cierra la puerta a publicar.
Si la Terminal responde keytool: command not found o «Unable to locate a Java Runtime», usa el que trae Android Studio, que está en su propio JDK: sustituye keytool por "/Applications/Android Studio.app/Contents/jbr/Contents/Home/bin/keytool" (con las comillas, por el espacio de «Android Studio») en este comando y en el siguiente.
Comprueba que existe:
keytool -list -keystore ~/claves/pokedex.jksTe pide la contraseña y debe listar una entrada llamada pokedex. Haz ahora una copia de ~/claves/pokedex.jks en un disco externo o en tu gestor de contraseñas.
Dile a Gradle dónde está. Los datos de la clave son tuyos y de tu máquina, igual que tus claves de Supabase en Configuración y secretos por entorno: van en Gradle Scripts → local.properties (SDK Location). Añade al final (pon tu ruta y tu contraseña; los valores de abajo son de ejemplo, y la contraseña no se copia de aquí):
RELEASE_STORE_FILE=/Users/tu-usuario/claves/pokedex.jks
RELEASE_STORE_PASSWORD=la-contraseña-que-elegiste
RELEASE_KEY_ALIAS=pokedex
RELEASE_KEY_PASSWORD=la-contraseña-que-elegisteLa ruta es completa y sin ~: Gradle no expande la virgulilla y la busca dentro de androidApp/. Si te equivocas, el error lo dice con claridad: Keystore file '…/androidApp/~/claves/pokedex.jks' not found for signing config 'release'. RELEASE_KEY_PASSWORD repite la contraseña del almacén, por lo que se acaba de decir.
Un detalle honesto: el fichero de la clave (.jks) vive fuera de la carpeta del proyecto, pero su contraseña queda escrita en local.properties, que sí está dentro. Es el fichero que no se comparte (lo dijo la lección de configuración), así que no lo envíes ni lo copies con la carpeta a ningún sitio, y la clave sola no sirve sin su contraseña.
Y a la compilación de release. En build.gradle.kts (Module :androidApp), justo debajo de la línea val secretos = Properties().apply { … } (la de Configuración y secretos por entorno):
// ¿Están los datos de la firma? Si no, la release se compila SIN firmar en
// vez de fallar: sirve para comprobar que compila, pero no se puede instalar.
val hayFirma = secretos.getProperty("RELEASE_STORE_PASSWORD").orEmpty().isNotBlank()Y dentro de android { … }, justo después de defaultConfig { … }, estos dos bloques nuevos:
signingConfigs {
// Solo si local.properties trae los datos de la clave.
if (hayFirma) {
create("release") {
storeFile = file(secretos.getProperty("RELEASE_STORE_FILE"))
storePassword = secretos.getProperty("RELEASE_STORE_PASSWORD")
keyAlias = secretos.getProperty("RELEASE_KEY_ALIAS")
keyPassword = secretos.getProperty("RELEASE_KEY_PASSWORD")
// Las versiones del esquema de firma (v1, v2, v3): v2 y v3 son
// las actuales; v1, la antigua, no hace falta con minSdk 24.
enableV1Signing = false
enableV2Signing = true
enableV3Signing = true
}
}
}
buildTypes {
release {
// Firma la release con la clave de arriba, si existe.
if (hayFirma) signingConfig = signingConfigs.getByName("release")
}
}Pulsa Sync Now.
Si tu build.gradle.kts ya tenía un bloque buildTypes { release { … } } (algunas plantillas lo traen con isMinifyEnabled = false y proguardFiles(…)), no pegues un segundo: deja uno solo y pon dentro la línea if (hayFirma) …. Si sobreviven isMinifyEnabled = false y la línea optimization { enable = true } del paso siguiente a la vez, gana la segunda (R8 se ejecuta igualmente), pero dos ajustes que se contradicen confunden: borra isMinifyEnabled y proguardFiles.
Paso 3 · Encender R8, y medir
El concepto. R8 es la herramienta de Google que, al construir la release, elimina el código que nadie usa, acorta los nombres de clases y métodos (ofuscar) y optimiza el resto. Por eso el APK adelgaza.
Primero construye la release sin optimizar, para tener con qué comparar:
./gradlew :androidApp:assembleReleaseCon la Terminal de Android Studio, en la carpeta del proyecto. Termina en BUILD SUCCESSFUL, y ya está firmada: el fichero se llama androidApp-release.apk (sin firma se llamaría androidApp-release-unsigned.apk). Mira cuánto pesa con la tarea tamanos de Depurar, medir y adelgazar. Su línea del release apunta todavía al nombre «sin firma»: abre build.gradle.kts (el de la raíz, Gradle Scripts → build.gradle.kts (Project: …)) y cambia "APK release sin firmar" por "APK release" y androidApp-release-unsigned.apk por androidApp-release.apk. La tarea ya lleva desde esa lección un mustRunAfter, que la hace esperar a que terminen las construcciones, así que puedes pedirla en la misma orden. Entonces:
./gradlew tamanosAPK debug 21,8 MB
APK release 17,1 MB(Las dos líneas de framework de iOS salen «(sin construir)» si no los has construido; no importa. Tus cifras serán parecidas, no idénticas. Si prefieres no tocar la tarea: ls -lh androidApp/build/outputs/apk/release/ enseña el tamaño del .apk directamente.) Ahora enciende R8. Con la sintaxis de AGP 9.3 en adelante (AGP es el Android Gradle Plugin, el que construye tu app) es una línea que enciende las dos cosas, optimización de código y de recursos, y ya trae las reglas de la plataforma. En build.gradle.kts (Module :androidApp), dentro de buildTypes { release { … } }:
buildTypes {
release {
if (hayFirma) signingConfig = signingConfigs.getByName("release")
// Una línea enciende la optimización de código y la de recursos, e
// incluye las reglas por defecto de Android.
optimization {
enable = true
}
}
}Sync, vuelve a construir (tarda unos minutos: R8 trabaja sobre todo el código) y mide en la misma orden:
./gradlew :androidApp:assembleRelease tamanosAPK debug 21,8 MB
APK release 7,2 MBDe en torno a 17 MB a en torno a 7.
Paso 4 · Probarla, que no es compilarla
Que R8 termine solo dice que el optimizador no se quejó. Lo que hay que saber es si la app funciona con las clases renombradas. Con el emulador de Ejecutar en Android encendido:
# La release lleva TU firma y la de debug es otra: Android no deja instalar
# una encima de la otra, así que se desinstala la de debug primero.
# Ojo: desinstalar borra los datos de la app en el emulador (tu sesión y tus
# favoritos); tras instalar la release tendrás que entrar y marcarlos de nuevo.
adb uninstall com.sebastiannarvaez.pokedex
adb install androidApp/build/outputs/apk/release/androidApp-release.apkDebe responder Success. (Si la Terminal dice adb: command not found, usa la ruta completa: ~/Library/Android/sdk/platform-tools/adb.) Abre la app desde el emulador y recórrela: la lista carga, abres una ficha, marcas un favorito y lo ves en la pestaña de favoritos. Cada una ejercita una pieza que R8 podría haber roto: el JSON de PokeAPI, las peticiones de red, la base de datos y la navegación.
Un resultado importante: la release funciona sin escribir ninguna regla propia. Las librerías que usas (serialización, Room, Ktor, navegación) ya le dicen a R8 lo que necesitan. Las reglas propias hacen falta solo para tu código, y el paso 6 te enseña cuándo.
Paso 5 · La pantalla de arranque
Desde Android 12 el sistema pinta siempre una pantalla de arranque mientras tu app abre: lo único que eliges es qué aparece en ella. Sin declararla, sale el icono sobre un color por defecto y el paso a la app es un parpadeo. La librería core-splashscreen te deja decidirlo y funciona igual en versiones anteriores.
Instala core-splashscreen. Es una librería de AndroidX que usa solo androidApp, y es la receta de HTTP y Ktor: el cliente de PokeAPI, sin plugin de compilador.
1 · En [versions] y en [libraries] de libs.versions.toml:
splashscreen = "1.2.0"androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "splashscreen" }2 · En el módulo que la usa. En build.gradle.kts (Module :androidApp), dentro de dependencies, junto a androidx.core.ktx:
implementation(libs.androidx.core.splashscreen) // implementation: solo la usa esta app3 · Sync Now, y comprueba. Pulsa Sync Now. El error típico es libs.androidx-core-splashscreen con guion en lugar de punto. Para comprobar que llegó:
./gradlew :androidApp:dependencies --configuration releaseRuntimeClasspath | grep core-splashscreenDebe aparecer androidx.core:core-splashscreen:1.2.0.
El color de fondo. Crea colors.xml: clic derecho sobre el nodo androidApp → New → File, con la ruta src/main/res/values/colors.xml. (Si en androidApp → res → values ya hay un colors.xml, no lo crees ni lo sustituyas: ábrelo y añade solo la línea <color name="arranque">…</color> dentro de su <resources>.)
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- El fondo del arranque. Un color plano, no un degradado: el sistema no
admite dibujos complejos aquí y los ignora sin avisar. -->
<color name="arranque">#F3F1FA</color>
</resources>El tema de arranque. Abre androidApp → res → values → themes.xml: lo creó el asistente con el proyecto y trae un tema llamado Theme.Pokedex (el nombre acaba como tu app; es el mismo que lleva android:theme en <application> del manifiesto). Añade este estilo dentro de <resources>, debajo de ese tema:
<!-- El tema de arranque: solo vale mientras se pinta la pantalla de
arranque. Hereda de `Theme.SplashScreen`, que pone la librería. -->
<style name="Theme.Pokedex.Arranque" parent="Theme.SplashScreen">
<!-- Color de fondo y dibujo que aparece en el centro. -->
<item name="windowSplashScreenBackground">@color/arranque</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
<!-- El tema que se aplica DESPUÉS: el `Theme.Pokedex` que ya tenías.
Sin esta línea la app se queda con el tema de arranque y la barra
de estado sale con el color equivocado. -->
<item name="postSplashScreenTheme">@style/Theme.Pokedex</item>
</style>Plan B del tema. Si tu themes.xml no existe, o su tema se llama de otra manera, crea (o completa) el fichero con un tema mínimo y usa ese nombre en parent/postSplashScreenTheme y en el manifiesto (arriba, el estilo nuevo se llama como el tuyo más .Arranque):
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Pokedex" parent="android:Theme.Material.Light.NoActionBar" />
</resources>Y si @drawable/ic_launcher_foreground no existe en tu proyecto (en androidApp → res → drawable no está), pon @mipmap/ic_launcher en windowSplashScreenAnimatedIcon.
El manifiesto. Abre androidApp → manifests → AndroidManifest.xml. En la etiqueta <activity android:name=".MainActivity" …> (la de la Activity, no la de <application>) cambia el valor de android:theme por el de abajo; si esa etiqueta no lo tuviera, añádelo:
android:theme="@style/Theme.Pokedex.Arranque">La Activity. Abre androidApp → kotlin+java → com.sebastiannarvaez.pokedex.android → MainActivity.kt. Añade el import y, como primera línea de onCreate (encima de enableEdgeToEdge()), la llamada:
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen // ANTES de `super.onCreate`, siempre. Instalarla después no hace nada y
// no avisa: la pantalla de arranque del sistema ya se ha ido.
installSplashScreen()Reconstruye, instala y abre la app desde cero (ciérrala del todo antes):
./gradlew :androidApp:assembleRelease
adb install -r androidApp/build/outputs/apk/release/androidApp-release.apkDebes ver un instante el fondo lila claro con el icono de la app en el centro, y después la lista. (Android 12 y posteriores dibujan el icono dentro de un círculo; en versiones anteriores la librería usa el tema de arranque como ventana.) Si te sale directamente la lista, ciérrala del todo (deslizándola fuera de las recientes) y ábrela otra vez: con la app ya en memoria no hay arranque que pintar.
Paso 6 · Cuando sí hacen falta reglas: la reflexión
R8 decide qué borrar mirando quién llama a quién. Tiene un punto ciego: el código que se busca por reflexión, es decir, por su nombre escrito como texto en tiempo de ejecución. Nadie llama a esa clase en el código, así que R8 la considera sobrante y la borra. Las librerías que usas ya avisan a R8 de lo suyo; solo las reglas propias van en tu fichero .keep.
Provócalo tú. Crea una clase que nadie nombra: clic derecho sobre androidApp → New → File, src/main/kotlin/com/sebastiannarvaez/pokedex/android/Sonda.kt:
package com.sebastiannarvaez.pokedex.android
// Una clase que NADIE nombra en el código: solo se buscará por su nombre en texto.
class SondaY en MainActivity.kt, en onCreate, justo debajo del bloque if (savedInstanceState == null) { … } (temporal):
// El nombre se construye en tiempo de ejecución (no es un texto fijo,
// que R8 sí sabría leer).
val nombre = packageName + ".android.Sonda"
// `Class.forName` busca una clase por su nombre en texto: reflexión.
val existe = runCatching { Class.forName(nombre) }.isSuccess
android.util.Log.i("Sonda", "existe=" + existe)Construye, instala y mira el registro:
./gradlew :androidApp:assembleRelease
adb install -r androidApp/build/outputs/apk/release/androidApp-release.apk
adb shell am start -n com.sebastiannarvaez.pokedex/.android.MainActivity
adb logcat -d -s Sondalogcat es el registro del sistema: -d lo vuelca y termina (sin él se queda escuchando), y -s Sonda deja solo las líneas con la etiqueta Sonda. Si la app ya estaba abierta, am start solo la trae al frente y no verás nada: ciérrala del todo antes (adb shell am force-stop com.sebastiannarvaez.pokedex).
I Sonda : existe=falseEn la release, R8 borró Sonda. En debug saldría existe=true. Ahora, la regla: clic derecho sobre androidApp → New → File, src/main/keepRules/pokedex.keep:
# Cada regla lleva escrito POR QUÉ existe: un fichero de reglas sin
# explicaciones acaba convertido en «conserva todo» al primer susto, y eso apaga
# el optimizador.
# `Sonda` se busca por su nombre en texto (Class.forName) y R8 no ve esa
# llamada. `-keep` significa «ni la borres ni la renombres».
-keep class com.sebastiannarvaez.pokedex.android.SondaLa vista Android puede no mostrar la carpeta keepRules; compruébala desde la Terminal con ls androidApp/src/main/keepRules. Repite los cuatro comandos de antes: ahora sale existe=true.
Borra la prueba: la clase Sonda, las líneas temporales de MainActivity.kt y la regla. Deja el fichero pokedex.keep con su comentario de cabecera: es donde irán tus reglas el día que una librería con reflexión lo pida.
Paso 7 · Guarda el mapa de nombres
Al ofuscar, R8 escribe una tabla que dice qué nombre corto corresponde a cada clase y método originales: mapping.txt. Sin ella, un informe de fallo de la versión publicada trae nombres como a.b.c y no hay forma de saber qué código son.
ls -l androidApp/build/outputs/mapping/release/mapping.txtExiste tras cada compilación de release y se sobrescribe en la siguiente. Cópiala a tu carpeta ~/claves con la versión en el nombre (mapping-1.0.0.txt) cada vez que publiques una.
Punto de control
./gradlew tamanos, ejecutada después de construir, enseñaAPK releaseen torno a 7 MB, no 17.adb installdeandroidApp-release.apkrespondeSuccessy la app, ya en el emulador, carga la lista, abre una ficha y guarda un favorito.adb shell dumpsys package com.sebastiannarvaez.pokedex | grep versionNamediceversionName=1.0.0, que sale del catálogo.- Tu clave está en
~/claves/pokedex.jks, con una copia aparte, y no dentro de la carpeta del proyecto; su contraseña está enlocal.properties, que no compartes. - Al abrir la app desde cero se ve el fondo lila con el icono, y después la lista.
pokedex.keepexiste con su comentario y sin la regla deSonda.
Reto · Sube de versión
Prepara la versión 1.0.1: cambia las dos líneas del catálogo (appVersionName a "1.0.1" y appVersionCode a "2"), construye la release e instálala encima de la que ya tienes con adb install -r. Comprueba con dumpsys que el nombre y el número cambiaron. Después vuelve a poner 1.0.0 y 1, construye e intenta instalarla encima: ¿qué dice Android, y qué te cuenta eso sobre el versionCode?
Ver solución
La actualización funciona sin desinstalar nada, porque lleva la misma firma:
versionCode=2 minSdk=24 targetSdk=37
versionName=1.0.1Al volver a la versión anterior, Android lo rechaza:
Failure [INSTALL_FAILED_VERSION_DOWNGRADE: Downgrade detected: Update version code 1 is older than current 2]El versionCode es el que decide qué es «más nuevo»: tiene que subir en cada versión que subas a una tienda, aunque el versionName sea el mismo. El versionName es solo un texto para las personas. Por eso los dos salen del catálogo y se cambian juntos.
Cuando termines, deja el catálogo en 1.0.0 y 1: es la versión que iOS lleva en la lección siguiente. (Y recuerda que instalar otra firma o desinstalar vacía los datos de la app en el emulador.)
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.Con AGP 9.3 o posterior,
optimization { enable = true }enciende…2.¿Cuándo hace falta una regla propia en el fichero
.keep?3.Compilas la release sin los datos de la firma en
local.properties. ¿Qué ocurre?4.¿Por qué el almacén de claves se crea con 10.000 días de validez y se guarda fuera del proyecto?
5.Instalas la versión con
versionCode1 encima de otra conversionCode2. ¿Qué responde Android?