Lesson 10 / 19
Networking and Serialization
A width request that comes from the server, the cache, or a default is clamped if it falls outside the range. The lesson decodes the request of a banner from JSON and measures that the origin does not change the sizing.
Published Updated
In this lesson
- Tells the request layer, JSON decoding, and model conversion apart
- Measures that a width request coming from the server falls outside the constraint range
- Shows that the sizing stays clamped even when the origin of the request changes
- Shows that a request that falls inside the range stays chosen and is not clamped
Contents
The previous lesson showed that the navigator gives a page a tight constraint at the screen width and that the page’s request does not enter the result. In this lesson the request comes from the network. The server reports the width of a banner; the app reads it from a response, decodes it, and hands it to the tree. The width of the banner cannot be known before the response arrives, and the decoded value is not under the control of the app. The course question is the same again: who determines this widget’s width? The lesson looks for the answer through a request that comes from the server.
Request Layer, JSON, and Model
An app that takes data from the network separates three jobs. The request layer calls the address and returns the response as a string. JSON decoding turns the string into a key-value structure. Model conversion transfers that structure to the app’s own type, for example an object that carries the width of a banner. The request is asynchronous; in Dart this job is expressed with a Future and awaited with await. There is no network in this lesson: the response is a fixed string, and the Future only shows the order.
The Core
Every lesson uses the same core. measure walks the tree with a tight constraint at the screen width, produces a record for each node, and gives the sizing in the record by a rule: if the constraint is tight, forced; if the request stays inside the range, chosen; if the request falls outside the range, or there is no request and there is no upper bound, clamped; if there is no request and the upper bound is finite, stretched. The four names are written in ASCII in the output: forced, chosen, clamped, stretched. The parent: line is the total of forced, clamped, and stretched; it counts the records whose width is not the widget’s own request.
// 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}');
}
The Framework Side
The snippet below is the framework side of these three jobs. The snippet is not measured; the model does not run the framework’s code.
// Platform code: HTTP client
abstract class ApiClient {
Future<String> get(String path);
}
class BannerDto {
final int width;
const BannerDto(this.width);
factory BannerDto.fromJson(Map<String, dynamic> json) =>
BannerDto(json['width'] as int);
}
Future<BannerDto> loadBanner(ApiClient client) async {
final body = await client.get('/banner');
return BannerDto.fromJson(jsonDecode(body) as Map<String, dynamic>);
}
ApiClient fetches the response, fromJson turns the structure into the type, and loadBanner connects the two. The question of this lesson comes after that: once the decoded width enters the tree, what the rule does to that value.
From Response to Banner
The responses live in a helper file. fetchBanner takes the name of a source and returns the response of that source, or null if the source is offline. There are four sources: network is the current response from the server, cache is a copy stored earlier, default is the value embedded in the app, and narrow is a small banner. The helper file prints nothing to the output.
// api.dart — fixed responses; every source returns a Future, there is no network
Future<String?> fetchBanner(String source) async {
switch (source) {
case 'network':
return '{"banner": {"width": 1200}}';
case 'cache':
return '{"banner": {"width": 1000}}';
case 'default':
return '{"banner": {"width": 400}}';
case 'narrow':
return '{"banner": {"width": 300}}';
default:
return null;
}
}
decodeBanner decodes the response with jsonDecode and hands the width of the banner to the tree. The tree is a column inside the screen, and inside the column a title and the banner. Decoding comes from the dart:convert library and needs no network or file access.
// main.dart
import 'dart:convert';
import 'sizing.dart';
import 'api.dart';
Part decodeBanner(String json) {
final data = jsonDecode(json) as Map<String, dynamic>;
final banner = data['banner'] as Map<String, dynamic>;
final width = banner['width'] as int;
return pad('Screen', 16, column('Body', [
leaf('Title', 120),
leaf('Banner', width),
]));
}
Future<void> main() async {
final json = await fetchBanner('network');
print('response: $json');
final log = measure(decodeBanner(json!), 360);
printSizes(log);
countSizings(log);
}
response: {"banner": {"width": 1200}}
forced: /Screen width=360 constraint=360..360
forced: /Screen/Body width=328 constraint=328..328
chosen: /Screen/Body/Title width=120 constraint=0..328
clamped: /Screen/Body/Banner width=328 constraint=0..328
forced: 2
chosen: 1
clamped: 1
stretched: 0
parent: 3
The server has reported a banner 1200 units wide. The column gives its children a horizontally bounded range; the constraint is 0..328. /Screen/Body/Banner brought a request outside this range, and the rule pulls the request to the upper bound: the record is clamped, and the width is 328. The title asks for 120 and stays inside the range, so it is chosen. The count shows this: two nodes are forced, one is chosen, one is clamped; the parent is three.
The server asked for the width of the banner, but the server did not give the width. The request came from the network, the tree carried it as it was, and the constraint cut it down.
Changing the Origin
Now read the same banner from four sources and print only the record of the banner each time. The first three sources bring a request outside the range, and the fourth brings a request that stays inside.
// main.dart
import 'dart:convert';
import 'sizing.dart';
import 'api.dart';
Part decodeBanner(String json) {
final data = jsonDecode(json) as Map<String, dynamic>;
final banner = data['banner'] as Map<String, dynamic>;
final width = banner['width'] as int;
return pad('Screen', 16, column('Body', [
leaf('Title', 120),
leaf('Banner', width),
]));
}
Future<void> main() async {
for (final source in ['network', 'cache', 'default', 'narrow']) {
final json = await fetchBanner(source);
final log = measure(decodeBanner(json!), 360);
final banner = log.firstWhere((entry) => entry.path == '/Screen/Body/Banner');
print('source=$source ${banner.sizing} width=${banner.width} constraint=${banner.limit.bounds()}');
}
}
source=network clamped width=328 constraint=0..328 source=cache clamped width=328 constraint=0..328 source=default clamped width=328 constraint=0..328 source=narrow chosen width=300 constraint=0..328
In all three sources that ask for 1200, 1000, and 400, the record is clamped and the width is 328. In the narrow source, which asks for 300, the record is chosen and the width is 300. The name of the source does not determine the sizing; the relation of the request to the range does. The question where does this read get its response from chooses an origin among memory, disk, network, or default; this lesson does not ask it. Whatever the origin, a request outside the range is clamped.
The Bounding Measurement
Whether the origin is only the network, or the cache, or the default, the rule stays the same. Take an edge case: the network is offline, fetchBanner returns null, and the app falls back first to the cache, then to the default. Measure the response that is used at two screen widths. The range is 0..328 at 360 and 0..448 at 480.
// main.dart
import 'dart:convert';
import 'sizing.dart';
import 'api.dart';
Part decodeBanner(String json) {
final data = jsonDecode(json) as Map<String, dynamic>;
final banner = data['banner'] as Map<String, dynamic>;
final width = banner['width'] as int;
return pad('Screen', 16, column('Body', [
leaf('Title', 120),
leaf('Banner', width),
]));
}
Future<void> main() async {
final order = ['offline', 'cache', 'default'];
var used = '';
String? json;
for (final source in order) {
json = await fetchBanner(source);
if (json != null) {
used = source;
break;
}
}
for (final screen in [360, 480]) {
final log = measure(decodeBanner(json!), screen);
final banner = log.firstWhere((entry) => entry.path == '/Screen/Body/Banner');
print('screen=$screen source=$used ${banner.sizing} width=${banner.width} constraint=${banner.limit.bounds()}');
}
}
screen=360 source=cache clamped width=328 constraint=0..328 screen=480 source=cache clamped width=448 constraint=0..448
The focus node is /Screen/Body/Banner. Because the network is offline, the response came from the cache, and the request is 1000. On the screen of 360 the record is clamped and the width is 328; on the screen of 480 the range widened, so the width is 448, but the record is clamped again. The value of the request stayed 1000, while the clamped width changed with the screen: the upper bound of the constraint determines the width of a clamped node. The banner coming down to 328 or to 448 is not the decision of the server, the cache, or the default.
AS1. The model measures only the width. The delay of the response, the handling of a faulty response, the height of the banner, and the image itself are not in the model. The model builds this sizing’s rule; the model is not the framework itself. That the response always carries a valid banner key is an assumption of the lesson; a real client handles a missing or corrupt response separately.
The practical consequence is this: trusting a width that comes from the server does not mean assuming that the width fits the screen. The decision to fit is on the side of the constraint; the request layer and the model conversion carry the request as it is. However large the value the server sends, while the node is clamped the other records in the tree do not change.
Summary
- This lesson’s sizing: clamped. The focus node is
/Screen/Body/Banner: the 1200 reported by the server falls outside the0..328range the column gives and is pulled to 328. - The request layer fetches the response, JSON decoding extracts the structure, and model conversion transfers it to the type; all three carry the request, none gives the width.
- The requests of 1200, 1000, and 400 from the network, the cache, and the default all came out clamped; the source that asked for 300 stayed inside the range and was chosen.
- The 1000 that came from the cache while the network was offline was clamped to 328 on the screen of 360 and to 448 on the screen of 480; the constraint determines the clamped width.
- The origin does not enter the record: the rule sees only the request and the range.
Next Step
In this lesson the request came from outside and the rule pulled it into the range. The next lesson, Local Storage, measures the case where the request comes from a preference stored on the device: it looks at what happens to the sizing when a stored preference makes a widget flexible and when the preference goes stale.
To keep your progress and take notes, Log in
My notes
Log in to take notes.