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
.soper 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
- Misurare con
--analyze-sizee DevTools prima di toccare qualsiasi cosa. - Distribuire un App Bundle (o APK split per ABI).
- Aggiungere
--obfuscate --split-debug-infoe archiviare i simboli. - Verificare che il tree shaking delle icone non sia disattivato da
IconDatadinamici. - Abilitare R8 +
shrinkResourcese testare la build su dispositivo. - Convertire le immagini in WebP e ridimensionarle alla risoluzione reale.
- Dichiarare asset e pesi dei font in modo selettivo nel
pubspec.yaml. - Fare l'audit delle dipendenze e rimuovere quelle inutilizzate.
- Spostare le feature pesanti in deferred components.
- 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.