wandres.dev
TCA CON UIKIT · más allá de SwiftUI

Migración gradual: UIKit y SwiftUI conviviendo

Ninguna app grande se reescribe de golpe, y la que lo intenta suele morir en la rama larga que nunca se fusiona. La alternativa es una migración pantalla a pantalla en la que cada paso es desplegable, y TCA la habilita porque la costura entre lo viejo y lo nuevo deja de ser un archivo y pasa a ser un store. Esta lección explica cómo `UIHostingController` inyecta una vista de SwiftUI en una pila de UIKit, cómo `UIViewControllerRepresentable` hace el viaje inverso con un método de actualización vacío, por qué conviene separar la migración en dos fases independientes —primero el dominio, después el dibujo— y qué criterios justifican detener la migración a mitad y quedarse ahí para siempre.

⏱ 21 min

La pregunta que abrió este nivel vuelve al final, ya contestable con precisión: si adoptar TCA no obliga a reescribir la interfaz, ¿cómo se avanza entonces desde una app de doscientas pantallas de UIKit hasta donde quiera que se quiera llegar? La respuesta es que se avanza por donde el sistema ya tenía una junta. En una arquitectura donde las pantallas se comunican por delegados, segues y referencias mutuas, no hay ninguna junta: cualquier corte deja cables colgando, y por eso las migraciones grandes se plantean como reescrituras completas y fracasan. En una arquitectura donde cada feature habla con su padre a través de acciones y recibe del padre un store rebanado, las juntas están por todas partes, y una de ellas puede tener UIKit a un lado y SwiftUI al otro sin que ninguno de los dos se entere.

🎯 Al terminar esta lección sabrás
  • Alojar una vista de SwiftUI dentro de una jerarquía de UIKit con UIHostingController y un store rebanado.
  • Envolver un controlador heredado para una vista de SwiftUI con UIViewControllerRepresentable y un método de actualización vacío.
  • Separar la migración en dos fases desplegables por separado: llevar el dominio al reducer y después cambiar el dibujo.
  • Fijar criterios objetivos para elegir el orden de las pantallas y para decidir dónde detenerse.

El store es la costura

Un UIHostingController es un UIViewController corriente que dibuja una vista de SwiftUI. Se empuja en una pila, se presenta como hoja o se añade como hijo igual que cualquier otro. Lo interesante no es él, sino de dónde saca la vista su store: del scope del padre, exactamente igual que lo sacaría un controlador escrito a mano.

final class BandejaViewController: UIViewController {
  private let store: StoreOf<Bandeja>

  private func abrirDetalle(id: Mensaje.ID) {
    store.send(.filaSeleccionada(id: id))
  }

  private func empujarDetalle(_ hijo: StoreOf<Detalle>) {
    let vc = UIHostingController(rootView: DetalleView(store: hijo))
    vc.title = "Detalle"
    navigationController?.pushViewController(vc, animated: true)
  }
}

DetalleView no sabe que su padre es un controlador. Bandeja no sabe que su hijo se dibuja con SwiftUI. La frontera entre los dos frameworks coincide con la frontera entre las dos features, que ya existía y que ya estaba definida por un Scope y un caso de acción. Ese es el truco entero, y explica por qué la migración se puede hacer de una pantalla en una: cada pantalla es un hijo, y cambiar el dibujo de un hijo no toca a nadie más.

Quedan asperezas de integración que conviene conocer de antemano. El UIHostingController trae su propio fondo, que hay que poner transparente si se aloja dentro de una vista con diseño propio. Cuando se usa como vista incrustada, sizingOptions con intrinsicContentSize hace que participe en el Auto Layout del contenedor. Y al añadirlo como hijo hay que respetar la coreografía de addChild, incorporación de la vista y didMove, porque sin ella los eventos de aparición no llegan y los efectos ligados al ciclo de vida no arrancan.

private func incrustar(_ hijo: StoreOf<Resumen>, en contenedor: UIView) {
  let vc = UIHostingController(rootView: ResumenView(store: hijo))
  vc.view.backgroundColor = .clear
  vc.sizingOptions = .intrinsicContentSize
  addChild(vc)
  contenedor.addSubview(vc.view)
  vc.view.frame = contenedor.bounds
  vc.didMove(toParent: self)
}

El viaje inverso, con un método vacío

A veces la pantalla nueva es la de SwiftUI y la vieja es un controlador que nadie quiere tocar todavía: un editor de texto complejo, una vista de cámara, un mapa con anotaciones personalizadas. UIViewControllerRepresentable lo envuelve, y aquí aparece un detalle que sorprende la primera vez.

struct EditorLegado: UIViewControllerRepresentable {
  let store: StoreOf<Editor>

  func makeUIViewController(context: Context) -> EditorViewController {
    EditorViewController(store: store)
  }

  func updateUIViewController(_ vc: EditorViewController, context: Context) {
    // Intencionadamente vacio.
  }
}

El método de actualización está vacío, y no por descuido. En un Representable corriente ese método es el que traslada los cambios del mundo declarativo al objeto imperativo, y suele acumular comparaciones para no reasignar de más. Con TCA no hace falta: el controlador recibió el store en su construcción y se observa a sí mismo con observe, de modo que se entera de los cambios por su cuenta y en el momento exacto. El Representable deja de ser un canal de datos y se queda en lo que debería haber sido siempre, un adaptador de ciclo de vida.

💡
Un store es una referencia estable, y eso simplifica el puente

El store es un tipo de referencia que envuelve un valor. Por eso puede pasarse una sola vez, en la construcción, y seguir siendo válido durante toda la vida del controlador aunque el estado cambie mil veces. Si te sorprendes reconstruyendo el Representable para propagar datos nuevos, es que estás usando el mecanismo de SwiftUI para algo que el store ya hace mejor.

Dos fases, y la segunda es la barata

Antes de la primera pantalla hay una decisión estructural que conviene tomar bien, porque condiciona todo lo demás: dónde nace el store raíz. La respuesta es en el punto de arranque de la escena, una sola vez, y desde ahí baja rebanado hasta cada pantalla, sea del framework que sea. Un store creado dentro de un controlador es un store que muere cuando ese controlador se descarta, y con él mueren los efectos en vuelo y el estado que la pantalla siguiente esperaba encontrar.

final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
  let store = Store(initialState: App.State()) { App() }
  var window: UIWindow?

  func scene(_ escena: UIScene, willConnectTo _: UISceneSession, options _: UIScene.ConnectionOptions) {
    guard let escena = escena as? UIWindowScene else { return }
    let raiz = BandejaViewController(store: store.scope(state: \.bandeja, action: \.bandeja))
    window = UIWindow(windowScene: escena)
    window?.rootViewController = UINavigationController(rootViewController: raiz)
    window?.makeKeyAndVisible()
  }
}

Con esa raíz en su sitio, la migración es siempre la misma operación repetida: cambiar quién dibuja un hijo sin cambiar quién lo alimenta. Y el error de planificación más caro es tratar adoptar TCA y pasar a SwiftUI como una sola tarea. Son dos, y separarlas cambia el perfil de riesgo del proyecto por completo, porque la primera aporta casi todo el valor y la segunda casi todo el riesgo visual.

En la primera fase la pantalla sigue siendo el mismo UIViewController, con las mismas vistas y el mismo xib si lo tenía. Lo que cambia es que sus propiedades mutables se mudan a un State, sus manejadores pasan a enviar acciones, sus llamadas a la red se vuelven dependencias y su lógica queda en un reducer. Al terminar hay tests donde no los había, y la pantalla se puede desplegar esa misma tarde. En la segunda fase se sustituye el controlador por un UIHostingController con una vista nueva, y el reducer, las dependencias y los tests no se tocan: siguen siendo los mismos archivos, verdes, sin editar una línea. Esa continuidad de la suite entre fases es lo que convierte la segunda en una operación mecánica y reversible.

🌱

Empieza por las hojas

Las pantallas sin hijos son las de menor superficie de contacto. Una hoja migrada no obliga a migrar a nadie.

🔨

Migra lo que ya ibas a tocar

La pantalla del próximo ticket es la candidata perfecta: el coste marginal es bajo y el trabajo ya estaba presupuestado.

🧪

Los tests cruzan la frontera

Escritos en la fase uno, sobreviven a la fase dos sin cambios. Son el activo que justifica el orden.

🛑

Parar es una decisión válida

Cámaras, editores de texto ricos y colecciones con diseño complejo pueden quedarse en UIKit indefinidamente.

📝
Los dos frameworks pueden compartir la misma pila de navegación

No hace falta segregar la app en zonas. Un UINavigationController acepta indistintamente controladores escritos a mano y UIHostingController, y el usuario no percibe la costura porque la barra, las transiciones y el gesto de retroceso los sigue gobernando UIKit. La única precaución es no repartir la verdad de la navegación entre los dos mundos: si la pila la modela StackState, la modela para todos sus elementos, sin excepciones para las pantallas heredadas.

El último punto de las tarjetas merece defensa explícita, porque va contra el instinto de terminar lo empezado. La homogeneidad del framework de dibujo no es un objetivo de ingeniería: no reduce fallos, no acelera nada y no simplifica ningún razonamiento. Lo que sí lo hace es la homogeneidad del dominio, es decir, que toda la app comparta la misma forma de representar el estado, de nombrar los eventos, de inyectar dependencias y de probar. Una app con ciento noventa pantallas en SwiftUI y diez en UIKit, todas sobre el mismo árbol de reducers, es una app coherente. Una app con doscientas pantallas en SwiftUI y la lógica repartida entre modelos de vista improvisados no lo es.

flowchart TD
R[Store raiz creado en el arranque] --> A[Feature bandeja aun en UIKit]
A --> S1[scope hacia el detalle]
S1 --> H[UIHostingController con vista SwiftUI]
A --> S2[scope hacia el editor]
S2 --> L[UIViewController heredado con observe]
N[Pantalla nueva en SwiftUI] --> S3[scope hacia el editor]
S3 --> W[UIViewControllerRepresentable]
W --> L
style R fill:#89b4fa,color:#11111b
Una migración solo es posible cuando existe una junta, y la junta nunca es un archivo

Las reescrituras fracasan por una razón que rara vez se nombra con precisión: no porque el trabajo sea mucho, sino porque el sistema no admite un corte limpio y por tanto no admite entregas parciales. Un corte limpio exige que los dos lados se comuniquen exclusivamente a través de un contrato explícito, y que ninguno de los dos necesite saber cómo está construido el otro. En una app tradicional de UIKit ese contrato no existe en ninguna parte: las pantallas se conocen por referencias directas, se hablan por delegados que nombran tipos concretos, se pasan objetos mutables que ambas retienen y a veces se coordinan a través de un singleton que las dos consultan. Cortar ahí no separa dos módulos, arranca un tejido. Por eso la única propuesta que parece viable es rehacerlo todo, por eso la rama vive seis meses y por eso al fusionarla aparecen regresiones que nadie sabe atribuir. Lo que TCA cambia no es la cantidad de trabajo sino la topología: al obligar a que un hijo reciba un store rebanado y devuelva acciones, crea en cada frontera padre-hijo un contrato que consiste en un valor y una función, es decir, en lo más pequeño que un contrato puede ser. Y un contrato tan pequeño es indiferente a casi todo lo que hay a cada lado, incluido el framework de dibujo, el estilo de código, la década en la que se escribió y hasta el lenguaje. UIHostingController no es la razón por la que la migración funciona; es apenas un UIViewController que sabe dibujar SwiftUI, y existiría igual sin arquitectura ninguna. La razón es que las pantallas dejaron de necesitarse entre sí, y dos cosas que no se necesitan pueden reemplazarse por separado. Ahí está el parentesco con la técnica que se conoce como estrangulamiento por higuera: no se demuele el edificio viejo, se hace crecer el nuevo alrededor y se le va quitando la luz al primero hasta que un día no sostiene nada y se retira sin ceremonia. La condición para que esa estrategia funcione, tanto en el jardín como aquí, es que el tronco viejo y las raíces nuevas puedan coexistir sin disputarse el mismo sitio. El store es ese sitio compartido, y por eso la respuesta correcta a la pregunta de cuándo migrar una pantalla no es cuando toque en el plan, sino la próxima vez que haya que abrirla por otro motivo: si cada apertura la deja mejor y ninguna apertura rompe a las demás, la migración deja de ser un proyecto con fecha y se convierte en una propiedad del mantenimiento ordinario, que es la única forma en que estas cosas terminan de verdad.

⚔️ Ejecuta una migración de una sola pantalla, en dos entregas
  1. Elige una pantalla hoja de tu app en UIKit y describe en una lista todo su estado mutable, incluidas banderas de carga y textos temporales.
  2. Primera entrega: mueve esa lista a un State, convierte los manejadores en acciones y la red en dependencias. Escribe tres tests y despliega sin tocar las vistas.
  3. Comprueba que el controlador no guarda ya ninguna propiedad de dominio y que toda su pantalla se describe desde un bloque observe.
  4. Segunda entrega: escribe la vista de SwiftUI equivalente y sustituye el controlador por un UIHostingController en el punto donde el padre lo empuja. Ejecuta los tres tests sin editarlos.
  5. Documenta cuántas líneas cambiaron en cada entrega y en qué archivos. Usa ese reparto para decidir el orden de las cinco pantallas siguientes.