16 apps a Flutter moderno en 8 semanas: qué se rompió
6 apps WebView pasaron a nativo y 10 apps Flutter viejas se actualizaron en 8 semanas: errores de Kotlin, rechazos ITMS-91064 y 2.3.6 y lo que revertimos.
Si tienes una app que en realidad es una página web metida en un WebView, o una app de Flutter que nadie toca desde hace tres años, este relato es para ti: ya sea que trabajes como freelancer heredando proyectos de clientes, o en una startup donde "migrar a Flutter moderno" cayó en tu escritorio. Aquí no hay teoría, sino la lista de lo que se rompió de verdad al migrar 16 apps publicadas. Cada error de build, cada rechazo de la tienda y cada decisión que tuvimos que deshacer.
Entre el 2026-05-10 y el 2026-07-05 pasamos nuestras 16 apps a Flutter actual. Hablamos de apps de lotería y horóscopo, una calculadora, un conversor de unidades, un lector de QR, un letrero LED, una app de resultados de carreras y un juego pequeño. "Migración" significó dos trabajos distintos:
- Seis apps eran envoltorios de WebView. La app era un cascarón alrededor de páginas en nuestro propio servidor, una carpeta por app. Tenían que convertirse en apps nativas de verdad.
- Diez ya eran Flutter, pero con años de atraso. Necesitaban SDKs nuevos, código nuevo de anuncios y consentimiento, Material 3 y las políticas actuales de las tiendas.
- Una más, un juego casual hecho originalmente en Unity, se portó al motor Flame de Flutter.
Todas las apps conservaron su ficha en la tienda y su package ID, así que los usuarios recibieron una actualización, no una app nueva. Esta es la lista de lo que salió mal, tomada de las notas de migración que llevamos para cada app.
La regla: el WebView se va, sin excepciones
Las instrucciones de la primera migración empiezan con una frase tajante: "se elimina el WebView, sin excepciones". Hoy ninguna de las 16 apps depende de webview_flutter ni de flutter_inappwebview. Un pubspec.yaml todavía tiene el comentario que marca dónde estaba.
Esa regla trasladó el trabajo de verdad al servidor. Un envoltorio muestra HTML generado en el servidor, así que muchas veces no había ninguna API sobre la cual construir pantallas:
- La app de carreras necesitó una API JSON nueva, de solo lectura, con 11 acciones (años, lista, detalle, búsqueda, premios, tres rankings, tres conteos y predicción con IA) antes de que pudiera existir la primera pantalla nativa. La app no podía hablar directamente con la base de datos, y tampoco debía.
- Las pantallas de una de las apps de lotería se habían armado haciendo scraping de sus propias páginas PHP. Se movieron a endpoints REST (detalle por secciones y un endpoint de predicción con IA).
- En el envoltorio, los anuncios se disparaban con
onUrlChange, es decir, cada carga de página podía mostrar un intersticial. Las pantallas nativas necesitan puntos de anuncio explícitos, y de ahí salió más tarde la configuración remota de frecuencia de anuncios.
Una vez que existía la API, la conversión en sí fue rápida. La app de carreras pasó de WebView a nativo en un día (06-24) y se envió por primera vez a App Store tres días después.
Errores de build que aparecieron en todas las apps
google_mobile_ads y los metadatos de Kotlin. Pasar de google_mobile_ads 6.x a 9.x trae librerías de Android compiladas con un Kotlin más nuevo. Con el Kotlin Gradle Plugin 2.0.20:
incompatible version of Kotlin ... metadata is 2.3.0
La solución fue subir el KGP. Las apps terminaron en 2.2.20, 2.3.x o 2.4.0 según sus otros plugins. En una app, un KGP más viejo (2.1.21) no hacía fallar los builds de debug, solo el AAB de release, que es justo el build que ejecutas al final.
Versión mínima de iOS. Firebase 12 y el Google Mobile Ads SDK 13 requieren iOS 15 o superior. El Podfile.lock viejo fijaba GMA 11.2 y Firebase 10.29 y hubo que borrarlo, no editarlo. Los targets subieron a 15 o 16. Eso deja fuera al iPhone 6s, al 7 y al SE de primera generación, y los changelogs registran ese costo de forma explícita.
flutter_local_notifications 19 → 22 pasó a parámetros con nombre (initialize(settings: …), show(id: …, …)). Fueron cambios mecánicos, pero cada llamada necesitó uno.
Dos paquetes se quedaron atrás a propósito. share_plus 13.2 no compilaba con nuestra configuración de Kotlin, así que se quedó en 10.1.4. flutter_native_splash 2.4.8 tenía un conflicto de versión con meta, así que se quedó en 2.4.7. "Actualizar todo" tuvo dos excepciones documentadas, cada una con su motivo.
Crashes al arrancar: lo que pasa una sola vez, al abrir la app
[core/duplicate-app]en iOS dejaba la app clavada en la pantalla de splash. Firebase se estaba configurando dos veces, una de forma nativa y otra desde Dart. Quitar elFirebaseApp.configure()nativo y capturar la excepción lo resolvió.- Un crash justo después del primer arranque, reportado por App Review en la app de carreras. El formulario de consentimiento de Google (UMP) y el aviso de tracking de Apple (ATT) se mostraban al mismo tiempo. La solución los pone en serie: un completer
consentReadytermina cuando termina el flujo de consentimiento, se haya mostrado o no el formulario, y recién entonces la app pide permiso de notificaciones y de tracking, con un tope de 10 segundos. Las notas reconocen con honestidad que nunca se obtuvo un log del crash; la solución coincidía con el síntoma, y un build posterior de la app fue aprobado.
Rechazos de la tienda y lo que nos enseñó cada uno
| Rechazo | Causa | Solución |
|---|---|---|
| ITMS-91064 "Invalid tracking information" | PrivacyInfo.xcprivacy declaraba NSPrivacyTracking = true con la lista de dominios de tracking vacía |
Privacy manifest de la app en false; los SDKs declaran lo suyo |
| Guideline 2.3.6 (clasificación de edad) | La app de resultados de carreras no declaraba contenido relacionado con apuestas | Cuestionario de clasificación respondido con precisión |
| Guideline 2.3.10 (capturas de pantalla) | Las capturas mostraban una barra de estado de Android | Un flag kScreenshotMode para capturas limpias; la app quedó solo para iPhone |
| Texto de propósito de ATT | Se consideró un texto de relleno | Reescrito para explicar qué permite el tracking |
El blindaje que agregamos a todas las apps
Una vez que cada app corría en nativo, el mismo conjunto de cambios entró en todas:
- Consentimiento de Google UMP antes de cualquier solicitud de anuncio.
- Adaptadores de mediación, más los IDs de SKAdNetwork que esas redes exigen. A una app le faltaban 86 de los 135 IDs que necesitaba.
- Ad Inspector solo en builds de debug.
- Íconos de notificación como siluetas blancas (
ic_stat_*), no el ícono del launcher a todo color, que Android dibuja como un cuadrado blanco. - Íconos adaptativos y la API de splash de Android 12.
- Versiones del
Info.plistleídas de$(FLUTTER_BUILD_NAME)/$(FLUTTER_BUILD_NUMBER), para quepubspec.yamlsea la única fuente de los números de versión. NSAllowsArbitraryLoadseliminado.- Correcciones de inyección SQL y XSS en el PHP del servidor al que ahora llamaban directamente las nuevas pantallas nativas.
La mediación tiene un precio que se puede medir. En la app de carreras, el build de iOS pasó de 24.7 MB a 32.9 MB y el Android App Bundle de 49.3 MB a 57.7 MB una vez que entraron los adaptadores.
Lo que revertimos
- Cambiar un package ID. Al principio de una migración, el ID de Android de una app se "ordenó" de
tlabo.SignBoardacom.tlabo.signboard. En Google Play eso habría creado una app nueva con cero usuarios y dejado huérfana la ficha existente. Se revirtió. Varias de nuestras apps usan a propósito IDs distintos por plataforma (tlabo.Calculatoren Android,com.tlabo.calculatoren iOS), y eso está bien. Cambiar un ID que ya está publicado, no. - Una función de IA en la nube. La app de lectura de rostro usaba un modelo en la nube para su análisis. La migración lo reemplazó por un generador en el dispositivo: sin costo de operación, sin subir nada y con una cosa menos que App Review pudiera cuestionar.
- Firebase donde no hacía falta. A tres apps que no enviaban notificaciones push se les quitó Firebase por completo.
Un proceso que vale la pena copiar, y un error en cómo lo copiamos
Cada migración siguió la misma checklist en su carpeta de trabajo:
- Confirmar la carpeta.
- Analizar el proyecto y evaluar el riesgo.
- Decidir qué hacer con el WebView.
- Revisar Material 3.
- Leer los avisos actuales del sistema operativo, de Play y de Apple. Reportar cuántos usuarios se pierden con una nueva versión mínima del sistema antes de subirla.
- Escribir el plan.
- Convertir una pantalla a la vez.
- Ejecutar
flutter analyzecon cero issues, correr los tests, generar el AAB y el IPA, y actualizar el registro.
Las apps posteriores usaron la primera app terminada como implementación de referencia.
El error: los archivos de memoria de trabajo del asistente de IA (contexto, evaluación de riesgos) se copiaron de la carpeta de una app a la siguiente como plantilla, y no siempre se reiniciaron. Varias carpetas todavía contienen las notas de otra app, y una incluso nombra la app equivocada. El código estaba bien, pero quien lea esas notas más adelante se va a confundir. Si usas plantillas para las notas de un proyecto, haz que vaciarlas sea el primer paso de la checklist.
En números
| Concepto | Valor |
|---|---|
| Apps migradas | 16 (6 envoltorios WebView, 10 modernizaciones de Flutter) + 1 port desde Unity |
| Período | 2026-05-10 → 2026-07-05 |
| Paso más rápido de WebView a nativo | 1 día (app de carreras), primer envío a iOS 3 días después |
| Archivo más grande antes del refactor | 5,189 líneas (common.dart, una app de lotería) |
| Pantallas de horóscopo después del refactor | de 400 a 85 y de 454 a 78 líneas |
lib/ de la app conversora |
776 líneas (WebView) → 3,283 líneas (nativa) |
La cantidad de líneas del conversor se multiplicó por cuatro, más o menos. Ese es el costo honesto de lo nativo: el envoltorio era chico porque el trabajo lo hacía el servidor. A cambio, la app funciona sin conexión, sus anuncios están puestos a propósito y ya no se rompe cuando cambia una página del servidor.
Si estás empezando con Dart antes de encarar una migración así, la lección 1 del curso de Dart sobre null safety y tipos explica el error Null check operator used on a null value, que es el crash más frecuente que vas a ver en apps recién migradas.
Preguntas frecuentes
¿Qué es lo más difícil de reemplazar una app WebView por Flutter nativo?
La API que no existe. Una app WebView muestra HTML generado en el servidor, así que muchas veces no hay ningún endpoint JSON detrás. Para una app de resultados de carreras tuvimos que escribir en el servidor una API de solo lectura con 11 endpoints antes de poder construir una sola pantalla nativa.
¿Por qué falla el build de google_mobile_ads después de actualizarlo?
Las versiones nuevas traen librerías compiladas con metadatos de Kotlin más nuevos. Con Kotlin 2.0.20 el build falló con 'incompatible version of Kotlin … metadata is 2.3.0'. Subir el plugin de Gradle de Kotlin (a 2.2.x o más) lo resolvió.
¿Qué rechazos de App Store aparecieron al migrar apps de Flutter?
ITMS-91064 por un privacy manifest que declaraba tracking sin dominios de tracking, la Guideline 2.3.6 por una clasificación de edad que no declaraba contenido de apuestas, la 2.3.10 por capturas con la barra de estado de Android, y un crash en el primer arranque causado por el formulario de consentimiento y el aviso de tracking apareciendo al mismo tiempo.