Delayed code execution in Flutter

  • 8 min read time
  • |
  • 07 February 2021
Image not Found

In this article, I'd like to take you on a little journey into the depths of Flutter and learn more about scheduling code execution. As a conversation starter, let's assume that we are building an application with a standard BLoC architecture, using the Provider library. To make this task challenging, upon opening a new screen we will have to initiate a network request to fetch something over the Internet. In this case, we have several options where to initiate our request:

  1. Fetch the data before displaying our screen and show it with the data pre-loaded. This may not be the best option. You are likely to load a lot of unnecessary data or block the user interface with spinners if you decide to fetch only the required portions of data.
  2. Initiate the loading procedure in BLoC, just before the screen presentation, when creating the BLoC itself or by using a coordinator object to start it for you. If you want to keep your architecture nice and tidy, this would be the recommended approach.
  3. Initiate the loading procedure in the initState of the screen, trying to encapsulate this logic in the screen itself.

The third option may not be the best in terms of architectural correctness, but is actually a fairly common approach in the Flutter world. Let's examine it, as it perfectly demonstrates our topic in a real-world scenario.

For demonstration purposes, here is a sample code. Notice anything wrong with it?

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

void main() {
  runApp(
    MaterialApp(
      title: 'Demo',
      home: ChangeNotifierProvider(
        create: (_) => MyHomePageBloc(),
        child: MyHomePage(),
      ),
    ),
  );
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key}) : super(key: key);

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  void initState() {
    super.initState();

    context.read<MyHomePageBloc>().fetchData();
  }

  @override
  Widget build(BuildContext context) {
    final bloc = context.watch<MyHomePageBloc>();

    return Scaffold(
      appBar: AppBar(),
      body: Center(
        child: bloc.loading ? CircularProgressIndicator() : Text(bloc.data),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => context.read<MyHomePageBloc>().fetchData(),
        tooltip: 'Fetch',
        child: Icon(Icons.add),
      ),
    );
  }
}

class MyHomePageBloc with ChangeNotifier {
  String data = "Loading";
  bool loading = false;

  void fetchData() {
    loading = true;
    data = "Loading";
    notifyListeners();

    Future.delayed(Duration(seconds: 3), () {
      loading = false;
      data = "Done";
      notifyListeners();
    });
  }
}

At first glance, it may seem like everything is fine. However, if you run it, it will inevitably crash and you will see something similar in your logs: 'package:flutter/src/widgets/framework.dart': Failed assertion: line 4349 pos 12: '!_dirty': is not true.

This error indicates that we are trying to modify the widget tree at build time. The widget's initStatemethod is called in the middle of the build process, so any attempt to modify the widget tree from there will fail. In our case, when the fetch method is called, it synchronously executes notifyListeners(), which results in changes in the widget tree.

You may encounter a similar error when trying to do even more seemingly unrelated things. For example, showing a dialogue, which will also fail for a similar reason, because the context( Element) is not currently mounted in the widget tree yet.

Regardless of what you are trying to do, you must delay code execution until the build process is complete. In other words, you need to execute your code asynchronously. Now to our options.

How to delay code execution in Flutter?

By researching this topic on the internet, I've compiled a list of the most commonly recommended solutions. You can even find some additional options, but here are the most noticeable ones:

That's quite a number of options, you might say, and you'd be right. Speaking of our aforementioned problem, any of these will fix it. However, now that we are faced with such a variety of options, let's indulge our curiosity and try to understand the differences between them.

Event Loop and Multithreading

As you may know, Dart is a single-threaded system. Surprisingly enough, your application can do multiple things at once, or at least it looks that way. This is where the Event Loop comes into play. An Event Loop is literally an endless loop ( Run Loop for iOS developers) that executes scheduled events. The events (or just blocks of code, if you like) have to be lightweight, otherwise, your app will feel laggy or completely freeze. Each event, such as a button press or a network response, is scheduled in an events queue and waits to be picked up and executed by the Event Loop. This design pattern is quite common in UI and other systems that handle any kind of events. It might be difficult to explain this concept in a couple of sentences, so I would suggest reading something on the side if you are new to this subject. Don't overthink it, we are literally talking about a simple infinite loop and a list of tasks (blocks of code) scheduled for execution, one at a time, each iteration of the loop.

The special guest at the Dart Event Loop party we are about to learn is the Microtask . Our Event Loop has another queue inside, which is the Microtask Queue. The only thing to keep in mind about this queue is that all the tasks scheduled in it will be executed within a single iteration of the Event Loop before the event itself is executed.

Each iteration first performs all the microtasks, followed by one event. Cycle repeats.

Each iteration first performs all the microtasks, followed by one event. Cycle repeats.

Unfortunately, there isn't much information on this subject, and the best explanation I've seen can be found in the web archive here or here.

Having this knowledge, let's take a look at all the options listed above and understand the way they work and the differences between them.

Events

Anything that goes into the event queue. This is your default approach for scheduling an asynchronous task in Flutter. Scheduling an event we add it to the event queue to be picked up by the Event Loop. This approach is used by many Flutter mechanisms such as I/O, gesture events, timers, etc.

Timer

Timer is the foundation for asynchronous tasks in Flutter. It is used to schedule code execution in the event queue with or without a delay. The resulting fun fact is that if the queue is busy, your timer will never be executed, even if time is up.

How to use:
Timer.run(() {
    print("Timer");
});

Future<T> and Future<T>.delayed

A well-known and widely used Dart feature . This may come as a surprise, but if you look under the hood, you will see nothing more than a wrapper of the aforementioned Timer.

How to use:
Future<void>(() {
    print("Future Event");
});

Future<void>.delayed(Duration.zero, () {
    print("Future.delayed Event");
});
Internal Implementation ( link):
factory Future(FutureOr<T> computation()) {
  _Future<T> result = new _Future<T>();
  Timer.run(() {
    try {
      result._complete(computation());
    } catch (e, s) {
      _completeWithErrorCallback(result, e, s);
    }
  });
  return result;
}

Microtasks

As mentioned before, all scheduled microtasks are executed before the next scheduled event. It is recommended to avoid this queue unless it is absolutely necessary to execute code asynchronously, but before the next event from the event queue. You can also think of this queue as a queue of tasks that belong to the previous event, as they will be completed before the next event. Overloading this queue can completely freeze your application, since it must execute everything in this queue before it can proceed to the next iteration of its event queue, such as processing user input or even rendering the application itself. Nevertheless, here are our options:

scheduleMicrotask

As the name implies, schedules a block code in the microtask queue. Similar to the Timer, crashes the application if something goes wrong.

How to use:
scheduleMicrotask(() {
    print("Microtask");
});

Future<T>.microtask

Similar to what we saw before, wraps our microtask in a try-catch block, returning the result of the execution or error in a nice and clean way.

How to use:
Future<void>.microtask(() {
    print("Microtask");
});
Internal Implementation ( link):
factory Future.microtask(FutureOr<T> computation()) {
  _Future<T> result = new _Future<T>();
  scheduleMicrotask(() {
    try {
      result._complete(computation());
    } catch (e, s) {
      _completeWithErrorCallback(result, e, s);
    }
  });
  return result;
}

Post Frame Callback

Whereas the previous two approaches involved a lower-level Event Loop mechanism, we are now moving to the Flutter domain. This callback is called when the rendering pipeline completes, so it is tied to the widget's lifecycle. When it's scheduled, it's called only once, not on every frame. Using the addPostFrameCallback method, you can schedule one or more callbacks to be called once the frame is built. All scheduled callbacks will be executed at the end of the frame in the same order in which they were added. By the time this callback is called, it is guaranteed that the widget-building process is complete. With some smoke and mirrors, you can even access the layout of the widget ( RenderBox), such as its size, and do other kinds of unrecommended hacks. The callback itself will run in the normal event queue that Flutter uses by default for almost everything.

SchedulerBinding

This is a mixinresponsible for the drawing callbacks and implements this method we are interested in.

How to use:
SchedulerBinding.instance.addPostFrameCallback((_) {
    print("SchedulerBinding");
});

WidgetsBinding

I deliberately included this one as it is often mentioned along with SchedulerBinding. It inherits this method from the SchedulerBinding and has additional methods unrelated to our topic. In general, it doesn't matter if you use the SchedulerBinding or the WidgetsBinding, both will execute exactly the same code located in the SchedulerBinding.

How to use:
WidgetsBinding.instance.addPostFrameCallback((_) {
    print("WidgetsBinding");
});

Putting our knowledge into practice

Since we have learned a lot of theory today, I strongly recommend playing with it for a while to make sure we get it right. We can use the following code in our previous initState and try to predict in which order it will be executed, which is not an easy task as it may seem.

SchedulerBinding.instance.addPostFrameCallback((_) {
  print("SchedulerBinding");
});

WidgetsBinding.instance.addPostFrameCallback((_) {
  print("WidgetsBinding");
});

Timer.run(() {
  print("Timer");
});

scheduleMicrotask(() {
  print("scheduleMicrotask");
});

Future<void>.microtask(() {
  print("Future Microtask");
});

Future<void>(() {
  print("Future");

  Future<void>.microtask(() {
    print("Microtask from Event");
  });
});

Future<void>.delayed(Duration.zero, () {
  print("Future.delayed");

  Future<void>.microtask(() {
    print("Microtask from Future.delayed");
  });
});

Conclusion

Now that we learned so many details, you can make a considered decision on how to schedule your code. As a rule of thumb, if you need your context or something related to Layout or UI, use addPostFrameCallback. In any other case, scheduling in a standard event queue with Future<T> or Future<T>.delayed should be enough. The microtask queue is something very niche that you'll probably never come across, but it's still worth knowing about. And, of course, if you have a heavy task, you're looking at creating an Isolate, which as you might have guessed, will be communicated by the event queue. But that's a topic for another article. Thanks for your time and see you next time.

You May Also Like