Perché il peso dell'app è un problema di business

Ogni megabyte in più incide sul tasso di installazione: su rete mobile gli utenti abbandonano i download lunghi, i dispositivi con poco spazio disinstallano per primi le app più grandi e gli store impongono limiti precisi (200 MB di dimensione di download per un Android App Bundle su Google Play, avviso di rete cellulare su iOS oltre i 200 MB).

Un'app Flutter parte con un "costo fisso": il motore di rendering, la runtime Dart e il codice AOT compilato. Per una app vuota in release siamo nell'ordine di pochi megabyte per singola architettura, ma è sorprendentemente facile arrivare a 60-80 MB con qualche asset non compresso, tre SDK di analytics e una build sbagliata. La buona notizia è che gran parte del sovrappeso è recuperabile con interventi meccanici e ripetibili.

Prima regola: misurare, non indovinare

Flutter include uno strumento di analisi dedicato che produce un report navigabile in DevTools.

# Android (APK per una singola architettura)
flutter build apk --release --analyze-size --target-platform android-arm64

# Android App Bundle
flutter build appbundle --release --analyze-size

# iOS
flutter build ios --release --analyze-size

Al termine, il comando stampa un riepilogo a terminale e salva un file JSON in .dart_tool/flutter_build/ (il percorso esatto viene indicato nell'output). Per esplorarlo:

flutter pub global activate devtools
devtools

Nella tab App Size si carica il file e si ottiene un treemap con il peso di ogni libreria Dart, di ogni package e di ogni asset. È l'unico modo serio per capire se il problema sono le immagini, i plugin nativi o il codice Dart.

Un secondo strumento utile è l'APK Analyzer di Android Studio (Build > Analyze APK), che mostra la dimensione compressa reale del file consegnato all'utente, e bundletool per stimare il download effettivo di un AAB:

bundletool build-apks --bundle=app-release.aab --output=app.apks
bundletool get-size total --apks=app.apks

Attenzione: confrontate sempre la dimensione di download (compressa, per singolo dispositivo) e non il peso del file universale, altrimenti ottimizzerete il numero sbagliato.

Le build giuste: i guadagni facili

App Bundle invece di APK universale

Un APK universale contiene le librerie native per tutte le architetture (arm64, armeabi-v7a, x86_64). Il formato AAB permette a Play di generare l'APK su misura del dispositivo:

flutter build appbundle --release

Se dovete distribuire APK (store alternativi, sideload), usate almeno lo split per ABI:

flutter build apk --release --split-per-abi

Il risultato sono tre APK separati, ciascuno tipicamente 30-40% più leggero dell'universale.

Offuscamento e separazione dei simboli di debug

flutter build appbundle --release \
  --obfuscate \
  --split-debug-info=build/symbols

I simboli di debug vengono estratti dal binario e salvati a parte: l'app si alleggerisce e il codice diventa più difficile da analizzare. Conservate la cartella build/symbols per ogni release: senza quei file non sarete in grado di simbolicare gli stack trace provenienti da Crashlytics o Sentry.

Tree shaking delle icone

In release Flutter rimuove automaticamente i glifi non usati dai font di icone (--tree-shake-icons è attivo di default). Il tree shaking però funziona solo se gli IconData sono costanti note a compile time. Questo pattern lo disattiva silenziosamente:

// ❌ Impedisce il tree shaking: l'intero font Material Icons finisce nel bundle
Icon(IconData(codePoint, fontFamily: 'MaterialIcons'));
// ✅ Mappa esplicita di costanti: il compilatore sa esattamente cosa serve
const _icone = <String, IconData>{
  'home': Icons.home,
  'search': Icons.search,
  'profile': Icons.person,
};

Icon(_icone[chiave] ?? Icons.help_outline);

Il font completo di Material Icons pesa oltre 1,5 MB: è uno dei regali più costosi che si possano fare a un'app.

R8 per il codice nativo Android

I plugin (Firebase, Google Play Services, librerie di terze parti) portano con sé molto codice Java/Kotlin. Abilitare R8 e la rimozione delle risorse inutilizzate può far risparmiare diversi MB:

// android/app/build.gradle.kts
android {
    buildTypes {
        release {
            isMinifyEnabled = true
            isShrinkResources = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
}

Testate sempre la build offuscata su dispositivo reale: se una libreria usa reflection (tipicamente parser JSON nativi o SDK di pagamento) servono regole di keep nel file proguard-rules.pro.

Gli asset: dove si nasconde il peso vero

Nella maggior parte dei progetti che ho analizzato, il codice Dart è la parte piccola del bundle. Il grosso sono immagini e font.

Immagini

  • Convertite PNG/JPEG in WebP: risparmio tipico del 25-35% a parità di qualità.
    cwebp -q 82 assets/hero.png -o assets/hero.webp
    
  • Non spedite immagini più grandi di quanto verranno mostrate: un'immagine 4000×3000 per una card da 320 px è puro spreco (e costa anche RAM a runtime).
  • Per illustrazioni e icone complesse preferite SVG precompilato con vector_graphics: il formato .vec è compatto e si disegna senza parsing XML a runtime.

pubspec.yaml selettivo

Dichiarare una cartella intera include tutto quello che c'è dentro, comprese le versioni di lavorazione dimenticate:

flutter:
  assets:
    # ❌ include anche i file .psd, i backup e le prove
    # - assets/
    # ✅ solo quello che serve davvero
    - assets/images/logo.webp
    - assets/icons/

Font

Un font di testo con tutti i pesi (regular, medium, bold, italic) può superare il megabyte. Includete solo i pesi effettivamente usati e valutate il subsetting con fonttools:

pyftsubset Inter.ttf --unicodes="U+0000-00FF,U+20AC" --output-file=Inter-subset.ttf

Attenzione al package google_fonts: di default scarica i font a runtime (bundle più leggero, ma richiede rete al primo avvio e può far comparire un flash di testo). Se vi serve il font offline, includetelo negli asset e disattivate il fetch runtime.

Dipendenze: ogni package ha un prezzo

Prima di aggiungere un package, chiedetevi quanto codice nativo si porta dietro. Alcuni esempi tipici:

  • i modelli bundled di Google ML Kit possono aggiungere decine di MB: se possibile usate le varianti "unbundled" servite da Google Play Services;
  • SDK di mappe, video streaming e pagamenti includono librerie .so per ogni ABI;
  • molte utility con una sola funzione si sostituiscono con venti righe di Dart.

Un audit periodico aiuta:

dart pub deps --style=compact
flutter pub outdated

E ricordate che il tree shaking di Dart AOT è efficace solo sul codice statisticamente raggiungibile: registrazioni dinamiche, grosse mappe di factory e switch su stringhe che istanziano mezzo progetto mantengono vivo codice che non userete mai.

Deferred components: scaricare le feature su richiesta

Per funzionalità voluminose e usate da una minoranza di utenti (editor avanzato, modelli ML, moduli AR) Android supporta il download on demand tramite Play Feature Delivery. Lato Dart si usa l'import differito:

import 'package:my_app/editor/editor.dart' deferred as editor;

Future<void> apriEditor(BuildContext context) async {
  await editor.loadLibrary(); // scarica e carica il modulo
  if (!context.mounted) return;
  Navigator.of(context).push(
    MaterialPageRoute(builder: (_) => editor.EditorPage()),
  );
}

Nel pubspec.yaml si dichiarano i componenti:

flutter:
  deferred-components:
    - name: editorComponent
      libraries:
        - package:my_app/editor/editor.dart
      assets:
        - assets/editor/brushes.webp

Si compila con flutter build appbundle --release, che valida la configurazione e, la prima volta, suggerisce i file Android da generare. Mostrate sempre uno stato di caricamento e gestite l'errore di rete: loadLibrary() può fallire.

Su Flutter Web lo stesso meccanismo produce chunk JavaScript separati, ed è uno degli strumenti più efficaci per ridurre il tempo al primo frame.

Nota su Flutter Web

Il peso iniziale sul web si governa con leve diverse:

flutter build web --release --wasm --pwa-strategy=offline-first

Punti chiave: sfruttare la cache del service worker, servire tutto con compressione Brotli lato server, evitare di pubblicare le source map in produzione e usare gli import differiti per le rotte secondarie.

Checklist operativa

  1. Misurare con --analyze-size e DevTools prima di toccare qualsiasi cosa.
  2. Distribuire un App Bundle (o APK split per ABI).
  3. Aggiungere --obfuscate --split-debug-info e archiviare i simboli.
  4. Verificare che il tree shaking delle icone non sia disattivato da IconData dinamici.
  5. Abilitare R8 + shrinkResources e testare la build su dispositivo.
  6. Convertire le immagini in WebP e ridimensionarle alla risoluzione reale.
  7. Dichiarare asset e pesi dei font in modo selettivo nel pubspec.yaml.
  8. Fare l'audit delle dipendenze e rimuovere quelle inutilizzate.
  9. Spostare le feature pesanti in deferred components.
  10. Aggiungere al CI un controllo che fallisca se la dimensione supera una soglia.

Quest'ultimo punto è ciò che rende il lavoro permanente: senza un guardrail automatico, il bundle torna a gonfiarsi nel giro di pochi sprint. Una riga nel workflow di CI che confronta la dimensione dell'artefatto con quella della release precedente costa dieci minuti e protegge mesi di ottimizzazioni.