Perché le liste possono diventare un problema

Le liste sono uno dei componenti più usati in qualsiasi app: feed social, elenchi di prodotti, chat, cronologie. Finché gli elementi sono pochi, qualsiasi approccio funziona. Ma quando dobbiamo mostrare centinaia o migliaia di righe, un uso ingenuo dei widget porta a scatti, consumo eccessivo di memoria e rebuild inutili.

In questo articolo vediamo come costruire liste realmente performanti sfruttando gli strumenti che Flutter mette a disposizione: il costruttore lazy, il controllo dell'area di cache, le key corrette e il mantenimento dello stato.

ListView vs ListView.builder

Il primo errore comune è usare il costruttore di default di ListView passando una lista di children. In questo modo tutti i widget vengono costruiti subito, anche quelli fuori dallo schermo.

// NON fare questo con liste lunghe
ListView(
  children: items.map((item) => ItemTile(item: item)).toList(),
)

La soluzione corretta è ListView.builder, che costruisce gli elementi in modo pigro (lazy), solo quando stanno per entrare nel viewport:

ListView.builder(
  itemCount: items.length,
  itemBuilder: (context, index) {
    return ItemTile(item: items[index]);
  },
)

Con ListView.builder Flutter mantiene in memoria solo gli elementi visibili più un piccolo margine, riducendo drasticamente il costo di rendering.

itemExtent e prototypeItem: aiutare il layout

Quando tutti gli elementi hanno la stessa altezza, possiamo comunicarlo a Flutter tramite itemExtent. Questo evita al framework di dover calcolare la dimensione di ogni figlio, migliorando le performance di scroll e permettendo salti diretti a una posizione.

ListView.builder(
  itemExtent: 72,
  itemCount: items.length,
  itemBuilder: (context, index) => ItemTile(item: items[index]),
)

Se l'altezza è costante ma non la conosci a priori, puoi usare prototypeItem: Flutter misura quel widget campione una sola volta e applica la dimensione a tutti.

ListView.builder(
  prototypeItem: const ItemTile.placeholder(),
  itemCount: items.length,
  itemBuilder: (context, index) => ItemTile(item: items[index]),
)

cacheExtent: bilanciare fluidità e memoria

Il parametro cacheExtent definisce quanti pixel oltre il viewport visibile Flutter deve pre-costruire. Un valore più alto rende lo scroll più fluido (gli elementi sono già pronti) ma consuma più memoria; un valore basso risparmia memoria ma può causare micro-scatti.

ListView.builder(
  cacheExtent: 500, // pixel pre-renderizzati oltre lo schermo
  itemCount: items.length,
  itemBuilder: (context, index) => ItemTile(item: items[index]),
)

Un buon punto di partenza è lasciare il default e regolarlo solo se noti problemi. Per liste con elementi pesanti (immagini, grafici) conviene tenerlo contenuto.

Le key: evitare rebuild sbagliati

Quando la lista cambia (inserimenti, rimozioni, riordini), Flutter deve capire quali widget riutilizzare. Senza key corrette, il framework potromo associare lo stato al widget sbagliato, causando bug visivi.

Usa una ValueKey basata su un identificatore stabile dell'elemento:

ListView.builder(
  itemCount: items.length,
  itemBuilder: (context, index) {
    final item = items[index];
    return ItemTile(
      key: ValueKey(item.id),
      item: item,
    );
  },
)

Le key sono particolarmente importanti quando gli elementi hanno uno stato interno (form, checkbox, animazioni) o quando la lista viene riordinata.

Mantenere lo stato con AutomaticKeepAliveClientMixin

Per default, quando un elemento esce dal viewport il suo stato viene distrutto per liberare memoria. Questo è desiderabile nella maggior parte dei casi, ma a volte vogliamo preservare lo stato di un elemento costoso (ad esempio un video in pausa o un widget con animazione in corso).

In quei casi usiamo AutomaticKeepAliveClientMixin:

class VideoTile extends StatefulWidget {
  const VideoTile({super.key, required this.item});
  final Item item;

  @override
  State<VideoTile> createState() => _VideoTileState();
}

class _VideoTileState extends State<VideoTile>
    with AutomaticKeepAliveClientMixin {
  @override
  bool get wantKeepAlive => true;

  @override
  Widget build(BuildContext context) {
    super.build(context); // obbligatorio con il mixin
    return /* il tuo widget con stato da preservare */;
  }
}

Attenzione: usa wantKeepAlive con parsimonia. Mantenere vivi molti elementi vanifica i benefici della lazy list e aumenta il consumo di memoria.

RepaintBoundary per elementi complessi

Se ogni riga contiene grafica costosa da ridisegnare, avvolgerla in un RepaintBoundary isola il suo layer di pittura, evitando che un ridisegno si propaghi agli elementi vicini.

itemBuilder: (context, index) {
  return RepaintBoundary(
    child: ComplexTile(item: items[index]),
  );
}

Non abusarne: ogni boundary ha un costo. Usalo solo quando profili mostrano problemi di repaint.

ListView.separated per i divisori

Se hai bisogno di separatori tra gli elementi, ListView.separated è più pulito ed efficiente rispetto a inserire i divisori manualmente:

ListView.separated(
  itemCount: items.length,
  itemBuilder: (context, index) => ItemTile(item: items[index]),
  separatorBuilder: (context, index) => const Divider(height: 1),
)

Checklist per liste performanti

  • Usa sempre ListView.builder (o .separated) per liste lunghe.
  • Imposta itemExtent o prototypeItem se l'altezza è costante.
  • Assegna ValueKey stabili quando gli elementi hanno stato o vengono riordinati.
  • Usa AutomaticKeepAliveClientMixin solo per stati costosi da ricreare.
  • Considera RepaintBoundary per righe graficamente pesanti.
  • Profila con DevTools prima di ottimizzare: misura, non indovinare.

Conclusione

Le liste performanti in Flutter non richiedono magia, ma la conoscenza degli strumenti giusti. Partendo da ListView.builder e affinando con itemExtent, cacheExtent, key corrette e mantenimento selettivo dello stato, puoi gestire migliaia di elementi mantenendo lo scroll fluido a 60 (o 120) fps. La regola d'oro resta sempre la stessa: misura con Flutter DevTools e ottimizza solo dove serve davvero.