Eseguire task in background in Flutter con WorkManager: sincronizzazione periodica affidabile

Foto di Mike Hindle su Unsplash

GuideAvanzato55 min Flutter 3.x (testato su 3.24)

Eseguire task in background in Flutter con WorkManager: sincronizzazione periodica affidabile

Prima o poi ogni app seria deve fare qualcosa quando non è in primo piano: sincronizzare ordini, caricare log, ripulire cache, scaricare contenuti. Su Android e iOS non basta un Timer: il processo viene ucciso, l'app viene messa in doze e il sistema decide quando farti girare.

Il pacchetto workmanager è un ponte verso le API native di scheduling: WorkManager/JobScheduler su Android e BGTaskScheduler / Background Fetch su iOS. Il codice Dart viene eseguito in un isolate separato, senza albero dei widget, senza BuildContext, senza i tuoi singleton già inizializzati.

In questo tutorial avanzato costruiamo una pipeline di sincronizzazione completa:

  • registrazione dell'entrypoint di background con @pragma('vm:entry-point');
  • inizializzazione dei plugin dentro l'isolate (DartPluginRegistrant);
  • task one-off e periodici con vincoli (rete, batteria, carica) e backoff esponenziale;
  • logica idempotente con lease/lock per evitare doppie esecuzioni;
  • comunicazione dei risultati alla UI (SharedPreferences + IsolateNameServer + notifica locale);
  • configurazione iOS e strumenti per forzare l'esecuzione in fase di test.

Prerequisiti: buona confidenza con async/await, isolate e configurazione nativa Android/iOS.

  1. 1

    Dipendenze e configurazione nativa

    Aggiungi workmanager e le dipendenze che useremo nel task (HTTP, preferenze, notifiche). Poi sistema le parti native.

    Android (android/app/build.gradle): assicurati di avere minSdk 23 e, se usi R8, nessuna regola aggiuntiva è necessaria: il plugin registra da solo il Worker nativo.

    iOS: apri ios/Runner/Info.plist e dichiara le modalità di background e gli identificatori dei task; in AppDelegate.swift registra l'identificatore prima di GeneratedPluginRegistrant.

    // ios/Runner/AppDelegate.swift
    import UIKit
    import Flutter
    import workmanager
    
    @main
    @objc class AppDelegate: FlutterAppDelegate {
      override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
      ) -> Bool {
        WorkmanagerPlugin.registerTask(withIdentifier: "it.example.app.syncOrdini")
        UIApplication.shared.setMinimumBackgroundFetchInterval(TimeInterval(60 * 15))
        GeneratedPluginRegistrant.register(with: self)
        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
      }
    }
    

    ⚠️ L'identificatore usato in registerTask deve coincidere esattamente con il nome del task che passerai a registerPeriodicTask/registerOneOffTask.

    # pubspec.yaml
    dependencies:
      flutter:
        sdk: flutter
      workmanager: ^0.5.2
      http: ^1.2.2
      shared_preferences: ^2.3.2
      flutter_local_notifications: ^17.2.3
    
    # ios/Runner/Info.plist (estratto)
    # <key>UIBackgroundModes</key>
    # <array>
    #   <string>fetch</string>
    #   <string>processing</string>
    # </array>
    # <key>BGTaskSchedulerPermittedIdentifiers</key>
    # <array>
    #   <string>it.example.app.syncOrdini</string>
    # </array>

    Risultato atteso

    `flutter pub get` completa senza errori e il progetto compila su Android e iOS con le capability Background Modes attive.

  2. 2

    Definire l'entrypoint di background

    Il codice di background gira in un isolate dedicato avviato dal sistema operativo: non c'è runApp, non esistono i tuoi provider, i tuoi singleton o le variabili globali inizializzate in main().

    Due accorgimenti obbligatori:

    1. @pragma('vm:entry-point') sulla funzione callbackDispatcher, altrimenti il tree-shaking dell'AOT la elimina e in release il task fallisce silenziosamente.
    2. Dentro l'isolate chiama WidgetsFlutterBinding.ensureInitialized() e DartPluginRegistrant.ensureInitialized(): senza il secondo, i plugin (shared_preferences, notifiche, path_provider) lanciano MissingPluginException.

    Il valore restituito da executeTask è il contratto con il sistema: true = successo, false = riprova secondo la backoff policy, Future.error = fallimento definitivo.

    // lib/background/worker.dart
    import 'dart:ui';
    import 'package:flutter/widgets.dart';
    import 'package:workmanager/workmanager.dart';
    
    const kTaskSyncOrdini = 'it.example.app.syncOrdini';
    const kTaskUploadLog = 'it.example.app.uploadLog';
    
    @pragma('vm:entry-point')
    void callbackDispatcher() {
      Workmanager().executeTask((String task, Map<String, dynamic>? inputData) async {
        WidgetsFlutterBinding.ensureInitialized();
        DartPluginRegistrant.ensureInitialized();
    
        try {
          switch (task) {
            case kTaskSyncOrdini:
            case Workmanager.iOSBackgroundTask: // fetch opportunistico iOS
              return await SyncService().run(inputData);
            case kTaskUploadLog:
              return await LogUploader().run();
            default:
              // Task sconosciuto: non ha senso ritentare.
              return true;
          }
        } catch (e, st) {
          debugPrint('[bg] errore in $task: $e\n$st');
          return false; // il sistema ritenterà con backoff
        }
      });
    }
    
    // lib/main.dart
    Future<void> main() async {
      WidgetsFlutterBinding.ensureInitialized();
      await Workmanager().initialize(
        callbackDispatcher,
        isInDebugMode: kDebugMode, // notifiche di debug a ogni esecuzione
      );
      runApp(const MyApp());
    }

    Risultato atteso

    In debug, ogni esecuzione del task mostra una notifica di sistema generata dal plugin con esito e durata.

  3. 3

    Registrare task one-off e periodici con vincoli e backoff

    Ora pianifichiamo il lavoro. Punti chiave da tenere a mente:

    • uniqueName identifica l'istanza pianificata (serve per cancellarla); taskName è ciò che arriva a executeTask.
    • La frequenza minima reale su Android è 15 minuti; valori inferiori vengono arrotondati.
    • ExistingWorkPolicy.keep evita di resettare la schedulazione a ogni avvio dell'app: usalo per i periodici, replace per i one-off che devono sostituire il precedente.
    • inputData accetta solo tipi primitivi (String, int, double, bool e liste di questi): serializza in JSON qualsiasi struttura complessa.
    • backoffPolicy: BackoffPolicy.exponential con delay iniziale di 30 s è un ottimo default per la rete.

    Su iOS registerPeriodicTask è supportato con un solo identificatore registrato lato nativo e l'OS decide se e quando eseguirlo: non fare affidamento sulla puntualità.

    // lib/background/scheduler.dart
    import 'dart:convert';
    import 'package:workmanager/workmanager.dart';
    import 'worker.dart';
    
    class BackgroundScheduler {
      static const _uniquePeriodic = 'sync-ordini-periodic';
    
      Future<void> schedulePeriodicSync() {
        return Workmanager().registerPeriodicTask(
          _uniquePeriodic,
          kTaskSyncOrdini,
          frequency: const Duration(hours: 1),
          initialDelay: const Duration(minutes: 5),
          existingWorkPolicy: ExistingWorkPolicy.keep,
          constraints: Constraints(
            networkType: NetworkType.connected,
            requiresBatteryNotLow: true,
            requiresDeviceIdle: false,
          ),
          backoffPolicy: BackoffPolicy.exponential,
          backoffPolicyDelay: const Duration(seconds: 30),
          inputData: <String, dynamic>{'source': 'periodic'},
        );
      }
    
      Future<void> scheduleImmediateSync(List<String> ordiniIds) {
        return Workmanager().registerOneOffTask(
          'sync-ordini-${DateTime.now().millisecondsSinceEpoch}',
          kTaskSyncOrdini,
          initialDelay: const Duration(seconds: 5),
          existingWorkPolicy: ExistingWorkPolicy.append,
          constraints: Constraints(networkType: NetworkType.connected),
          backoffPolicy: BackoffPolicy.exponential,
          backoffPolicyDelay: const Duration(seconds: 30),
          inputData: <String, dynamic>{
            'source': 'manual',
            'payload': jsonEncode({'ids': ordiniIds}),
          },
        );
      }
    
      Future<void> cancelAll() => Workmanager().cancelAll();
      Future<void> cancelPeriodic() =>
          Workmanager().cancelByUniqueName(_uniquePeriodic);
    }

    Risultato atteso

    Con `adb shell dumpsys jobscheduler | grep it.example.app` vedi il job registrato con i vincoli richiesti.

  4. 4

    Scrivere una sincronizzazione idempotente e resiliente

    Il sistema può eseguire lo stesso task più volte (retry, riavvio del device, doppia schedulazione). La logica deve quindi essere idempotente e rispettare il budget di tempo: su Android il worker viene interrotto dopo ~10 minuti, su iOS spesso dopo ~30 secondi.

    Strategie applicate nel codice qui sotto:

    • un lock con scadenza su SharedPreferences per evitare esecuzioni concorrenti;
    • un cursore (lastSyncAt) per sincronizzare in modo incrementale;
    • un timeout esplicito su ogni chiamata di rete, così da chiudere ordinatamente prima che sia il sistema a ucciderci;
    • distinzione tra errori transitori (5xx, timeout → false, si ritenta) ed errori permanenti (4xx → true, inutile insistere).

    Estrai sempre la logica in una classe pura e testabile: l'entrypoint deve limitarsi a fare da router.

    // lib/background/sync_service.dart
    import 'dart:convert';
    import 'package:http/http.dart' as http;
    import 'package:shared_preferences/shared_preferences.dart';
    
    class SyncService {
      static const _kLock = 'sync.lock.until';
      static const _kCursor = 'sync.lastSyncAt';
      static const _kLastResult = 'sync.lastResult';
    
      final http.Client _client;
      SyncService({http.Client? client}) : _client = client ?? http.Client();
    
      Future<bool> run(Map<String, dynamic>? inputData) async {
        final prefs = await SharedPreferences.getInstance();
        await prefs.reload();
    
        final now = DateTime.now().millisecondsSinceEpoch;
        final lockUntil = prefs.getInt(_kLock) ?? 0;
        if (now < lockUntil) {
          return true; // un'altra esecuzione è già in corso
        }
        await prefs.setInt(_kLock, now + const Duration(minutes: 5).inMilliseconds);
    
        try {
          final cursor = prefs.getString(_kCursor) ?? '1970-01-01T00:00:00Z';
          final uri = Uri.https('api.example.com', '/ordini', {'since': cursor});
    
          final res = await _client
              .get(uri, headers: {'Accept': 'application/json'})
              .timeout(const Duration(seconds: 20));
    
          if (res.statusCode >= 500) return false;            // transitorio → retry
          if (res.statusCode >= 400) {                        // permanente
            await prefs.setString(_kLastResult, 'errore ${res.statusCode}');
            return true;
          }
    
          final body = jsonDecode(res.body) as Map<String, dynamic>;
          final items = (body['items'] as List).cast<Map<String, dynamic>>();
          // ...persisti su Hive/SQLite in modo transazionale...
    
          await prefs.setString(_kCursor, body['serverTime'] as String);
          await prefs.setString(
            _kLastResult,
            '${items.length} ordini · ${DateTime.now().toIso8601String()}',
          );
          return true;
        } on Exception {
          return false; // timeout / socket → retry con backoff
        } finally {
          await prefs.remove(_kLock);
          _client.close();
        }
      }
    }

    Risultato atteso

    Il task completa entro il budget di tempo, aggiorna il cursore e rilascia sempre il lock anche in caso di eccezione.

  5. 5

    Comunicare l'esito all'app: prefs, IsolateNameServer e notifiche

    L'isolate di background non condivide memoria con quello della UI: non puoi aggiornare un ChangeNotifier direttamente. Hai tre canali complementari:

    1. Storage condiviso (SharedPreferences, Hive): l'UI legge al ritorno in foreground. Ricorda prefs.reload() lato UI, altrimenti leggi la cache in memoria e non vedi le scritture dell'altro isolate.
    2. IsolateNameServer + SendPort: se l'app è viva in foreground, aggiorni la UI in tempo reale.
    3. Notifica locale: l'unico modo per avvisare l'utente ad app chiusa.

    Nel codice registriamo una porta nella UI e, dal background, proviamo a usarla; se non esiste, ci limitiamo allo storage (+ notifica).

    // lib/background/bg_bridge.dart
    import 'dart:isolate';
    import 'dart:ui';
    
    const kSyncPortName = 'sync_port';
    
    /// Lato UI: apri il canale e ascolta gli aggiornamenti.
    ReceivePort openSyncPort(void Function(dynamic) onMessage) {
      IsolateNameServer.removePortNameMapping(kSyncPortName);
      final port = ReceivePort();
      IsolateNameServer.registerPortWithName(port.sendPort, kSyncPortName);
      port.listen(onMessage);
      return port;
    }
    
    /// Lato background: notifica l'UI se è in ascolto.
    void notifyUi(Map<String, dynamic> payload) {
      final send = IsolateNameServer.lookupPortByName(kSyncPortName);
      send?.send(payload);
    }
    
    // --- utilizzo nella UI ---
    class _HomeState extends State<Home> {
      late final ReceivePort _port;
      String _status = 'in attesa';
    
      @override
      void initState() {
        super.initState();
        _port = openSyncPort((msg) {
          if (!mounted) return;
          setState(() => _status = '${(msg as Map)['items']} nuovi ordini');
        });
      }
    
      @override
      void dispose() {
        IsolateNameServer.removePortNameMapping(kSyncPortName);
        _port.close();
        super.dispose();
      }
    }

    Risultato atteso

    Con l'app aperta la UI si aggiorna a fine sincronizzazione; ad app chiusa i dati vengono comunque persistiti e letti al rientro.

  6. 6

    Adattarsi ai vincoli di iOS

    Su iOS non esiste un vero scheduler affidabile: il sistema assegna budget in base all'uso dell'app. Adegua le aspettative e il codice:

    • Un solo identificatore di task registrato lato nativo (quello dichiarato in Info.plist).
    • Il frequency è un suggerimento: iOS esegue quando vuole, a volte solo di notte in carica.
    • Il tempo utile è brevissimo (spesso < 30 s per il fetch): progetta il task come batch piccolo interrompibile e riprendibile grazie al cursore.
    • Workmanager.iOSBackgroundTask è il nome del task ricevuto per i fetch opportunistici: gestiscilo nello switch (l'abbiamo già fatto).
    • Per testare, usa Xcode: metti un breakpoint dopo l'avvio, poi in console LLDB forza l'esecuzione con e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"it.example.app.syncOrdini"].

    Un pattern robusto è degradare su iOS: schedulazione opportunistica in background + sincronizzazione forzata al ritorno in foreground tramite AppLifecycleListener.

    // lib/background/foreground_catchup.dart
    import 'package:flutter/widgets.dart';
    
    class ForegroundCatchUp extends StatefulWidget {
      final Widget child;
      final Future<void> Function() onResume;
      const ForegroundCatchUp({super.key, required this.child, required this.onResume});
    
      @override
      State<ForegroundCatchUp> createState() => _ForegroundCatchUpState();
    }
    
    class _ForegroundCatchUpState extends State<ForegroundCatchUp> {
      late final AppLifecycleListener _listener;
      DateTime _last = DateTime.fromMillisecondsSinceEpoch(0);
    
      @override
      void initState() {
        super.initState();
        _listener = AppLifecycleListener(onResume: () async {
          // throttling: al massimo una sync ogni 2 minuti
          if (DateTime.now().difference(_last) < const Duration(minutes: 2)) return;
          _last = DateTime.now();
          await widget.onResume();
        });
      }
    
      @override
      void dispose() {
        _listener.dispose();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) => widget.child;
    }

    Risultato atteso

    Su iOS la sincronizzazione avviene opportunisticamente in background e comunque entro pochi secondi dal ritorno in foreground.

  7. 7

    Debug, forzatura dell'esecuzione e test automatici

    Aspettare 15 minuti per verificare un task è insostenibile. Ecco gli strumenti che uso quotidianamente.

    Android — forzare un job già schedulato

    adb shell dumpsys jobscheduler | grep -A 3 it.example.app
    # individua il JOB #u0aXXX/<id>, poi:
    adb shell cmd jobscheduler run -f it.example.app <id>
    

    Android — simulare i vincoli

    adb shell cmd connectivity airplane-mode enable   # niente rete → job in attesa
    adb shell dumpsys deviceidle force-idle           # doze mode
    

    Log dell'isolate: i debugPrint del background compaiono in adb logcat -s flutter, non nella console di flutter run se l'app è stata chiusa.

    Test: non testare Workmanager(); testa la classe SyncService con un http.Client mockato e SharedPreferences.setMockInitialValues. Verifica i tre comportamenti critici: successo, retry su 5xx/timeout, e idempotenza in presenza di lock.

    Checklist finale di produzione

    • @pragma('vm:entry-point') presente e verificato in release;
    • nessun accesso a BuildContext o a singleton della UI nell'isolate;
    • lock e cursori sempre rilasciati in finally;
    • isInDebugMode: false in release (altrimenti l'utente vede le notifiche di debug);
    • cancellazione dei task al logout (cancelAll).
    // test/sync_service_test.dart
    import 'package:flutter_test/flutter_test.dart';
    import 'package:http/http.dart' as http;
    import 'package:http/testing.dart';
    import 'package:shared_preferences/shared_preferences.dart';
    import 'package:myapp/background/sync_service.dart';
    
    void main() {
      TestWidgetsFlutterBinding.ensureInitialized();
    
      setUp(() => SharedPreferences.setMockInitialValues({}));
    
      test('successo: aggiorna il cursore e restituisce true', () async {
        final client = MockClient((req) async => http.Response(
              '{"items":[{"id":"1"}],"serverTime":"2024-05-01T10:00:00Z"}',
              200,
            ));
    
        final ok = await SyncService(client: client).run(null);
        final prefs = await SharedPreferences.getInstance();
    
        expect(ok, isTrue);
        expect(prefs.getString('sync.lastSyncAt'), '2024-05-01T10:00:00Z');
        expect(prefs.getInt('sync.lock.until'), isNull); // lock rilasciato
      });
    
      test('errore 500: richiede retry', () async {
        final client = MockClient((req) async => http.Response('boom', 500));
        expect(await SyncService(client: client).run(null), isFalse);
      });
    }

    Risultato atteso

    I test passano con `flutter test` e il task può essere eseguito a comando dal terminale senza attendere la finestra di scheduling.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!