Le immagini sono quasi sempre l'elemento più pesante di un'app Flutter: pesano sul bundle, sulla rete e — soprattutto — sulla RAM. Una schermata con una griglia di foto può passare da 300 MB a 80 MB di memoria con tre righe di codice, se si capisce come funziona davvero la pipeline di decodifica.
In questo articolo vediamo il percorso che un'immagine compie da file a pixel sullo schermo, e tutte le leve che Flutter mette a disposizione per renderlo efficiente.
Perché le immagini pesano più di quanto sembra
Il punto chiave è che la dimensione del file non ha nulla a che vedere con la memoria occupata. Un JPEG compresso di 400 KB, una volta decodificato, diventa un bitmap non compresso in memoria. La formula è semplice:
memoria (byte) = larghezza_px × altezza_px × 4
I 4 byte sono i canali RGBA. Quindi:
- un'immagine 1200×1200 occupa
1200 × 1200 × 4= 5,76 MB in RAM - venti immagini del genere in una griglia = 115 MB, anche se visualizzate in riquadri da 100×100 logici
Il disastro è che quei 100×100 logici su un telefono con devicePixelRatio 3 richiedono solo 300×300 pixel fisici, cioè 360 KB. Stiamo buttando via il 94% della memoria allocata.
La pipeline: ImageProvider, ImageCache e ui.Image
Capire i quattro attori in gioco rende tutto il resto ovvio:
ImageProvider(AssetImage,NetworkImage,FileImage,MemoryImage): non contiene pixel, è solo una descrizione di dove stanno i byte. Per questo è importante che implementi correttamente==ehashCode: due provider uguali devono finire sulla stessa chiave di cache.ImageCache: una mappa in memoria, globale, accessibile viaPaintingBinding.instance.imageCache. Di default conserva fino a 1000 immagini e 100 MiB di pixel decodificati.- Il codec (
dart:ui): decodifica i byte compressi in unui.Image. La decodifica avviene in modo asincrono, ma l'allocazione di memoria è reale e immediata. RenderImage: disegna ilui.Imagenel riquadro assegnato dal layout, scalandolo con la GPU.
Il passaggio critico è il punto 3: Flutter decodifica alla risoluzione nativa del file, a meno che non gli diciamo esplicitamente il contrario. Il ridimensionamento fatto dal layout (BoxFit.cover in un SizedBox piccolo) avviene dopo, sulla GPU, e non risparmia un solo byte di RAM.
Prima leva: cacheWidth e cacheHeight
I costruttori Image.asset, Image.network, Image.file e Image.memory accettano cacheWidth e cacheHeight. Dietro le quinte avvolgono il provider in un ResizeImage, che istruisce il codec a decodificare direttamente a quella dimensione.
Image.network(
photo.url,
width: 100,
height: 100,
fit: BoxFit.cover,
// Attenzione: valori in PIXEL FISICI, non logici
cacheWidth: 300,
cacheHeight: 300,
)
L'errore più comune è passare la dimensione logica del widget. Su un display con devicePixelRatio 3 otterremmo un'immagine sgranata. La soluzione corretta è calcolare il valore a runtime:
class ScaledImage extends StatelessWidget {
const ScaledImage({
super.key,
required this.url,
required this.size,
});
final String url;
final Size size;
@override
Widget build(BuildContext context) {
final dpr = MediaQuery.devicePixelRatioOf(context);
return Image.network(
url,
width: size.width,
height: size.height,
fit: BoxFit.cover,
cacheWidth: (size.width * dpr).round(),
cacheHeight: (size.height * dpr).round(),
);
}
}
Un dettaglio importante: specificando entrambi i valori, il codec può deformare l'immagine se le proporzioni non coincidono. Nella maggior parte dei casi conviene indicarne uno solo — tipicamente quello del lato vincolante — e lasciare che l'altro segua l'aspect ratio.
ResizeImage: controllo fine sul provider
Quando si usa un ImageProvider direttamente (ad esempio in un DecorationImage, in un CircleAvatar o con precacheImage), cacheWidth non è disponibile: bisogna usare ResizeImage esplicitamente.
final provider = ResizeImage(
NetworkImage(user.avatarUrl),
width: 128,
policy: ResizeImagePolicy.fit,
allowUpscaling: false,
);
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
image: DecorationImage(image: provider, fit: BoxFit.cover),
),
);
Due parametri meritano attenzione:
policy: conResizeImagePolicy.exact(default) larghezza e altezza indicate sono quelle finali esatte; conResizeImagePolicy.fitl'immagine viene ridimensionata in modo da contenere il riquadro, mantenendo le proporzioni.fitè quasi sempre la scelta giusta per le thumbnail.allowUpscaling: lasciandolo afalsesi evita di sprecare memoria ingrandendo immagini già piccole.
ResizeImage modifica anche la chiave di cache: ResizeImage(NetworkImage(url), width: 128) e ResizeImage(NetworkImage(url), width: 512) sono due voci distinte. È il comportamento desiderato, ma significa anche che usare dimensioni diverse per la stessa immagine in schermate diverse raddoppia l'occupazione. Meglio standardizzare pochi "tagli" (thumbnail, card, full screen) e riutilizzarli.
Asset a risoluzioni multiple
Per gli asset locali Flutter ha un meccanismo nativo: le varianti per densità. La struttura è questa:
assets/
images/
hero.png # 1x
2.0x/hero.png
3.0x/hero.png
E nel pubspec.yaml si dichiara solo il percorso base:
flutter:
assets:
- assets/images/hero.png
A runtime AssetImage sceglie la variante corretta in base al devicePixelRatio del dispositivo. Su un tablet a 1x non verrà mai decodificata la versione 3x: un risparmio automatico di memoria e di tempo di decodifica.
Suggerimento: dichiarando una cartella (
- assets/images/) invece dei singoli file, le sottocartelle2.0x/3.0xvengono comunque gestite correttamente, ma si perde la possibilità di escludere asset inutilizzati.
precacheImage: eliminare il flash al primo frame
Quando un'immagine non è in cache, il primo frame in cui compare mostra uno spazio vuoto. Per immagini critiche (hero di un onboarding, sfondo della home) conviene scaldare la cache in anticipo.
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Qui il context ha già accesso a MediaQuery: il provider
// risolverà la variante di densità corretta.
precacheImage(const AssetImage('assets/images/hero.png'), context);
}
@override
Widget build(BuildContext context) => const Scaffold(
body: Image(image: AssetImage('assets/images/hero.png')),
);
}
precacheImage va chiamato in didChangeDependencies, non in initState, perché ha bisogno di un BuildContext con MediaQuery disponibile. Restituisce un Future che si completa quando l'immagine è decodificata: si può usare anche per bloccare una splash screen finché gli asset essenziali non sono pronti.
Attenzione a non abusarne: precaricare 50 immagini all'avvio significa allocare decine di MB prima ancora che l'utente veda qualcosa.
Configurare e governare l'ImageCache
I default (1000 immagini, 100 MiB) sono pensati per un telefono di fascia media. Su app con molte immagini grandi, o su dispositivi con poca RAM, vale la pena regolarli:
void main() {
WidgetsFlutterBinding.ensureInitialized();
final cache = PaintingBinding.instance.imageCache;
cache.maximumSizeBytes = 60 << 20; // 60 MiB
cache.maximumSize = 200; // numero massimo di voci
runApp(const MyApp());
}
Alcune operazioni utili sulla cache:
final cache = PaintingBinding.instance.imageCache;
// Rimuove una specifica immagine (es. l'avatar è cambiato sul server)
cache.evict(NetworkImage(user.avatarUrl));
// Svuota le immagini non più referenziate
cache.clear();
// Svuota anche quelle attualmente "vive" (in uso da un widget)
cache.clearLiveImages();
// Diagnostica
debugPrint('${cache.currentSize} immagini, '
'${(cache.currentSizeBytes / 1024 / 1024).toStringAsFixed(1)} MB');
Un pattern utile è reagire alla pressione di memoria del sistema operativo. WidgetsBindingObserver espone didHaveMemoryPressure:
class _AppState extends State<App> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void didHaveMemoryPressure() {
PaintingBinding.instance.imageCache.clear();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
}
Nota: ImageCache è una cache in memoria, non su disco. Image.network non salva nulla sul filesystem, quindi al riavvio dell'app tutto viene riscaricato. Per la persistenza serve un livello aggiuntivo (come cached_network_image, che internamente combina cache su disco e ImageCache).
Stati di caricamento: frameBuilder, loadingBuilder, errorBuilder
Un'immagine che appare di colpo è percepita come un glitch. Il widget Image offre tre hook:
Image.network(
url,
fit: BoxFit.cover,
// Chiamato a ogni frame disponibile: ideale per un fade-in
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded) return child;
return AnimatedOpacity(
opacity: frame == null ? 0 : 1,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
child: child,
);
},
// Solo per immagini di rete: progresso del download
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
final value = progress.expectedTotalBytes != null
? progress.cumulativeBytesLoaded / progress.expectedTotalBytes!
: null;
return Center(child: CircularProgressIndicator(value: value));
},
errorBuilder: (context, error, stackTrace) =>
const Icon(Icons.broken_image_outlined),
)
Il flag wasSynchronouslyLoaded è prezioso: se l'immagine era già in cache non ha senso animare un fade-in, risulterebbe solo un lampeggio.
Un altro parametro poco noto è gaplessPlayback: true: quando l'URL cambia, mantiene a schermo l'immagine precedente finché la nuova non è pronta, invece di mostrare un buco bianco. Perfetto per carousel e cambio avatar.
Immagini in liste e griglie
Nelle liste scrollabili si sommano tutti i problemi. Regole pratiche:
- Sempre
cacheWidth/cacheHeightcalcolati sulla cella, mai sull'immagine originale. - Usare
ListView.builder/GridView.buildere non costruire tutte le celle in anticipo. - Tenere basso il
cacheExtentquando le celle contengono immagini pesanti: uncacheExtentdi 1000 px su una lista di foto significa decodificare molte immagini fuori schermo. - Evitare
AutomaticKeepAlivenelle celle con immagini: mantenere vivi i widget fuori schermo impedisce all'ImageCachedi liberare le voci "live". - Costruttori
constdove possibile, per evitare che un rebuild ricreiImageProvidernon uguali e quindi cache miss.
GridView.builder(
cacheExtent: 200,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 4,
crossAxisSpacing: 4,
),
itemCount: photos.length,
itemBuilder: (context, index) {
final dpr = MediaQuery.devicePixelRatioOf(context);
final cellWidth = MediaQuery.sizeOf(context).width / 3;
return Image.network(
photos[index].thumbUrl,
fit: BoxFit.cover,
cacheWidth: (cellWidth * dpr).round(),
filterQuality: FilterQuality.medium,
);
},
)
Piccola nota su filterQuality: il default FilterQuality.medium (da Flutter 3.27 in poi, al posto del vecchio low) è un buon compromesso. high applica un filtro bicubico costoso: usatelo solo su singole immagini grandi, mai in griglie.
Scovare le immagini sovradimensionate
Flutter ha un flag di debug dedicato che capovolge verticalmente ogni immagine decodificata a una risoluzione molto maggiore di quella visualizzata. L'effetto è impossibile da ignorare:
import 'package:flutter/rendering.dart';
void main() {
debugInvertOversizedImages = true;
runApp(const MyApp());
}
Ogni immagine "capovolta" sullo schermo è un errore da correggere con cacheWidth. In console compare anche un warning con dimensione reale, dimensione visualizzata e spreco in KB. La soglia è configurabile con debugImageOverheadAllowance.
In DevTools, inoltre:
- la tab Memory permette di tracciare le allocazioni e vedere crescere la heap mentre si scorre una lista;
- la tab Performance evidenzia i frame in cui la decodifica causa jank;
- il Flutter Inspector espone l'opzione Invert Oversized Images senza ricompilare.
Peso del bundle: formati e tree shaking
Ultimo fronte, quello del download iniziale:
- WebP è supportato nativamente da Flutter (anche animato) e riduce tipicamente del 25-35% rispetto a PNG/JPEG. AVIF invece non è supportato dal motore: serve un plugin.
- Per le icone,
flutter buildin modalità release applica di default il tree shaking dei font di icone: solo i glyph effettivamente usati finiscono nel binario. Questo funziona però solo conIconDatacostanti; costruire icone dinamicamente (IconData(codePoint)) disattiva l'ottimizzazione e produce un warning. - Per illustrazioni e loghi, la grafica vettoriale (SVG precompilato) pesa pochi KB e scala a qualsiasi densità.
- Le immagini caricate dall'utente (foto da fotocamera o galleria) vanno compresse prima dell'upload: un pacchetto come
flutter_image_compressesegue il lavoro su codice nativo senza bloccare l'isolate principale.
Checklist finale
Prima di considerare chiusa una schermata con immagini, verificate:
- Ogni
Imagein lista o griglia hacacheWidth/cacheHeightcalcolati con ildevicePixelRatio. - I provider usati in
DecorationImage/CircleAvatarsono avvolti inResizeImage. - Gli asset locali hanno le varianti
2.0xe3.0x. - Le immagini critiche sono precaricate con
precacheImageindidChangeDependencies. maximumSizeBytesdell'ImageCacheè dimensionato sul target reale dell'app.- Sono gestiti
errorBuildere un placeholder/fade-in. debugInvertOversizedImages = truenon mostra nessuna immagine capovolta.- Le immagini di rete passano da una cache su disco, non da
Image.networkpuro.
Sono otto controlli che richiedono mezza giornata di lavoro e che, su un'app ricca di contenuti visuali, fanno spesso la differenza tra uno scroll a 60 fps e crash per OOM sui dispositivi di fascia bassa.