Start Debugging

shrinkWrap vs Expanded vs slivers para listas largas en Flutter: ¿cuál elegir?

Para una lista larga, nunca uses shrinkWrap. Usa Expanded cuando la lista es el único scrollable, y slivers (CustomScrollView) cuando comparte scroll con otras secciones. Aquí está el porqué, con un benchmark de conteo de builds.

Para una lista larga en Flutter, la clasificación no está reñida: usa Expanded cuando la lista es el único scrollable bajo un encabezado, y cambia a slivers (un CustomScrollView) en cuanto la lista comparta scroll con otras secciones. Nunca recurras a shrinkWrap: true en una lista larga. Es el diff más corto y silencia el error de layout, pero también obliga a la lista a construir cada elemento en el primer frame, desechando el reciclaje perezoso que mantiene un scroll a 60fps. Este artículo enfrenta las tres opciones en Flutter 3.x (probado en 3.44, Dart 3.x), con una matriz de características, un benchmark de conteo de builds que muestra el costo exacto, y el único detalle que decide por ti.

Las tres no son perillas intercambiables del mismo widget. Expanded y shrinkWrap son dos formas de responder “¿qué altura tiene este ListView dentro de un Column?”, mientras que los slivers reemplazan por completo la forma Column más ListView. Chocan porque son las tres respuestas que la gente prueba cuando un ListView dentro de un Column lanza Vertical viewport was given unbounded height. Si esa aserción es lo que te trajo aquí, el análisis completo de la causa raíz está en cómo anidar un ListView dentro de un Column sin un error de altura no acotada; este artículo es la pregunta más afilada de qué solución conservar una vez que la pantalla roja desaparece.

La matriz de características

PropiedadshrinkWrap: trueExpandedSlivers (CustomScrollView)
Construye solo elementos visibles (lazy)No, construye todo
Costo del primer frame para N elementosO(N)O(viewport)O(viewport)
La memoria escala conconteo de elementostamaño del viewporttamaño del viewport
Funciona para una lista no acotada / que creceNo
Requiere altura de padre acotadaNoSí (el padre debe)Sí (Scaffold la da)
Varias secciones scrollables en unaPelea consigo mismoSolo una listaSí, nativo
Encabezado colapsable (SliverAppBar)NoNo
Líneas de boilerplate13~6 por sección

Lee primero las tres filas superiores. Son todo el argumento. shrinkWrap es la única de las tres cuyo costo crece con el número de elementos, y “lista larga” es exactamente el caso donde ese costo muerde.

Por qué shrinkWrap es O(N) y las otras dos no

Un ListView normal es un viewport perezoso: construye y hace layout solo de los elementos visibles en su ventana más una pequeña extensión de caché, y luego recicla esos widgets al desplazarte. Construye una lista de 10.000 filas y solo existen ~15 filas en cualquier momento. Esa pereza es toda la historia del rendimiento de las listas en Flutter.

shrinkWrap: true lo apaga. Para dimensionarse según su contenido (en lugar de llenar su eje), el viewport debe conocer su altura total, y la única forma de saberlo es construir y medir cada hijo por adelantado. Así que shrinkWrap convierte una lista perezosa en una ansiosa: N elementos significan N llamadas a build en el primer frame, N layouts de RenderBox, y memoria proporcional a N. Para las tres filas de ajustes en las que la gente suele probarlo, eso no es nada. Para un feed, es un bloqueo en el primer frame que puedes ver en la línea de tiempo (consulta cómo perfilar el jank en una app de Flutter con DevTools).

Expanded mantiene la lista perezosa. Es un hijo de Flex que fuerza una altura ajustada igual al espacio que sobra después de que el Column mide sus otros hijos. El ListView recibe un maxHeight acotado, llena exactamente esa ventana y recicla filas dentro de ella. El costo es O(viewport), independiente del número de elementos.

Los slivers también mantienen la lista perezosa, mediante un mecanismo distinto: un SliverList nunca necesita conocer su altura total. Negocia directamente con el viewport padre (“estás desplazado al offset X, pintaré los hijos en ese rango”), así que no hay handshake de altura no acotada y ninguna razón para construir hijos fuera de pantalla. El costo es de nuevo O(viewport).

El benchmark de conteo de builds

La forma más limpia de medir esto no son los milisegundos (que varían según la máquina) sino el número de builds de elementos en el primer frame, que es determinista y basado en el mecanismo. Instrumenta cada elemento con un contador:

// Flutter 3.x (tested 3.44), Dart 3.x
int buildCount = 0;

Widget itemBuilder(BuildContext context, int index) {
  buildCount++;                 // increment on every item build
  return ListTile(title: Text('Row $index'));
}

Renderiza una lista de 5.000 elementos de tres formas y lee buildCount después del primer frame (WidgetsBinding.instance.addPostFrameCallback). Los resultados no están reñidos:

Layout para 5.000 elementosBuilds de elementos en el primer frameEscala con
ListView(shrinkWrap: true)5.000conteo de elementos
Expanded(child: ListView)~12 a 15solo viewport
SliverList en CustomScrollView~12 a 15solo viewport

El número exacto en modo perezoso depende de la altura de fila y del cacheExtent por defecto (250 píxeles lógicos más allá del viewport a cada lado), pero la forma es fija: shrinkWrap construye los 5.000, las otras dos construyen solo lo que cabe más la caché. En un Pixel 7 en modo profile el primer frame de shrinkWrap superó ampliamente el presupuesto de 16ms construyendo esos 5.000 tiles, mientras que las versiones con Expanded y con slivers renderizaron el primer frame dentro del presupuesto y se mantuvieron ahí al desplazarse. El número que importa es la proporción: ~5.000 builds frente a ~15, una diferencia que crece cada vez que la lista lo hace.

Para reproducir esto tú mismo, ejecuta en modo profile (flutter run --profile), no en debug, porque las mediciones en modo debug están dominadas por aserciones y no son representativas. La proporción de conteo de builds es idéntica en todos los modos; solo difiere el tiempo de reloj.

Cuándo elegir Expanded

Expanded es la respuesta correcta cuando la lista es la única región scrollable de la pantalla, situada debajo (o encima) de algún chrome fijo.

El único requisito: el Column mismo debe tener una altura acotada para que Expanded tenga algo que dividir. Dentro del body de un Scaffold la tiene. Si el Column está a su vez en un contexto no acotado, solo has movido el problema un nivel más arriba.

// Flutter 3.x (tested 3.44)
Column(
  children: [
    const SearchBar(),
    Expanded(
      child: ListView.builder(       // stays lazy
        itemCount: items.length,
        itemBuilder: (context, i) => ItemTile(items[i]),
      ),
    ),
  ],
)

Cuándo elegir slivers

Recurre a un CustomScrollView con slivers cuando la lista larga no está sola: comparte un scroll con otras secciones, o la pantalla necesita un encabezado que se desplace fuera de vista.

// Flutter 3.x (tested 3.44)
CustomScrollView(
  slivers: [
    const SliverAppBar(title: Text('Explore'), floating: true),
    SliverList.builder(              // lazy, shares the one scroll position
      itemCount: items.length,
      itemBuilder: (context, i) => ItemTile(items[i]),
    ),
  ],
)

Los slivers son más verbosos, unas seis líneas por sección en lugar de tres, y esa verbosidad es la única razón para no usarlos en una lista solitaria. Cuando hay exactamente un scrollable, Expanded da la misma pereza con menos ceremonia.

Cuándo shrinkWrap está realmente bien

shrinkWrap: true no es un bug, es una herramienta con un uso correcto estrecho: una lista corta y acotada cuyo número de elementos controlas y que debe dimensionarse según su contenido para que otros widgets puedan situarse encima y debajo de ella en el mismo Column.

En cuanto N esté impulsado por datos que pueden crecer (publicaciones, fotos, resultados de búsqueda, mensajes), shrinkWrap es la herramienta equivocada, porque su costo crece con esos datos. Si lo usas para una lista corta dentro de un Column scrollable, establece también physics: const NeverScrollableScrollPhysics() para que la lista interna no pelee con el SingleChildScrollView externo por el gesto de scroll.

// Flutter 3.x (tested 3.44) -- fine ONLY because this list is short and fixed
Column(
  children: [
    const Text('Settings'),
    ListView(
      shrinkWrap: true,
      physics: const NeverScrollableScrollPhysics(),
      children: const [
        SwitchListTile(title: Text('Notifications'), value: true, onChanged: null),
        SwitchListTile(title: Text('Dark mode'), value: false, onChanged: null),
      ],
    ),
  ],
)

El detalle que decide por ti

Dos restricciones anulan por completo la preferencia.

Si la lista puede crecer sin límite, shrinkWrap queda descartada. No es cuestión de gusto ni de unos frames perdidos que podrías tolerar. Un build ansioso no acotado es un problema de correctitud y de memoria: el tiempo de build y la memoria escalan ambos con el número de elementos, así que una lista que está bien en pruebas con 20 filas puede bloquear el primer frame durante cientos de milisegundos en producción con 2.000. El framework no te advertirá, porque shrinkWrap hizo exactamente lo que pediste. Esta es la forma más común en que una lista de Flutter regresiona silenciosamente.

Si tienes más de una sección scrollable, los slivers son la única respuesta limpia. Expanded maneja exactamente una lista. Dos listas con Expanded en un Column dividen la altura entre ellas en dos scroll views separados, lo cual casi nunca es lo que quieres. En el instante en que tienes una lista más una cuadrícula, o una lista más otra lista, o cualquier sección que deba desplazarse como una sola superficie con la lista, un CustomScrollView es la forma estructuralmente correcta y todo lo demás es un apaño.

Todo lo demás, adaptabilidad de pantalla, boilerplate, un encabezado colapsable, es un desempate dentro de esas dos reglas duras.

La recomendación, reafirmada

Para una lista larga, clasifícalas: slivers si la lista comparte scroll con cualquier otra cosa, Expanded si es el único scrollable, y shrinkWrap nunca. Conserva shrinkWrap: true en tu caja de herramientas solo para listas cortas y fijas que deban dimensionarse según su contenido dentro de un Column. La trampa es que las tres hacen desaparecer el error de altura no acotada, así que es fácil enviar la que además cambia silenciosamente una ruidosa aserción de layout por una regresión de rendimiento que solo aparece bajo datos reales. Lee la elección como “¿cómo mantengo la lista perezosa?”, y la respuesta es siempre Expanded o slivers, nunca shrinkWrap.

Si una fila dentro de cualquiera de estos alguna vez desborda su propio ancho, ese es un problema aparte de RenderFlex overflowed a nivel del tile, no relacionado con la elección de layout externa. Y sea cual sea el layout que elijas, si usa un ScrollController, recuerda liberarlo para no filtrarlo cuando el widget se desmonte.

Fuentes

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Volver