Skip to main content ppwriters
Asynchronous programming with streams in Dart and Flutter

Asynchronous programming with streams in Dart and Flutter

Published: Sat Jul 01 2023

Imagine a pipe where water is continuously flowing; you can drink from it anytime you want, or perhaps you’re filling a bucket of water and you want to know when it’s full. In the world of programming, we often have similar situations where data continuously flows from one end and we’re interested in receiving and processing that data. That’s where Streams come in Dart.

A Stream in Dart is a way to receive a sequence of data over time. It’s a flow of asynchronous events, ordered in time. They can emit three different things:

  1. A data event (also simply called events): the things that are coming through the stream. In the water pipe analogy, these are the drops of water.
  2. An error event: If something goes wrong in producing the event. For example, if your water pipe gets clogged.
  3. A done event: A signal to say that no more events will be added to the stream. In other words, the water pipe is turned off.

Creating a Stream

You can create a Stream in Dart by using the Stream class. Here’s a simple example of creating a Stream that emits numbers from 1 to 5.

Stream<int> numberStream() async* {
  for(int i = 1; i <= 5; i++) {
    yield i;
  }
}

In the above code, async* signifies that this function is an asynchronous generator. The yield keyword is used to emit data events to the stream.

Listening to a Stream

To receive data from a stream, you have to “listen” to it. Here is an example of listening to the numberStream we created earlier.

void main() {
  numberStream().listen(
    (data) {
      print("Received: $data");
    },
    onError: (err) {
      print("Got error: $err");
    },
    onDone: () {
      print("Stream is done");
    }
  );
}

In the code above, we pass three arguments to the listen() function. The first argument is a function that gets called whenever the stream emits a data event. The second argument, onError, is a function that gets called when the stream emits an error. The third argument, onDone, is a function that gets called when the stream signals that it’s done.

Transforming Streams

Streams can be transformed, similar to how you might purify water before drinking. You can use the map() function to transform the data events of a stream. Here’s an example of doubling the numbers from our numberStream.

void main() {
  numberStream()
    .map((number) => number * 2)
    .listen(
      (data) {
        print("Received: $data");
      },
      onError: (err) {
        print("Got error: $err");
      },
      onDone: () {
        print("Stream is done");
      }
  );
}

The map() function can be used to transform the data events by applying a function to each event.

StreamController

Stream controllers are a fundamental component of Streams in Dart. You can think of a StreamController as the manager of a Stream, handling how data is added to the Stream.

A StreamController gives you a new stream and a way to add events to the stream at any point in time from anywhere in the code. It’s like a faucet for the water pipe.

Here’s how you create a StreamController:

var controller = StreamController<int>();

Adding Data to a Stream

To add data to the stream, you use the add method on the controller. This is like opening the faucet and allowing the water (data) to flow into the stream.

controller.sink.add(1);

Listening to a StreamController

Just like any other Stream, you can listen to a StreamController:

controller.stream.listen(
  (data) {
    print("Received: $data");
  },
  onError: (err) {
    print("Got error: $err");
  },
  onDone: () {
    print("Stream is done");
  },
);

Closing a StreamController

Remember, every time you create a StreamController, you are creating a resource that needs to be released when you’re done with it. This prevents memory leaks and potential issues with your code.

To close the StreamController, you simply call:

controller.close();

Full Example of StreamController

Let’s see a full example where we create a StreamController, add some data, listen to it, and then close it.

void main() async {
  var controller = StreamController<int>();

  controller.stream.listen(
    (data) {
      print("Received: $data");
    },
    onError: (err) {
      print("Got error: $err");
    },
    onDone: () {
      print("Stream is done");
    },
  );

  for(int i = 1; i <= 5; i++) {
    controller.sink.add(i);
  }

  await controller.close();
}

Broadcast StreamController

In the examples above, our StreamController was a single-subscription StreamController, meaning only one listener can listen to the stream at a time. If we try to add another listener, we’ll get an error.

However, there are situations where we want multiple listeners to be able to listen to the same stream. In that case, we can use a Broadcast StreamController:

var controller = StreamController<int>.broadcast();

Now, we can add multiple listeners to our stream:

controller.stream.listen((data) {
  print("Received: $data");
});

controller.stream.listen((data) {
  print("Received again: $data");
});

That’s it for StreamControllers! As always, remember to close your controllers when you’re done with them to prevent any potential issues.

StreamBuilder

StreamBuilder is a widget that comes built-in with Flutter. It uses a Stream to reactively rebuild itself and give you the current state of the Stream it’s listening to. It’s great for handling the state in your Flutter apps in a declarative way.

The StreamBuilder is essentially a wrapper around the listen() method that we discussed earlier. It takes in a Stream and a builder function, which returns a widget that should be displayed given the current state of the Stream.

The builder function gives you an AsyncSnapshot, which contains the current state of the Stream. It can be in one of several states:

  1. Connection state none: No stream has been provided yet.
  2. Connection state waiting: The Stream is not null, but it has not yet emitted any values or errors.
  3. Connection state active: The Stream has emitted at least one value.
  4. Connection state done: The Stream has been closed.

Let’s see how to use StreamBuilder with our counter stream example:

class CounterWidget extends StatefulWidget {
  
  _CounterWidgetState createState() => _CounterWidgetState();
}

class _CounterWidgetState extends State<CounterWidget> {
  Stream<int> counter() {
    return Stream<int>.periodic(
      Duration(seconds: 1), 
      (x) => x,
    ).take(10); // Ends stream after 10 seconds
  }

  
  Widget build(BuildContext context) {
    return StreamBuilder<int>(
      stream: counter(),
      builder: (BuildContext context, AsyncSnapshot<int> snapshot) {
        if (snapshot.hasError) {
          return Text('Error: ${snapshot.error}');
        } else if (snapshot.connectionState == ConnectionState.waiting) {
          return CircularProgressIndicator();
        } else {
          return Text('Counter: ${snapshot.data}');
        }
      },
    );
  }
}

In this example, StreamBuilder listens to the stream we passed to it and automatically rebuilds the widget tree whenever a new event is emitted. The AsyncSnapshot provides the data of the latest event from the stream.

The CounterWidget displays a CircularProgressIndicator while waiting for the first value from the stream. Once the stream emits a value, it displays that value. If the stream emits an error, it displays the error.

This is just the tip of the iceberg of what you can do with StreamBuilder. With it, you can handle real-time data updates, display the progress of file uploads, handle user input, and much more. It’s a powerful tool to have in your Flutter toolkit.

Summary

Streams in Dart and Flutter are powerful tools for handling sequences of asynchronous events. They allow you to write clean, reactive code, and they’re especially useful for operations that can be represented as a series of events over time, like data arriving from a network request or user input.

Remember to practice and experiment with these concepts and try to create more complex streams, transformations, and use cases in your Flutter applications. Happy coding!

Enjoyed? Tell your friends.