Even though Dart, with its convenient async/await
syntax, is able to take a lot of the complexity out of managing asynchronous calls, sometimes you need to interact with a more traditional callback library or a persistent connection that doesn't operate with futures. In those cases, it's often desirable to hide the inner workings from your code's users and present a more Dart-like façade. That's where completers come into the picture. You can simplify the API surface of a stateless callback library, like one used to access REST web APIs, by wrapping it with a future-based API, and you can do the same with stateful, persistent connection APIs, as well.
The code for this article was tested with Dart 2.8.4 and Flutter 1.17.5.
Note: In order to get the most out of this article, it's best to be familiar with the concepts detailed in the Asynchrony Primer for Dart and Flutter.
Wrapping a callback library to use futures
A completer allows you to create and manage a future. Once you've instantiated a completer, you can use it to return a future to your API's callers, and when a lengthy asynchronous call returns data or an error, you can complete that future, delivering the result.
Consider the following example:
import 'dart:async';
Future<String> asyncQuery() {
final completer = Completer<String>();
getHttpData(
onComplete: (results) {
completer.complete(results);
},
onError: (error) {
completer.completeError(error);
}
);
return completer.future;
}
In order to use completers, you need to import the dart:async
core library. In the example, we've created a function, asyncQuery()
, that interacts with a fictional network call represented by getHttpData()
. Our function will return a future initially, but it will resolve into string data at a later time. The first line of the function creates an instance of Completer, where we again specify the type of the eventual data.
Let's skip over the call to getHttpData()
for the moment and look at the last line of asyncQuery()
. There, we return the future instance associated with the completer we've instantiated. When our function is executed, the completer (and future) are created, then the network call occurs asynchronously as we register the callbacks it needs. Before getHttpData()
has a chance to do its work, our function returns the future to the caller. If the caller is using a statement like await asyncQuery()
, the future will be unpacked automatically when it completes. If the caller has registered their own callback using the future's then()
method, that callback will be executed once we've completed the future.
The getHttpData()
function takes two parameters, the first being a completion callback and the second an error callback. The completion callback completes the future we've already returned, sending results (the data) with it. The error callback also completes the future, but this time with the appropriate error details.
With this pattern, you can prevent users of your code from having to deal with callbacks, allowing them to get data from getHttpData()
using the simpler future paradigm. In our contrived example, getHttpData()
didn't offer a future-based interface, so we created one around it, making it possible to connect this data to a Flutter UI with a FutureBuilder widget.
This approach works well for stateless, one-off network requests, but what if your app communicates with a server over a persistent connection?
Wrapping a persistent connection to use futures
If your app interacts with a persistent connection, using sockets or something similar, it's common to expect a response from the socket server after making a request. Over a stateful, persistent connection, your app can't predict when or in what order responses or other unsolicited messages may arrive. You can use completers and futures to keep your UI code blissfully unaware of this unpredictability.
What is a socket? When a client application needs to communicate with a server computer, there are two main ways to do it. In a stateless scenario, such as with a REST web API, each time the client makes a request, it must first establish a connection and authenticate with the server. Once the request completes, the connection is discarded and must be reestablished with the next request. Sockets are a way to create a lasting, persistent connection between the client and server. Authentication happens only once, and then the client and server are free to communicate with each other at will over the established communication channel, which is referred to as a socket.
To illustrate this pattern, let's look at an excerpt from a socket service class you might use in your app:
class SocketService {
final _socketConnection = SomeSocketConnection();
final Map<String, Completer<String>> _requests = {};
Future<String> sendSocketMessage(String data) {
final completer = Completer<String>();
final requestId = getUniqueId();
final request = {
'id': requestId,
'data': data,
};
_requests[requestId] = completer;
_socketConnection.send(jsonEncode(request));
return completer.future;
}
void _onSocketMessage(String json) {
final decodedJson = jsonDecode(json);
final requestId = decodedJson['id'];
final data = decodedJson['data'];
if (_requests.containsKey(requestId)) {
_requests[requestId].complete(data);
_requests.remove(requestId);
}
}
}
As mentioned, this is just an excerpt, but what's included will demonstrate the future management pattern. The class starts off by initializing a fictional socket connection object and an empty map that is used to keep track of active socket requests. The map will use string request IDs as keys and completers as values, creating a table of completers. In the example, all data going out and coming back will be string data, so the completers and futures use generic types of String.
In order to keep track of which requests are associated with which responses, we need to generate a unique ID and attach it to each request. The socket server will need to include that same ID with each response. This way, when socket messages arrive, we can examine the ID and look it up in our request table to determine if the message is a response to an earlier request. It is assumed here that all requests and responses will be in JSON format.
The public sendSocketRequest()
method takes some string data as an argument and returns a future. The method first creates a few convenience variables as it generates a completer and a request ID for the request. Note that there is no actual getUniqueId()
function. You can generate unique IDs by whatever means you favor. Next, we put the ID and the data into a Map<String, dynamic>
so that we can encode it as JSON to be sent over the socket. With that done, we save the completer into the requests table, keyed by ID, then we send the encoded request over the socket using another fictional function, referred to in the code as _socketConnection.send()
. At the end, a future is returned, with which UI code can await
a result.
The next method, _onSocketMessage()
, will have been registered as the callback for any socket message the app receives from the server. When a message arrives, it is decoded into a map, and the ID and data are extracted into convenience variables. Then we check whether the request table has a record of the incoming request ID. If it does, we complete the associated future with the response data, which will deliver the result to the code that sent the original request. Once the future has been completed, we have no further use for the completer, so it's removed from the request table.
Somewhere in your Flutter app code, you will have written something like this to use the service:
final response = await mySocketService.sendSocketMessage("Hi!");
The response variable will be filled when a response to this specific request is received by the client app, and this calling code never has to know about all the bookkeeping happening behind the scenes to keep requests and responses correctly paired.
Conclusion
Now you know how to make interacting with callback libraries easier using Dart futures and completers. For further reading, check out these related articles: