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:

  1. ImageProvider (AssetImage, NetworkImage, FileImage, MemoryImage): non contiene pixel, è solo una descrizione di dove stanno i byte. Per questo è importante che implementi correttamente == e hashCode: due provider uguali devono finire sulla stessa chiave di cache.
  2. ImageCache: una mappa in memoria, globale, accessibile via PaintingBinding.instance.imageCache. Di default conserva fino a 1000 immagini e 100 MiB di pixel decodificati.
  3. Il codec (dart:ui): decodifica i byte compressi in un ui.Image. La decodifica avviene in modo asincrono, ma l'allocazione di memoria è reale e immediata.
  4. RenderImage: disegna il ui.Image nel 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: con ResizeImagePolicy.exact (default) larghezza e altezza indicate sono quelle finali esatte; con ResizeImagePolicy.fit l'immagine viene ridimensionata in modo da contenere il riquadro, mantenendo le proporzioni. fit è quasi sempre la scelta giusta per le thumbnail.
  • allowUpscaling: lasciandolo a false si 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 sottocartelle 2.0x/3.0x vengono 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/cacheHeight calcolati sulla cella, mai sull'immagine originale.
  • Usare ListView.builder/GridView.builder e non costruire tutte le celle in anticipo.
  • Tenere basso il cacheExtent quando le celle contengono immagini pesanti: un cacheExtent di 1000 px su una lista di foto significa decodificare molte immagini fuori schermo.
  • Evitare AutomaticKeepAlive nelle celle con immagini: mantenere vivi i widget fuori schermo impedisce all'ImageCache di liberare le voci "live".
  • Costruttori const dove possibile, per evitare che un rebuild ricrei ImageProvider non 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 build in modalità release applica di default il tree shaking dei font di icone: solo i glyph effettivamente usati finiscono nel binario. Questo funziona però solo con IconData costanti; 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_compress esegue il lavoro su codice nativo senza bloccare l'isolate principale.

Checklist finale

Prima di considerare chiusa una schermata con immagini, verificate:

  1. Ogni Image in lista o griglia ha cacheWidth/cacheHeight calcolati con il devicePixelRatio.
  2. I provider usati in DecorationImage/CircleAvatar sono avvolti in ResizeImage.
  3. Gli asset locali hanno le varianti 2.0x e 3.0x.
  4. Le immagini critiche sono precaricate con precacheImage in didChangeDependencies.
  5. maximumSizeBytes dell'ImageCache è dimensionato sul target reale dell'app.
  6. Sono gestiti errorBuilder e un placeholder/fade-in.
  7. debugInvertOversizedImages = true non mostra nessuna immagine capovolta.
  8. Le immagini di rete passano da una cache su disco, non da Image.network puro.

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.