Migrar pantalla a pantalla: la estrategia incremental
Reescribir una app de UIKit en SwiftUI de una vez es una decisión que casi siempre termina mal, y no por razones técnicas. Esta lección propone un método concreto: elegir el orden de migración con criterios medibles, mantener la columna vertebral de navegación en un solo mundo, y aceptar la convivencia prolongada como estado normal y no como fracaso.
La pregunta que todo equipo con una base de código de UIKit acaba haciéndose no es si migrar, sino cómo. Y la respuesta que suena más razonable —una reescritura limpia, en una rama aparte, con el diseño por fin bien hecho— es la que tiene el peor historial documentado de la industria: durante meses no se entrega valor, el producto original sigue cambiando y la rama nueva persigue un blanco móvil, y cuando por fin se fusiona reintroduce fallos que la versión vieja había corregido durante años. La alternativa madura es la estrategia incremental: la app sigue siendo la de siempre, se entrega cada semana, y por dentro una pantalla tras otra cambia de tecnología sin que nadie fuera del equipo lo note. Hacerlo bien no es cuestión de voluntad sino de orden, y elegir ese orden con criterios explícitos es la totalidad del trabajo estratégico.
- Justificar por qué la migración incremental gana a la reescritura en bases de código vivas.
- Elegir qué pantallas migrar primero con criterios medibles y no por preferencia personal.
- Decidir dónde vive la columna vertebral de navegación y por qué es lo último que se toca.
- Diseñar la capa de adaptación que hace sostenible la convivencia durante años.
Por qué incremental gana
El argumento no es de gusto sino de riesgo. Una migración incremental mantiene invariante lo único que de verdad importa: que la app se puede publicar hoy. Cada pantalla convertida es un cambio pequeño, revisable, medible y reversible; si la nueva versión de la pantalla de ajustes degrada una métrica, se revierte esa pantalla y no medio producto. Una reescritura completa, en cambio, convierte todo el proyecto en una única apuesta que solo se puede validar al final, justo cuando ya no queda margen para reaccionar.
Hay además un argumento de aprendizaje que se subestima. Un equipo que migra pantalla a pantalla escribe su primera pantalla declarativa con poca experiencia y la décima con mucha, y puede volver sobre la primera con lo aprendido. Un equipo que reescribe entero toma todas las decisiones de arquitectura en el momento de máxima ignorancia, las consolida en cien archivos y luego convive con ellas para siempre.
Entrega continua
La app se puede publicar en cualquier momento. La migración no compite con el producto por el mismo espacio de versiones.
Riesgo acotado
Cada conversión es una unidad de reversión. Una métrica que empeora señala una pantalla concreta, no un cambio de era.
Aprendizaje compuesto
Las convenciones se descubren sobre casos reales y se aplican a los siguientes, en lugar de decidirse todas el primer día.
El orden: qué migrar primero
Las pantallas no son intercambiables y la intuición aquí engaña. La tentación es empezar por la pantalla más importante —para demostrar que se puede— o por la más fea —para que se note—, y ambas son malas primeras opciones: la más importante concentra el riesgo y la más fea suele ser fea justo por la complejidad que la hará difícil. El criterio útil combina cuatro variables: cuántas hojas del árbol es la pantalla, cuánta lógica de negocio arrastra, con qué frecuencia cambia y cuánto tráfico recibe.
| Candidata | Por qué es buena o mala primera pantalla |
|---|---|
| Hoja sin navegación saliente | Ideal: frontera única, sin coordinar rutas ni estado compartido |
| Pantalla nueva del producto | Ideal: sin equivalente que mantener, sin riesgo de regresión |
| Formulario de ajustes | Buena: mucha ganancia de código, poca lógica, poco tráfico |
| Lista con celdas complejas | Media: gran ganancia visual, pero exige decidir rendimiento pronto |
| Pantalla de pago o de acceso | Mala: máximo riesgo de negocio y mínimo margen de error |
| Contenedor raíz de navegación | Mala al principio: es la columna vertebral, va al final |
De ese cuadro sale la regla operativa más útil de toda la migración: empieza por las hojas y avanza hacia la raíz. Una hoja tiene una sola frontera, no decide rutas, no comparte estado con hermanas y su conversión no obliga a tocar nada más. A medida que las hojas de una rama se van convirtiendo, llega un momento en que el contenedor intermedio ya solo alberga vistas declarativas, y entonces convertirlo es casi gratis porque el trabajo difícil ya está hecho.
flowchart BT A[Hoja de detalle] --> D[Contenedor de seccion] B[Hoja de ajustes] --> D C[Hoja de perfil] --> D D --> E[Pila de navegacion] E --> F[Raiz de la app] style A fill:#a6e3a1,color:#11111b style B fill:#a6e3a1,color:#11111b style C fill:#f9e2af,color:#11111b style E fill:#f38ba8,color:#11111b
Antes de convertir nada conviene además fijar el criterio de terminado, porque una pantalla migrada a medias es peor que una sin migrar. Una conversión está cerrada cuando la pantalla se comporta igual o mejor que antes en accesibilidad, cuando sus pruebas pasan sin haber relajado ninguna afirmación, cuando su versión antigua se ha borrado del proyecto y cuando no ha dejado ninguna pieza de puente nueva fuera de la capa de adaptación. Sin ese criterio explícito, el equipo acumula pantallas que nadie considera pendientes y que sin embargo siguen costando dinero.
Hay una excepción que conviene aplicar desde el primer día y que no depende del árbol: todo lo nuevo se escribe en SwiftUI. Una pantalla que no existía no tiene equivalente que mantener, no puede regresionar contra nada y su coste de migración es exactamente cero porque nunca se escribió dos veces. Un equipo que solo aplicara esta regla ya vería su proporción de código declarativo crecer sola con cada versión.
La navegación es lo último
Aquí está el error estratégico que más migraciones ha descarrilado: empezar cambiando el contenedor de navegación. Es comprensible —parece la raíz del problema— y es exactamente al revés. La navegación es el único estado que todas las pantallas comparten, de modo que cambiarla obliga a que todas se adapten a la vez, que es justamente la reescritura completa que querías evitar, solo que disfrazada.
Mientras la pila de navegación viva en UIKit, la regla es simple: una pantalla declarativa se empuja envuelta en su anfitrión y navega hacia fuera llamando a un enrutador que le llega inyectado. Ese enrutador es un protocolo diminuto, la vista no sabe qué tecnología hay al otro lado, y el día que el contenedor se convierta bastará con cambiar su implementación.
protocol Enrutador {
func abrirDetalle(id: String)
func cerrar()
}
struct ListaView: View {
let enrutador: Enrutador
var body: some View {
List(elementos) { elemento in
Button(elemento.titulo) { enrutador.abrirDetalle(id: elemento.id) }
}
}
}
final class EnrutadorUIKit: Enrutador {
weak var navegacion: UINavigationController?
func abrirDetalle(id: String) {
navegacion?.pushViewController(DetalleFactory.crear(id: id), animated: true)
}
func cerrar() { navegacion?.popViewController(animated: true) }
}
Antes de la navegación hay algo que sí conviene migrar pronto y que casi nadie prioriza: el sistema de diseño. Colores, tipografías, espaciados e iconografía compartidos entre ambos mundos, definidos una sola vez en un paquete propio. Sin eso, cada pantalla convertida es una negociación estética nueva, la app se ve inconsistente durante la transición y esa inconsistencia visible es el argumento con el que la migración se suele cancelar.
El patrón de fracaso más frecuente no es abandonar al principio, es detenerse cerca del final. Quedan las cinco pantallas difíciles —la cámara personalizada, el editor con gestos, el flujo de pago— y como su coste es alto y su beneficio parece bajo, nunca ganan la priorización. La app se queda con dos sistemas de diseño, dos convenciones de navegación y dos formas de escribir todo, indefinidamente. Dos defensas: fija desde el principio qué pantallas no se van a migrar y decláralo por escrito, y mide el avance en pantallas cerradas y no en porcentaje de líneas.
Convivir durante años
Conviene decirlo sin rodeos: una app grande tarda años en migrar, y eso no es un fracaso sino la forma normal del proceso. Lo que distingue una convivencia sana de una insoportable no es la proporción de cada tecnología, sino si el borde entre ambas está nombrado. Una app con la mitad de cada mundo, con una capa de adaptación explícita, un enrutador común y un sistema de diseño compartido, es perfectamente mantenible. Una app con el noventa por ciento declarativo y puentes improvisados en veinte sitios distintos es un campo de minas.
Esa capa de adaptación es pequeña y merece nombre y carpeta propios: los envoltorios de las vistas de UIKit que aún se usan, los anfitriones y sus fábricas, el protocolo de enrutamiento con sus dos implementaciones y los adaptadores de entorno que reinyectan el modelo en cada frontera. Toda pieza de puente vive ahí y en ningún otro sitio. La ventaja es que la carpeta hace visible la deuda: se puede contar, se puede vigilar su crecimiento y, cuando la migración termine, se borra entera.
Dentro de esa capa hay una pieza que conviene diseñar bien desde el primer día: la fábrica de pantallas. Cada pantalla se pide por su nombre y devuelve un controlador, sin que quien la pide sepa de qué mundo viene. Eso permite convertir una pantalla cambiando una sola línea, mantener las dos versiones a la vez detrás de un interruptor y revertir en producción sin publicar una versión nueva.
enum PantallaFactory {
static func detalle(id: String, modelo: AppModel) -> UIViewController {
guard Flags.detalleEnSwiftUI else { return DetalleViewController(id: id) }
let vista = DetalleView(id: id).environment(modelo)
return UIHostingController(rootView: vista)
}
}
El interruptor merece un comentario, porque tiene una fecha de caducidad que casi nadie fija. Mantener las dos implementaciones vivas es útil durante una o dos versiones, mientras las métricas confirman que la nueva no degrada nada; a partir de ahí deja de ser una red de seguridad y se convierte en el doble de código que mantener, con el agravante de que la versión antigua se pudre sin que nadie la ejecute. La disciplina es escribir la fecha de retirada del interruptor en el mismo cambio que lo introduce.
Conviene además decidir pronto qué ocurre con las pruebas. Las de interfaz suelen sobrevivir a la migración si los identificadores de accesibilidad se conservan, y esa es una razón excelente para tratarlos como parte del contrato de la pantalla y no como un detalle. Las de lógica, si extrajiste el dominio antes de tocar la interfaz, no se enteran del cambio: siguen ejecutándose sobre los mismos tipos puros y son la única red que te avisa de una regresión real de comportamiento.
Contar líneas de código miente en las dos direcciones, porque una pantalla declarativa suele tener bastantes menos líneas que su equivalente imperativa. Mide en unidades que le importen al producto: cuántas de las diez pantallas más visitadas están convertidas, cuántos controladores quedan en la ruta crítica y cuántos archivos hay en la carpeta de adaptación. Esta última cifra es la mejor de todas, porque debe subir al principio y bajar hasta cero al final; si lleva un año subiendo, la migración no avanza, se ramifica.
Todo equipo que migra en serio hace el mismo hallazgo incómodo hacia la tercera o cuarta pantalla: la parte difícil no es traducir la interfaz. Traducir una tabla a una lista declarativa es trabajo mecánico y se hace en una tarde. Lo que consume las semanas es que dentro de aquel controlador de seiscientas líneas había reglas de negocio que no vivían en ningún otro sitio —una validación en el método que se dispara al terminar la edición, un caso especial en la ruta de aparición, un orden de operaciones que solo era correcto porque coincidía con el ciclo de vida de UIKit— y ninguna de esas reglas está escrita en el modelo, ni probada, ni documentada. La migración no las crea: las hace visibles, porque el destino declarativo no tiene dónde alojarlas. En ese sentido, cada pantalla migrada es una auditoría forzosa de tu arquitectura, y explica dos fenómenos que desconciertan a la dirección: por qué las primeras pantallas tardan tanto más de lo estimado, y por qué a partir de cierto punto la velocidad se dispara. Lo que se está pagando al principio no es el aprendizaje de un framework, es la extracción de un modelo de dominio que llevaba años disuelto en controladores. Y de ahí sale el consejo más valioso que se le puede dar a un equipo que empieza: si extraes esa lógica antes de tocar la interfaz, en tipos puros con sus pruebas, la migración posterior se convierte en lo que siempre debió ser, un cambio de capa de presentación. Además, ese trabajo conserva su valor íntegro aunque la migración se detenga a mitad de camino, que es la mejor propiedad que puede tener cualquier inversión técnica.
Migra de forma incremental y publica siempre. Empieza por las hojas y avanza hacia la raíz; escribe todo lo nuevo en SwiftUI desde hoy. Unifica el sistema de diseño pronto y deja la navegación para el final, escondida tras un protocolo de enrutamiento con dos implementaciones. Declara por escrito qué pantallas no se migrarán. Concentra todo el código de puente en una carpeta con nombre y usa su tamaño como métrica de avance. Y extrae la lógica de negocio a tipos puros antes de tocar la interfaz.
- Dibuja el árbol de navegación de tu app y marca cada pantalla con sus cuatro variables: profundidad, lógica arrastrada, frecuencia de cambio y tráfico.
- Elige las tres primeras candidatas justificando cada una con esas variables, y escribe además la lista de pantallas que no se migrarán nunca.
- Extrae la lógica de negocio de una de esas pantallas a tipos puros con pruebas, sin tocar todavía una sola línea de interfaz. Anota cuántas reglas no estaban en ningún modelo.
- Define el protocolo de enrutamiento mínimo de esa rama e implementa la versión de UIKit; migra la hoja y comprueba que la vista no conoce el contenedor.
- Crea la carpeta de adaptación, mueve allí todo el código de puente existente y registra su número de archivos como métrica semanal.