Flutter Background Isolates: Concurrencia real sin bloquear la UI

Flutter corre sobre un único hilo principal — el main isolate — que se encarga de renderizar la UI a 60 o 120 fps y de procesar los eventos del usuario. Cuando introduces trabajo pesado en ese hilo, el resultado es inmediato: frames perdidos, animaciones cortadas y una experiencia que se siente lenta.
La solución de Dart es el isolate: una unidad de ejecución completamente independiente, con su propia memoria aislada y su propio event loop. Lanzar trabajo a un isolate en segundo plano libera al hilo principal para que haga lo único que debe hacer bien — dibujar la interfaz.
En este artículo exploraremos qué son los background isolates, cómo funcionan internamente, cuándo usarlos y cómo se integran en aplicaciones BLE.
¡Comencemos!
¿Por qué existe el problema?
Dart es single-threaded by design. A diferencia de Java o Kotlin, no hay threads compartidos ni mutexes. Toda la ejecución ocurre dentro de un isolate — y por defecto, tu app solo tiene uno.
El main isolate tiene un presupuesto estricto por frame: ~16 ms a 60 fps o ~8 ms a 120 fps. Cualquier operación que tarde más que eso bloquea el renderizador.
1 | Timeline del main isolate (sin isolates): |
Con un background isolate:
1 | Main isolate: [frame 1] [render] [frame 2] [render] [frame 3] ← suave |
Modelo de isolates en Dart
Un isolate en Dart es similar a un proceso del sistema operativo en miniatura:
- Tiene su propia memoria heap — no comparte objetos con otros isolates.
- Se comunica exclusivamente mediante paso de mensajes a través de
SendPort/ReceivePort. - Corre en un hilo del SO diferente, lo que permite paralelismo real en CPUs multi-core.
1 | ┌──────────────────────────────────────────────────┐ |
Dato clave: los isolates no comparten memoria. Para enviar datos entre ellos, Dart los copia (para tipos primitivos y colecciones simples) o los transfiere (para tipos especiales como
TransferableTypedData). Esto elimina las condiciones de carrera por diseño.
Cómo usar un Background Isolate
Opción 1 — compute() (la forma más simple)
compute es un helper de Flutter que lanza una función en un isolate temporal, espera el resultado y cierra el isolate. Es ideal para tareas únicas y sin estado.
1 | import 'package:flutter/foundation.dart'; |
Restricción importante: la función top-level (o método estático) pasada a compute no puede capturar closures del entorno del main isolate. Debe ser una función pura.
Opción 2 — Isolate.spawn() (control total)
Para tareas de larga duración o comunicación bidireccional, usa Isolate.spawn directamente.
1 | import 'dart:isolate'; |
Opción 3 — Isolate.run() (Dart 2.19+, la forma moderna)
A partir de Dart 2.19, Isolate.run() combina lo mejor de ambos mundos: la simplicidad de compute con soporte para closures.
1 | import 'dart:isolate'; |
Prefiere
Isolate.run()sobrecompute()en proyectos nuevos — es más ergonómico y es el estándar moderno de Dart.
Acceso a Plugins desde Background Isolates (Flutter 3.7+)
Antes de Flutter 3.7, los background isolates no podían llamar a plugins nativos (platform channels). Esto era una limitación importante para apps BLE o de sensores.
Desde Flutter 3.7, esto es posible gracias a BackgroundIsolateBinaryMessenger:
1 | import 'dart:isolate'; |
Transferencia eficiente de datos — TransferableTypedData
Copiar grandes bloques de bytes entre isolates puede ser costoso. Para datos binarios (como tramas BLE), usa TransferableTypedData, que transfiere la memoria sin copiarla:
1 | // En el main isolate — empaquetar para transferencia |
Casos de Uso
| Caso de uso | Por qué un isolate |
|---|---|
| Parseo de JSON grande | Bloquearía el render thread si se hace inline |
| Compresión / descompresión | CPU-intensivo, tarda decenas de ms |
| Encriptación / hashing | AES, SHA256 sobre buffers grandes |
| Decodificación de imágenes | Antes de pasarlas a un Canvas o Image |
| Procesamiento de tramas BLE | Bytes crudos → structs de dominio |
| Queries pesadas a SQLite | Evita latencia de I/O en el main thread |
| Inferencia de modelos ML | TFLite sobre el background isolate |
Isolates y Apps BLE
Esta es quizás la combinación más práctica. Las apps BLE reciben un stream continuo de datos — notificaciones de características, resultados de escaneo, tramas de protocolo — y necesitan procesarlos sin afectar la UI.
Problema sin isolates
1 | BLE Plugin → Main Isolate → [parseo de trama] → UI update |
Solución con Background Isolate
1 | // Arquitectura recomendada para BLE + Isolate |
Con Flutter 3.7+ — El isolate llama al plugin BLE directamente
1 | void _bleBackgroundIsolate(RootIsolateToken token) async { |
Mejores Prácticas
1. Usa Isolate.run() para tareas únicas
1 | // ✅ Correcto — limpio, moderno, sin boilerplate |
2. No abuses de los isolates para tareas rápidas
Lanzar un isolate tiene un overhead de ~1-2 ms (más la copia de datos). Para operaciones que tardan menos de ~5 ms, el overhead supera el beneficio.
1 | // ❌ No vale la pena — demasiado simple |
3. Reutiliza isolates de larga duración para streams BLE
No lances un isolate nuevo por cada trama BLE recibida. Crea un isolate dedicado al inicio y mantenlo vivo durante toda la sesión de conexión.
1 | // ✅ Un isolate que procesa muchas tramas |
4. Prefiere TransferableTypedData para buffers grandes
1 | // ❌ Copia el buffer completo |
5. Llama a BackgroundIsolateBinaryMessenger.ensureInitialized() antes que nada
Si tu isolate necesita acceder a plugins nativos, esta debe ser la primera línea que ejecute. De lo contrario, cualquier llamada a un plugin lanzará un MissingPluginException.
1 | void _myIsolate(RootIsolateToken token) async { |
6. Maneja errores del isolate desde el main isolate
Los errores no capturados dentro de un isolate no se propagan al main isolate automáticamente. Usa Isolate.addErrorListener para capturarlos.
1 | final errorPort = ReceivePort(); |
7. Siempre cierra los ReceivePorts que ya no usas
Los ReceivePort activos impiden la recolección de basura del isolate. Ciérralos explícitamente cuando termines.
1 | final port = ReceivePort(); |
Resumen
Los background isolates son la respuesta de Dart al problema de concurrencia: paralelismo real sin condiciones de carrera, gracias a la memoria aislada y el paso de mensajes.
Para apps BLE en Flutter, representan una herramienta indispensable: el stream continuo de datos del dispositivo puede procesarse, decodificarse y filtrarse en un isolate dedicado, mientras la UI permanece completamente fluida. Con Flutter 3.7+, ese isolate puede incluso llamar directamente a los plugins nativos, eliminando la última barrera que existía para arquitecturas BLE robustas en background.
La regla de oro es simple: si bloquea el hilo principal por más de un frame, muévelo a un isolate.
¡Hasta el próximo artículo!