Diagnosticar: rastreo de cambios, señales e Instruments
Optimizar sin diagnosticar es apostar, y la casa siempre gana. Esta lección establece el protocolo completo de investigación de rendimiento en TCA: el rastreo de cambios del reducer para atribuir cada mutación a la acción que la causó, los impresores alternativos y los operadores de medición propios para cronometrar acciones sin contaminar la consola, las señales de os_signpost que convierten acciones y efectos en intervalos visibles en la pista de puntos de interés, y los instrumentos de Apple —perfilador de tiempo, SwiftUI, tirones de animación— con la disciplina de compilación en release, dispositivo real y escenario reproducible que hace que sus números signifiquen algo.
Un perfilador no responde preguntas: responde a preguntas. Si abres Instruments sin una hipótesis, obtienes un árbol de llamadas de veinte mil nodos donde el nodo más caro es siempre algo del sistema que no puedes tocar, y del que sales con la sensación de haber trabajado mucho y la certeza de nada. El orden correcto es el inverso y no es negociable: primero un síntoma concreto y reproducible, después una hipótesis sobre cuál de las tres fases del ciclo lo produce, después el instrumento que puede confirmar o refutar esa hipótesis concreta, y solo entonces un cambio, seguido de una medición del mismo escenario. TCA tiene la ventaja de que su ciclo está hecho de eventos con nombre —cada cosa que ocurre en la app es una acción identificable— y esa propiedad convierte el diagnóstico en algo cualitativamente más fácil que en una arquitectura donde el trabajo aparece desde cualquier sitio. Esta lección enseña a explotar esa ventaja hasta el final: convertir acciones en intervalos medibles y encontrar la que cuesta cien veces lo que aparenta.
- Aplicar un protocolo de diagnóstico que fije síntoma, hipótesis, instrumento y medida antes de tocar código.
- Atribuir mutaciones a acciones con el rastreo de cambios del reducer y elegir el impresor adecuado a cada situación.
- Instrumentar acciones y efectos con señales para leerlos como intervalos en la pista de puntos de interés.
- Distinguir una acción cara de una tormenta de acciones baratas, que exigen soluciones opuestas.
El protocolo antes que la herramienta
Cinco pasos, y saltarse cualquiera invalida el resultado. Primero, escribe el síntoma como un escenario con principio y fin: «teclear veinte caracteres en el buscador con la lista cargada», no «la app va lenta». Segundo, formula una hipótesis en términos de las tres fases: enrutar y reducir, comparar, invalidar y dibujar. Tercero, elige el instrumento que puede refutarla. Cuarto, mide el escenario en compilación de release y en el dispositivo más modesto que soportes, porque el simulador miente en ambos sentidos y las comprobaciones de la compilación de depuración distorsionan los tiempos hasta un orden de magnitud. Quinto, cambia una sola cosa y vuelve a medir el mismo escenario.
| Pregunta | Instrumento | Métrica que devuelve |
|---|---|---|
| ¿Qué acción cambió qué parte del estado? | ._printChanges() en el reducer |
Diferencial textual por acción |
| ¿Cuánto tarda cada acción y su efecto? | .signpost() o un operador de medición propio |
Intervalos con nombre en la pista de puntos de interés |
| ¿Por qué se reevalúa esta vista? | Self._printChanges() en el cuerpo |
Causa de la invalidación |
| ¿Cuántos cuerpos y confirmaciones hay? | Instrumento SwiftUI | Conteos y tiempos por vista |
| ¿Dónde se quema la CPU? | Perfilador de tiempo | Árbol de llamadas con pesos |
| ¿Se pierden fotogramas? | Tirones de animación | Tiempo de tirón por segundo |
Atribuir en el reducer
El operador ._printChanges() se apila sobre un reducer y, por cada acción recibida, imprime la acción y un diferencial legible entre el estado anterior y el resultante. Responde a la pregunta de atribución y a ninguna otra, pero esa pregunta es el punto de partida de casi toda investigación: qué acción provoca qué cambio, y con qué frecuencia llega.
var body: some ReducerOf<Self> {
Reduce { state, action in
// ...
return .none
}
._printChanges() // diferencial completo, caro
// ._printChanges(.actionLabels) // solo el nombre del caso, mucho mas barato
}
La variante que imprime únicamente etiquetas de acción existe precisamente porque el diferencial completo es costoso: calcularlo obliga a recorrer el estado entero dos veces y a formatearlo, de modo que activarlo en una pantalla con una colección grande puede añadir más latencia que el problema que investigas. La regla es usar el diferencial cuando la pregunta es qué cambió y las etiquetas cuando la pregunta es cuántas acciones llegan. Y en ninguno de los dos casos se perfila con la impresión activa: la salida por consola es cara y contamina cualquier medida de tiempo.
Cuando lo que quieres es cronometrar, el envoltorio correcto es un reducer de orden superior propio, que mide el trabajo síncrono de cada acción y solo habla cuando se pasa de un umbral.
struct Cronometrado<Base: Reducer>: Reducer {
let base: Base
let umbral: Duration
func reduce(into state: inout Base.State, action: Base.Action) -> Effect<Base.Action> {
let inicio = ContinuousClock.now
let efecto = base.reduce(into: &state, action: action)
let transcurrido = ContinuousClock.now - inicio
if transcurrido > umbral {
print("[lento] \(transcurrido) por \(String(describing: action).prefix(80))")
}
return efecto
}
}
extension Reducer {
func cronometrado(umbral: Duration = .milliseconds(2)) -> some Reducer<State, Action> {
Cronometrado(base: self, umbral: umbral)
}
}
Ese umbral de dos milisegundos no es arbitrario: con un presupuesto de fotograma de 8,33 ms en pantallas de 120 Hz, una acción que consuma dos ya se ha llevado la cuarta parte antes de que SwiftUI empiece a trabajar. Lo importante del operador no es la cifra sino que mide solo la parte síncrona, que es exactamente la que bloquea el hilo principal; lo que devuelve un Effect y se ejecuta después pertenece a la lección siguiente y se mide de otra manera.
El operador .signpost() emite intervalos de os_signpost al recibir cada acción y al iniciar y terminar cada efecto. Su virtud es que los nombres que aparecen en la pista de puntos de interés de Instruments son los de tus acciones, así que el gráfico deja de hablar de funciones del sistema y empieza a hablar de tu dominio. Las señales son órdenes de magnitud más baratas que imprimir en consola, no se compilan fuera en release y pueden dejarse activas durante una sesión de perfilado completa sin distorsionar el resultado. Es la herramienta correcta para correlacionar una caída de fotogramas con la acción exacta que la provocó.
Leer el ciclo completo en Instruments
Con las señales activas, la sesión de perfilado se convierte en una lectura de alineaciones temporales. En la pista de puntos de interés ves los intervalos de tus acciones; en la del instrumento SwiftUI, las evaluaciones de cuerpo y las confirmaciones; en la de tirones de animación, los fotogramas perdidos. La pregunta que resuelve el noventa por ciento de los casos es puramente visual: ¿qué hay justo debajo de cada tirón?
flowchart TD H[Se pierden fotogramas en un escenario reproducible] --> A[Mira la pista de puntos de interes] A -->|un intervalo largo de accion| L[Accion cara: perfila su tiempo con el perfilador] A -->|muchos intervalos cortos| T[Tormenta de acciones: reduce la frecuencia en el origen] A -->|nada bajo el tiron| V[El coste esta en la vista] V --> B[Mira evaluaciones de cuerpo en el instrumento SwiftUI] B -->|muchas evaluaciones| G[Granularidad rota: revisa las lecturas] B -->|pocas y largas| C[Cuerpo caro: extrae trabajo fuera del body] style L fill:#f38ba8,color:#11111b style T fill:#f9e2af,color:#11111b style G fill:#89b4fa,color:#11111b
Cuando las señales que emite el operador no bastan porque el intervalo interesante está dentro de una vista o dentro de un efecto concreto, se instrumenta a mano con el emisor de señales del sistema, que cuesta unos pocos nanosegundos por marca y aparece en la misma pista.
import OSLog
enum Perfil {
static let medidor = OSSignposter(
subsystem: "com.ejemplo.app", category: .pointsOfInterest
)
}
// En el punto sospechoso, dentro de un efecto o de una funcion cara:
let estado = Perfil.medidor.beginInterval("preparar documentos")
defer { Perfil.medidor.endInterval("preparar documentos", estado) }
La ventaja de instrumentar a mano no es la precisión sino el vocabulario: los intervalos llevan el nombre que tú les das, y en una sesión de perfilado con veinte pistas la diferencia entre una investigación de diez minutos y una de dos horas suele ser exactamente esa.
Dos advertencias sobre la lectura del perfilador de tiempo. La primera es que hay que invertir el árbol de llamadas y ocultar las pilas del sistema antes de sacar cualquier conclusión, porque en la vista directa el nodo dominante siempre es el bucle principal y no dice nada. La segunda es que el trabajo de TCA aparece bajo nombres genéricos de cierres y funciones genéricas especializadas; sin las señales, correlacionar ese árbol con tu código es un ejercicio de arqueología. Con ellas, seleccionas el intervalo de la acción sospechosa y el perfilador filtra el árbol a esa ventana temporal.
La acción desproporcionada
El hallazgo característico de una investigación en TCA tiene una forma reconocible: una acción cuyo nombre sugiere trabajo trivial y cuyo intervalo mide cincuenta veces lo esperado. Las causas se repiten con una regularidad casi aburrida.
Cascada de acciones
Una acción que devuelve varios Effect de tipo send, cada uno de los cuales dispara más. El intervalo del padre engloba a todos y parece un coste propio.
Trabajo síncrono en el reducer
Decodificar, ordenar diez mil elementos, formatear fechas o tocar el disco dentro de Reduce. El reducer corre en el hilo principal por construcción.
Fuente de alta frecuencia
Un campo de texto, un gesto o un temporizador que envía por evento. Cada acción es barata y la suma es letal; se corrige en el origen, no en el destino.
Efecto que se reinicia solo
Una suscripción sin identificador de cancelación que se vuelve a lanzar en cada aparición y acumula productores vivos. Se ve como intervalos que nunca cierran.
La distinción entre las dos primeras y la tercera es la que más consecuencias tiene, porque las soluciones son opuestas. Una acción cara se arregla haciendo menos trabajo o moviéndolo fuera del hilo principal. Una tormenta de acciones baratas no mejora nada haciendo cada una más rápida: hay que emitir menos, y eso significa amortiguar en el origen, agrupar eventos o dejar de enviar por cada pulsación de tecla. Confundirlas produce el resultado más desmoralizante posible, que es optimizar de verdad algo real y no ver ninguna mejora en la pantalla.
Vale la pena reconocer que la facilidad de diagnóstico de la que disfrutas aquí no es un accidente de la biblioteca sino la consecuencia directa de una decisión de diseño tomada mucho antes, por motivos que nada tenían que ver con el rendimiento. Al exigir que todo cambio de estado pase por una acción nombrada y que todo trabajo asíncrono se declare como un valor devuelto, TCA impuso que el conjunto de cosas que pueden ocurrir en la app sea finito, enumerable y etiquetado en tiempo de compilación. Esa restricción se justificó en su día por la testabilidad, y resulta que paga un segundo dividendo enorme y no anticipado: convierte el perfilado de un problema de arqueología en un problema de lectura. En una arquitectura convencional, un tirón de dos fotogramas exige reconstruir qué estaba pasando a partir de un árbol de llamadas anónimo, y la parte difícil no es interpretar los números sino averiguar qué acción del usuario produjo esa pila; aquí la pregunta ya está respondida porque el intervalo lleva escrito el nombre del caso del enum. Hay una lección general escondida en esto que trasciende el rendimiento y que conviene interiorizar antes de diseñar cualquier sistema grande: las decisiones que hacen un programa comprensible y las que lo hacen medible son casi siempre la misma decisión, porque ambas consisten en darle nombres estables a las cosas que ocurren y en impedir que ocurran cosas sin nombre. Un sistema donde el trabajo puede aparecer desde cualquier sitio es simultáneamente difícil de testear, difícil de razonar y difícil de perfilar, y no por tres motivos distintos sino por uno solo. La contrapartida honesta es que el vocabulario tiene que ser el correcto: si tus acciones se llaman actualizar y procesar, el perfilador te devolverá intervalos etiquetados actualizar y procesar, y habrás perdido exactamente la ventaja por la que pagaste toda esta ceremonia. La granularidad y la honestidad de los nombres de acción no son un asunto estético; son la resolución de tu instrumental.
- Define un escenario reproducible de diez segundos en la pantalla más pesada de tu app y descríbelo por escrito para poder repetirlo idéntico.
- Apila
._printChanges(.actionLabels)en el reducer raíz y cuenta cuántas acciones llegan durante el escenario. Anota las tres más frecuentes. - Sustituye la impresión por el operador de medición de esta lección con un umbral de dos milisegundos y registra qué acciones lo superan.
- Activa
.signpost(), perfila en release y en dispositivo con el perfilador de tiempo y el instrumento de tirones, y alinea cada tirón con el intervalo que tiene debajo. - Clasifica el hallazgo en una de las dos categorías: acción cara o tormenta de acciones baratas. Escribe qué solución exige cada una antes de escribir ni una línea de código.