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

Observar el estado desde un controlador

UIKit no vuelve a preguntar nunca cómo debería verse la pantalla: hay que decírselo. La función `observe`, disponible sobre cualquier `NSObject`, cierra ese hueco registrando una closure que se reejecuta cuando cambia alguna de las propiedades de `@ObservableState` que esa misma closure leyó. Esta lección explica el mecanismo de seguimiento dinámico de accesos, por qué la closure debe ser idempotente y total en lugar de incremental, cómo repartir el trabajo entre varios bloques, cómo animar transiciones dentro de un modelo que no diferencia árboles, y qué se gana respecto al estilo anterior con `viewStore.publisher` y Combine.

⏱ 20 min

El error clásico de UIKit no es un crash ni una fuga de memoria: es una etiqueta que se quedó con el texto de antes. Ocurre porque el estado de una pantalla vive repartido entre propiedades del controlador y propiedades de las vistas, y cada camino de código que modifica lo primero tiene que acordarse de actualizar lo segundo. Con siete caminos y cuatro vistas hay veintiocho oportunidades de olvido, y basta una para que el usuario vea una mentira. TCA elimina esa aritmética de golpe: como la única fuente de verdad es el estado del store, y como existe un mecanismo que avisa cuando ese estado cambia, se puede escribir un solo bloque que describa la pantalla completa a partir del valor actual y dejar que el sistema lo reejecute cuando haga falta. Ese bloque es observe, y aprender a escribirlo bien es aprender a pensar la interfaz imperativa en términos declarativos.

🎯 Al terminar esta lección sabrás
  • Registrar observaciones con observe y entender su ciclo de vida ligado al del objeto que la declara.
  • Explicar el seguimiento dinámico de accesos y predecir qué propiedades quedan bajo vigilancia en cada ejecución.
  • Escribir cuerpos idempotentes y totales, distinguiendo asignación de estado de creación de jerarquía.
  • Traducir el estilo previo con viewStore.publisher y suscripciones de Combine al modelo observado.

Una closure que se vuelve a ejecutar sola

observe es un método disponible sobre NSObject, y por tanto sobre cualquier UIViewController y cualquier UIView. Recibe una closure, la ejecuta de inmediato una primera vez y registra qué propiedades observables se leyeron durante esa ejecución. Cuando alguna de ellas se muta, la closure vuelve a ejecutarse en el hilo principal, y el conjunto de propiedades vigiladas se recalcula desde cero con las lecturas de la nueva pasada.

final class PerfilViewController: UIViewController {
  private let store: StoreOf<Perfil>
  private let nombre = UILabel()
  private let insignia = UILabel()
  private let guardar = UIButton(type: .system)

  override func viewDidLoad() {
    super.viewDidLoad()
    configurarJerarquia()

    observe { [weak self] in
      guard let self else { return }
      nombre.text = store.nombre
      insignia.isHidden = !store.esVerificado
      guardar.isEnabled = store.puedeGuardar
      title = store.titulo
    }
  }
}

Cuatro observaciones sobre esas seis líneas. La captura débil de self es obligatoria: la closure la retiene el registrador de observación, y sin ella el controlador nunca se liberaría. La observación se cancela sola cuando el objeto que la declaró se destruye, de modo que no hace falta guardar tokens ni limpiar en deinit; el método devuelve un ObserveToken descartable que solo necesitas si quieres detener la observación antes de tiempo. La configuración de la jerarquía queda fuera del bloque, porque crear vistas no es describir estado. Y title se asigna dentro, porque el título de la barra de navegación es tan estado de pantalla como el texto de una etiqueta.

El detalle que más sorprende es que las dependencias son dinámicas, no estáticas. Si la closure contiene una condición, las lecturas de la rama no tomada no se registran, y por tanto un cambio en esas propiedades no dispara nada hasta que la condición vuelva a hacerlas visibles. Es exactamente el mismo comportamiento que tiene un body de SwiftUI bajo el framework Observation, con la misma consecuencia práctica: se observa lo que se lee, y solo lo que se lee.

Idempotente y total, nunca incremental

La regla que gobierna el interior del bloque es que su ejecución debe poder repetirse cualquier número de veces sin cambiar el resultado. Asignar etiqueta.text es idempotente: asignarlo cinco veces deja lo mismo que asignarlo una. Añadir una subvista, insertar una fila o incrementar un contador local no lo son, y colocados ahí producen jerarquías duplicadas o números disparatados. La distinción que hay que interiorizar es entre describir y modificar: dentro de observe se describe el aspecto actual de una vista ya existente; fuera se construye la vista y se gestionan sus ciclos de vida.

⚠️
Nunca envíes acciones desde dentro de un bloque de observación

Un store.send dentro de observe cierra un ciclo: la acción muta el estado, la mutación reejecuta el bloque, el bloque vuelve a enviar. Aunque en un caso concreto no llegue a divergir, has convertido una descripción de pantalla en una regla de negocio escondida en la capa de dibujo, invisible para los tests. Todo lo que sea reaccionar a un cambio de estado con más trabajo pertenece al reducer, expresado como efecto.

El corolario práctico es que el bloque puede permitirse ser generoso. Reasignar el texto de una etiqueta que ya lo tenía cuesta prácticamente nada y no provoca un nuevo paso de layout si el valor no cambió. Por eso la estrategia por defecto es escribir una sola descripción completa de la pantalla y no preocuparse por ejecuciones de más, y solo dividir en varios bloques cuando una de las asignaciones sea genuinamente costosa: recargar una tabla, recalcular un layout de colección, decodificar una imagen.

observe { [weak self] in
  guard let self else { return }
  cabecera.text = store.titulo
  contador.text = "\(store.elementos.count)"
}

observe { [weak self] in
  guard let self else { return }
  aplicarSnapshot(store.elementos)
}

Con ese reparto, teclear en el título no vuelve a construir el snapshot de la fuente de datos, porque el segundo bloque no lee store.titulo y por tanto no está suscrito a él. La granularidad de la observación se controla decidiendo qué lee cada bloque, y no existe ningún otro mando.

Animar sin un árbol que comparar

SwiftUI anima porque compara dos árboles y sabe qué apareció, qué desapareció y qué se movió. Aquí no hay comparación: hay una asignación que ocurre después de un cambio. Animar significa entonces envolver esa asignación en la primitiva de UIKit correspondiente, sabiendo que el bloque ya se está ejecutando por un motivo conocido.

observe { [weak self] in
  guard let self else { return }
  UIView.animate(withDuration: 0.25) {
    self.panel.alpha = self.store.mostrandoPanel ? 1 : 0
    self.panel.transform = self.store.mostrandoPanel
      ? .identity
      : CGAffineTransform(translationX: 0, y: 40)
  }
}

La primera ejecución también anima, lo cual casi nunca es lo deseado en viewDidLoad. Hay dos soluciones honestas: separar el estado inicial en una asignación previa al registro, o dejar que la animación arranque desde un estado ya correcto porque la jerarquía se construyó con esos valores. La segunda es preferible, porque mantiene una única descripción de la pantalla en lugar de dos que hay que mantener sincronizadas entre sí.

🎯

Lee dentro, decide fuera

El bloque lee estado y asigna propiedades. Cualquier decisión, cálculo de negocio o disparo de trabajo pertenece al reducer.

🔁

Idempotencia obligatoria

Todo lo que haya dentro debe tolerar ejecutarse muchas veces. Si algo se acumula al repetirse, está en el sitio equivocado.

🪟

Granularidad por lectura

Un bloque se suscribe exactamente a lo que lee. Para aislar un trabajo caro, dale su propio bloque y no leas nada más ahí.

🧹

Ciclo de vida automático

La observación muere con el objeto que la registró. Captura self de forma débil y olvídate de cancelar a mano.

Del publicador al bloque observado

En bases de código anteriores a @ObservableState la misma pantalla se escribía con ViewStore y Combine, y conviene reconocer el patrón porque seguirá apareciendo durante años.

// Estilo previo, con ViewStore y suscripciones explicitas
private var cancellables: Set<AnyCancellable> = []

override func viewDidLoad() {
  super.viewDidLoad()
  let viewStore = ViewStore(store, observe: { $0 })

  viewStore.publisher.nombre
    .sink { [weak self] valor in self?.nombre.text = valor }
    .store(in: &cancellables)

  viewStore.publisher.puedeGuardar
    .sink { [weak self] valor in self?.guardar.isEnabled = valor }
    .store(in: &cancellables)
}

La traducción es mecánica y casi siempre reduce el volumen a la mitad: desaparece el conjunto de cancelables, desaparece la construcción del ViewStore, y las N suscripciones se funden en un bloque. Hay un matiz de comportamiento que conviene tener presente: el publicador eliminaba duplicados, mientras que la observación se dispara ante cualquier mutación aunque el valor asignado sea igual al anterior. Como el interior del bloque es idempotente por construcción, esa diferencia es inofensiva; solo importa si dentro hay algo caro, y para eso está el reparto en varios bloques de la sección anterior.

flowchart TD
A[Accion enviada al store] --> R[Reducer muta el estado]
R --> O[Registrador de observacion detecta la mutacion]
O --> C[Comprueba si el bloque leyo esa propiedad]
C -->|Si la leyo| E[Reejecuta el bloque en el hilo principal]
C -->|Si no la leyo| X[No ocurre nada]
E --> U[Asigna propiedades de las vistas]
E --> L[Recalcula el conjunto de propiedades vigiladas]
style E fill:#89b4fa,color:#11111b
Un bloque total y repetible es la manera de ser declarativo sin un framework declarativo

Lo que hace observe no es suscribirse a un cambio: es permitirte escribir una función y fingir que el sistema la evalúa continuamente. Esa distinción tiene consecuencias profundas. En el estilo imperativo tradicional, la pantalla se mantiene al día mediante parches: ocurrió esto, luego cambia aquello. Cada parche es correcto por separado y el conjunto es incorrecto en cuanto alguien añade un octavo camino de código y olvida uno de los cuatro parches; el error no está en ninguna línea concreta sino en la ausencia de una línea, que es la clase de defecto que ninguna revisión de código detecta con fiabilidad. Al escribir un bloque total —que describe todas las propiedades relevantes a partir del estado, sin condicionar esa descripción a qué acaba de pasar— eliminas la categoría entera: ya no hay N sitios de actualización, hay uno, y ese uno no puede quedarse corto porque no sabe qué provocó su ejecución ni le importa. La idempotencia deja de ser una recomendación estilística y se revela como el requisito técnico que hace posible el truco, porque solo una función que tolera repetirse puede ser invocada por un sistema que no promete cuántas veces la invocará. Y aquí aparece la simetría que cierra el argumento: el body de SwiftUI es exactamente eso, un bloque total e idempotente que el framework reejecuta a discreción, con la única diferencia de que devuelve una descripción en lugar de asignar propiedades y de que un reconciliador convierte esa descripción en mutaciones. La técnica es idéntica; lo que cambia es quién escribe el reconciliador. En SwiftUI lo escribió Apple; en UIKit con TCA lo escribes tú, y resulta ser tan corto que cabe en una closure, porque asignar una propiedad ya es la operación mínima que un reconciliador realizaría. Vista así, la interfaz declarativa nunca fue una propiedad de un framework, sino una forma de organizar la relación entre un valor y una pantalla, y observe es la prueba de que esa forma sobrevive intacta cuando le quitas el framework por debajo.

⚔️ Convierte una pantalla de parches en una descripción total
  1. Toma un UIViewController existente y localiza todos los sitios donde asigna propiedades de sus vistas. Cuenta cuántos son.
  2. Mueve cada dato que esas asignaciones consultan al State de un reducer, aunque de momento el reducer solo tenga acciones triviales.
  3. Sustituye los N sitios por un único bloque observe que describa la pantalla completa a partir del estado. Verifica que ninguna asignación quedó fuera.
  4. Introduce una condición dentro del bloque y comprueba en el depurador que una propiedad leída solo en la rama no tomada deja de disparar reejecuciones.
  5. Extrae a un segundo bloque la operación más costosa de la pantalla y mide, con instrumentación o con un contador, cuántas ejecuciones te ahorras al teclear en un campo.