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
itemExtentoprototypeItemse l'altezza è costante. - Assegna
ValueKeystabili quando gli elementi hanno stato o vengono riordinati. - Usa
AutomaticKeepAliveClientMixinsolo per stati costosi da ricreare. - Considera
RepaintBoundaryper 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.