Building modern mobile apps is impossible without asynchronous code. Most of the tasks that an application has to perform, to one degree or another, require a long wait for the result of the operation: network queries, working with databases, reading user input. An asynchronous approach (executing a process without blocking it) allows more rational use of device resources. Given the limitations of mobile devices, asynchronous development in Android and iOS is indispensable.
In this article, we will look at implementing asynchrony using the features of the Dart language and the ‘dart: async’ package. This is primarily about the Future and Stream classes.
Dart code execution mechanism
Dart, the language for writing Flutter applications, is a single threaded language. However, it is available for tools like streams, futures, async / await operators. They are similar to the same elements in other programming languages, especially Java and Javascript, but they have several peculiarities. Therefore, first you need to understand how the Dart code execution mechanism works.
Dart code is executed in separate chunks of memory called isolates. In theory, all Dart applications can run in one isolate. However, if you need to create several more threads, then the Dart virtual machine can create several more isolates with allocated memory. Unlike many programming languages, such as Java or C ++, in Dart, different threads cannot directly access the same chunk of memory. They can exchange messages to use each other’s data, but they cannot work with memory directly. It is a more robust multithreading implementation that does not require locking and simplifies memory management. Inside each isolate, there is an event loop that is responsible for handling events. It deals with incoming events and has the option to defer the execution of events that require an asynchronous approach. This solves the multithreading dilemma in a natively single-threaded environment.
How should our Dart application tell the event loop that this piece of code will be executed asynchronously? Let’s look at two of the most common approaches to asynchronous programming in Flutter.
Futures
The way Future works is very similar to Promise from Javascript. It has 2 states: unfinished and completed. The completed Future will have either value (on success) or error (on failure). The class has several constructors:
- Future.delayed (accepts a Duration object as arguments indicating the time interval and a function to be executed after postponement).
- Encoded memory cache (stores compressed images in the original state in memory).
- Future.error (creates a Future that completes with an error).
- Future.microtask (returns a Future with the result of the computation specified by scheduleMicrotask).
- Future.sync (Future, which ends immediately).
- Future.value (Future, which returns the specified value).
In commercial development, of the above examples, Future.delayed is most often used to implement deferred tasks.
The more common and simpler way to create a Future is by using the async keyword in a function. It then returns the value wrapped in a Future.
An example of using async
1 2 3 4 5 |
Future getUserName() async { return await userRepository.getUserName; } |
There are several options for how you can use the result of this function in Flutter.
1) The easiest is to get the result of the function using the await keyword. An important point – await can only be used in functions with the async keyword.
1 2 3 4 5 |
assignName() async { String userName = await getUserName(); } |
2) Use callbacks .then and .catchError. With this option, working with Dart Future becomes as similar as possible to using Promise in Javascript. This example prints the username to the console on success or an error on failure.
1 2 3 4 5 6 7 8 |
handleUserNameWithCallbacks() { Future nameFuture = getUserName(); nameFuture .then((name) => print(name)) .catchError((error) => print(error)); } |
Dart Future also has several static methods that make it easier to work with asynchronous codes: wait (takes a list of futures as an argument, returns a Future that waits for them to complete), any (takes a list of futures as an argument, returns a Future that waits for any first one), forEach (takes a list of futures as an argument, applies the specified function to each element), doWhile (takes a function that returns a bool, executes until the function returns false).
There are also 2 non-static functions – whenComplete (executed when the Future completes, regardless of success or failure) and timeout (executes the function after a time interval). All of these functions can be combined to create complex chains.
3) Use the FutureBuilder widget from the Flutter SDK. Its advantage is the ability to take into account the state of the future when building the UI. In the example below, we will create 3 UI options for each nameFuture state: progress bar to wait, plain text on success, and notification on 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
The second part of asynchronous programming in Dart and Flutter is Streams. They are sequences of asynchronous events. They are identical in name and content to streams from other programming languages, such as Java. In Dart, streams are divided into 2 types: with a single subscription (allows you to have only one subscriber and emits events only if there is one) and broadcast (makes it possible to have several subscribers and emits events regardless of their presence). Like Future, Stream contains potential value. This class contains a large set of methods for transforming streams and manipulating their elements. You can create it, as in the case of a Future, in several ways:
1) Using the constructor: default Stream(), Stream.empty (creates an empty stream), Stream.error (creates a stream that throws an error), Stream.eventTransformed (takes a stream and modifies all its elements), Stream.fromFuture (transforms a single Future to a stream), Stream.fromFutures (the same, but accepts an Iterable of futures), Stream.fromIterable (converts a list of objects to a stream), Stream.periodic (creates a stream that emits events periodically), Stream.value (creates a stream that returns one item and exits). In commercial development, this method of creating streams is not common.
2) Using the keywords async * (pronounced [əˈsɪŋk stɑː], returns a stream), yield (returns an element of a stream), and yield * (pronounced [jiːld stɑː], returns a stream).
An example authorization that returns a stream of states
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) Using StreamController. This class is specially designed for manipulating streams and has 2 variations: the default (controls single subscription streams) and broadcast (respectively, controls broadcast streams).
In this example of creating a stream using a controller, we create a new single subscription controller that operates on the String data type. If a listener is added to it, the message “listening has started” will be displayed in the console. Using the add() function, we send new data to the stream, which will go to the subscriber. Finally, we close the controller, which causes the thread to be canceled and the listener to be unsubscribed. The message “stream was closed” is displayed in the console.
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 has a number of useful functions and attributes for manipulating streams. These include the already mentioned add () and close (), as well as addError (the stream throws an error), addStream (adds events from another stream), stream (link to the created stream), sink (link to the object responsible for receiving new data ) etc.
4) By converting Future to Stream. It is rarely used.
1 2 3 4 |
Future userNameFuture = getUserName(); Stream userNameStream = userNameFuture.asStream(); |
Ways to work with streams
Having created a stream in any of the above 4 ways, you need to learn how to use its data. Flutter has 2 main options for how you can work with streams.
The first is using subscriptions. To do this, we create a subscription to a specific stream and use it to manage data.
Subscription example
1 2 3 4 5 6 7 8 |
StreamController controller = StreamController(); StreamSubscription subscription = controller.stream.listen((event) { print(event); saveToDatabase(event); }); subscription.cancel(); |
Subscriptions provide ample arsenal to control your streams. With their help, you can pause or resume the work of a thread, define functions that will be triggered on a new event (onData), error (onError), or shutdown (onDone). And most importantly, subscriptions allow you to unsubscribe from a stream using the cancel() function.
The second option for working with streams is provided by the Flutter SDK. It’s about StreamBuilder. It provides a number of advantages: it binds the UI with the transferred data, ensures that the widget is redrawn with a new piece of data, automatically unsubscribes from the stream when the widget is deleted, and allows you to set initial data.
StreamBuilder example
We create a test list with names. The getNameStream() method returns a stream that sends a new name from the list every second. The StreamBuilderTest class monitors the state of the stream and displays it as text. Each piece of data will be displayed as a text on the screen.
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); } }, ); } } |
Thus, Flutter provides a rich arsenal for writing asynchronous code using the basic features of the Dart language. The general concept of working with asynchrony in it resembles the Javascript approach: one basic thread with an event loop, Promise-like Futures and the use of async / await keywords. To work with single asynchronous operations, you should use the Future from the ‘dart: async’ package. Combine it with FutureBuilder from the Flutter SDK to quickly and reliably provide a link between the result of an asynchronous operation and the state of the UI. Stream can be used to manage chains of asynchronous events. Flutter offers a similar StreamBuilder widget for combining UI and data streams. Together, this set of asynchronous tools makes Dart and Flutter a highly efficient stack for implementing asynchronous applications.