Perché il ciclo di vita conta

Ogni app mobile attraversa continuamente diversi stati: viene messa in background quando l'utente riceve una telefonata, torna in foreground quando riprende l'attività, oppure viene chiusa del tutto. Reagire correttamente a questi eventi è fondamentale per salvare lo stato dell'utente, mettere in pausa animazioni o video, liberare risorse e gestire le connessioni di rete.

Storicamente in Flutter si usava WidgetsBindingObserver con il metodo didChangeAppLifecycleState. A partire da Flutter 3.13 è disponibile un'API dedicata e più espressiva: AppLifecycleListener, che offre callback granulari per ogni transizione di stato.

Gli stati del ciclo di vita

L'enum AppLifecycleState prevede i seguenti valori:

  • resumed: l'app è visibile e riceve input dall'utente.
  • inactive: l'app è visibile ma non riceve input (es. durante una chiamata in arrivo o lo split-screen).
  • hidden: l'app non è più visibile (stato introdotto per uniformare il comportamento tra piattaforme).
  • paused: l'app è in background e non visibile.
  • detached: l'app è ancora ospitata dall'engine ma senza view collegate; tipico durante l'avvio o la chiusura.

Le transizioni seguono un percorso ben definito. Passando in background l'ordine è: resumed → inactive → hidden → paused. Al ritorno in foreground il percorso è inverso.

L'approccio classico con WidgetsBindingObserver

Prima di vedere la nuova API, ecco il metodo tradizionale, ancora perfettamente valido:

class _HomePageState extends State<HomePage>
    with WidgetsBindingObserver {
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state == AppLifecycleState.paused) {
      // Salva lo stato dell'utente
    }
  }

  @override
  Widget build(BuildContext context) => const Scaffold();
}

Questo approccio funziona, ma costringe a un unico metodo didChangeAppLifecycleState in cui gestire tutti i casi con una serie di if.

La nuova API: AppLifecycleListener

AppLifecycleListener espone callback separate per ogni transizione, rendendo il codice più leggibile:

import 'package:flutter/widgets.dart';

class LifecycleManager {
  late final AppLifecycleListener _listener;

  void init() {
    _listener = AppLifecycleListener(
      onResume: () => debugPrint('App tornata in primo piano'),
      onInactive: () => debugPrint('App inattiva'),
      onHide: () => debugPrint('App nascosta'),
      onPause: () => debugPrint('App in background'),
      onDetach: () => debugPrint('App scollegata'),
      onShow: () => debugPrint('App di nuovo visibile'),
      onStateChange: (state) => debugPrint('Nuovo stato: $state'),
    );
  }

  void dispose() {
    _listener.dispose();
  }
}

È importante chiamare sempre dispose() sul listener per evitare memory leak.

Intercettare la richiesta di chiusura

Una delle funzionalità più interessanti è onExitRequested, che permette di intercettare la richiesta di chiusura dell'app (principalmente su desktop) e decidere se consentirla o annullarla, ad esempio per mostrare un dialog di conferma:

_listener = AppLifecycleListener(
  onExitRequested: () async {
    final shouldExit = await _mostraDialogConferma();
    return shouldExit
        ? AppExitResponse.exit
        : AppExitResponse.cancel;
  },
);

Questo è utilissimo per app che devono salvare lavoro non completato prima di chiudersi.

Esempio pratico in un widget

Ecco come integrare AppLifecycleListener in uno StatefulWidget per mettere in pausa un video quando l'app va in background:

class VideoScreen extends StatefulWidget {
  const VideoScreen({super.key});

  @override
  State<VideoScreen> createState() => _VideoScreenState();
}

class _VideoScreenState extends State<VideoScreen> {
  late final AppLifecycleListener _listener;

  @override
  void initState() {
    super.initState();
    _listener = AppLifecycleListener(
      onPause: _pausaVideo,
      onResume: _riprendiVideo,
    );
  }

  void _pausaVideo() {
    // controller.pause();
  }

  void _riprendiVideo() {
    // controller.play();
  }

  @override
  void dispose() {
    _listener.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => const Scaffold();
}

Differenze tra piattaforme

È bene ricordare alcune particolarità:

  • Su iOS lo stato detached può non essere sempre emesso alla chiusura, perché il sistema può terminare il processo bruscamente.
  • onExitRequested è rilevante soprattutto su desktop (Windows, macOS, Linux) e sul web; su mobile la chiusura è gestita diversamente dal sistema operativo.
  • Lo stato hidden è stato aggiunto per uniformare il comportamento: prima non tutte le piattaforme lo emettevano.

Best practice

  • Salva sempre lo stato critico in onPause (o paused), non affidarti a detached, che non è garantito.
  • Rilascia le risorse pesanti (camera, microfono, connessioni) quando l'app passa in background per rispettare i limiti del sistema.
  • Non eseguire operazioni lunghe nelle callback: lo stato può cambiare rapidamente.
  • Usa onStateChange solo quando ti serve una logica trasversale a più stati, altrimenti preferisci le callback specifiche.

Conclusione

AppLifecycleListener rappresenta un'evoluzione naturale rispetto a WidgetsBindingObserver: offre un'API più chiara, callback dedicate e la possibilità di intercettare la chiusura dell'app. Integrarlo nelle proprie app significa gestire in modo affidabile il salvataggio dei dati e le risorse, migliorando l'esperienza utente su tutte le piattaforme.