Isolate e compute in Flutter: lavoro pesante fuori dal thread UI

Foto di Edgar Cornejo su Unsplash

GuideAvanzato45 min Flutter 3.x

Isolate e compute in Flutter: lavoro pesante fuori dal thread UI

Flutter esegue build, layout e paint in un singolo thread: l'UI isolate. Qualsiasi operazione sincrona che superi i ~16 ms fa saltare frame e produce jank visibile.

Dart non usa i thread condivisi: usa gli isolate, unità di esecuzione con heap separato che comunicano solo tramite messaggi. Questo elimina lock e race condition, ma impone regole precise su cosa si può inviare.

In questo tutorial avanzato vedremo:

  • quando conviene davvero usare un isolate (e quando no);
  • compute() e Isolate.run() per i task one-shot;
  • un isolate a lunga vita con ReceivePort/SendPort per flussi di richieste;
  • cancellazione, gestione errori e TransferableTypedData per lo zero-copy;
  • come misurare il guadagno con i DevTools.

Prerequisiti: Dart 3, dimestichezza con async/await e Stream.

  1. 1

    Riprodurre il jank: il problema del thread UI

    Prima di ottimizzare bisogna misurare. Creiamo un task volutamente costoso (parsing di un JSON grande + aggregazione) e chiamiamolo direttamente dal build context di un bottone.

    Con un'animazione attiva (es. CircularProgressIndicator) vedrai l'indicatore congelarsi per centinaia di millisecondi.

    Attiva anche l'overlay delle performance (flutter run --profile, poi P nel terminale o Performance Overlay nei DevTools): noterai le barre rosse sul grafico dell'UI thread.

    Nota: gli isolate hanno un costo di avvio (~1-2 ms) e di serializzazione dei messaggi. Sotto i ~10 ms di lavoro, restare sull'UI isolate è più veloce.

    import 'dart:convert';
    
    /// Simula un payload pesante (~20k record).
    String buildFakePayload() {
      final items = List.generate(20000, (i) => {
            'id': i,
            'name': 'Prodotto \$i',
            'price': (i % 97) * 1.37,
            'tags': ['a', 'b', 'c'],
          });
      return jsonEncode({'items': items});
    }
    
    /// Task CPU-bound: decode + aggregazione.
    double heavyParse(String raw) {
      final map = jsonDecode(raw) as Map<String, dynamic>;
      final items = (map['items'] as List).cast<Map<String, dynamic>>();
      var total = 0.0;
      for (final item in items) {
        total += (item['price'] as num).toDouble();
      }
      return total;
    }
    
    // Chiamata bloccante: NON fare così in produzione.
    void onPressedBlocking() {
      final total = heavyParse(buildFakePayload());
      debugPrint('Totale: \$total');
    }

    Risultato atteso

    L'animazione si blocca visibilmente durante il calcolo e il Performance Overlay mostra frame oltre i 16 ms.

  2. 2

    Il primo rimedio: compute() e Isolate.run()

    Per un task one-shot hai due strumenti:

    • compute(fn, message) di package:flutter/foundation.dart: crea un isolate, esegue fn e lo distrugge. Su Flutter Web degrada elegantemente a esecuzione sincrona.
    • Isolate.run(() => ...) di dart:isolate (Dart 2.19+): API più moderna, accetta una closure e supporta il capture delle variabili, ma non funziona su Web.

    Regole fondamentali:

    1. La funzione passata a compute deve essere top-level o static (non può catturare this).
    2. Il messaggio deve essere serializzabile: primitive, List, Map, TypedData, SendPort, istanze di classi deeply immutable. Niente oggetti Flutter come BuildContext, Image o handle di plugin.
    3. Un isolate secondario non può chiamare la maggior parte dei plugin (i platform channel richiedono il root isolate) a meno di usare BackgroundIsolateBinaryMessenger.ensureInitialized(rootToken).
    import 'dart:isolate';
    import 'package:flutter/foundation.dart';
    
    // 1) compute: funzione top-level obbligatoria
    Future<double> parseWithCompute(String raw) {
      return compute(heavyParse, raw, debugLabel: 'heavyParse');
    }
    
    // 2) Isolate.run: closure, più flessibile (no Web)
    Future<double> parseWithRun(String raw) {
      return Isolate.run(() => heavyParse(raw));
    }
    
    // Uso nella UI
    Future<void> _onPressed() async {
      setState(() => _loading = true);
      try {
        final total = await parseWithCompute(buildFakePayload());
        if (!mounted) return;
        setState(() => _total = total);
      } finally {
        if (mounted) setState(() => _loading = false);
      }
    }

    Risultato atteso

    L'indicatore di caricamento gira fluido durante il parsing: nessun frame perso sull'UI thread.

  3. 3

    Isolate a lunga vita: worker con ReceivePort e SendPort

    compute paga l'avvio dell'isolate ad ogni chiamata. Se devi processare molte richieste (es. decodifica continua di messaggi WebSocket, filtri su immagini, ricerca full-text) conviene un worker persistente.

    Il protocollo classico è un handshake in due tempi:

    1. L'isolate principale crea un ReceivePort e passa il suo sendPort a Isolate.spawn.
    2. Il worker crea a sua volta un ReceivePort e rispedisce indietro il proprio SendPort.
    3. Da quel momento si scambiano messaggi identificati da un id per correlare richiesta e risposta.

    Qui sotto un worker riutilizzabile con Completer per mappare le risposte.

    import 'dart:async';
    import 'dart:isolate';
    
    class _Request {
      const _Request(this.id, this.payload);
      final int id;
      final String payload;
    }
    
    class _Response {
      const _Response(this.id, this.result, this.error);
      final int id;
      final double? result;
      final Object? error;
    }
    
    class ParserWorker {
      ParserWorker._(this._isolate, this._toWorker, this._fromWorker) {
        _fromWorker.listen(_handle);
      }
    
      final Isolate _isolate;
      final SendPort _toWorker;
      final ReceivePort _fromWorker;
      final _pending = <int, Completer<double>>{};
      int _nextId = 0;
    
      static Future<ParserWorker> spawn() async {
        final init = ReceivePort();
        final isolate = await Isolate.spawn(_entryPoint, init.sendPort);
        final toWorker = await init.first as SendPort;
        init.close();
        final fromWorker = ReceivePort();
        toWorker.send(fromWorker.sendPort);
        return ParserWorker._(isolate, toWorker, fromWorker);
      }
    
      Future<double> parse(String payload) {
        final id = _nextId++;
        final completer = Completer<double>();
        _pending[id] = completer;
        _toWorker.send(_Request(id, payload));
        return completer.future;
      }
    
      void _handle(dynamic message) {
        final res = message as _Response;
        final completer = _pending.remove(res.id);
        if (completer == null) return;
        if (res.error != null) {
          completer.completeError(res.error!);
        } else {
          completer.complete(res.result!);
        }
      }
    
      void dispose() {
        for (final c in _pending.values) {
          c.completeError(StateError('Worker terminato'));
        }
        _pending.clear();
        _fromWorker.close();
        _isolate.kill(priority: Isolate.immediate);
      }
    
      static void _entryPoint(SendPort initPort) {
        final commands = ReceivePort();
        initPort.send(commands.sendPort);
        SendPort? replyTo;
        commands.listen((message) {
          if (message is SendPort) {
            replyTo = message;
            return;
          }
          final req = message as _Request;
          try {
            replyTo?.send(_Response(req.id, heavyParse(req.payload), null));
          } catch (e) {
            replyTo?.send(_Response(req.id, null, e.toString()));
          }
        });
      }
    }

    Risultato atteso

    Un worker unico gestisce N richieste concorrenti: il costo di spawn si paga una sola volta e ogni `parse()` risolve la propria Future.

  4. 4

    Cancellazione, errori e ciclo di vita nel widget

    Un isolate non si interrompe da solo. Servono tre accortezze:

    • Kill esplicito: isolate.kill(priority: Isolate.immediate) in dispose(), altrimenti resta vivo e consuma memoria.
    • Errori non catturati: registra un onError port in Isolate.spawn per non perdere le eccezioni.
    • Cooperative cancellation: per loop lunghi, fai controllare periodicamente al worker un flag ricevuto via porta e interrompi il ciclo.

    Nel widget, lega lo spawn a initState (con Future memorizzata) e la distruzione a dispose, ricordando il classico controllo mounted dopo ogni await.

    class ParserScreen extends StatefulWidget {
      const ParserScreen({super.key});
      @override
      State<ParserScreen> createState() => _ParserScreenState();
    }
    
    class _ParserScreenState extends State<ParserScreen> {
      ParserWorker? _worker;
      double? _total;
      bool _busy = false;
    
      @override
      void initState() {
        super.initState();
        ParserWorker.spawn().then((w) {
          if (!mounted) {
            w.dispose();
            return;
          }
          setState(() => _worker = w);
        });
      }
    
      Future<void> _run() async {
        final worker = _worker;
        if (worker == null || _busy) return;
        setState(() => _busy = true);
        try {
          final total = await worker.parse(buildFakePayload());
          if (!mounted) return;
          setState(() => _total = total);
        } catch (e) {
          if (!mounted) return;
          ScaffoldMessenger.of(context)
              .showSnackBar(SnackBar(content: Text('Errore: \$e')));
        } finally {
          if (mounted) setState(() => _busy = false);
        }
      }
    
      @override
      void dispose() {
        _worker?.dispose();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          body: Center(
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                const CircularProgressIndicator(),
                const SizedBox(height: 24),
                Text(_total?.toStringAsFixed(2) ?? '—'),
                const SizedBox(height: 16),
                FilledButton(
                  onPressed: _worker == null || _busy ? null : _run,
                  child: const Text('Elabora'),
                ),
              ],
            ),
          ),
        );
      }
    }

    Risultato atteso

    Nessun isolate orfano alla chiusura della schermata; gli errori del worker arrivano alla UI come SnackBar.

  5. 5

    Zero-copy con TransferableTypedData e misurazione nei DevTools

    I messaggi tra isolate vengono copiati. Con payload binari grandi (immagini, file, buffer audio) la copia può annullare il vantaggio del parallelismo.

    TransferableTypedData risolve il problema: il buffer viene trasferito (ownership move), senza copia. Attenzione: dopo materialize() il buffer non è più utilizzabile dal mittente.

    Per misurare il risultato:

    1. Lancia in --profile (mai in debug: la JIT falsa i tempi).
    2. Apri DevTools → Performance: nella timeline vedrai una track per ogni isolate; l'UI thread deve restare sotto i 16 ms.
    3. Usa Timeline.timeSync('label', () { ... }) da dart:developer per marcare le sezioni del worker.
    4. In Memory verifica che gli isolate terminati spariscano dalla lista.

    Checklist finale: usa compute/Isolate.run per task singoli > 10 ms; un worker persistente per flussi continui; TransferableTypedData per i binari; kill() sempre in dispose().

    import 'dart:developer' as dev;
    import 'dart:isolate';
    import 'dart:typed_data';
    
    /// Inverte i byte di un buffer senza copiarlo tra isolate.
    Future<Uint8List> invertBytes(Uint8List input) async {
      final transferable = TransferableTypedData.fromList([input]);
      return Isolate.run(() {
        return dev.Timeline.timeSync('invertBytes', () {
          final bytes = transferable.materialize().asUint8List();
          for (var i = 0; i < bytes.length; i++) {
            bytes[i] = 255 - bytes[i];
          }
          return bytes;
        });
      });
    }

    Risultato atteso

    Il trasferimento di buffer da decine di MB avviene senza picchi di memoria e la timeline dei DevTools mostra l'evento 'invertBytes' sull'isolate secondario, con l'UI thread libero.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!