Skip to content

Latest commit

 

History

History
264 lines (195 loc) · 20.3 KB

05.md

File metadata and controls

264 lines (195 loc) · 20.3 KB

Chapter 5: Asynchronous operations, State handling

In this chapter, we will learn about one of the most crucial features of Dart. We will focus on asynchronous programming and state management in Flutter apps. We will also see one of the most popular libraries out there for Flutter: provider.

Event loop

Up until now, we have only written simple example applications to demonstrate the basic usage of Flutter. Every application started with the main() function, where instructions were executed in order. After that, the functions inside our widgets and states are called one at a time. Every instruction is a blocking operation. No other instructions can run until the currently running instruction returns. This is also called synchronous programming.

Unfortunately, this is rarely enough for an average application. While most of the instructions will execute almost instantaneously, there will be some long-running tasks, such as database queries, network requests, or waiting for user input. In these cases, we can't wait for the end of such instructions because we need other tasks to run as well, such as updating the user interface and handling other events.

To support long-running tasks, different solutions have been proposed. Languages like Java and C# support multi-threading, where multiple threads of execution can run concurrently. This way, a thread can run a long-running task in blocking mode, while other threads handle other parts of the application. One of the drawbacks comes due to resources being shared: when two threads access the same resource (such as the same memory address), some thread synchronization must be used, which can end in a deadlock. This is why frameworks usually designate a single thread allowed to modify the UI, usually designated as the main thread.

In contrast, the Dart programming language (like JavaScript with NodeJS) is single-threaded. This dramatically simplifies memory management and garbage collection, which is heavily used due to new widgets being constructed often. An event loop is used to manage multiple tasks simultaneously.

In an event loop, we have an event queue, to which the running environment can add a new event at any time. This event can come from several sources: it can be the end of a file system operation, a network request, or even any kind of user input. When the event queue is not empty, the thread responsible for the event loop will take out the first event, process it, then continue with the next event until the queue is empty. This way, the system guarantees that the execution happens on a single thread while waiting for the tasks can occur in the background. This method is called asynchronous programming.

Dart event loop

Dart is also focused around the main event loop with a few additions. As we have previously stated, every Dart application starts with the top-level main() function. After exiting the main() function, every Dart application will run an event loop with two queues:

  • Microtask queue: These are special tasks that have priority over regular events. The event loop cannot process a new event before completely emptying the microtask queue. This is only used in very specialized circumstances.
  • Event queue: These are the usual events, such as user input and network responses.

If both queues are empty, the Dart virtual machine will check if there are any outstanding requests (meaning that the program still expects some events) and quit if none remains.

Due to being single-threaded, any computationally intensive task will block the event loop, which can cause hangups in our application. For these problems, Dart provides the Isolate class. An isolate allows us to run code concurrently to our main execution. The main difference compared to threads is that isolates do not share the memory space of the application: they run their event loop and manage their memory region. Isolates can communicate through their respective ReceivePort and SendPort, but only with primitive values (null, int, double, bool, String) or lists and maps containing primitive types. Due to these constraints, isolates are more akin to multiple processes running parallel than multi-threading.

Asynchronous operations in Dart

Dart provides two important classes to manage asynchronous tasks: Future<T> for a single result and Stream<T> for multiple results. Let's focus on Future first. A Future<T> represents a long-running task that will either return with an object of type T or return an error at some point in the future. We can assign corresponding callback functions to handle either the result or the error. To understand how futures work, let us look at the following example:

DartPad

import "dart:async";

Future<String> myLongRunningFunction() => Future.delayed(
  Duration(seconds: 3),
  (){
    print("Inside the future");
    throw Exception("No internet connection!");
    return "Hello in the future!";
  },
);

void main() {
  print("Starting main");
  var futureResult = myLongRunningFunction();
  print("Immediate result: $futureResult");
  futureResult.then(
    (result){
      print("Result of function: $result");
    },
  ).timeout(
    Duration(
      seconds: 1,
    ),
  ).catchError(
    (error){
      print("Caught TimeoutException error: $error");
    },
    test: (e) => e is TimeoutException,
  ).catchError(
    (error){
      print("Caught String error: $error");
    },
    test: (e) => e is String,
  ).whenComplete(
    (){
      print("Inside whenComplete");
    }
  );
  var i = 0;
  /*while(true){
    for (int j = 0; j < 1000; j++)
      print("${i++}");
  } !!FREEZES!!*/
  print("Ending main");
}

Play around with the location of the timeout function. What happens if we remove it?

In our example, we create our task with Future.delayed(), which waits for the specified duration before running the callback function. If we were to remove the exception from the callback, we would return a String object, and so the result of the function will be Future<String>.

To handle the result of our Future object, we chain a variety of different utility functions available from the Future<T> class. Note that these utility functions always return a new Future object, and so the order of the functions matter.

We use the then() function to register a callback if the Future returns successfully. We print the result in our callback now, but it is also possible to return a new value (or even a Future object) from this callback. In this case, we could use another then() function to observe this result (then() works similarly to a map() function).

Another useful utility function is the timeout() function. The resulting Future instance will wait for the duration specified before throwing a TimeoutException. If an exception is raised inside one of the Future instances, an error callback will be called. This can be specified in the optional onError callback of the then() function, or in a separate catchError() function. Within catchError(), with the optional test callback, we can filter out which errors should the callback handle.

If we want to run some code after a Future finishes either with a value or an error, we can provide a callback to the whenComplete() function. If this callback returns a Future, the resulting Future will wait for the callback to finish.

Besides these, there are four static utility functions found inside Future:

  • any(): Takes a collection of Future objects and uses the value of the first completed Future.
  • doWhile(): Runs the callback function until it returns false.
  • forEach(): Takes a collection of elements and an action callback. Calls the callback function with one element and waits for the result before calling it with the next one.
  • wait(): Waits for every Future object passed as a parameter and returns the results in a list.

While Future chaining can help us avoid callback hell, it still breaks our code into smaller, sequential parts. We can extract the callback functions into named functions, but it only boosts readability by a small margin. Luckily, Dart provides a way to write seemingly sequential code while also supporting long-running tasks.

Like in other languages such as JavaScript, C#, Dart also has built-in support for async functions. If we declare a function as async, we can use the await keyword on a Future object, which will return the result on success or throw an exception on error. With this, we can use standard Dart language features to handle the result such as a try-catch-finally structure (equivalent to then(), catchError() and whenComplete()). When returning a value of type T from an async function, the function must be declared as having a return type of Future<T>. void asynchronous functions are allowed, but we can't await these functions.

With these in mind, let's take a look at a simple example showcasing the various properties of asynchronous functions:

DartPad

Future<String> myLongRunningFunction() => Future.delayed(
  Duration(seconds: 3),
  (){
    print("Inside the future");
    throw Exception("No internet connection!");
    return "Hello in the future!";
  },
);

Future<String> myAsyncFunction() async {
  await Future.delayed(Duration(seconds: 3));
  print("Inside the future");
  throw Exception("No internet connection");
  return "Hello in the future";
}

void asyncVoidFunction() async {
  print("Starting async void function!");
  await Future.delayed(Duration(seconds: 1));
  print("Ending async void function!");
}

void myAsyncTestFunction() async {
  print("Starting async func");
  asyncVoidFunction();
  //await asyncVoidFunction(); //!ERROR! Cannot await void function
  print("Calling long running function!");
  try{
    var result = await myLongRunningFunction();
    print("Immediate result: $result");
  } catch(e) {
    print("Caught error: $e");
  } finally {
    print("Inside finally");
  }
}

void main() {
  print("Starting main function!");
  myAsyncTestFunction();
  print("Ending main function!");
}

We can see that our previously written function (myLongRunningFunction()) which only uses Future functions works the same way. Asynchronous functions behave similarly to Future objects. The myAsyncFunction() shows the equivalent async version of myLongRunningFunction().

As we have seen on the Dart execution diagram, our application starts with the main function. If we follow the order of the print messages, we can see how Dart executes async functions. Dart will always call a function and run the instructions until it sees an await. At this point, the control will return to the calling function (even if the awaited Future object is already completed). After the main function returns, the event loop will start processing events as they are coming in.

Generators

As we have previously seen in the first chapter, Dart has the Iterable class, representing a collection of elements and several utility functions capable of lazily calculating the resulting values.

Dart also provides support for lazily generating the contents of a (potentially infinite) Iterable. While the Iterable.generate() constructor creates a collection with a finite number of elements and constructs these elements with the help of a callback function, it becomes more challenging if the values depend on one another. For these cases, we can define generator functions. These functions always return an Iterable<T> object and are marked with the sync* keyword. These functions are special: while every Dart language feature is still available, we can also use the yield keyword.

Whenever we call a generator function, we will immediately get an Iterable instance (without running any code inside the function). Afterward, one way or another, we will create an Iterator from the Iterable instance. When we request an item from the Iterator, it will run our function up until the next yield keyword, which will return the value for the Iterator. The important part is that when requesting the next item, the code will resume running from the last yield keyword instead of the beginning of the function.

To show the power of generator functions, let us take a look at a prime number generator function: DartPad

import "dart:math";

Iterable<int> calculatePrimes() sync*{
  yield 2;
  for (int i = 3; true; i++){
    if (calculatePrimes().takeWhile((prime) => prime <= sqrt(i)).every((prime) => i % prime != 0)){
      yield i;
    }
  }
}

void main() {
  calculatePrimes().take(20).forEach(print);
}

This is a highly inefficient way to calculate prime numbers due to the function's recursive nature but it showcases how generator functions work.

As shown in the example, the calculatePrimes() function returns an infinite Iterable object. This means that we will have to specify some terminating condition whenever we want to use it, such as takeWhile() or take().

This type of generator is called a synchronous generator. Whenever the code needs a new value, it will continue running the generator function until a value is returned. But what can we do if the values are the result of some long-running task? Here asynchronous generators come into play. This way, the function is marked with the async* keyword.

In this case, we can't use Iterable as a return type since we cannot instantly return a value. Iterable<Future<T>> would be a somewhat better idea, but a Future object may depend on the previous result values, which the Iterable does not satisfy. Instead, Dart introduced the Stream<T> class.

Stream<T> is to Iterable<T> what Future<T> is to T. It represents a (potentially infinite) series of events, which will be received sometime in the future. To listen to these values, we either subscribe to the stream with the listen() function, call a terminating utility function (such as fold()), or use the await for keywords inside an async function, showed in the following example:

DartPad

Stream<int> myStreamGeneratorFunction() async*{
  yield 1;
  await Future.delayed(Duration(milliseconds: 200));
  yield 2;
  await Future.delayed(Duration(milliseconds: 200));
  yield 3;
  await Future.delayed(Duration(milliseconds: 200));
}

void main() async {
  await for (var value in myStreamGeneratorFunction()){
    print(value);
  }
}

There are two main types of Stream (quoted from the official Dart API):

  • A single-subscription stream allows only a single listener during the whole lifetime of the stream. It doesn't start generating events until it has a listener, and it stops sending events when the listener is unsubscribed, even if the source of events could still provide more. Listening twice on a single-subscription stream is not allowed, even after the first subscription has been canceled. This is similar to cold observables defined in ReactiveX.
  • A broadcast stream allows any number of listeners, and it fires its events when they are ready, whether there are listeners or not. They are similar to hot observables defined in ReactiveX.

Streams are similar to the Observable class defined in the widely used ReactiveX library. Many ReactiveX utility functions can be found inside the rxdart package.

State management - Provider

As we have already seen in the previous lectures, Flutter already provides a few state management classes. StatefulWidget provides a way for us to attach a mutable State object to our otherwise immutable Widget object (with the help of the element tree), while InheritedWidget makes it possible to share data to every widget in the widget's subtree without passing it as a constructor parameter. While these are enough for smaller applications, it is recommended to use some state management solutions for a cleaner code structure.

There is no one state management library that must be used for application development. Flutter is a relatively new framework, so new libraries might emerge in the future. We have chosen the libraries based on popularity, experiences, and ease of use.

Using InheritedWidget requires several classes:

  • A data class holding our (usually immutable) data variables.
  • A class extending InheritedWidget to provide the data object.
  • A class extending StatefulWidget to provide a way to change the data object.

The provider package contains many classes to help us avoid these boilerplate classes. While replacing InheritedWidget is the primary goal of the library, it can also be used as a simpler state management library.

There is a newer library made by the same developer called RiverPod. The main difference between the two library is how the objects are inserted into the hierarchy. In provider, the objects are inserted at runtime in the Widget tree with the Provider widget, while riverpod uses globally declared providers, which can be read with the help of a ProviderScope.

Provider can be broken up into two main functional parts: providing a value to the widget subtree and reading this value from one of the child widgets.

Reading a value can be achieved by the Provider.of<T>(BuildContext) function. This is also available as an extension function on the BuildContext class as watch(). There is also an optional listen parameter: when false, the element corresponding to the BuildContext instance will not rebuild itself when the value changes (this is the same as the read() extension function). This is especially important when we want to use the object outside of the build() functions (for example, in a button handler). Reading the value can also be achieved by the Consumer helper widget or the Selector widget, which can filter out values from a more complex data object and only updates itself when the selected values changed.

The primary way to provide a value is by using the Provider widget. The default constructor manages the lifecycle of the stored value with the help of the create() and dispose() callback functions. If the object's lifecycle is managed outside of the scope of the Provider, the named Provider.value() constructor must be used. Note that while the value can be of any type, Provider cannot detect any changes in the object. To alleviate this, provider contains several specialized Provider classes:

  • ChangeNotifierProvider: Contains an object extending the ChangeNotifier class. Whenever the object calls its notifyListeners() function, it rebuilds itself.
  • FutureProvider: Exposes the current value of a Future object.
  • ListenableProvider: A generalized version of ChangeNotifierProvider.
  • StreamProvider: Exposes the last received value on the Stream.
  • ValueListenableProvider: Exposes the value of a ValueListenable object.
  • ProxyProvider: Depends on the values of other Provider objects, transform them into a new type.

We can also use MultiProvider to group any number of Provider objects into one widget object.

Take a look into the flutter_counter_provider project to see how we can use the library in the sample counter application.

Conclusion

In this chapter, we have learned how Dart supports long-running tasks with the help of its event loop and asynchronous language features. We have also seen a unique language feature, generators, which can help us create synchronous and asynchronous collections. We have discussed how we can manage the state of our Flutter application with the help of the provider library. In the next chapter, we will look into how we can use the BLoC pattern within our project with the help of the flutter_bloc library.

References, materials, further reading