Blog
Cazando un bloqueo de interfaz: cuando la congelación no está en tu código de negocio
La pantalla seguía renderizando, pero no respondía al tacto y el WebSocket acabó desconectándose por tiempo de espera agotado. La causa real no estaba en el código de negocio, sino en un bucle de retroalimentación de layout en SwiftUI que se puede reproducir de forma estable. Aquí está el proceso completo de diagnóstico.
Sigue las novedades de producto e ingeniería de esta categoría.
Ver categoríaEste artículo es una crónica de depuración, no un texto de marketing. Si en algún momento tomamos una dirección equivocada, lo decimos claramente. Y en este caso nos equivocamos más de una vez.
El síntoma
Un usuario estaba usando el modo chat en iPad, pidiendo al agente que generara una respuesta muy larga. Mientras la respuesta aún se estaba mostrando carácter a carácter, la aplicación se congeló por completo: la pantalla seguía renderizando, pero no era posible desplazarse ni tocar nada, ningún botón respondía. Poco después, la conexión WebSocket entre el dispositivo y el servidor se cortó con un error de "solicitud agotada" (-1001).
Para el usuario, parecía un fallo de red. Sin embargo, teníamos una evidencia clave: había un observador de protocolo de solo lectura conectado como segundo cliente, escuchando el mismo flujo de datos del servidor. Sus registros confirmaban que durante ese intervalo el servidor funcionó con total normalidad, emitió todos los eventos de streaming sin excepción y la respuesta se completó correctamente.
Lo que se bloqueó fue el dispositivo móvil, exactamente en el momento en que esa respuesta estaba a punto de terminar y realizar su último ciclo de renderizado. La desconexión del WebSocket fue una consecuencia, no la causa: el bucle encargado de recibir paquetes también corre en el hilo principal, y cuando el hilo principal se bloqueó, la recepción de paquetes se detuvo. Los datos que el servidor seguía enviando se acumularon en el buffer TCP sin poder procesarse, y al cabo de varias decenas de segundos el sistema declaró la conexión como agotada por tiempo de espera.
Tres hipótesis incorrectas
En el momento del bloqueo capturamos una muestra. Miles de entradas de la pila de llamadas, todas detenidas dentro de las actualizaciones de layout de SwiftUI, sin que se ejecutara ningún código de negocio y sin que se escribiera ningún estado — _printChanges permaneció completamente silencioso durante todo el tiempo.
Eso ya era una pista importante. Si hubiera un bucle de recálculo impulsado por datos, _printChanges no pararía de emitir salidas; su silencio indicaba que el bucle estaba ocurriendo en la capa de layout, no en la capa de datos.
¿Pero dónde exactamente dentro del layout? Sospechamos de tres lugares sucesivamente, y verificamos cada uno con una reproducción controlada en el simulador: misma respuesta larga, misma operación, cambiando solo una variable cada vez.
El primer sospechoso fue la medición intrínseca de UITextView. Nuestras burbujas de mensaje usan UITextView para alojar texto enriquecido seleccionable, y su intrinsicContentSize dispara un layout completo de TextKit en cada llamada — sonaba como el culpable perfecto. Lo reemplazamos por un Text puro de SwiftUI y el bloqueo siguió igual. Descartado.
El segundo sospechoso fue TextKit 2. A partir de iOS 16, UITextView activa TextKit 2 por defecto, y su layout incremental sobre bloques de texto largos que no se desplazan no siempre produce resultados estables para la misma combinación (texto, ancho). Forzamos el retroceso a TextKit 1 y el bloqueo siguió igual. Descartado.
El tercer sospechoso fue el coste del renderizado en sí. La segmentación de Markdown y la reconstrucción del texto enriquecido en burbujas largas no son baratas. Añadimos caché — el pico bajó, pero el bloqueo persistió. Era un amplificador, no la causa raíz.
Las tres líneas de investigación llegaron a un callejón sin salida. Dos rondas independientes de análisis estático encontraron amplificadores "que parecían sospechosos", pero ninguno explicaba ese 100% de CPU que no cedía ni siquiera después de que la respuesta terminara.
Localización por bisección
Dado que el análisis estático no encontraba el origen, cambiamos al método más simple pero más fiable: desactivar uno a uno los componentes potencialmente implicados de la interfaz para ver cuál llevaba el uso de CPU a cero.
Primero desactivamos el scroll automático — la llamada proxy.scrollTo que desplaza la lista al fondo cuando llega un mensaje nuevo. El bloqueo desapareció al instante y la CPU volvió a cero.
A continuación cambiamos solo otra variable: reemplazamos el LazyVStack que contiene la lista de mensajes por un VStack que hace el layout de una sola vez, manteniendo todo lo demás igual, incluido el scroll automático. El bloqueo también desapareció.
Los dos experimentos juntos señalan una misma conclusión:
Cuando el contenido del chat supera la altura de la pantalla, cualquier llamada a
scrollTodentro de unScrollViewconLazyVStack— con o sin animación, anclando a un mensaje concreto o a una vista de marcador de posición — lleva al scroll view a un bucle de retroalimentación de layout puro.
El mecanismo es el siguiente. LazyVStack solo hace un layout preciso de la región visible; el tamaño del contenido fuera de pantalla se estima. Cuando se llama a scrollTo a una posición determinada, el cambio en el desplazamiento dispara la carga bajo demanda del contenido fuera de pantalla, lo que provoca que se reestime la altura total del contenido; al cambiar la altura, la posición del objetivo de anclaje también se mueve, lo que fuerza otro desplazamiento. Este ciclo de "re-anclar ⇄ re-estimar tamaño" no está impulsado por ningún cambio de datos — gira en vacío dentro de la capa de layout y, por eso, nunca se detiene por sí solo. Esa es exactamente la razón por la que el hilo principal estaba al 100% mientras _printChanges no mostraba nada.
La solución
Una vez clara la dirección, la solución se definió con precisión:
- Reemplazar el contenedor de la lista de mensajes de
LazyVStackpor unVStackcon layout completo. Un hilo de chat tiene a lo sumo unas pocas decenas de burbujas; el coste de hacer el layout completo es despreciable, y su altura total es determinista, sin dejar espacio para que el bucle gire en vacío. - Cambiar el scroll automático a modo sin animación, apuntando a una vista de marcador de posición de 1 píxel de alto al final de la lista, usando
anchor: nil(la semántica de "desplazar solo hasta que sea visible"). Dejamos de anclar el scroll a un mensaje que puede ser más alto que la pantalla — ese mecanismo de anclaje era exactamente el "motor de movimiento perpetuo" que habíamos visto en las muestras. - Conservamos las dos optimizaciones reales que encontramos por el camino: la segmentación de Markdown y el renderizado ahora usan una caché LRU predecible en lugar de
NSCache, que el sistema puede vaciar en bloque cuando la memoria escasea, convirtiendo cada recálculo en una simple consulta a la caché.
Verificamos la solución con una respuesta Markdown larga de 2500 caracteres. Antes del fix, el hilo principal se quedaba permanentemente al 100% al terminar la respuesta y la interfaz era inoperable; después del fix, en cuanto termina la respuesta la CPU vuelve a 0% inmediatamente, y el desplazamiento, la navegación por el historial y el renderizado de bloques de código funcionan con normalidad.
Cuatro notas para desarrolladores SwiftUI
- En escenarios donde un elemento individual puede ser más alto que la pantalla,
ScrollView+LazyVStack+scrollToes una combinación inherentemente inestable. Chat, logs y renderizado en streaming de textos largos entran todos en esta categoría. - Al depurar un "bucle de layout que no se detiene", el silencio de
_printChangeses la señal clave. Significa que el bucle está en la capa de layout, no en la capa de datos; en ese caso, mirar@StateyonChangeno lleva a ningún lado — hay que hacer bisección en el árbol de vistas. - Para el scroll automático, apunta preferentemente con
anchor: nila una vista de marcador de posición muy pequeña al final, en lugar de anclar a un elemento de contenido real de tamaño incierto. - Si puedes hacer el layout de una sola vez, no uses
Lazypor comodidad.Lazyahorra el coste de hacer layout del contenido fuera de pantalla, pero la estimación de tamaños que introduce es precisamente el combustible de este bucle de retroalimentación; cuando la lista no es larga, la ecuación no sale a cuenta.
¿Quieres probar Tactic Remote en tu propio equipo? La aplicación iOS está disponible gratis en el App Store, y tanto la aplicación complementaria como el CLI son también gratuitos. La historia arquitectónica más completa que hay detrás de este fix está en «El servidor como custodio del historial».
Prueba Tactic Remote
Controla tus agentes de programación desde el móvil
Conéctate a Claude Code, Codex y otros agentes en tu Mac, Windows o Linux. Consulta el progreso y envía la siguiente instrucción desde el iPhone o iPad.