Perché la gestione degli errori merita una strategia

In ogni app reale gli errori sono inevitabili: chiamate di rete che falliscono, parsing JSON malformato, eccezioni inattese nel codice. La differenza tra un'app amatoriale e una professionale sta nel modo in cui questi errori vengono catturati, comunicati all'utente e monitorati in produzione.

In questo articolo vedremo tre livelli di gestione:

  1. Gli error handler globali di Flutter per intercettare tutto.
  2. Il pattern Result per rappresentare in modo esplicito gli errori prevedibili.
  3. Il crash reporting in produzione con Sentry.

Gli error handler globali

Flutter offre diversi punti di aggancio per catturare gli errori non gestiti. È buona norma configurarli nel main.

void main() {
  runZonedGuarded(() {
    // Errori del framework (build, layout, ecc.)
    FlutterError.onError = (FlutterErrorDetails details) {
      FlutterError.presentError(details);
      logError(details.exception, details.stack);
    };

    // Errori a basso livello della piattaforma
    PlatformDispatcher.instance.onError = (error, stack) {
      logError(error, stack);
      return true;
    };

    runApp(const MyApp());
  }, (error, stack) {
    // Errori asincroni non catturati
    logError(error, stack);
  });
}

Questi tre canali insieme coprono la stragrande maggioranza degli errori: quelli del framework, quelli nativi e quelli asincroni fuori dalla zone.

Il pattern Result per errori prevedibili

Lanciare eccezioni per errori attesi (come una rete offline) rende il flusso difficile da seguire. Il pattern Result rende esplicito, nel tipo di ritorno, che un'operazione può fallire.

Da Dart 3 possiamo usare i sealed class e il pattern matching per un'implementazione elegante.

sealed class Result<T> {
  const Result();
}

final class Success<T> extends Result<T> {
  final T value;
  const Success(this.value);
}

final class Failure<T> extends Result<T> {
  final Object error;
  final StackTrace? stackTrace;
  const Failure(this.error, [this.stackTrace]);
}

Un repository può quindi restituire un Result invece di lanciare eccezioni:

Future<Result<User>> fetchUser(String id) async {
  try {
    final response = await dio.get('/users/$id');
    return Success(User.fromJson(response.data));
  } catch (e, stack) {
    return Failure(e, stack);
  }
}

Nel layer di presentazione il consumo è chiaro e obbliga il compilatore a gestire entrambi i casi:

final result = await repository.fetchUser('42');

switch (result) {
  case Success(:final value):
    emit(UserLoaded(value));
  case Failure(:final error):
    emit(UserError(_mapError(error)));
}

Convertire gli errori in messaggi utente

Gli utenti non devono mai vedere un'eccezione grezza. Mappa gli errori tecnici in messaggi comprensibili:

String _mapError(Object error) {
  return switch (error) {
    DioException(type: DioExceptionType.connectionError) =>
      'Nessuna connessione a Internet.',
    DioException(response: final r?) when r.statusCode == 404 =>
      'Risorsa non trovata.',
    _ => 'Si è verificato un errore imprevisto.',
  };
}

Crash reporting in produzione con Sentry

Gli error handler intercettano gli errori localmente, ma in produzione hai bisogno di sapere cosa succede sui dispositivi degli utenti. Sentry è tra le soluzioni più usate.

Aggiungi la dipendenza:

dependencies:
  sentry_flutter: ^8.0.0

Sentry semplifica l'inizializzazione gestendo internamente gli handler globali:

Future<void> main() async {
  await SentryFlutter.init(
    (options) {
      options.dsn = 'https://esempio@sentry.io/123';
      options.tracesSampleRate = 0.2; // performance monitoring
      options.environment = kReleaseMode ? 'production' : 'development';
    },
    appRunner: () => runApp(const MyApp()),
  );
}

Inviare errori catturati manualmente

Per gli errori che gestisci con il pattern Result puoi comunque loggarli su Sentry senza interrompere l'app:

void logError(Object error, StackTrace? stack) {
  Sentry.captureException(error, stackTrace: stack);
}

Aggiungere contesto

Un crash isolato dice poco. Aggiungi breadcrumb e informazioni sull'utente per una diagnosi più rapida:

Sentry.configureScope((scope) {
  scope.setUser(SentryUser(id: user.id));
  scope.setTag('feature', 'checkout');
});

Sentry.addBreadcrumb(
  Breadcrumb(message: 'Utente ha aperto il carrello'),
);

Best practice riepilogative

  • Non ignorare mai gli errori con un catch vuoto: almeno loggali.
  • Usa il pattern Result per gli errori attesi e le eccezioni solo per condizioni davvero inaspettate.
  • Configura tutti e tre gli handler globali (FlutterError.onError, PlatformDispatcher.onError, runZonedGuarded).
  • Attiva il crash reporting solo in release o distingui gli ambienti con environment.
  • Non loggare dati sensibili: filtra le informazioni prima di inviarle a servizi esterni.
  • Mostra all'utente messaggi chiari, non stack trace.

Conclusione

Una strategia di gestione degli errori a più livelli rende la tua app Flutter più robusta e manutenibile. Il pattern Result porta chiarezza nel codice, gli handler globali fanno da rete di sicurezza e strumenti come Sentry ti danno visibilità su ciò che accade dopo il rilascio. Investire in questa infrastruttura fin dall'inizio ripaga in ogni fase del ciclo di vita del prodotto.