Construir aplicaciones móviles modernas es imposible sin código asíncrono. La mayoría de las tareas que debe realizar una aplicación, en un grado u otro, requieren una larga espera del resultado de la operación: consultas a la red, trabajo con bases de datos, lectura de la entrada del usuario. Un enfoque asíncrono (ejecutar un proceso sin bloquearlo) permite un uso más racional de los recursos del dispositivo. Dadas las limitaciones de los dispositivos móviles, el desarrollo asíncrono en Android e iOS es indispensable.
En este artículo, veremos cómo implementar la asincronía utilizando las características del lenguaje Dart y el paquete ‘dart: async’. Se trata principalmente de las clases Future y Stream.
Mecanismo de ejecución de código Dart
Dart, el lenguaje para escribir aplicaciones Flutter, es un lenguaje de un solo hilo. Sin embargo, dispone de herramientas como streams, futures, operadores async / await. Son similares a los mismos elementos en otros lenguajes de programación, especialmente Java y Javascript, pero tienen varias peculiaridades. Por lo tanto, primero tiene que entender cómo funciona el mecanismo de ejecución de código de Dart.
El código Dart se ejecuta en trozos separados de memoria llamados aislados. En teoría, todas las aplicaciones Dart pueden ejecutarse en un solo isolate. Sin embargo, si necesita crear varios hilos más, la máquina virtual de Dart puede crear varios aislados más con memoria asignada. A diferencia de muchos lenguajes de programación, como Java o C+, en Dart, diferentes hilos no pueden acceder directamente al mismo trozo de memoria. Pueden intercambiar mensajes para utilizar los datos de los demás, pero no pueden trabajar con la memoria directamente. Se trata de una implementación multihilo más robusta que no requiere bloqueo y simplifica la gestión de la memoria. Dentro de cada aislado, hay un bucle de eventos que se encarga de manejar los eventos. Se ocupa de los eventos entrantes y tiene la opción de aplazar la ejecución de los eventos que requieren un enfoque asíncrono. Esto resuelve el dilema del multihilo en un entorno nativo de un solo hilo.
¿Cómo debe nuestra aplicación Dart indicar al bucle de eventos que esta pieza de código se ejecutará de forma asíncrona? Veamos dos de los enfoques más comunes de la programación asíncrona en Flutter.
Futuros
El funcionamiento de los Futuros es muy similar al de las Promesas de Javascript. Tiene 2 estados: inacabado y completado. El Futuro completado tendrá un valor (en caso de éxito) o un error (en caso de fallo). La clase tiene varios constructores:
- Future.delayed (acepta un objeto Duration como argumentos indicando el intervalo de tiempo y una función a ejecutar tras el aplazamiento).
- Caché de memoria codificada (almacena las imágenes comprimidas en el estado original en la memoria).
- Futuro.error (crea un Futuro que se completa con un error).
- Futuro.microtarea (devuelve un Futuro con el resultado del cálculo especificado por scheduleMicrotask).
- Future.sync (Futuro, que termina inmediatamente).
- Future.value (Futuro, que devuelve el valor especificado).
En el desarrollo comercial, de los ejemplos anteriores, Future.delayed es el más utilizado para implementar tareas diferidas.
La forma más común y sencilla de crear un Futuro es utilizando la palabra clave async en una función. A continuación, devuelve el valor envuelto en un Futuro.
Un ejemplo de uso de async
1 2 3 4 5 | Future getUserName() async { return await userRepository.getUserName; } |
Hay varias opciones de cómo puede utilizar el resultado de esta función en Flutter.
1) La más sencilla es obtener el resultado de la función utilizando la palabra clave await. Un punto importante – await sólo puede utilizarse en funciones con la palabra clave async.
1 2 3 4 5 | assignName() async { String userName = await getUserName(); } |
2) Utilizar callbacks .then y .catchError. Con esta opción, trabajar con Dart Future se asemeja al máximo al uso de Promise en Javascript. Este ejemplo imprime el nombre de usuario en la consola en caso de éxito o un error en caso de fallo.
1 2 3 4 5 6 7 8 | handleUserNameWithCallbacks() { Future nameFuture = getUserName(); nameFuture .then((name) => print(name)) .catchError((error) => print(error)); } |
Dart Future también tiene varios métodos estáticos que facilitan el trabajo con códigos asíncronos: wait (toma una lista de futuros como argumento, devuelve un Futuro que espera a que se completen), any (toma una lista de futuros como argumento, devuelve un Futuro que espera al primero), forEach (toma una lista de futuros como argumento, aplica la función especificada a cada elemento), doWhile (toma una función que devuelve un bool, se ejecuta hasta que la función devuelve false).
También hay dos funciones no estáticas: whenComplete (se ejecuta cuando el futuro se completa, independientemente del éxito o el fracaso) y timeout (ejecuta la función después de un intervalo de tiempo). Todas estas funciones pueden combinarse para crear cadenas complejas.
3) Utilice el widget FutureBuilder del SDK de Flutter. Su ventaja es la capacidad de tener en cuenta el estado del futuro al construir la UI. En el ejemplo siguiente, crearemos 3 opciones de UI para cada estado de nameFuture: barra de progreso para esperar, texto sin formato en caso de éxito y notificación en caso de error.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 | import 'package:flutter/material.dart'; class FutureBuilderExample extends StatefulWidget { FutureBuilderExample({Key key, @required UserRepository repository}) : super(key: key); @override _FutureBuilderExampleState createState() => _FutureBuilderExampleState(repository); } class _FutureBuilderExampleState extends State { Future _nameFuture = _userRepository.getUserName; _FutureBuilderExampleState(UserRepository repository); Widget build(BuildContext context) { return FutureBuilder( future: _nameFuture, builder: (BuildContext context, AsyncSnapshot snapshot) { List children; if (snapshot.hasData) { children = [ Padding( padding: const EdgeInsets.only(top: 16), child: Text('Username: ${snapshot.data}'), ) ]; } else if (snapshot.hasError) { children = [ Padding( padding: const EdgeInsets.only(top: 16), child: Text('Error: ${snapshot.error}'), ) ]; } else { children = [ SizedBox( child: CircularProgressIndicator(), width: 50, height: 50, ) ]; } return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: children, ), ); }, ); } } |
Streams
La segunda parte de la programación asíncrona en Dart y Flutter son los Streams. Son secuencias de eventos asíncronos. Son idénticos en nombre y contenido a los streams de otros lenguajes de programación, como Java. En Dart, los streams se dividen en 2 tipos: con una sola suscripción (permite tener un solo suscriptor y emite eventos sólo si hay uno) y broadcast (permite tener varios suscriptores y emite eventos independientemente de su presencia). Al igual que Future, Stream contiene un valor potencial. Esta clase contiene un amplio conjunto de métodos para transformar los flujos y manipular sus elementos. Se puede crear, como en el caso de un Futuro, de varias maneras:
1) Utilizando el constructor: Stream() por defecto, Stream.empty (crea un flujo vacío), Stream.error (crea un flujo que lanza un error), Stream.eventTransformed (toma un flujo y modifica todos sus elementos), Stream.fromFuture (transforma un único Futuro en un flujo), Stream.fromFutures (lo mismo, pero acepta un Iterable de futuros), Stream.fromIterable (convierte una lista de objetos en un flujo), Stream.periodic (crea un flujo que emite eventos periódicamente), Stream.value (crea un flujo que devuelve un elemento y sale). En el desarrollo comercial, este método de creación de flujos no es habitual.
2) Utilizando las palabras clave async * (pronunciado [əˈsɪŋk stɑː], devuelve un flujo), yield (devuelve un elemento de un flujo), y yield * (pronunciado [jiːld stɑː], devuelve un flujo).
Un ejemplo de autorización que devuelve un flujo de estados
1 2 3 4 5 6 7 8 9 10 11 | Stream signInWithCredentials(String email, String password) async* { yield LoginState.loading(); try { await _userRepository.signInWithCredentials(email, password); yield LoginState.success(); } catch (_) { yield LoginState.failure(); } } |
3) Uso de StreamController. Esta clase está especialmente diseñada para manipular flujos y tiene 2 variaciones: la predeterminada (controla los flujos de suscripción única) y la de difusión (respectivamente, controla los flujos de difusión).
En este ejemplo de creación de un flujo mediante un controlador, creamos un nuevo controlador de suscripción única que opera sobre el tipo de datos String. Si se le añade un oyente, se mostrará en la consola el mensaje “la escucha ha comenzado”. Mediante la función add(), enviamos nuevos datos al flujo, que irán a parar al suscriptor. Por último, cerramos el controlador, lo que hace que el hilo se cancele y el oyente se dé de baja. El mensaje “el flujo se ha cerrado” aparece en la consola.
1 2 3 4 5 6 7 | StreamController controller = StreamController(onListen: () => print("listening has started")); controller.add("Message 1"); controller.add("Message 2"); controller.add("Message 3"); controller.close().then((value) => print("stream was closed")); |
StreamController tiene una serie de funciones y atributos útiles para manipular los flujos. Entre ellas se encuentran las ya mencionadas add () y close (), así como addError (el flujo lanza un error), addStream (añade eventos de otro flujo), stream (enlace al flujo creado), sink (enlace al objeto responsable de recibir nuevos datos ) etc.
4) Convirtiendo Future en Stream. Se utiliza raramente.
1 2 3 4 | Future userNameFuture = getUserName(); Stream userNameStream = userNameFuture.asStream(); |
Formas de trabajar con flujos
Una vez creado un flujo de cualquiera de las 4 formas anteriores, es necesario aprender a utilizar sus datos. Flutter dispone de 2 opciones principales para trabajar con los flujos.
La primera es utilizar las suscripciones. Para ello, creamos una suscripción a un flujo específico y lo utilizamos para gestionar los datos.
Ejemplo de suscripción
1 2 3 4 5 6 7 8 | StreamController controller = StreamController(); StreamSubscription subscription = controller.stream.listen((event) { print(event); saveToDatabase(event); }); subscription.cancel(); |
Las suscripciones proporcionan un amplio arsenal para controlar sus flujos. Con su ayuda, puede pausar o reanudar el trabajo de un hilo, definir funciones que se activen ante un nuevo evento (onData), un error (onError) o un cierre (onDone). Y lo más importante, las suscripciones le permiten darse de baja de un flujo mediante la función cancel().
La segunda opción para trabajar con flujos la proporciona el SDK de Flutter. Se trata de StreamBuilder. Ofrece una serie de ventajas: vincula la interfaz de usuario con los datos transferidos, garantiza que el widget se redibuje con un nuevo dato, se da de baja automáticamente del flujo cuando se elimina el widget y permite establecer los datos iniciales.
Ejemplo de StreamBuilder
Creamos una lista de prueba con nombres. El método getNameStream() devuelve un flujo que envía un nuevo nombre de la lista cada segundo. La clase StreamBuilderTest controla el estado del flujo y lo muestra como texto. Cada dato se mostrará como un texto en la pantalla.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | List names = ["Rincewind", "Esme", "Gytha", "Magrat", "Sam", "Carrot", "Death"]; Stream getNameStream() async* { for (int i = 0; i < names.length; i++) { await Future.delayed(Duration(seconds: 1)); yield names[i]; } } class StreamBuilderTest extends StatelessWidget { @override Widget build(BuildContext context) { return StreamBuilder( stream: getNameStream(), builder: (BuildContext context, AsyncSnapshot snapshot) { if (snapshot.hasError) { return Text('Error: ${snapshot.error}'); } switch (snapshot.connectionState) { case ConnectionState.waiting: return const Text('Loading...'); case ConnectionState.done: return const Text('Names have finished'); default: if (snapshot.data.isEmpty) { return Text('No data'); } return Text(snapshot.data); } }, ); } } |
Así, Flutter proporciona un rico arsenal para escribir código asíncrono utilizando las características básicas del lenguaje Dart. El concepto general de trabajar con asincronía en él se asemeja al enfoque de Javascript: un hilo básico con un bucle de eventos, Futuros tipo Promise y el uso de las palabras clave async / await. Para trabajar con operaciones asíncronas simples, debe utilizar el Futuro del paquete ‘dart: async’. Combínelo con FutureBuilder del SDK de Flutter para proporcionar de forma rápida y fiable un vínculo entre el resultado de una operación asíncrona y el estado de la interfaz de usuario. Stream puede utilizarse para gestionar cadenas de eventos asíncronos. Flutter ofrece un widget StreamBuilder similar para combinar flujos de datos y de UI. Juntos, este conjunto de herramientas asíncronas hace de Dart y Flutter una pila muy eficiente para implementar aplicaciones asíncronas.