wandres.dev
RESULT BUILDERS · DSLs en el lenguaje

Cómo funciona SwiftUI: el ViewBuilder por dentro

El `body` de una vista es un bloque transformado por `ViewBuilder`. De dónde salen los `TupleView`, por qué un condicional produce un `_ConditionalContent`, por qué hacía falta escribir `ForEach` en lugar de un bucle, y de dónde venía el famoso límite de diez vistas.

⏱ 18 min

SwiftUI se presenta como un framework declarativo, y esa etiqueta esconde el mecanismo real: no hay nada declarativo en el lenguaje, solo un result builder llamado ViewBuilder que reescribe el cuerpo de cada vista como una expresión de tipos anidados. Todo lo que resulta desconcertante al aprender SwiftUI —que un condicional cambie el comportamiento de una animación, que haya que escribir ForEach en vez de un bucle, que el compilador se atragante con una pantalla larga, que aparezcan en los mensajes de error tipos que nadie ha escrito— se explica por completo mirando ese builder y los tipos que fabrica. Entenderlo convierte un conjunto de reglas arbitrarias en un puñado de consecuencias, y transforma la relación con los errores del framework.

🎯 Al terminar esta lección sabrás
  • Explicar por qué el body de una vista se transforma sin que aparezca ningún atributo escrito.
  • Identificar el origen de TupleView, _ConditionalContent, EmptyView y AnyView en la transformación.
  • Justificar la ausencia de bucles dentro de un bloque de vistas y el papel de ForEach.
  • Situar el origen del límite de vistas por bloque y qué lo hizo desaparecer.

El body es un bloque transformado

El protocolo View declara su requisito de esta forma:

public protocol View {
    associatedtype Body: View
    @ViewBuilder var body: Self.Body { get }
}

Ese atributo en el requisito es toda la explicación. Cualquier implementación de body hereda la transformación sin escribir nada, y por eso las vistas se escriben como listas de sentencias. ViewBuilder es, a su vez, un tipo perfectamente ordinario de la biblioteca:

@resultBuilder
public struct ViewBuilder {
    public static func buildBlock() -> EmptyView
    public static func buildBlock<C: View>(_ c: C) -> C
    public static func buildBlock<C0: View, C1: View>(_ c0: C0, _ c1: C1) -> TupleView<(C0, C1)>
    // ... y así sucesivamente
}

Fíjate en lo que no hace: no borra los tipos, no mete las vistas en un array, no devuelve un tipo común. Cada sobrecarga devuelve un tipo distinto que recuerda exactamente cuántos hijos había y de qué tipo era cada uno. Esa decisión es el eje de todo el diseño de SwiftUI, y de ella cuelgan tanto sus virtudes como sus molestias.

De dónde salen los TupleView

Con eso en la mano, la expansión de una vista corriente deja de tener misterio:

struct Ficha: View {
    let destacada: Bool
    var body: some View {
        VStack {
            Text("Titulo")
            Text("Subtitulo")
            if destacada {
                Image(systemName: "star")
            }
        }
    }
}

El bloque del VStack tiene tres sentencias, y la tercera es un condicional sin else. La transformación produce, aproximadamente, esto:

// Tipo real del contenido del VStack
TupleView<(
    Text,
    Text,
    Optional<Image>
)>

Cada pieza tiene un origen concreto. El TupleView viene de la sobrecarga de buildBlock con tres parámetros. El Optional viene de buildOptional, que en ViewBuilder envuelve la rama ausente. Un bloque vacío produce EmptyView, y un if de disponibilidad de versión produce un AnyView, porque buildLimitedAvailability es el único punto donde el framework renuncia al tipo estático.

Si el condicional tuviera un else, el tipo cambiaría de forma reveladora:

// Con else, las dos ramas se unen en un tipo condicional
VStack {
    if destacada {
        Image(systemName: "star")
    } else {
        Text("Normal")
    }
}

// Tipo resultante del contenido
_ConditionalContent<Image, Text>

Ese tipo, fabricado por las dos formas de buildEither, guarda en su propia declaración cuáles eran las dos alternativas posibles, y en ejecución guarda cuál se tomó. La diferencia con el opcional del caso anterior no es cosmética: un opcional dice que aquí puede haber algo o nada, y un _ConditionalContent dice que aquí hay una de dos cosas distintas. SwiftUI trata ambos casos de forma distinta al decidir qué animar y qué estado conservar, y esa distinción está tomada antes de ejecutar nada, en el tipo.

flowchart TB
body[Cuerpo de la vista con tres sentencias] --> vb[ViewBuilder transforma el bloque]
vb --> t1[Text queda como Text]
vb --> t2[Text queda como Text]
vb --> cond[If sin else pasa por buildOptional]
cond --> opt[Optional de Image]
t1 --> tup[buildBlock de tres devuelve TupleView]
t2 --> tup
opt --> tup
tup --> tipo[Tipo estatico completo del contenido]
style body fill:#cba6f7,color:#11111b
style vb fill:#89b4fa,color:#11111b
style tipo fill:#a6e3a1,color:#11111b

Por qué hay que escribir ForEach

Aquí es donde la lección anterior rinde su mejor fruto. La primera vez que alguien intenta escribir un bucle dentro de un bloque de vistas, se encuentra con un error y con una regla que parece caprichosa: hay que usar ForEach. La razón es exacta y ya la conoces: ViewBuilder no implementa buildArray. No es un olvido, es una decisión coherente con todo lo anterior. Un bucle produce un número de elementos que solo se conoce en ejecución, y eso destruiría el tipo estático que el resto del builder se esfuerza en conservar.

ForEach resuelve el mismo problema por otra vía: no es una construcción del lenguaje sino una vista más, genérica sobre una colección y sobre el tipo de vista que produce cada elemento. Como es un solo valor de un solo tipo, encaja como una sentencia cualquiera dentro del bloque, y la variabilidad queda encapsulada dentro de ella en vez de contaminar el tipo del bloque. Es también el motivo de que ForEach exija identidad —un Identifiable o un id— mientras que un if no la necesita: en el condicional, la posición y el tipo bastan para saber de qué vista se habla; en una colección dinámica, no.

🧬

El tipo recuerda la estructura

TupleView, _ConditionalContent y Optional no son ruido: codifican en el sistema de tipos cuántos hijos hay y qué rama se tomó, información disponible antes de ejecutar nada.

🆔

Identidad estructural

Dos vistas en la misma posición y con el mismo tipo son la misma vista para el framework, y su estado sobrevive. Cambiar de rama en un condicional cambia el tipo, y ahí el estado se pierde.

🔁

ForEach no es un bucle

Es una vista genérica que encapsula la variabilidad. Por eso encaja en un bloque sin buildArray y por eso necesita una identidad explícita que un condicional no necesita.

El límite de diez y su final

Durante años, ViewBuilder declaraba sobrecargas de buildBlock para uno, dos, tres… hasta diez parámetros, escritas a mano, y ahí se acababa. Superar ese número producía un error que rara vez decía la verdad, y la respuesta comunitaria era siempre la misma: agrupar en un Group o extraer una subvista. El límite no venía de ninguna restricción de la interfaz, sino de que Swift no tenía forma de escribir una función genérica con un número arbitrario de parámetros de tipo. La biblioteca solo podía enumerar sobrecargas, y alguien tuvo que decidir dónde parar.

Los parameter packs, incorporados en Swift 5.9, eliminaron esa imposibilidad. Con ellos, la familia entera de sobrecargas se reduce a una sola declaración genérica sobre un número variable de tipos, y los SDK que la adoptan ya no imponen tope alguno. El límite sobrevive únicamente al compilar para versiones anteriores del sistema, donde siguen usándose las sobrecargas históricas.

La moraleja práctica no cambió tanto como parece. Extraer subvistas sigue siendo buena idea, ahora por razones de tiempo de compilación y de legibilidad en vez de por una restricción de la interfaz. Un bloque con veinte sentencias produce un tipo enorme que el comprobador debe inferir entero, y esa inferencia es superlineal.

⚠️
Añadir un condicional cambia el tipo

Envolver una vista en un if no es una operación neutra: sustituye su tipo por un _ConditionalContent. Para SwiftUI eso es otra vista distinta en esa posición, y el estado asociado a la anterior se descarta. Cuando un campo pierde su contenido al cambiar una condición, el culpable suele ser un condicional recién añadido, no el estado.

Por qué SwiftUI se negó a borrar los tipos

Hay una pregunta que todo el mundo se hace al ver TupleView de siete parámetros en un mensaje de error: por qué no meter simplemente las vistas en un array de un tipo común y acabar con la complejidad de una vez. La respuesta explica el framework entero. SwiftUI reconstruye el body de una vista muchísimas veces por segundo, y en cada reconstrucción debe decidir, para cada elemento, si es el mismo que antes —y entonces conserva su estado, su animación en curso y su posición en la jerarquía— o si es uno nuevo que sustituye al anterior. Esa decisión es el corazón del sistema, y se llama identidad. Con un array de vistas homogéneas, la única identidad posible sería el índice, con todos los problemas que arrastra: insertar un elemento al principio renumera a todos los demás y el framework creería que todo cambió. Al conservar el tipo estático completo, SwiftUI obtiene identidad gratis y de una forma mucho más fina: la posición dentro del TupleView más el tipo de cada componente forman una huella estructural que el compilador ya ha calculado, y que permite comparar dos versiones del árbol sin recorrerlo entero ni ejecutar nada. La lección de diseño trasciende a SwiftUI. Hay un intercambio permanente entre borrar tipos y conservarlos: borrarlos simplifica las firmas, mejora los mensajes de error y acelera la compilación, pero traslada a la ejecución decisiones que el compilador podría haber tomado. Conservarlos permite razonar estáticamente sobre la estructura del programa, y se paga en complejidad de tipos, tiempos de compilación y diagnósticos que hablan de nombres que nadie escribió. SwiftUI eligió conservar, hasta el extremo, y todos sus defectos característicos son la factura de esa elección. Es útil reconocer ese mismo dilema en el propio código: cada vez que dudas entre AnyView y un tipo opaco, entre un protocolo existencial y un genérico, estás decidiendo lo mismo a menor escala.

📝
Lo esencial del ViewBuilder

El body se transforma porque el protocolo View marca el requisito con @ViewBuilder. El builder conserva los tipos: varias sentencias producen TupleView, un if sin else produce un opcional, un if con else produce _ConditionalContent, un bloque vacío produce EmptyView y una comprobación de disponibilidad produce AnyView. No hay buildArray, y por eso existe ForEach. El límite de diez venía de enumerar sobrecargas a mano y desapareció con los parameter packs.

⚔️ Desmonta una vista
  1. Escribe una vista con cuatro sentencias en un VStack e imprime el tipo de su contenido para localizar el TupleView.
  2. Añade un if sin else y luego un else; anota cómo cambia el tipo en cada paso.
  3. Intenta escribir un for dentro del bloque, anota el error y reescríbelo con ForEach.
  4. Crea una vista con un campo de texto dentro de una rama condicional y comprueba qué le ocurre a su contenido al alternar la condición.
  5. Explica, con la transformación en la mano, por qué extraer una subvista reduce el tiempo de compilación de una pantalla larga.