Saltar al contenido principal

Concurrencia en Flutter para desarrolladores de Swift

Aprovecha tus conocimientos de concurrencia en Swift mientras aprendes Flutter y Dart.

Tanto Dart como Swift admiten la programación concurrente. Esta guía debería ayudarte a comprender cómo funciona la concurrencia en Dart y cómo se compara con Swift. Con este conocimiento, puedes crear aplicaciones iOS de alto rendimiento.

Al desarrollar en el ecosistema de Apple, algunas tareas pueden tardar mucho tiempo en completarse. Estas tareas incluyen la obtención o el procesamiento de grandes cantidades de datos. Los desarrolladores de iOS suelen utilizar Grand Central Dispatch (GCD) para programar tareas utilizando un grupo de hilos (thread pool) compartido. Con GCD, los desarrolladores añaden tareas a colas de despacho (dispatch queues) y GCD decide en qué hilo ejecutarlas.

Pero GCD crea hilos para manejar los elementos de trabajo restantes. Esto significa que puedes terminar con una gran cantidad de hilos y el sistema puede verse sobrecargado. Con Swift, el modelo de concurrencia estructurada redujo la cantidad de hilos y cambios de contexto. Ahora, cada núcleo tiene solo un hilo.

Dart tiene un modelo de ejecución de un solo hilo, con soporte para Isolates, un bucle de eventos (event loop) y código asíncrono. Un Isolate es la implementación de Dart de un hilo ligero. A menos que generes un Isolate, tu código de Dart se ejecuta en el hilo principal de la UI impulsado por un bucle de eventos. El bucle de eventos de Flutter es equivalente al bucle principal de iOS, en otras palabras, el Looper adjunto al hilo principal.

El modelo de un solo hilo de Dart no significa que estés obligado a ejecutar todo como una operación bloqueante que cause que la UI se congele. En su lugar, utiliza las características asíncronas que proporciona el lenguaje Dart, como async/await.

Programación asíncrona

#

Una operación asíncrona permite que otras operaciones se ejecuten antes de que esta se complete. Tanto Dart como Swift admiten funciones asíncronas utilizando las palabras clave async y await. En ambos casos, async marca que una función realiza trabajo asíncrono, y await le indica al sistema que espere un resultado de la función. Esto significa que la VM de Dart podría suspender la función si es necesario. Para obtener más detalles sobre la programación asíncrona, consulta Concurrencia en Dart (Concurrency in Dart).

Aprovechar el hilo/isolate principal

#

Para los sistemas operativos de Apple, el hilo primario (también llamado principal) es donde la aplicación comienza a ejecutarse. El renderizado de la interfaz de usuario siempre ocurre en el hilo principal. Una diferencia entre Swift y Dart es que Swift podría usar diferentes hilos para diferentes tareas, y Swift no garantiza qué hilo se utiliza. Por lo tanto, al despachar actualizaciones de UI en Swift, es posible que debas asegurarte de que el trabajo ocurra en el hilo principal.

Supongamos que deseas escribir una función que obtenga el clima de forma asíncrona y muestre los resultados.

En GCD, para despachar manualmente un proceso al hilo principal, podrías hacer algo como lo siguiente.

Primero, define el enum Weather:

swift
enum Weather: String {
    case rainy, sunny
}

A continuación, define el view model y márcalo como un @Observable que publique el result de tipo Weather?. Utiliza GCD para crear una DispatchQueue en segundo plano para enviar el trabajo al grupo de hilos, y luego realiza el despacho de vuelta al hilo principal para actualizar el result.

swift
@Observable class ContentViewModel {
    private(set) var result: Weather?

    private let queue = DispatchQueue(label: "weather_io_queue")
    func load() {
        // Mimic 1 second network delay.
        queue.asyncAfter(deadline: .now() + 1) { [weak self] in
            DispatchQueue.main.async {
                self?.result = .sunny
            }
        }
    }
}

Finalmente, muestra los resultados:

swift
struct ContentView: View {
    @State var viewModel = ContentViewModel()
    var body: some View {
        Text(viewModel.result?.rawValue ?? "Loading...")
            .onAppear {
                viewModel.load()
        }
    }
}

Más recientemente, Swift introdujo actors para dar soporte a la sincronización del state compartido y mutable. Para garantizar que el trabajo se realice en el hilo principal, define una clase view model que esté marcada como @MainActor, con una función load() que llame internamente a una función asíncrona utilizando Task.

swift
@MainActor @Observable class ContentViewModel {
  private(set) var result: Weather?

  func load() async {
    // Mimic 1 second network delay.
    try? await Task.sleep(nanoseconds: 1_000_000_000)
    self.result = .sunny
  }
}

A continuación, define el view model como un state utilizando @State, con una función load() que pueda ser llamada por el view model:

swift
struct ContentView: View {
  @State var viewModel = ContentViewModel()
  var body: some View {
    Text(viewModel.result?.rawValue ?? "Loading...")
      .task {
        await viewModel.load()
      }
  }
}

En Dart, por defecto todo el trabajo se ejecuta en el isolate principal. Para implementar el mismo ejemplo en Dart, primero crea el enum Weather:

dart
enum Weather { rainy, windy, sunny }

Luego, define un view model simple (similar al que se creó en SwiftUI) para obtener el clima. En Dart, un objeto Future representa un valor que se proporcionará en el futuro. Un Future es similar al @Observable de Swift. En este ejemplo, una función dentro del view model devuelve un objeto Future<Weather>:

dart
@immutable
class HomePageViewModel {
  const HomePageViewModel();
  Future<Weather> load() async {
    await Future.delayed(const Duration(seconds: 1));
    return Weather.sunny;
  }
}

La función load() en este ejemplo comparte similitudes con el código de Swift. La función de Dart está marcada como async porque utiliza la palabra clave await.

Además, una función de Dart marcada como async devuelve automáticamente un Future. En otras palabras, no tienes que crear una instancia de Future manualmente dentro de las funciones marcadas como async.

Para el último paso, muestra el valor del clima. En Flutter, los widgets FutureBuilder y StreamBuilder se utilizan para mostrar los resultados de un Future en la UI. El siguiente ejemplo utiliza un FutureBuilder:

dart
class HomePage extends StatelessWidget {
  const HomePage({super.key});

  final HomePageViewModel viewModel = const HomePageViewModel();

  @override
  Widget build(BuildContext context) {
    return CupertinoPageScaffold(
      // Feed a FutureBuilder to your widget tree.
      child: FutureBuilder<Weather>(
        // Specify the Future that you want to track.
        future: viewModel.load(),
        builder: (context, snapshot) {
          // A snapshot is of type `AsyncSnapshot` and contains the
          // state of the Future. By looking if the snapshot contains
          // an error or if the data is null, you can decide what to
          // show to the user.
          if (snapshot.hasData) {
            return Center(child: Text(snapshot.data.toString()));
          } else {
            return const Center(child: CupertinoActivityIndicator());
          }
        },
      ),
    );
  }
}

Para ver el ejemplo completo, consulta el archivo async_weather en GitHub.

Aprovechar un hilo/isolate de fondo

#

Las aplicaciones Flutter pueden ejecutarse en una variedad de hardware multinúcleo, incluidos dispositivos con macOS e iOS. Para mejorar el rendimiento de estas aplicaciones, a veces debes ejecutar tareas en diferentes núcleos de forma concurrente. Esto es especialmente importante para evitar bloquear el renderizado de la UI con operaciones de larga duración.

En Swift, puedes aprovechar GCD para ejecutar tareas en colas globales con diferentes propiedades de clase de calidad de servicio (qos). Esto indica la prioridad de la tarea.

swift
func parse(string: String, completion: @escaping ([String:Any]) -> Void) {
  // Mimic 1 sec delay.
  DispatchQueue(label: "data_processing_queue", qos: .userInitiated)
    .asyncAfter(deadline: .now() + 1) {
      let result: [String:Any] = ["foo": 123]
      completion(result)
    }
  }
}

En Dart, puedes delegar el cómputo a un isolate de trabajo, a menudo llamado background worker. Un escenario común genera un isolate de trabajo simple y devuelve los resultados en un mensaje cuando el trabajador finaliza. Puedes usar Isolate.run() para generar un isolate y ejecutar cómputos:

dart
void main() async {
  // Read some data.
  final jsonData = await Isolate.run(() => jsonDecode(jsonString) as Map<String, dynamic>);`

  // Use that data.
  print('Number of JSON keys: ${jsonData.length}');
}

En Flutter, también puedes usar la función compute para iniciar un isolate que ejecute una función callback:

dart
final jsonData = await compute(getNumberOfKeys, jsonString);

In this case, the callback function is a top-level function as shown below:

dart
Map<String, dynamic> getNumberOfKeys(String jsonString) {
 return jsonDecode(jsonString);
}

You can find more information on Dart at Learning Dart as a Swift developer, and more information on Flutter at Flutter for SwiftUI developers or Flutter for UIKit developers.