Lesson 02 / 19
Asynchronous Dart
Two measurements of a widget whose width arrives later. The lesson explains futures, async and await, streams, the event loop, and isolates through the order in which the tree is built.
Published Updated
In this lesson
- Read future and stream types and the async and await syntax on a sizing model
- Explain the order in which synchronous code, microtasks, and the task queue run in the event loop
- Measure the sizing of a widget whose width has not yet arrived, before and after the value comes
- Show that a failed future is caught with try and catch and turned into a fallback value
Contents
In the previous lesson, Banner‘s request of 400 was a ready-made number. On a real screen, a widget’s width often arrives later: an image’s size is read, a file’s content is decoded, a response is awaited. Until then the tree still has to be built. The question of this lesson is who determines the same widget’s width before the value arrives and after.
Dart answers this situation with the future (Future) type. A Future<int> is a value that does not exist now and will complete later as an int or as an error. A function marked async always returns a future; await holds the rest of that function until a future completes, and the caller continues its own flow in the meantime.
The Tree and the Helper File
Throughout the lesson the tree is built from a single place. The buildBody function uses fill('Avatar') if the width is unknown and leaf('Avatar', width) if it is known. printAvatar measures the tree at a width of 360 and prints only the /Screen/Body/Avatar line. This file has no output and contains no main; the blocks below import it.
// sizing.dart — classifies who decides a widget's width
const sizings = ['forced', 'chosen', 'clamped', 'stretched'];
class Limit {
final int min;
final int? max; // null: unbounded
const Limit(this.min, this.max);
bool get tight => max == min;
Limit loosen() => Limit(0, max);
Limit deflate(int by) => Limit(_floor(min - by), max == null ? null : _floor(max! - by));
String bounds() => '$min..${max ?? 'inf'}';
}
int _floor(int value) => value < 0 ? 0 : value;
class Part {
final String kind;
final String name;
final int? width; // null: as wide as it can get
final List<Part> children;
final int factor;
final bool tightFlex;
const Part(this.kind, this.name, this.width, this.children,
{this.factor = 0, this.tightFlex = true});
}
Part leaf(String name, int width) => Part('leaf', name, width, const []);
Part fill(String name) => Part('leaf', name, null, const []);
Part pad(String name, int padding, Part child) => Part('pad', name, padding, [child]);
Part center(String name, Part child) => Part('center', name, null, [child]);
Part fixed(String name, int width, Part child) => Part('fixed', name, width, [child]);
Part column(String name, List<Part> children) => Part('column', name, null, children);
Part row(String name, List<Part> children) => Part('row', name, null, children);
Part flex(String name, int factor, Part child, {bool tight = true}) =>
Part('flex', name, null, [child], factor: factor, tightFlex: tight);
class Sizing {
final String sizing;
final String path;
final int width;
final Limit limit;
const Sizing(this.sizing, this.path, this.width, this.limit);
}
Sizing classify(String path, int? desire, Limit limit) {
if (limit.tight) return Sizing('forced', path, limit.min, limit);
if (desire == null) {
return limit.max == null
? Sizing('clamped', path, limit.min, limit)
: Sizing('stretched', path, limit.max!, limit);
}
if (desire < limit.min) return Sizing('clamped', path, limit.min, limit);
if (limit.max != null && desire > limit.max!) {
return Sizing('clamped', path, limit.max!, limit);
}
return Sizing('chosen', path, desire, limit);
}
int layout(Part part, Limit limit, List<Sizing> log, [String path = '']) {
final here = '$path/${part.name}';
final below = <Sizing>[];
if (part.kind == 'fixed') {
final own = classify(here, part.width, limit);
layout(part.children.first, Limit(own.width, own.width), below, here);
log..add(own)..addAll(below);
return own.width;
}
int? desire;
switch (part.kind) {
case 'leaf':
desire = part.width;
case 'pad':
final space = 2 * part.width!;
desire = layout(part.children.first, limit.deflate(space), below, here) + space;
case 'center':
final inner = layout(part.children.first, limit.loosen(), below, here);
desire = limit.max == null ? inner : null;
case 'flex':
desire = layout(part.children.first, limit, below, here);
case 'column':
var widest = 0;
for (final child in part.children) {
final width = layout(child, limit.loosen(), below, here);
if (width > widest) widest = width;
}
desire = widest;
case 'row':
final logs = [for (final _ in part.children) <Sizing>[]];
final bounded = limit.max != null;
var used = 0;
var factors = 0;
for (var i = 0; i < part.children.length; i++) {
final child = part.children[i];
if (child.kind == 'flex' && bounded) {
factors += child.factor;
} else {
used += layout(child, const Limit(0, null), logs[i], here);
}
}
final left = bounded ? _floor(limit.max! - used) : 0;
var given = 0;
var seen = 0;
for (var i = 0; i < part.children.length; i++) {
final child = part.children[i];
if (child.kind != 'flex' || !bounded) continue;
seen += child.factor;
final share = (seen == factors ? left : left * seen ~/ factors) - given;
given += share;
final room = child.tightFlex ? Limit(share, share) : Limit(0, share);
used += layout(child, room, logs[i], here);
}
for (final entries in logs) {
below.addAll(entries);
}
desire = bounded ? null : used;
}
final own = classify(here, desire, limit);
log..add(own)..addAll(below);
return own.width;
}
List<Sizing> measure(Part root, int screen) {
final log = <Sizing>[];
layout(root, Limit(screen, screen), log);
return log;
}
void printSizes(List<Sizing> log) {
for (final entry in log) {
print('${entry.sizing}: ${entry.path} width=${entry.width} constraint=${entry.limit.bounds()}');
}
}
void countSizings(List<Sizing> log) {
for (final name in sizings) {
print('$name: ${log.where((entry) => entry.sizing == name).length}');
}
print('parent: ${log.where((entry) => entry.sizing != 'chosen').length}');
}
// avatar_body.dart — builds the column tree from the avatar width and measures the avatar
import 'sizing.dart';
Part buildBody(int? width) {
final avatar = width == null ? fill('Avatar') : leaf('Avatar', width);
return pad('Screen', 16, column('Body', [leaf('Name', 120), avatar]));
}
void printAvatar(int? width) {
final log = measure(buildBody(width), 360);
printSizes(log.where((entry) => entry.path == '/Screen/Body/Avatar').toList());
}
The int? width parameter carries the idea from the first lesson: null says the width is not yet known. width == null ? fill('Avatar') : leaf('Avatar', width) is a conditional expression; in the second branch, width can be used as an int when it is not null, because the compiler tracks the null check.
DC1. The model has no waiting time, only order. loadWidth completes immediately; what is measured is the two trees, before and after the moment the value arrives. The model is not the framework itself.
Futures and await
The block below builds a future, shows that the caller continues its flow, and receives the value with await. Each step prints one line.
// main.dart
import 'sizing.dart';
import 'avatar_body.dart';
Future<int> loadWidth() async {
print('loading started');
return 200;
}
Future<void> main() async {
print('request sent');
final pending = loadWidth();
print('continuing without waiting');
final width = await pending;
print('value arrived: $width');
printAvatar(width);
}
request sent loading started continuing without waiting value arrived: 200 chosen: /Screen/Body/Avatar width=200 constraint=0..328
When loadWidth is called, its body runs immediately up to the first await or to the end; that is why the loading started line comes before continuing without waiting. The future that loadWidth returns goes into the pending variable. When execution reaches await pending, main stops and resumes where it left off once the value arrives. The value has arrived, Avatar requests 200, and because that stays inside the range, it is chosen.
The Event Loop
Dart code runs on a single thread, driven by an event loop. Running code continues to its end. Then the loop looks at two queues: it first empties the microtask queue, then takes one task from the task queue. An await or a Future(...) call leaves its continuation in one of these queues. The block below shows the order with numbers.
// main.dart
import 'sizing.dart';
import 'dart:async';
import 'avatar_body.dart';
void main() {
print('1 synchronous: first build');
printAvatar(null);
scheduleMicrotask(() {
print('3 microtask');
printAvatar(200);
});
Future(() {
print('4 task queue');
printAvatar(320);
});
print('2 synchronous: end of main');
}
1 synchronous: first build stretched: /Screen/Body/Avatar width=328 constraint=0..328 2 synchronous: end of main 3 microtask chosen: /Screen/Body/Avatar width=200 constraint=0..328 4 task queue chosen: /Screen/Body/Avatar width=320 constraint=0..328
The numbers belong to the running order, not to the order in which the code is written. No queue is processed before main finishes; that is why line 2 is printed before 3. In the first measurement there is no value and Avatar is stretched. The microtask makes the value 200, the job in the task queue makes it 320; in both, the request is inside the range and Avatar is chosen.
Errors and Streams
A future can complete not only with a value but also with an error. await throws that error to the caller, and try/catch catches it. In the block below the load fails, the function returns to a fallback width, and the tree is built with that width. Because the error is caught, it does not spread through the program; in the output, the single line announcing the fallback width is followed by the measurement of the tree.
// main.dart
import 'sizing.dart';
import 'avatar_body.dart';
Future<int> loadWidth() async {
throw StateError('no connection');
}
Future<int> loadWidthOrFallback() async {
try {
return await loadWidth();
} catch (_) {
print('load failed, using 96');
return 96;
}
}
Future<void> main() async {
final width = await loadWidthOrFallback();
printAvatar(width);
}
load failed, using 96 chosen: /Screen/Body/Avatar width=96 constraint=0..328
A future carries a single value. For several values spread over time, Dart uses the stream (Stream) type. A function marked async* produces values one after another with yield; an await for loop receives each value in the order it arrives.
// main.dart
import 'sizing.dart';
import 'avatar_body.dart';
Stream<int> widths() async* {
yield 200;
yield 320;
yield 400;
}
Future<void> main() async {
await for (final width in widths()) {
print('stream value: $width');
printAvatar(width);
}
}
stream value: 200 chosen: /Screen/Body/Avatar width=200 constraint=0..328 stream value: 320 chosen: /Screen/Body/Avatar width=320 constraint=0..328 stream value: 400 clamped: /Screen/Body/Avatar width=328 constraint=0..328
Each new value brings a new measurement of the tree. The first two values stay in the range 0..328 and are chosen; the third is 400, so the constraint clamps it. On the same widget, in the same place, the owner of the sizing changes as the value flows.
DC2. In the model, the stream consists of three fixed values. A real stream spreads its values over time; the model keeps only the order, and there is no waiting between the values.
The Isolate
The event loop shares out work but does not run it in parallel: if a heavy computation holds the loop, the queues cannot be processed and the interface freezes. For this Dart offers the isolate. An isolate is a separate execution unit with its own memory and its own event loop; it shares no memory with the main unit and only exchanges messages. The piece below moves a heavy parse to a separate unit. The piece is not measured; the model does not build the isolate.
// Platform code: isolate
import 'dart:isolate';
Future<int> parseInBackground(String raw) {
return Isolate.run(() => int.parse(raw));
}
The return type is again a Future<int>. For the caller there is no difference: the value comes later, and the tree is built, as in the two measurements above, before the value arrives and after. Building the tree stays in the main unit; the isolate carries only the work that computes the width.
The Bounding Measurement
The block below measures the whole tree before a widget’s width arrives and after, and gives the count for each measurement. The focus is /Screen/Body/Avatar in the second measurement.
// main.dart
import 'sizing.dart';
import 'avatar_body.dart';
Future<int> loadWidth() async => 200;
Future<void> main() async {
print('measurement 1: before the value arrives');
final before = measure(buildBody(null), 360);
printSizes(before);
countSizings(before);
final width = await loadWidth();
print('measurement 2: after the value arrives');
final after = measure(buildBody(width), 360);
printSizes(after);
countSizings(after);
}
measurement 1: before the value arrives forced: /Screen width=360 constraint=360..360 forced: /Screen/Body width=328 constraint=328..328 chosen: /Screen/Body/Name width=120 constraint=0..328 stretched: /Screen/Body/Avatar width=328 constraint=0..328 forced: 2 chosen: 1 clamped: 0 stretched: 1 parent: 3 measurement 2: after the value arrives forced: /Screen width=360 constraint=360..360 forced: /Screen/Body width=328 constraint=328..328 chosen: /Screen/Body/Name width=120 constraint=0..328 chosen: /Screen/Body/Avatar width=200 constraint=0..328 forced: 2 chosen: 2 clamped: 0 stretched: 0 parent: 2
This lesson’s sizing: chosen. Before the value arrives, Avatar reports no request; since the column’s constraint is 0..328, it grows as wide as it can get and is stretched. When the value arrives, Avatar requests 200, 200 is inside the range, and Avatar chooses the width. Between the two measurements only Avatar’s request has changed in the tree, and the sizing changes hands at this single node. The count summarizes this: the parent count falls from 3 to 2.
Summary
- This lesson’s sizing: chosen. Before the value arrives,
/Screen/Body/Avataris stretched; when 200 arrives, its request stays in the range0..328andAvatarchooses the width. Future<int>is a value that will complete later. Anasyncfunction returns a future,awaitholds the function until the value arrives, and the caller continues in the meantime.- The event loop finishes synchronous code, empties the microtask queue, then takes one task from the task queue. The numbers in the output show this order.
- A failed future is caught with
try/catchand turned into a fallback value. A stream carries values spread over time, and each value causes the tree to be measured again. - The isolate runs heavy work in a separate memory and returns the result as a future again.
Next Step
The tree so far was a composition of leaf, pad, and column calls. The Widget Concept lesson connects these calls to the framework’s widget concept: what a widget is, how it nests in the tree, and why a card meets a different owner under a different constraint.
To keep your progress and take notes, Log in
My notes
Log in to take notes.