En tu móvil, con el cable puesto, todo va fino. Subes la versión a Google Play y llegan los cierres al abrir, el login que no entra o la notificación que nunca suena. No es magia negra: la compilación de release es otra app, y tiene sus propios fallos.
Una app funciona en debug y falla en release porque la compilación de release es otra app: R8 elimina código que se usaba por reflexión, Google Play la vuelve a firmar con otro certificado, se quitan recursos y el permiso de internet de debug, y se desactivan los assert. Las 8 causas se cazan probando la compilación de release en un móvil real antes de cada subida.
Cuando ejecutas flutter run estás en modo debug: sin optimizar, firmado con una clave de desarrollo, con permisos extra para que las herramientas hablen con la app y con las aserciones activas. La compilación de release cambia todo eso a la vez. Por eso hay fallos que no existen en debug ni en los tests y aparecen el día que la app llega a la tienda.
¿Por qué R8 borra algo que se usaba por reflexión?
En release, Android pasa tu código por R8, que elimina lo que cree que nadie usa y renombra el resto. Flutter lo activa siempre en las compilaciones de release, y desde el Android Gradle Plugin 8.0 viene en modo completo por defecto, más agresivo. El problema: R8 no ve el código que se invoca por reflexión. Si una librería crea una clase por su nombre, R8 puede haberse cargado justo eso.
La pista la dio el propio R8. Junto a cada compilación deja en build/app/outputs/mapping/release/ un usage.txt con lo que eliminó y un seeds.txt con lo que conservó por una regla. Ahí aparecía el constructor borrado; tras el arreglo, ya no. La documentación de Android lo resume bien: si ves ClassNotFoundException, NoSuchMethodException o parecidos solo en release, sospecha de reflexión y añade una regla -keep.
R8 también rompe cosas sin cerrar la app
El cierre al arrancar al menos se nota. Peor es lo que falla en silencio. En Pax, la notificación diaria —el gancho central de la app— nunca se programaba en release. El logcat decía TypeToken must be created with a type argument: R8 había quitado las firmas genéricas que Gson necesita dentro del plugin flutter_local_notifications. Y el mismo fallo tumbaba el receptor que reprograma las notificaciones al reiniciar el móvil.
Arreglo: -keepattributes Signature más las reglas de Gson y del plugin. La lección general: cuando metes una dependencia nueva con código nativo, revisa si documenta reglas de R8, y prueba la función que la usa en release, no solo que la app abra.
¿Por qué falla el login solo con la firma de Play?
Tu app tiene al menos tres certificados: el de depuración que genera el SDK, el de subida con el que firmas el bundle y el de firma de apps de Play, porque con Play App Signing Google vuelve a firmar lo que subes antes de entregarlo a los usuarios (el mismo fallo, visto desde las apps hechas con IA, en las 9 causas). Firebase, el login con Google y cualquier clave de API restringida a «Apps para Android» comprueban esa huella.
En Pax, el login con Google funcionaba en debug y en release devolvía Requests from this Android client application ... are blocked. Dos huecos a la vez: la clave de API de Android solo tenía autorizada la huella de depuración, y Firebase no conocía la de Play App Signing. Resultado: los ocho testers de la prueba cerrada no podían entrar. El arreglo no necesitó recompilar, porque la validación es del lado del servidor: añadir las huellas que faltaban y esperar a que se propagaran.
Recursos que desaparecen del APK
Con shrinkResources activo, el empaquetado elimina los recursos de Android que no ve referenciados. Si un recurso solo se nombra desde Dart, para Android no existe. Nos pasó con el icono pequeño de la notificación de Pax, que se pasa por nombre desde el código Flutter: lo protegimos con un res/raw/keep.xml y su atributo tools:keep antes de que la compilación de release lo dejara fuera.
¿Falta el permiso de internet en release, o hay HTTP a pelo?
Un clásico de Flutter: la plantilla de proyecto declara android.permission.INTERNET en los manifiestos de debug y profile, porque las herramientas lo necesitan para la recarga en caliente. Si tu manifiesto principal no lo declara, en debug hay red y en release no. Las llamadas fallan y, según cómo trates el error, la app solo muestra listas vacías.
El primo de este fallo: desde Android 9, las apps que apuntan a ese nivel de API o superior tienen el tráfico HTTP sin cifrar bloqueado por defecto. Si en desarrollo apuntabas a un servidor local por http://, en producción todo debe ir por HTTPS, y la excepción, si de verdad la necesitas, se declara en la configuración de seguridad de red solo para ese dominio.
Código que solo corre en debug sin que lo sepas
En release, Flutter desactiva las aserciones: todo lo que esté dentro de un assert(...) no se ejecuta. Si dentro había una inicialización «de paso», en release no ocurre. Lo mismo con ramas condicionadas a kDebugMode, valores que llegan por --dart-define en tu comando local y no en el de compilación, o un google-services.json distinto por entorno.
- Busca
assert(ykDebugModeen el código y comprueba que ninguno esconde lógica que la app necesita. - Escribe en un script el comando exacto de compilación de release, con todos sus
--dart-define. Que no dependa de la memoria de nadie. - Comprueba que la configuración de Firebase del bundle es la del proyecto de producción.
Debug miente sobre el rendimiento (en los dos sentidos)
El modo debug está pensado para iterar rápido, no para ir rápido. Una animación que tartamudea en debug puede ir perfecta en release, y al revés: una carrera entre dos tareas asíncronas que en debug nunca se cruzaban porque todo iba lento puede aparecer cuando la app compila optimizada. Para medir rendimiento, Flutter tiene el modo profile (y Flutter o nativo explica dónde se va de verdad el tiempo); para cazar carreras, prueba en release en un móvil modesto, no solo en el tuyo.
Los logs desaparecen y la red de la oficina es demasiado buena
En release no hay depurador ni pantalla roja de error: el fallo se queda en una pantalla gris o en nada. Si no tienes un informe de errores, los cierres de tus usuarios no los ves. Y hay fallos que no son de release pero se esconden igual de bien: Pax descargaba sus fuentes de Google Fonts al arrancar, y una conexión que se cortó a mitad provocó un error no capturado que Crashlytics nos trajo de los testers. Lo arreglamos empaquetando las fuentes, desactivando la descarga en tiempo de ejecución y con un test que falla si alguien vuelve a pedir una por red.
¿Cómo cazarlo antes que tus usuarios?
Ninguno de estos fallos se ve en debug, y casi ninguno en los tests unitarios. La única defensa es probar la compilación de release de verdad, antes de cada subida. Flutter no ejecuta release en emuladores, así que hace falta un móvil físico. Nuestra rutina:
- Compila en release (`flutter build apk --release`, o genera APKs desde el AAB con
bundletool) e instálala en un móvil real conflutter installoadb install. - Deja
adb logcatabierto y recorre lo que da dinero o retiene: arranque en frío, login, compra, notificaciones, permisos. Cinco minutos mínimo. - Reinicia el móvil y abre la app otra vez: los receptores de arranque fallan ahí.
- Prueba con el modo avión a mitad de una carga.
- Si has metido una dependencia nueva, mira el
usage.txtde R8 antes de subir. - Sube primero a la pista de prueba interna o cerrada: es la única forma de probar con la firma de Play, la que de verdad llegará a los usuarios.
La compilación de release no es la de debug con otro nombre. Es otra app, y merece su propia QA.
Preguntas frecuentes sobre fallos solo en release
¿Por qué mi app Flutter funciona en debug y falla en release?
Porque la compilación de release cambia varias cosas a la vez: R8 optimiza y elimina código, Google Play firma con otro certificado, shrinkResources quita recursos, desaparece el permiso de internet de los manifiestos de debug y se desactivan los assert. Son fallos que no existen en debug ni en los tests unitarios.
¿Cómo sé si R8 está rompiendo mi app?
Si ves ClassNotFoundException, NoSuchMethodException o errores parecidos solo en release, sospecha de reflexión. R8 deja en build/app/outputs/mapping/release/ un usage.txt con lo que eliminó y un seeds.txt con lo que conservó. Se arregla con una regla -keep en proguard-rules.pro para la clase eliminada.
¿Por qué falla el login con Google solo en la versión de la tienda?
Porque Firebase y las claves de API restringidas validan la huella del certificado, y con Play App Signing Google vuelve a firmar la app con el suyo. Hay que registrar las tres huellas: depuración, subida y Play App Signing. La validación es del lado del servidor, así que no hace falta recompilar.
¿Falta el permiso de internet en mi app en release?
Puede ser. La plantilla de Flutter declara android.permission.INTERNET solo en los manifiestos de debug y profile, porque las herramientas lo necesitan. Si tu manifiesto principal no lo declara, en debug hay red y en release no. Además, desde Android 9 el tráfico HTTP sin cifrar está bloqueado por defecto.
¿Cómo pruebo la versión de release antes de subirla a Google Play?
En un móvil físico, porque Flutter no ejecuta release en emuladores. Instala la build de release, deja adb logcat abierto y recorre arranque en frío, login, compra y notificaciones; reinicia el móvil y prueba con modo avión. Después sube a una pista de prueba, que es la única que usa la firma de Play.
Fuentes
- Flutter build modes — Flutter · consultado el 3 de octubre de 2026
- Build and release an Android app — Flutter · consultado el 3 de octubre de 2026
- Plantilla del AndroidManifest.xml de debug de Flutter — GitHub · consultado el 3 de octubre de 2026
- Android Gradle plugin 8.0 release notes — Android Developers · consultado el 3 de octubre de 2026
- Troubleshoot app optimization (R8) — Android Developers · consultado el 3 de octubre de 2026
- Customize which resources to keep — Android Developers · consultado el 3 de octubre de 2026
- Sign your app — Android Developers · consultado el 3 de octubre de 2026
- Network security configuration — Android Developers · consultado el 3 de octubre de 2026
- Get deobfuscated crash reports (Flutter) — Firebase Crashlytics · consultado el 3 de octubre de 2026