Introduction
Asynchronous programming is not merely a best practice or an advanced technique. In reality, it is crucial for designing Flutter applications that instantly respond to user interactions while maximizing the efficiency of available resources.
With this in mind, Dart/Flutter has integrated powerful and intuitive tools for managing asynchronicity. Concepts like Future, Isolate, and Stream are the cornerstones of this approach, enabling developers to ensure a smooth and responsive user experience.
This article introduces a somewhat obscure concept: the Completer, through some of the most common and suitable use cases.
In the realm of asynchronous programming with Dart, the Completer can be seen as a complement to Futures. It gives developers refined control over when and how a Future "completes", thus providing enhanced flexibility in managing asynchronous tasks.
Although it is often (if not always) possible to develop without using a Completer, employing a Completer frequently results in more modular code, which is easier to read and therefore, more maintainable.
What is a Completer?
A Completer is a way to produce a Future, which can be completed (or rejected) at a later time.
In other words, the Completer allows us to manually control the success or failure of a Future.
What are the basic functions of the Completer?
//
// Initialization
//
final Completer<T> completer = Completer<T>();
//
// Returns the linked "future"
//
final Future<T> future = completer.future;
//
// Checks if the "future" is already completed
//
bool completer.isCompleted
//
// Completes a "future"
//
completer.complete(...);
//
// Rejects a "future" with an error
//
completer.completeError(...);
Why is using a Completer useful?
In various scenarios, there are multiple ways to achieve a result and most certainly avoid using a Completer.
However, the Completer stands out as a straightforward and effective solution for precise synchronization, manual management of upcoming results, and cases where we need full control over the asynchronous flow.
To better show what a Completer could be used for, let's consider a series of use cases.
Case 1: Waiting for a callback with timeout
For this first example, suppose we need to call an external API (which we can't modify) that uses a callback to deliver a response. This kind of behavior might occur with native plugins or merely a package.
Consider the following API signature:
void externalFunction(Function(String result) callback);
Also, the use case is as follows: We need to call this API and wait either for a response via the callback or a timeout (to add a bit of complexity). If a timeout is encountered, the function returns an error.
Here is a solution using a Completer.
1Future<String> callAPIWithTimeout() async {
2 final Completer<String> completer = Completer<String>();
3 Timer? timer;
4
5 ///
6 /// Initiate the timeout timer.
7 ///
8 timer = Timer(const Duration(seconds: 10), () {
9 // Timeout => let's return an error
10 if (!completer.isCompleted) {
11 completer.completeError("Timeout");
12 }
13 });
14
15 ///
16 /// Let's invoke the API
17 ///
18 externalFunction((String result) {
19 timer?.cancel();
20 if (!completer.isCompleted) {
21 completer.complete(result);
22 }
23 });
24
25 return completer.future;
26}
27
Explanation:
- Line 2: We initialize the Completer, specifying that the Future will return a String.
- Lines 10-12: If no response is received after the timeout, we return an error.
- Line 18: We invoke the API and await the response.
- Line 19: Since we received a response, we cancel the timer (in case it hasn't already triggered a timeout).
- Lines 20-22: If no timeout has been triggered, we deliver the response.
- Line 25: We return the Future so it can be awaited.
Here is a call example:
1
2Future<void> _onWaitForAPIResult() async {
3 String result = "pending";
4 result = await callAPIWithTimeout().catchError((error) {
5 return error;
6 });
7 print("result: $result");
8}
9
Additional Note:
We could achieve the same result without using a Timer but through a Future.any, as shown here below, but, for me, the code is somewhat less obvious to read, don't you think?
As the initState is only invoked the very first time you inflate the StatefulWidget, this method will never be called a second time.
Therefore, you may request the addPostFrameCallback to display your dialog from that method. The showDialog will be executed after the build is complete.
Case 2: Do something once the build is complete
This might be very useful to wait for the rendering to be complete to, for example, get the exact dimensions of Widget. To do this:
1
2Future<String> callAPIWithTimeout2() async {
3 ///
4 /// Initiate the timeout future.
5 ///
6 final Future<String> timeoutFuture = Future.delayed(
7 const Duration(seconds: 10),
8 () => throw ("Timeout"),
9 );
10
11 ///
12 /// Initiate the API call future
13 ///
14 final Future<String> apiCallFuture = Future(() {
15 final Completer<String> completer = Completer<String>();
16
17 externalFunction((String result) {
18 if (!completer.isCompleted) {
19 completer.complete(result);
20 }
21 });
22
23 return completer.future;
24 });
25
26 ///
27 /// Let's invoke the API
28 ///
29 try {
30 return await Future.any([apiCallFuture, timeoutFuture]);
31 } catch (e) {
32 rethrow;
33 }
34}
35
Case 2: Cancellation
Handling cancellation is a common challenge in asynchronous programming. Dart does not natively support the cancellation of Futures, but a Completer can be used to mimic this behavior, providing a way to interrupt an asynchronous operation.
To illustrate this, let's consider a download service allowing users to cancel an ongoing download.
1
2class DownloadService {
3 final Completer<void> _downloadCompleter = Completer<void>();
4
5 Future<void> startDownload() async {
6 try {
7 // Simulate a download that lasts 20 seconds
8 await Future.delayed(const Duration(seconds: 20));
9
10 // Everything went fine
11 if (!_downloadCompleter.isCompleted) {
12 _downloadCompleter.complete();
13 }
14 } catch (e) {
15 if (!_downloadCompleter.isCompleted) {
16 _downloadCompleter.completeError("Download failed: $e");
17 }
18 }
19 }
20
21 // Method to be invoked to cancel the download
22 void cancelDownload() {
23 if (!_downloadCompleter.isCompleted) {
24 _downloadCompleter.completeError("Download was cancelled by user");
25 }
26 }
27
28 Future<void> get download => _downloadCompleter.future;
29}
30
Why is Completer essential here?
The Completer allows us to prematurely conclude the Future with a specific error, simulating cancel behavior. Without this, we wouldn't have a simple way of reporting cancellations.
Here is an example of a call code. I hope the code is sufficiently clear.
1
2class DownloadApp extends StatefulWidget {
3
4 _DownloadAppState createState() => _DownloadAppState();
5}
6
7class _DownloadAppState extends State<DownloadApp> {
8 final DownloadService _downloadService = DownloadService();
9 bool _isDownloading = false;
10 String _message = "Ready";
11
12
13 Widget build(BuildContext context) {
14 return MaterialApp(
15 home: Scaffold(
16 appBar: AppBar(title: Text("Download App")),
17 body: Center(
18 child: Column(
19 mainAxisAlignment: MainAxisAlignment.center,
20 children: [
21 Text(_message),
22 ElevatedButton(
23 child: Text("Start downloading"),
24 onPressed: _isDownloading ? null : _startDownload,
25 ),
26 ElevatedButton(
27 child: Text("Cancel"),
28 onPressed: _isDownloading ? _cancelDownload : null,
29 ),
30 ],
31 ),
32 ),
33 ),
34 );
35 }
36
37 void updateInfo({
38 required bool isDownloading,
39 required String message,
40 }) {
41 if (mounted) {
42 setState(() {
43 _isDownloading = isDownloading;
44 _message = message;
45 });
46 }
47 }
48
49 void _startDownload() async {
50 updateInfo(isDownloading: true, message: "Downloading...");
51
52 try {
53 await _downloadService.startDownload();
54
55 updateInfo(isDownloading: false, message: "Download success");
56 } catch (e) {
57 updateInfo(isDownloading: false, message: "Failure : $e");
58 }
59 }
60
61 void _cancelDownload() {
62 _downloadService.cancelDownload();
63 updateInfo(isDownloading: false, message: "Cancelled");
64 }
65}
66
Case 3: Dependent Futures with specific logic
Suppose you have an application that needs to query three different services.
For performance purposes, all three services are called concurrently, but each service has a different priority.
Suppose that if the high-priority service fails (critical error), we want to immediately stop all other requests and notify something specific. For the other services, if they fail, we want to simply log the error but not interrupt the other services. This is a scenario where the Completer can be really useful.
1
2class PriorityDataService {
3 final Completer<List<String>> _aggregateCompleter = Completer<List<String>>();
4 final List<String> _dataResults = [];
5 final List<String> minorErrors = [];
6 int responseCount = 0;
7
8 //
9 // Invokes the 3 services in parallel
10 //
11 Future<List<String>> fetchData() async {
12 reset();
13 Future.wait(
14 [
15 _fetchHighPriorityService(),
16 _fetchMediumPriorityService(),
17 _fetchLowPriorityService(),
18 ],
19 eagerError: true,
20 );
21
22 return _aggregateCompleter.future;
23 }
24
25 void reset() {
26 _dataResults.clear();
27 minorErrors.clear();
28 responseCount = 0;
29 }
30
31 //
32 // If this service fails, this is critical
33 //
34 Future<void> _fetchHighPriorityService() async {
35 try {
36 _dataResults.add(await highPriorityService());
37 } catch (e) {
38 _handleCriticalError(e);
39 }
40 _handleCompletion();
41 }
42
43 //
44 // 'Regular' services
45 //
46 Future<void> _fetchMediumPriorityService() async {
47 try {
48 _dataResults.add(await mediumPriorityService());
49 } catch (e) {
50 _handleMinorError(e);
51 }
52 _handleCompletion();
53 }
54
55 Future<void> _fetchLowPriorityService() async {
56 try {
57 _dataResults.add(await lowPriorityService());
58 } catch (e) {
59 _handleMinorError(e);
60 }
61 _handleCompletion();
62 }
63
64 //
65 // Once we have the 3 responses, let's complete
66 //
67 void _handleCompletion() {
68 responseCount++;
69 if (responseCount == 3 && !_aggregateCompleter.isCompleted) {
70 _aggregateCompleter.complete(_dataResults);
71 }
72 }
73
74 //
75 // Record the error
76 //
77 void _handleMinorError(dynamic error) {
78 minorErrors.add("Service error: $error");
79 }
80
81 //
82 // In case of critical error, we directly complete with an error
83 //
84 void _handleCriticalError(dynamic error) {
85 if (!_aggregateCompleter.isCompleted) {
86 _aggregateCompleter.completeError("Critical service error: $error, minor errors: $minorErrors");
87 }
88 }
89}
90
Thus, with a Completer, we have the flexibility to add specific logics based on the error's nature, something we couldn't simply do with Future.wait.
Here is an invocation example:
1
2Future<void> _onInvokeServices() async {
3 final PriorityDataService priorityDataService = PriorityDataService();
4 final List<String> result = await priorityDataService.fetchData().catchError((error) {
5 print("errors: $error");
6 return <String>[];
7 });
8 print("result: $result ---> minor errors: ${priorityDataService.minorErrors}");
9}
10
Case 4: Using Completers as semaphores for task synchronization
In this example, we will explore how Completers can serve as Semaphores to control the execution of independent asynchronous tasks.
Consider three asynchronous tasks A, B, C.
The execution course of task A stops at a certain point and waits for at least one of the other two tasks to complete before continuing with the result of the first task that finished.
1
2import "dart:async";
3
4class FlexibleSemaphore {
5 Completer<String> _completer = Completer<String>();
6
7 Future<String> get wait => _completer.future;
8
9 void release(String message) {
10 _completer.complete(message);
11 _completer = Completer<String>(); // Allow re-use
12 }
13}
14
15Future<void> taskA(FlexibleSemaphore semaphore) async {
16 print("Task A: Starting");
17 await Future.delayed(Duration(seconds: 2));
18 print("Task A: Waiting for signal");
19 String message = await semaphore.wait;
20 print("Task A: Resuming with message: $message");
21}
22
23Future<void> taskB(FlexibleSemaphore semaphore) async {
24 print("Task B: Starting");
25 await Future.delayed(Duration(seconds: 1));
26 print("Task B: Sending signal");
27 semaphore.release("Signal from Task B");
28}
29
30Future<void> taskC(FlexibleSemaphore semaphore) async {
31 print("Task C: Starting");
32 await Future.delayed(Duration(seconds: 3));
33 print("Task C: Sending signal");
34 semaphore.release("Signal from Task C");
35}
36
37void main() async {
38 FlexibleSemaphore semaphore = FlexibleSemaphore();
39
40 taskA(semaphore);
41 taskB(semaphore);
42
43 await Future.delayed(Duration(seconds: 4));
44 taskC(semaphore);
45
46 print("All tasks started");
47}
48
This example demonstrates that the Completer allows for more refined control and reuse due to its ability to be completed from multiple places in the code and also to be reset for future use. This can be particularly helpful in complex scenarios with variable dependencies among asynchronous tasks.
Conclusion
Opting to use a Completer in your developments is not a necessity, but it proves to be a valuable asset for making the code more organized and manageable.
I wanted to share this technique with you because it has significantly eased my management of asynchronous processes, and I believe it could also be interesting for you.
Completers are not the universal answer to the challenges of asynchronicity, but they bring appreciable flexibility and strength in managing asynchronous tasks. It's worth incorporating them into your development tools.
Other methods are also available, but the Completer has a distinct effectiveness. Feel free to test it and discover its benefits for yourself!
Stay tuned for more helpful tips. I wish you happy coding!