wandres.dev
RENDIMIENTO · observación y coste

Dónde se va el tiempo: comparar, recorrer, redibujar

Una app de TCA que va lenta no tiene un problema de rendimiento, tiene tres, y viven en fases distintas del mismo ciclo. Esta lección construye el modelo de coste completo: el precio de la igualdad estructural sobre árboles de valores grandes, el precio de enrutar cada acción por el árbol entero de reducers y de reconstruir ese árbol en cada envío, y el precio de invalidar y reevaluar cuerpos de SwiftUI. Establece con qué magnitud crece cada uno, qué síntoma produce, por qué la intuición los confunde sistemáticamente y por qué elegir mal el sospechoso convierte cualquier optimización en trabajo perdido.

⏱ 20 min

El diagnóstico más caro que existe es el que se salta la fase de diagnóstico. Cuando una pantalla se atasca al teclear, la conjetura habitual es que «TCA es lento», y a partir de ahí empieza una liturgia de micro-optimizaciones sin dirección: marcar campos como ignorados, romper vistas en trozos, envolver cosas en EquatableView. A veces funciona por accidente y esa es la peor de las suertes, porque refuerza un modelo mental falso. La verdad es más útil y también más exigente: entre que el dedo toca la pantalla y que el píxel cambia hay tres máquinas encadenadas —una que enruta la acción por el árbol de reducers, otra que compara y notifica valores, y otra que evalúa cuerpos y compone capas— y cada una tiene su propia ley de crecimiento, su propio síntoma y su propia herramienta de medida. Esta lección no enseña a optimizar nada; enseña a saber cuál de las tres te está costando el tiempo, que es la única pregunta cuya respuesta hace útil todo lo demás.

🎯 Al terminar esta lección sabrás
  • Descomponer el ciclo de una interacción en sus tres fases de coste y asignar a cada una su ley de crecimiento.
  • Predecir cuándo la igualdad estructural sobre un State grande pasa de irrelevante a dominante.
  • Explicar por qué el coste de un envío es proporcional al tamaño del árbol de reducers y no a su profundidad.
  • Separar el coste de invalidar un cuerpo del coste de evaluarlo, y saber cuál ataca cada técnica.

Tres relojes en el mismo ciclo

Una interacción tiene un presupuesto duro. A 60 Hz dispones de 16,67 ms para que todo el ciclo termine; en una pantalla ProMotion a 120 Hz el presupuesto se reduce a 8,33 ms. Dentro de esa ventana ocurre siempre la misma secuencia: la vista llama a send, el store enruta la acción por el árbol de reducers hasta el nodo que sabe manejarla, ese nodo muta el State en sitio, el registrador de observación anuncia qué rutas de clave cambiaron, SwiftUI marca como inválidas las vistas que habían leído esas rutas, evalúa sus cuerpos, diferencia el resultado y confirma las capas a Core Animation.

Los tres relojes no comparten unidades ni crecen igual, y ese es exactamente el motivo por el que la intuición falla.

Fase Crece con Síntoma característico
Enrutar y reducir El número de nodos del árbol de reducers y lo que cueste construir cada body Latencia plana en todas las acciones, incluso en las que no hacen nada
Comparar e igualar El tamaño total del valor alcanzable desde la raíz del State Degradación progresiva al llenarse la app de datos; suites de tests que tardan minutos
Invalidar y dibujar El número de vistas que leyeron lo mutado, multiplicado por el coste de cada cuerpo Tirones al teclear o al desplazar, con el hilo principal saturado

La primera fila es una constante que pagas siempre; la segunda es un término que explota con los datos; la tercera es un producto de dos factores que se atacan con técnicas distintas. Confundir las filas es la causa de casi todo el esfuerzo desperdiciado en este terreno.

El coste de comparar

El == que Swift sintetiza para una estructura compara campo a campo en orden de declaración, con cortocircuito en la primera desigualdad. Esa asimetría importa más de lo que parece: comparar dos valores distintos suele ser barato porque la diferencia aparece pronto, mientras que comparar dos valores iguales es siempre el caso peor, porque obliga a recorrer todo el árbol hasta el último byte. Y el caso peor es el frecuente, porque el propósito de comparar es casi siempre confirmar que nada cambió.

@ObservableState
struct State: Equatable {
  var filtro = ""
  var documentos: IdentifiedArrayOf<Documento> = []   // 20 000 elementos
  var adjuntoOriginal: Data = Data()                  // 8 MB sin comprimir
}

Un State así no es raro y es una bomba de relojería. Cada comparación completa recorre veinte mil elementos y, además, ejecuta un memcmp de ocho megabytes que ninguna optimización del compilador puede acortar. La pregunta relevante es entonces dónde se dispara esa comparación. La buena noticia es que la observación fina la sacó del camino crítico del dibujo: @ObservableState notifica por mutación de ruta de clave, no por desigualdad de valores, así que una app moderna ya no compara el estado en cada envío solo para decidir si redibujar. La mala es que la igualdad sigue viva en muchos otros sitios: el TestStore calcula un diferencial del estado completo por cada acción afirmada, onChange de SwiftUI vigila valores comparándolos, removeDuplicates sobre cualquier secuencia derivada del estado lo hace explícitamente, los envoltorios de persistencia deciden si reescribir en disco por igualdad, y el estilo previo con ViewStore y observe deduplicaba en cada acción.

Conviene además saber dónde se esconde la comparación cuando no la escribiste tú. Los identificadores de cancelación de efectos son Hashable, y si usas como identificador un valor derivado del estado en lugar de un enum pequeño, cada registro y cada cancelación calculan un resumen sobre ese valor. Los envoltorios de estado compartido con persistencia deciden si escribir en disco comparando el valor nuevo con el guardado, de modo que un campo grande y compartido paga la igualdad en cada mutación aunque nadie lo dibuje. Y cualquier onChange sobre un valor derivado del estado lo compara en cada evaluación del cuerpo que lo contiene. Ninguno de esos costes aparece en tu código como una llamada a ==, y por eso sobreviven tanto tiempo.

ℹ️
Un test lento suele ser el primer aviso de un `State` obeso

Si una suite de TestStore empieza a tardar sin que hayas añadido tests, sospecha del tamaño del estado antes que de la máquina. El TestStore compara el estado antes y después de cada acción para construir el diferencial que te enseña al fallar; ese trabajo es proporcional al valor entero, no a lo que cambió. Es la señal más temprana y más barata de que el árbol de datos ha crecido más allá de lo razonable.

El coste de recorrer el árbol

La propiedad body de un reducer no es una función que se ejecuta: es una expresión que construye un valor, un árbol de combinadores anidados donde cada Scope, cada ifLet, cada forEach y cada Reduce es un nodo. Cuando llega una acción a la raíz, el store la entrega a ese árbol y el árbol la enruta: cada nodo de ámbito intenta extraer su caso de la acción mediante una ruta de caso, y si falla devuelve inmediatamente. El coste, por tanto, no es proporcional a la profundidad del camino hasta el nodo que atiende la acción, sino al número total de nodos que la acción tiene que atravesar o descartar.

var body: some ReducerOf<Self> {
  // Esto se construye en cada envio, no una sola vez.
  let formateador = DateFormatter()
  formateador.dateStyle = .medium

  Reduce { state, action in
    switch action {
    case .aparecio:
      state.fecha = formateador.string(from: state.instante)
      return .none
    }
  }
}

Hay un matiz importante sobre las colecciones que conviene no confundir con lo anterior. Un forEach sobre un IdentifiedArray no recorre la colección para encontrar el destinatario de una acción: la acción lleva el identificador y la búsqueda es prácticamente constante, así que tener mil elementos no encarece el enrutado de una acción dirigida a uno. Lo que sí crece con el tamaño de la colección es todo lo demás —la igualdad, el diferencial de los tests, la memoria— y por eso el consejo de reducir colecciones es correcto por otros motivos, no por este. Distinguir esas dos cosas evita optimizaciones dirigidas al blanco equivocado.

El detalle que sorprende a todo el mundo la primera vez es que body es una propiedad computada, así que el árbol se reconstruye en cada invocación del reducer. Un DateFormatter creado ahí no se crea una vez: se crea con cada acción que reciba esa feature, incluidas las que ni siquiera lo usan. La regla operativa es sencilla y no admite excepciones cómodas: dentro de body, y fuera de la closure de Reduce, solo debe haber composición de reducers; cualquier objeto costoso pertenece a una dependencia o a una propiedad estática. El mismo argumento condena a ._printChanges() olvidado en una rama de producción, que además de imprimir calcula un diferencial completo del estado por acción.

flowchart LR
T[Toque en la pantalla] --> S[send al store]
S --> R[Recorrido del arbol de reducers]
R --> M[Mutacion del State en sitio]
M --> O[Registrador anuncia rutas mutadas]
O --> I[Invalidacion de las vistas lectoras]
I --> B[Evaluacion de cuerpos]
B --> D[Diferenciacion y layout]
D --> C[Confirmacion a Core Animation]
style R fill:#f9e2af,color:#11111b
style I fill:#89b4fa,color:#11111b
style B fill:#f38ba8,color:#11111b

El coste de redibujar

La tercera fase es un producto de dos factores independientes: cuántos cuerpos se invalidan y cuánto cuesta evaluar cada uno. La observación fina ataca el primer factor y no toca el segundo; extraer subvistas, memorizar cálculos o mover formateo fuera del cuerpo atacan el segundo y no tocan el primero. Elegir la técnica equivocada da exactamente cero de mejora, y como la mejora es cero pero el código cambió, se atribuye el fracaso a otra cosa y empieza la deriva.

struct FilaView: View {
  let store: StoreOf<Fila>

  // Caro por si mismo: se construye un formateador y se recorre una
  // coleccion en cada evaluacion, ocurra una vez o cien.
  var body: some View {
    let f = DateFormatter()
    f.dateStyle = .long
    return HStack {
      Text(store.titulo)
      Text(f.string(from: store.fecha))
      Text("\(store.historial.filter(\.esRelevante).count)")
    }
  }
}

Un cuerpo así no mejora al reducir sus invalidaciones de diez a una: sigue costando lo mismo cada vez que se evalúa, y hay evaluaciones que no puedes evitar porque el usuario está desplazando la lista. La corrección pertenece al segundo factor y consiste en subir el trabajo a donde se hace una sola vez: un formateador estático o inyectado como dependencia, y un recuento precalculado en el State o en el propio modelo. Conviene recordar además que el trabajo del cuerpo no acaba cuando la función devuelve: SwiftUI todavía tiene que diferenciar el árbol resultante, y las construcciones que borran el tipo estático de una vista destruyen su capacidad de compararlo estructuralmente, con lo que la diferenciación se degrada a sustituir el subárbol completo.

🧮

Comparar crece con los datos

La igualdad estructural recorre el valor entero en el caso de igualdad. Un blob binario o una colección enorme dentro del State la vuelven dominante.

🌲

Recorrer crece con los nodos

Cada acción atraviesa todo el árbol de reducers y ese árbol se reconstruye por envío. Nada caro debe vivir en body.

📣

Invalidar crece con las lecturas

Solo se marcan inválidas las vistas que leyeron la ruta mutada. Se corrige moviendo lecturas hacia abajo, no simplificando el estado.

🎨

Dibujar crece con el cuerpo

Un cuerpo caro sigue siendo caro aunque se evalúe una sola vez. Se corrige extrayendo trabajo, no observando mejor.

Hay además un multiplicador transversal que conviene tener siempre presente: la frecuencia. Una acción que cuesta 0,4 ms es irrelevante si ocurre al pulsar un botón y es una catástrofe si la emite un temporizador a 60 Hz, un gesto de desplazamiento o un campo de texto que envía por pulsación. Las tres fases se multiplican por igual ante una fuente de alta frecuencia, y por eso el primer reflejo ante un tirón no debería ser abrir un perfilador sino preguntarse cuántas acciones por segundo está recibiendo el store.

El rendimiento no es una propiedad del código sino de la forma de los datos y del grafo que inducen

Merece la pena detenerse en por qué la intuición humana falla tan sistemáticamente aquí, porque el fallo no es de disciplina sino estructural. Al leer código evaluamos su coste contando operaciones visibles: un bucle parece caro, una asignación parece barata, una llamada a función parece neutra. Ese método funciona razonablemente en programación imperativa sobre datos pequeños y deja de funcionar por completo en una arquitectura donde el trabajo real lo determinan tres cosas que el editor no muestra. La primera es el tamaño de los valores en tiempo de ejecución: state.documentos == otro.documentos ocupa treinta caracteres en pantalla y puede costar veinte mil comparaciones, y no hay ninguna pista tipográfica que distinga ese caso del que compara dos enteros. La segunda es la forma del árbol de reducers, que es un valor construido dinámicamente por un constructor de resultados y cuyo tamaño no aparece en ningún sitio del fichero que estás leyendo, porque cada Scope esconde un subárbol completo definido en otro módulo. La tercera es el grafo de lecturas que la última evaluación de cada cuerpo dejó registrado, un objeto que ni siquiera existe en el código fuente: existe solo en memoria, cambia con cada rama tomada y determina por completo cuántos cuerpos se invalidan. De las tres, ninguna es legible estáticamente, y de ahí se sigue la consecuencia metodológica que gobierna todo este nivel: cualquier afirmación sobre rendimiento hecha sin medición es una conjetura sobre objetos invisibles, y la tasa de acierto de esas conjeturas es indistinguible del azar. La contrapartida optimista es que las tres son perfectamente observables cuando decides mirarlas —el tamaño con un perfilador de asignaciones, el árbol con un punto de interrupción en el reducer, el grafo con el rastreo de cambios— y que basta identificar cuál de las tres domina para que la solución correcta se vuelva casi siempre obvia. El trabajo difícil de optimizar en TCA está entero en la fase de diagnóstico; lo que viene después suele ser una tarde.

⚔️ Construye el presupuesto de una interacción real
  1. Elige la pantalla más pesada de tu app y cuenta cuántas acciones llegan al store durante cinco segundos de uso normal. Anota la cifra: es tu multiplicador.
  2. Estima el tamaño del State alcanzable desde la raíz. Localiza el campo más grande y calcula cuántos elementos o bytes recorrería una comparación completa.
  3. Coloca un punto de interrupción en la closure de Reduce de la raíz y cuenta cuántos nodos de ámbito atraviesa una acción de hoja hasta llegar a su destino.
  4. Busca en todos los body de tus reducers cualquier objeto construido fuera de la closure de Reduce. Comprueba con un contador estático cuántas veces se crea por sesión.
  5. Escribe en tres líneas cuál de las tres fases crees que domina en esa pantalla y por qué. Guarda esa hipótesis: las lecciones siguientes te darán las herramientas para confirmarla o refutarla.