Start Debugging

shrinkWrap vs Expanded vs slivers para listas longas no Flutter: qual escolher?

Para uma lista longa, nunca use shrinkWrap. Use Expanded quando a lista for o único scrollable, e slivers (CustomScrollView) quando ela compartilhar o scroll com outras seções. Aqui está o porquê, com um benchmark de contagem de builds.

Para uma lista longa no Flutter, a classificação não é disputada: use Expanded quando a lista for o único scrollable sob um cabeçalho, e mude para slivers (um CustomScrollView) assim que a lista compartilhar o scroll com outras seções. Nunca recorra a shrinkWrap: true em uma lista longa. É o diff mais curto e silencia o erro de layout, mas também obriga a lista a construir cada item no primeiro frame, descartando a reciclagem preguiçosa que mantém um scroll a 60fps. Este artigo coloca as três em confronto no Flutter 3.x (testado no 3.44, Dart 3.x), com uma matriz de recursos, um benchmark de contagem de builds que mostra o custo exato, e o único detalhe que decide por você.

As três não são botões intercambiáveis do mesmo widget. Expanded e shrinkWrap são duas formas de responder “qual a altura deste ListView dentro de um Column?”, enquanto os slivers substituem por completo o formato Column mais ListView. Elas colidem porque são as três respostas que as pessoas tentam quando um ListView dentro de um Column lança Vertical viewport was given unbounded height. Se essa asserção foi o que te trouxe aqui, a análise completa da causa raiz está em como aninhar um ListView dentro de um Column sem um erro de altura não delimitada; este artigo é a pergunta mais afiada de qual solução manter depois que a tela vermelha some.

A matriz de recursos

PropriedadeshrinkWrap: trueExpandedSlivers (CustomScrollView)
Constrói só itens visíveis (lazy)Não, constrói tudoSimSim
Custo do primeiro frame para N itensO(N)O(viewport)O(viewport)
A memória escala comcontagem de itenstamanho do viewporttamanho do viewport
Funciona para lista não delimitada / que cresceNãoSimSim
Precisa de altura de pai delimitadaNãoSim (o pai precisa)Sim (Scaffold dá)
Várias seções scrollables em umaBriga consigo mesmoSó uma listaSim, nativo
Cabeçalho colapsável (SliverAppBar)NãoNãoSim
Linhas de boilerplate13~6 por seção

Leia primeiro as três linhas de cima. Elas são o argumento inteiro. shrinkWrap é a única das três cujo custo cresce com o número de itens, e “lista longa” é exatamente o caso onde esse custo morde.

Por que shrinkWrap é O(N) e as outras duas não

Um ListView normal é um viewport preguiçoso: ele constrói e faz o layout só dos itens visíveis na sua janela mais uma pequena extensão de cache, e depois recicla esses widgets conforme você rola. Construa uma lista de 10.000 linhas e só ~15 linhas existem a qualquer momento. Essa preguiça é toda a história de desempenho das listas no Flutter.

shrinkWrap: true desliga isso. Para se dimensionar de acordo com o conteúdo (em vez de preencher o eixo), o viewport precisa saber sua altura total, e a única forma de saber isso é construir e medir cada filho antecipadamente. Então shrinkWrap converte uma lista preguiçosa em uma ansiosa: N itens significam N chamadas a build no primeiro frame, N layouts de RenderBox, e memória proporcional a N. Para as três linhas de configurações em que as pessoas costumam testá-lo, isso não é nada. Para um feed, é um travamento no primeiro frame que você pode ver na linha do tempo (veja como perfilar o jank em um app Flutter com o DevTools).

Expanded mantém a lista preguiçosa. É um filho de Flex que força uma altura ajustada igual ao espaço que sobra depois que o Column mede seus outros filhos. O ListView recebe um maxHeight delimitado, preenche exatamente essa janela e recicla linhas dentro dela. O custo é O(viewport), independente da contagem de itens.

Os slivers também mantêm a lista preguiçosa, por um mecanismo diferente: um SliverList nunca precisa saber sua altura total. Ele negocia diretamente com o viewport pai (“você está rolado até o offset X, vou pintar os filhos nessa faixa”), então não há handshake de altura não delimitada e nenhuma razão para construir filhos fora da tela. O custo é de novo O(viewport).

O benchmark de contagem de builds

A forma mais limpa de medir isso não são milissegundos (que variam por máquina) mas o número de builds de itens no primeiro frame, que é determinístico e baseado no mecanismo. Instrumente cada item com um 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'));
}

Renderize uma lista de 5.000 itens de três formas e leia buildCount depois do primeiro frame (WidgetsBinding.instance.addPostFrameCallback). Os resultados não são disputados:

Layout para 5.000 itensBuilds de itens no primeiro frameEscala com
ListView(shrinkWrap: true)5.000contagem de itens
Expanded(child: ListView)~12 a 15só viewport
SliverList em CustomScrollView~12 a 15só viewport

O número exato no modo preguiçoso depende da altura da linha e do cacheExtent padrão (250 pixels lógicos além do viewport de cada lado), mas o formato é fixo: shrinkWrap constrói os 5.000, as outras duas constroem só o que cabe mais o cache. Em um Pixel 7 em modo profile o primeiro frame do shrinkWrap ultrapassou de longe o orçamento de 16ms construindo esses 5.000 tiles, enquanto as versões com Expanded e com slivers renderizaram o primeiro frame dentro do orçamento e ficaram lá durante a rolagem. O número que importa é a proporção: ~5.000 builds contra ~15, uma diferença que cresce toda vez que a lista cresce.

Para reproduzir isso você mesmo, execute em modo profile (flutter run --profile), não em debug, porque as medições em modo debug são dominadas por asserções e não são representativas. A proporção de contagem de builds é idêntica em todos os modos; só o tempo de relógio difere.

Quando escolher Expanded

Expanded é a resposta certa quando a lista é a única região scrollable da tela, situada abaixo (ou acima) de algum chrome fixo.

O único requisito: o próprio Column precisa ter uma altura delimitada para que Expanded tenha algo para dividir. Dentro do body de um Scaffold ele tem. Se o Column estiver, por sua vez, em um contexto não delimitado, você só moveu o problema um nível acima.

// 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]),
      ),
    ),
  ],
)

Quando escolher slivers

Recorra a um CustomScrollView com slivers quando a lista longa não estiver sozinha: ela compartilha um scroll com outras seções, ou a tela precisa de um cabeçalho que role para fora 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]),
    ),
  ],
)

Os slivers são mais verbosos, cerca de seis linhas por seção em vez de três, e essa verbosidade é a única razão para não usá-los em uma lista solitária. Quando há exatamente um scrollable, Expanded dá a mesma preguiça com menos cerimônia.

Quando shrinkWrap está realmente ok

shrinkWrap: true não é um bug, é uma ferramenta com um uso correto estreito: uma lista curta e delimitada cuja contagem de itens você controla e que precisa se dimensionar de acordo com o conteúdo para que outros widgets possam ficar acima e abaixo dela no mesmo Column.

No momento em que N for impulsionado por dados que podem crescer (posts, fotos, resultados de busca, mensagens), shrinkWrap é a ferramenta errada, porque seu custo cresce com esses dados. Se você usá-lo para uma lista curta dentro de um Column scrollable, defina também physics: const NeverScrollableScrollPhysics() para que a lista interna não brigue com o SingleChildScrollView externo pelo 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),
      ],
    ),
  ],
)

O detalhe que decide por você

Duas restrições anulam a preferência por completo.

Se a lista pode crescer sem limite, shrinkWrap está fora de cogitação. Não é questão de gosto nem de alguns frames perdidos que você poderia tolerar. Um build ansioso não delimitado é um problema de correção e de memória: o tempo de build e a memória ambos escalam com a contagem de itens, então uma lista que está bem em testes com 20 linhas pode travar o primeiro frame por centenas de milissegundos em produção com 2.000. O framework não vai avisar você, porque shrinkWrap fez exatamente o que você pediu. Essa é a forma mais comum de uma lista Flutter regredir silenciosamente.

Se você tem mais de uma seção scrollable, os slivers são a única resposta limpa. Expanded cuida de exatamente uma lista. Duas listas com Expanded em um Column dividem a altura entre elas em dois scroll views separados, o que quase nunca é o que você quer. No instante em que você tem uma lista mais uma grade, ou uma lista mais outra lista, ou qualquer seção que deva rolar como uma única superfície com a lista, um CustomScrollView é o formato estruturalmente correto e todo o resto é um remendo.

Todo o resto, adaptabilidade de tela, boilerplate, um cabeçalho colapsável, é um desempate dentro dessas duas regras rígidas.

A recomendação, reafirmada

Para uma lista longa, classifique-as: slivers se a lista compartilha scroll com qualquer outra coisa, Expanded se é o único scrollable, e shrinkWrap nunca. Mantenha shrinkWrap: true na sua caixa de ferramentas só para listas curtas e fixas que precisam se dimensionar de acordo com o conteúdo dentro de um Column. A armadilha é que as três fazem o erro de altura não delimitada desaparecer, então é fácil enviar aquela que também troca silenciosamente uma ruidosa asserção de layout por uma regressão de desempenho que só aparece sob dados reais. Leia a escolha como “como eu mantenho a lista preguiçosa?”, e a resposta é sempre Expanded ou slivers, nunca shrinkWrap.

Se uma linha dentro de qualquer um desses um dia estourar a própria largura, esse é um problema à parte de RenderFlex overflowed no nível do tile, não relacionado à escolha de layout externa. E seja qual for o layout que você escolher, se ele usa um ScrollController, lembre-se de liberá-lo para não vazá-lo quando o widget for desmontado.

Fontes

Comments

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

< Voltar