Mastering Concurrency in Flutter: Futures and Streams Explained

Mastering Concurrency in Flutter: Futures and Streams Explained

In previous article we learned about difference between Future and Stream in flutter.

Now let’s dive a little bit more in Flutter concurrency. First of all, lets wipe the dust around difference between synchronous and asynchronous programming in Flutter:


Table of Contents:

1- Future

2- Stream

2.1- Single Subscription Stream

2.2- Broadcast Stream

2.3- StreamBuilder

3- Conclusion


Synchronous programming means each statement in our code will execute one at a time and after that we’ll move to the next statement.

But in contrast, when we’re using asynchronous programming principals we don’t have such limits. Async programming empowers us to execute multiple statements simultaneously. So, imagine how helpful will be async programming in a scenario that we have to handle multiple requests at once and concurrent operations.


1- Future

First of all, let's take a look at a simple Future implementation. In this code snippet we have a function that returns every string that it gets with 1 second delay to mimicking a server behavior.

//1
Future<String> delayedData(String data) async {
  //2
  await Future.delayed(const Duration(seconds: 1));
  return data;
}

//3
void main() async {
  //4
  print(await delayedData('A ${DateTime.now()}'));
  print(await delayedData('B ${DateTime.now()}'));
  print(await delayedData('C ${DateTime.now()}'));
  print('1 ${DateTime.now()}');
  print(await delayedData('D ${DateTime.now()}'));
}        
Output:  A 13:05:01
         B 13:05:02
         C 13:05:03
         1 13:05:04
         D 12:05:04        

  • //1: As you can see, declaring a Future function delayedData is a little bit different with normal functions that we all have seen before. The return type should be a Future<> in this case and the actual return type will be places between these angle brackets: <>. At the end of the declaration of Future function we have to use async generator. this means that we need to lazily produce the value.
  • //2: In this line, we want to mimic the server behavior that needs some time to give us the response. for example, 1 second per request. If we have such line in our code, we have to use await to use asynchronous response.
  • //3: The main function is also marked as async, indicating that it can perform asynchronous operations.
  • //4: Inside main, it calls delayedData multiple times with different strings, each containing the current date and time. The await keyword is used to wait for the Future returned by delayedData to complete before continuing to the next line.
  • Notice that the print statement without await, which is ( print('1 ${DateTime.now()}'); ) is executed immediately after the third await without waiting for another second, showing the current time right after the third await completes. The last print with await is delayed by another second. This demonstrates how asynchronous execution affects the flow of the program.


Okay, that was a solid start with using Future in the code. Now, let's explore what Stream has to offer us.


2- Stream

Streams can seem more complex than Futures, but this complexity will disappear once you grasp their workings and implementation. Understanding the Stream structure is key to effectively utilizing it in various use cases. Additionally, Streams can be either single-subscription or broadcast types. We'll tear down a straightforward single subscription model and then tackle a more complex broadcast stream.

After that we'll became familiar with

2.1- Single Subscription Streams:

let’s go through the Single Subscription Stream code:

import 'dart:async';

//1
StreamController<String> streamController = StreamController();

//2
Future<void> delayedData(String data) async {
  await Future.delayed(const Duration(seconds: 1));
  streamController.add(data);
}

void main() async {
  //3
  streamController.stream.listen((event) {
    print(event);
  });

  //4
  await delayedData('A ${DateTime.now()}');
  await delayedData('B ${DateTime.now()}');
  await delayedData('C ${DateTime.now()}');
  await delayedData('D ${DateTime.now()}');
  print('1 ${DateTime.now()}');
  await delayedData('E ${DateTime.now()}');

  //5
  streamController.close();
}        
output: A 13:05:31
        B 13:05:32
        1 13:05:34
        C 13:05:33
        D 13:05:34        

  • //1: A StreamController is created, which can be used to add data to a stream and listen for that data elsewhere in the app. It serves a pivotal role, connecting the interaction between a stream and its subscribers. It is designed to manage the flow of data, providing a mechanism for introducing asynchronous events into a stream. This controller ensures that data can be added to the stream in a controlled manner, allowing subscribers to react to new data as it becomes available. Moreover, it provides the necessary tools to regulate the stream’s behavior, such as pausing, resuming, and canceling the propagation of events, which is offering a robust solution for handling asynchronous data streams with precision.
  • //2: The delayedData function is defined as an asynchronous function that returns a Future<void>. Inside delayedData, there’s a delay of 1 second simulated using Future.delayed mimicking the server behavior that needs some time to give us the response. we have this function in Future code snippet but in there we return data after delay. But in Streams, After the delay, the passed data string is added to the stream controlled by StreamController.
  • //3: In the main function, the app starts listening to the stream. Whenever data is added to the stream, the listen callback prints it out.
  • //4: The main function then calls delayedData multiple times with different strings that include the current timestamp. These calls are awaited, meaning the program waits for each call to complete before moving to the next. The output will show each string with its timestamp, printed one second apart, except for the ‘1 ${DateTime.now()}’ line, which will print immediately after the fourth delayed data without waiting for an additional second. The timestamps will reflect the actual time each string is processed.
  • //5: Finally, after all the data has been added and printed, the stream controller is closed. Closing a stream in Dart is crucial for several reasons, including preventing memory leaks, managing resources effectively, and avoiding errors. So, watch it.

The use of StreamController allows for the creation of a custom stream of events that can be listened to and acted upon within the application.


2.2- Broadcast Streams:

The core difference between a single subscription stream and a broadcast stream is that single subscription streams allow only one listener. Adding a new listener will automatically cancel the previous one. This is ideal for streams that produce a limited amount of data, like the result of a network request.

On the other hand, broadcast streams can support an unlimited number of listeners. It's important to note, however, that events added before subscribing will not be received by those who subscribe later. Since events are not buffered for late subscribers, it's vital to add listeners before adding events.

Another difference between single-subscription streams and broadcast streams is the way each one is declared. In the previous example, we became familiar with the declaration of single-subscription streams, which was as follows:

StreamController<String> streamController = StreamController();        

However, if a broadcast stream is required, the declaration would be as follows:

StreamController streamController = StreamController.broadcast();        


2.3- StreamBuilder

The StreamBuilder is particularly useful in scenarios where your UI needs to update dynamically based on a stream of data, such as chat applications, live feeds, or any other real-time data sources. This is a powerful tool that connects the asynchronous world of streams with your app’s user interface. It listens to a Stream and instructs the Flutter framework to rebuild the widget each time a new event is emitted by the stream.

When you provide a Stream to a StreamBuilder, it automatically subscribes to the stream and starts listening for events. Each time a new event is emitted from the stream, the StreamBuilder gets notified and schedules a rebuild of its widget tree. The StreamBuilder uses the latest snapshot of data from the stream to build its widgets. This snapshot contains the data and also information about the connection state of the stream (like whether it’s currently active, waiting, or done). You can customize the behavior of the StreamBuilder based on the state of the stream. For example, you might show a loading indicator while the stream is in the waiting state and display the actual data when values are received. If the stream emits an error, the StreamBuilder can capture that error and allow you to build a UI response accordingly, such as displaying an error message.

Here is a simple implantation of how StreamBuilder works:

class TestStreamBuilder extends StatelessWidget {
  final StreamController<String> streamController = StreamController();

  TestStreamBuilder({super.key});

  @override
  Widget build(BuildContext context) {
    addDataToStream();

    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('Stream Builder Example')),
        body: StreamBuilder<String>(
          stream: streamController.stream,
          builder: (context, snapshot) {
            if (snapshot.hasError) {
              return Text('Error: ${snapshot.error}');
            }
            switch (snapshot.connectionState) {
              case ConnectionState.none:
                return const Center(child: Text('No connection or null'));
              case ConnectionState.waiting:
                return const Center(child: CircularProgressIndicator());
              case ConnectionState.active:
                return Center(child: Text('Data: ${snapshot.data}'));
              case ConnectionState.done:
                return const Center(child: Text('Stream ended!'));
            }
          },
        ),
      ),
    );
  }

  Future<void> delayedData(String data) async {
    await Future.delayed(const Duration(seconds: 1));
    streamController.add(data);
  }

  Future<void> addDataToStream() async {
    await delayedData('A');
    await delayedData('B');
    await delayedData('C');
    await delayedData('D');
    streamController.close();
  }
}
        

This code snippet is fairly straightforward, and you should be able to understand it from here. Try creating a simple test project to observe the output of this code. However, if you have any questions about StreamBuilder, please feel free to ask.


3- Conclusion

The exploration of Flutter’s concurrency mechanisms reveals the distinct functionalities and applications of Futures and Streams. Futures provide a straightforward way to handle asynchronous operations, allowing the execution of code sequences with unintentional delays. Streams, on the other hand, offer a more complex but powerful approach to managing a sequence of asynchronous events, with the flexibility of single subscription or broadcast models to suit different scenarios. The StreamController plays a crucial role in controlling the flow of data within a Stream. By understanding and implementing these concepts, developers can effectively manage concurrent operations in Flutter, leading to more responsive and efficient applications.

This covers the basics you need to begin implementing Futures and Streams in your code. However, this is not the extent of what you should know.

Continue practicing on your own. Should you encounter any questions or issues, do not hesitate to reach out. Your feedback is always welcome. If you found this article helpful as well as to share it with your network if you found it useful. ??

要查看或添加评论,请登录

Amir Arani的更多文章

社区洞察

其他会员也浏览了