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.
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.
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.
La app publicada lleva otra firma (y nadie la conoce)
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. 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.
Sin permiso de internet, o con 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; 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 conbundletool) 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.