Introduzione
Flutter e Dart sono single-threaded per natura: tutto il codice gira di default sul main isolate, lo stesso che gestisce il rendering dell'interfaccia. Quando esegui un'operazione computazionalmente pesante (parsing di un grande JSON, elaborazione di immagini, calcoli matematici), rischi di bloccare il thread della UI, causando i temuti jank e frame rate sotto i 60 FPS.
In questo articolo vediamo come sfruttare gli Isolates di Dart per spostare il lavoro pesante su thread separati, mantenendo l'app fluida e reattiva.
Cos'è un Isolate
A differenza dei thread tradizionali, gli Isolates di Dart non condividono memoria. Ogni Isolate ha il proprio heap e comunica con gli altri esclusivamente tramite scambio di messaggi (message passing). Questo modello elimina alla radice molti problemi tipici della concorrenza, come le race condition.
Punti chiave:
- Ogni Isolate ha la propria memoria e il proprio event loop.
- La comunicazione avviene tramite
SendPorteReceivePort. - I dati scambiati vengono copiati (con alcune eccezioni come i
TransferableTypedData).
La soluzione rapida: compute
Per i casi più semplici, Flutter offre la funzione compute, una scorciatoia che crea un Isolate, esegue una funzione e restituisce il risultato.
import 'package:flutter/foundation.dart';
import 'dart:convert';
// La funzione deve essere top-level o statica
List<Map<String, dynamic>> parseJson(String rawJson) {
final List<dynamic> parsed = jsonDecode(rawJson);
return parsed.cast<Map<String, dynamic>>();
}
Future<void> caricaDati(String rawJson) async {
// L'elaborazione avviene su un Isolate separato
final risultato = await compute(parseJson, rawJson);
print('Elementi caricati: ${risultato.length}');
}
Attenzione: la funzione passata a
computedeve essere di livello superiore (top-level) o un metodo statico. Non può essere una closure che cattura variabili dal contesto, perché deve poter essere trasferita all'altro Isolate.
Quando usare compute
Usa compute quando hai un'operazione una tantum che:
- riceve un input,
- produce un output,
- non richiede comunicazione continua.
Esempi tipici: parsing di JSON pesanti, decodifica di immagini, calcoli matematici complessi.
Isolates manuali per scenari complessi
Quando hai bisogno di una comunicazione bidirezionale continua o di un Isolate che resta attivo a lungo, devi gestirlo manualmente con Isolate.spawn.
import 'dart:isolate';
Future<void> avviaWorker() async {
final receivePort = ReceivePort();
// Avvia un nuovo Isolate passando il SendPort
await Isolate.spawn(_isolateEntry, receivePort.sendPort);
// Riceve il SendPort dall'Isolate per poter inviare messaggi
final sendPort = await receivePort.first as SendPort;
// Comunicazione richiesta-risposta
final risposta = ReceivePort();
sendPort.send([42, risposta.sendPort]);
final risultato = await risposta.first;
print('Risultato dall\'Isolate: $risultato');
}
void _isolateEntry(SendPort mainSendPort) {
final port = ReceivePort();
mainSendPort.send(port.sendPort);
port.listen((messaggio) {
final numero = messaggio[0] as int;
final replyPort = messaggio[1] as SendPort;
// Lavoro pesante
final calcolo = numero * numero;
replyPort.send(calcolo);
});
}
Isolate.run: la novità di Dart 2.19+
Le versioni recenti di Dart hanno introdotto Isolate.run, un'API più moderna ed ergonomica rispetto a compute, che supporta anche le closure (in modo limitato) e gestisce automaticamente la chiusura dell'Isolate.
Future<int> calcolaPesante() async {
return await Isolate.run(() {
var somma = 0;
for (var i = 0; i < 1000000000; i++) {
somma += i;
}
return somma;
});
}
Questo approccio è oggi consigliato per la maggior parte dei casi d'uso semplici nel codice puro Dart.
Best practice
- Misura prima di ottimizzare: non spostare ogni operazione su un Isolate. La creazione di un Isolate ha un costo. Usali solo quando l'operazione supera i ~16ms.
- Minimizza i dati trasferiti: poiché i dati vengono copiati, evita di passare strutture enormi avanti e indietro.
- Evita di accedere a plugin: gli Isolate secondari non hanno accesso diretto ai binding della piattaforma. Le chiamate ai plugin vanno fatte sul main Isolate.
- Gestisci gli errori: usa
try/catchall'interno della funzione e consideraIsolate.runche propaga automaticamente le eccezioni.
Conclusione
Gli Isolates sono lo strumento giusto per mantenere fluida la UI di Flutter quando devi eseguire lavoro pesante. Per i casi semplici, compute e Isolate.run offrono un'API immediata; per scenari complessi con comunicazione continua, Isolate.spawn ti dà il pieno controllo. Ricorda sempre di misurare le performance per usare questi strumenti dove servono davvero.
