Perché non basta Dio (o http)
Scaricare un file con Dio e ReceiveProgress funziona benissimo… finché l'utente resta nell'app. Nel momento in cui mette l'app in background, cambia schermata o il sistema operativo decide di sospendere il processo, il download si interrompe e bisogna ricominciare da capo. Per un PDF da 200 KB è irrilevante, per un corso video da 800 MB o per l'upload di un video registrato dall'utente è un problema serio.
La soluzione corretta è delegare il trasferimento al sistema operativo: WorkManager/DownloadManager su Android e URLSession con configurazione background su iOS. È esattamente quello che fa background_downloader: un'unica API Dart che incapsula i meccanismi nativi, con progresso, notifiche, retry, pausa e ripresa.
Installazione e configurazione
dependencies:
background_downloader: ^9.2.0
Su Android il plugin registra da solo il servizio e il permesso POST_NOTIFICATIONS; su iOS non serve alcuna capability aggiuntiva per i trasferimenti (URLSession background è disponibile di default), ma se vuoi mostrare notifiche devi chiedere il permesso a runtime.
Nel main() conviene inizializzare il downloader prima di avviare l'app:
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Persiste i task in un database locale, così sopravvivono ai riavvii
await FileDownloader().trackTasks();
// Recupera gli update generati mentre l'app era terminata
await FileDownloader().resumeFromBackground();
await FileDownloader().configure(
globalConfig: (Config.requestTimeout, const Duration(seconds: 60)),
androidConfig: (Config.useCacheDir, Config.whenAble),
);
runApp(const MyApp());
}
Il primo download
Un trasferimento è descritto da un oggetto DownloadTask immutabile:
final task = DownloadTask(
url: 'https://cdn.example.com/corsi/flutter-avanzato.mp4',
filename: 'flutter-avanzato.mp4',
directory: 'corsi',
baseDirectory: BaseDirectory.applicationDocuments,
updates: Updates.statusAndProgress,
requiresWiFi: true,
retries: 3,
allowPause: true,
metaData: 'corso-42',
);
Alcuni parametri meritano attenzione:
baseDirectory+directory: il pacchetto salva la posizione in modo relativo. È fondamentale su iOS, dove il path assoluto del container dell'app cambia a ogni aggiornamento: memorizzare/var/mobile/.../Documents/file.mp4nel tuo database porta a file "spariti" dopo un update dallo store.requiresWiFi: evita che l'utente consumi il piano dati; su Android è una constraint nativa del job.retries: ritenta automaticamente con backoff esponenziale in caso di errore di rete.allowPause: abilita pausa e ripresa, ma richiede che il server supporti le richieste HTTPRange.metaData: campo libero per collegare il task alla tua entità di dominio (id del corso, dell'ordine, ecc.).
Modalità "attesa" (semplice)
final result = await FileDownloader().download(
task,
onProgress: (progress) => debugPrint('Progresso: ${(progress * 100).round()}%'),
onStatus: (status) => debugPrint('Stato: $status'),
);
switch (result.status) {
case TaskStatus.complete:
final path = await task.filePath();
debugPrint('Salvato in $path');
case TaskStatus.canceled:
debugPrint('Annullato dall\'utente');
case TaskStatus.failed:
debugPrint('Errore: ${result.exception?.description}');
default:
break;
}
Questa forma è comoda per download brevi, ma il Future si risolve solo finché l'app vive. Per i file pesanti usa la modalità a coda.
Modalità a coda (consigliata)
final accepted = await FileDownloader().enqueue(task);
enqueue restituisce immediatamente true se il sistema ha accettato il task: da quel momento il trasferimento è gestito dal SO e prosegue anche se l'utente chiude la schermata.
Gli aggiornamenti arrivano su uno stream globale, perfetto da collegare a un repository o a un notifier:
class DownloadRepository {
final _progress = <String, double>{};
final _controller = StreamController<Map<String, double>>.broadcast();
Stream<Map<String, double>> get progressStream => _controller.stream;
void listen() {
FileDownloader().updates.listen((update) {
switch (update) {
case TaskStatusUpdate(:final task, :final status):
if (status.isFinalState) {
_progress.remove(task.taskId);
_controller.add(Map.of(_progress));
}
case TaskProgressUpdate(:final task, :final progress):
_progress[task.taskId] = progress;
_controller.add(Map.of(_progress));
}
});
}
}
Nota il pattern matching di Dart 3 su TaskUpdate: è una gerarchia sealed, quindi lo switch è esaustivo senza default.
Attenzione ai valori negativi di progress, che non sono percentuali ma stati speciali: -1 fallito, -2 annullato, -3 non trovato, -4 in attesa di retry, -5 in pausa. Filtra sempre con progress >= 0 prima di passarlo a un LinearProgressIndicator.
Notifiche native
Su entrambe le piattaforme puoi mostrare una notifica di sistema con barra di avanzamento e pulsanti di pausa/annulla:
await FileDownloader().permissions.request(PermissionType.notifications);
FileDownloader().configureNotification(
running: const TaskNotification('Download in corso', '{filename} - {progress}'),
complete: const TaskNotification('Download completato', '{filename}'),
error: const TaskNotification('Download fallito', '{filename}'),
paused: const TaskNotification('Download in pausa', '{filename}'),
progressBar: true,
tapOpensFile: false,
);
I placeholder disponibili sono {filename}, {progress}, {networkSpeed}, {timeRemaining} e {metadata}. Puoi anche intercettare il tap sulla notifica per portare l'utente nella schermata giusta:
FileDownloader().registerCallbacks(
taskNotificationTapCallback: (task, type) {
router.go('/corsi/${task.metaData}');
},
);
Pausa, ripresa e cancellazione
// Richiede allowPause: true e supporto server per Range requests
await FileDownloader().pause(task);
await FileDownloader().resume(task);
// Cancellazione (anche di task avviati in una sessione precedente)
await FileDownloader().cancelTaskWithId(task.taskId);
// Stato corrente di un task in coda
final status = await FileDownloader().taskForId(task.taskId);
Persistenza dei task tra riavvii
Con trackTasks() attivo, ogni task e il suo ultimo stato vengono salvati in un database locale. È il modo più pulito per ricostruire una schermata "I miei download" al riavvio dell'app:
final records = await FileDownloader().database.allRecords();
for (final record in records) {
debugPrint('${record.task.filename}: '
'${record.status} (${(record.progress * 100).round()}%)');
}
// Pulizia dei record completati
await FileDownloader().database.deleteRecordWithId(someTaskId);
Puoi anche fornire un PersistentStorage personalizzato (ad esempio basato su SQLite) se vuoi integrarlo con il resto della tua persistenza.
Upload di file
L'API è speculare: cambia solo la classe del task.
final upload = UploadTask(
url: 'https://api.example.com/v1/documenti',
filename: 'contratto.pdf',
directory: 'documenti',
baseDirectory: BaseDirectory.applicationDocuments,
fileField: 'file',
fields: {'userId': '42', 'tipo': 'contratto'},
headers: {'Authorization': 'Bearer $token'},
updates: Updates.statusAndProgress,
);
await FileDownloader().enqueue(upload);
Di default viene usato un multipart/form-data. Se il backend si aspetta il contenuto grezzo nel body basta impostare post: 'binary'. Per upload multipli c'è MultiUploadTask, che accetta una lista di file con i rispettivi field name.
Un dettaglio operativo: gli header (e quindi il token di autenticazione) vengono "congelati" al momento dell'enqueue. Se il task resta in coda a lungo e il token scade, l'upload fallisce con 401. Soluzioni tipiche: accorciare la finestra accodando solo quando necessario, oppure usare FileDownloader().registerCallbacks(taskStatusCallback: ...) per rilevare il 401 (leggibile in responseStatusCode) e ri-accodare il task con un token fresco.
Operazioni in batch
Per scaricare un intero pacchetto di risorse (ad esempio i materiali di un corso) esiste downloadBatch:
final tasks = urls.map((u) => DownloadTask(url: u, directory: 'corso-42')).toList();
final batch = await FileDownloader().downloadBatch(
tasks,
batchProgressCallback: (succeeded, failed) {
debugPrint('$succeeded/${tasks.length} completati, $failed falliti');
},
);
if (batch.numFailed > 0) {
for (final t in batch.failed) {
debugPrint('Fallito: ${t.url}');
}
}
Esportare il file in uno spazio condiviso
La directory dell'app non è visibile all'utente. Per spostare un file scaricato nella cartella Download o in Galleria:
final newPath = await FileDownloader().moveToSharedStorage(
task,
SharedStorage.downloads,
);
Su Android recenti l'operazione usa MediaStore e non richiede permessi di storage; su iOS il concetto di "shared storage" è più limitato, quindi per condividere un file è spesso preferibile il classico share sheet.
Limiti e accortezze pratiche
- iOS non garantisce l'immediatezza: con URLSession in background il sistema decide quando eseguire i trasferimenti in base a batteria e condizioni di rete. Non progettare flussi che richiedono il completamento entro X secondi con app chiusa.
- Se l'utente killa l'app su iOS, i trasferimenti in corso vengono terminati dal sistema: al riavvio usa
resumeFromBackground()e il database per ricostruire lo stato e ri-accodare ciò che serve. - Non salvare path assoluti: conserva
baseDirectory+directory+filenamee ricalcola il path contask.filePath(). - Verifica l'integrità: per file di grandi dimensioni confronta un checksum o almeno la dimensione attesa prima di considerare il download valido.
- Non abusare della coda: centinaia di task simultanei degradano le performance; usa
Config.holdingQueueper limitare la concorrenza (await FileDownloader().configure(globalConfig: (Config.holdingQueue, (3, null, null)));). - Test su device reale: l'emulatore Android e il simulatore iOS non riproducono fedelmente le politiche di background del sistema.
Quando usarlo e quando no
Usa background_downloader quando i file sono pesanti, quando il trasferimento deve sopravvivere al background o quando vuoi notifiche di sistema "gratis". Per chiamate API brevi, JSON e immagini di pochi KB continua a usare Dio o http: l'overhead di accodare un task nativo non ha senso.
Rispetto a flutter_downloader, oggi meno mantenuto e solo per i download, questo pacchetto copre anche gli upload, ha un'API moderna basata su stream e sealed class, e una gestione della persistenza integrata: nel 2024-2025 è la scelta di riferimento per i trasferimenti file in Flutter.