Upload di file in Flutter con multipart/form-data e Dio

Foto di Luke Chesser su Unsplash

GuideIntermedio35 min Flutter 3.x

Upload di file in Flutter con multipart/form-data e Dio

Prima o poi ogni app deve inviare un file al backend: la foto del profilo, un documento, un allegato.

In questo tutorial costruiamo un servizio di upload basato su Dio e sul formato multipart/form-data, aggiungendo barra di avanzamento, annullamento della richiesta tramite CancelToken e una gestione degli errori leggibile.

Lavoreremo con un file già selezionato (ad esempio da image_picker o file_picker): qui ci concentriamo sulla parte di rete e sulla UI di feedback.

  1. 1

    Installare le dipendenze

    Aggiungi dio per le richieste HTTP e http_parser per impostare correttamente il MIME type delle parti multipart. In questo esempio usiamo anche image_picker per ottenere un file da caricare.

    Esegui poi flutter pub get.

    dependencies:
      dio: ^5.4.0
      http_parser: ^4.0.2
      image_picker: ^1.0.7
      path: ^1.9.0

    Risultato atteso

    Le dipendenze sono risolte e importabili nel progetto.

  2. 2

    Creare il servizio di upload con MultipartFile

    Il cuore dell'upload è FormData: contiene i campi testuali e uno o più MultipartFile.

    Punti chiave:

    • MultipartFile.fromFile legge il file in streaming, quindi non carica tutto in memoria;
    • filename è il nome che vedrà il server, non il percorso locale;
    • contentType evita che il backend riceva application/octet-stream quando si aspetta un'immagine.
    import 'dart:io';
    import 'package:dio/dio.dart';
    import 'package:http_parser/http_parser.dart';
    import 'package:path/path.dart' as p;
    
    class UploadService {
      UploadService(this._dio);
      final Dio _dio;
    
      Future<String> uploadFile({
        required File file,
        required String userId,
        MediaType? mediaType,
        void Function(int sent, int total)? onProgress,
        CancelToken? cancelToken,
      }) async {
        final formData = FormData.fromMap({
          'userId': userId,
          'file': await MultipartFile.fromFile(
            file.path,
            filename: p.basename(file.path),
            contentType: mediaType ?? MediaType('image', 'jpeg'),
          ),
        });
    
        final res = await _dio.post<Map<String, dynamic>>(
          '/uploads',
          data: formData,
          onSendProgress: onProgress,
          cancelToken: cancelToken,
          options: Options(sendTimeout: const Duration(minutes: 2)),
        );
    
        return res.data!['url'] as String;
      }
    }

    Risultato atteso

    Il servizio invia una richiesta POST multipart e restituisce l'URL pubblico del file.

  3. 3

    Configurare Dio e tradurre gli errori

    Un upload fallisce più spesso di una GET: connessione lenta, file troppo grande (413), token scaduto (401).

    Centralizziamo la configurazione di Dio e trasformiamo DioException in un errore di dominio con messaggio comprensibile, distinguendo l'annullamento volontario dall'errore reale.

    class UploadFailure implements Exception {
      UploadFailure(this.message);
      final String message;
      @override
      String toString() => message;
    }
    
    Dio buildDio(String baseUrl) {
      final dio = Dio(BaseOptions(
        baseUrl: baseUrl,
        connectTimeout: const Duration(seconds: 15),
        receiveTimeout: const Duration(seconds: 30),
      ));
    
      dio.interceptors.add(InterceptorsWrapper(
        onError: (e, handler) {
          if (e.type == DioExceptionType.cancel) return handler.next(e);
          final code = e.response?.statusCode;
          final msg = switch (code) {
            401 => 'Sessione scaduta, effettua di nuovo il login.',
            413 => 'Il file supera la dimensione massima consentita.',
            415 => 'Formato file non supportato.',
            _ when e.type == DioExceptionType.sendTimeout =>
              'Upload troppo lento: controlla la connessione.',
            _ => 'Caricamento non riuscito. Riprova.',
          };
          handler.reject(DioException(
            requestOptions: e.requestOptions,
            error: UploadFailure(msg),
            type: e.type,
            response: e.response,
          ));
        },
      ));
    
      return dio;
    }

    Risultato atteso

    Ogni errore di rete arriva alla UI con un messaggio già pronto da mostrare.

  4. 4

    Validare il file prima di inviarlo

    Inviare 20 MB per poi ricevere un 413 è spreco di dati e di tempo. Controlla dimensione ed estensione prima della richiesta e ricava il MediaType corretto dall'estensione.

    import 'package:path/path.dart' as p;
    import 'package:http_parser/http_parser.dart';
    
    const _maxBytes = 10 * 1024 * 1024; // 10 MB
    const _allowed = {'jpg', 'jpeg', 'png', 'pdf'};
    
    MediaType mediaTypeFor(String path) {
      final ext = p.extension(path).replaceFirst('.', '').toLowerCase();
      return switch (ext) {
        'png' => MediaType('image', 'png'),
        'jpg' || 'jpeg' => MediaType('image', 'jpeg'),
        'pdf' => MediaType('application', 'pdf'),
        _ => MediaType('application', 'octet-stream'),
      };
    }
    
    Future<void> validateFile(File file) async {
      final ext = p.extension(file.path).replaceFirst('.', '').toLowerCase();
      if (!_allowed.contains(ext)) {
        throw UploadFailure('Estensione .$ext non ammessa.');
      }
      final size = await file.length();
      if (size > _maxBytes) {
        throw UploadFailure('Il file pesa ${(size / 1048576).toStringAsFixed(1)} MB (max 10 MB).');
      }
    }

    Risultato atteso

    File non validi vengono bloccati subito con un messaggio chiaro, senza traffico inutile.

  5. 5

    Costruire la UI con progresso e annullamento

    Ora colleghiamo tutto a uno schermo: selezione del file, LinearProgressIndicator alimentato da onSendProgress e pulsante Annulla che richiama CancelToken.cancel().

    Nota: ricorda di annullare il token anche in dispose() se l'utente lascia la pagina durante l'upload.

    class UploadPage extends StatefulWidget {
      const UploadPage({super.key, required this.service});
      final UploadService service;
      @override
      State<UploadPage> createState() => _UploadPageState();
    }
    
    class _UploadPageState extends State<UploadPage> {
      double _progress = 0;
      bool _busy = false;
      CancelToken? _token;
    
      Future<void> _pickAndUpload() async {
        final picked = await ImagePicker().pickImage(source: ImageSource.gallery);
        if (picked == null) return;
        final file = File(picked.path);
    
        setState(() { _busy = true; _progress = 0; });
        _token = CancelToken();
        try {
          await validateFile(file);
          final url = await widget.service.uploadFile(
            file: file,
            userId: 'u-42',
            mediaType: mediaTypeFor(file.path),
            cancelToken: _token,
            onProgress: (sent, total) {
              if (total > 0) setState(() => _progress = sent / total);
            },
          );
          _show('Caricato: $url');
        } on DioException catch (e) {
          _show(e.type == DioExceptionType.cancel
              ? 'Upload annullato'
              : '${e.error ?? 'Errore di rete'}');
        } on UploadFailure catch (e) {
          _show(e.message);
        } finally {
          if (mounted) setState(() => _busy = false);
        }
      }
    
      void _show(String m) {
        if (!mounted) return;
        ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(m)));
      }
    
      @override
      void dispose() {
        _token?.cancel('page disposed');
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: const Text('Upload file')),
          body: Padding(
            padding: const EdgeInsets.all(24),
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                if (_busy) ...[
                  LinearProgressIndicator(value: _progress),
                  const SizedBox(height: 8),
                  Text('${(_progress * 100).toStringAsFixed(0)}%'),
                  TextButton(
                    onPressed: () => _token?.cancel('annullato'),
                    child: const Text('Annulla'),
                  ),
                ] else
                  ElevatedButton.icon(
                    onPressed: _pickAndUpload,
                    icon: const Icon(Icons.cloud_upload),
                    label: const Text('Scegli e carica'),
                  ),
              ],
            ),
          ),
        );
      }
    }

    Risultato atteso

    Selezionando un'immagine compare la percentuale di avanzamento; il pulsante Annulla interrompe subito la richiesta.

  6. 6

    Aggiungere retry e upload multipli

    Per reti instabili conviene ritentare solo gli errori transitori (timeout, 5xx), mai un 4xx. Attenzione: un FormData non è riutilizzabile dopo l'invio, va ricreato a ogni tentativo.

    Per più file, limita la concorrenza (2-3 upload paralleli) invece di lanciarne decine insieme.

    Future<T> withRetry<T>(Future<T> Function() action, {int max = 3}) async {
      var attempt = 0;
      while (true) {
        try {
          return await action();
        } on DioException catch (e) {
          final status = e.response?.statusCode ?? 0;
          final transient = e.type == DioExceptionType.connectionTimeout ||
              e.type == DioExceptionType.sendTimeout ||
              e.type == DioExceptionType.connectionError ||
              status >= 500;
          attempt++;
          if (!transient || attempt >= max) rethrow;
          await Future.delayed(Duration(seconds: 1 << attempt));
        }
      }
    }
    
    // Upload sequenziale a lotti di 2
    Future<List<String>> uploadAll(List<File> files, UploadService s) async {
      final urls = <String>[];
      for (var i = 0; i < files.length; i += 2) {
        final batch = files.skip(i).take(2);
        final res = await Future.wait(batch.map(
          (f) => withRetry(() => s.uploadFile(file: f, userId: 'u-42')),
        ));
        urls.addAll(res);
      }
      return urls;
    }

    Risultato atteso

    Gli errori temporanei vengono ritentati con backoff esponenziale e più file vengono caricati in modo controllato.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!