Chapitre 03 · Semaine 1

Comment Flutter fonctionne

Les questions « c'est quoi un BuildContext ? », « que se passe-t-il quand on appelle setState ? » ou « à quoi servent les keys ? » reviennent dans presque tous les entretiens Flutter. Elles testent une seule chose : savez-vous ce qui se passe sous les widgets. Ce chapitre décrit les trois arbres, le cycle de vie, le layout et le pipeline de rendu jusqu'à Impeller.

Durée 2 à 3 séances de 2 h Niveau fondamental Prérequis chapitres 01 et 02

Objectifs

  • Décrire les trois arbres et le rôle de chacun, en 2 minutes, au tableau.
  • Réciter le cycle de vie d'un StatefulWidget et dire quoi faire dans chaque méthode.
  • Expliquer quand et pourquoi utiliser une key, avec l'exemple de la liste.
  • Diagnostiquer les erreurs de layout classiques à partir de la règle des contraintes.
  • Décrire le pipeline d'une frame et le rôle d'Impeller.

Architecture en couches

Flutter ne s'appuie pas sur les composants graphiques natifs de la plateforme : il dessine lui-même chaque pixel. C'est ce qui garantit un rendu identique sur Android et iOS, et c'est aussi pourquoi il doit embarquer son propre moteur de rendu.

┌───────────────────────────────────────────────────────────┐ │ Framework (Dart) │ │ Material / Cupertino │ │ Widgets │ │ Rendering (RenderObject, layout, paint) │ │ Animation · Painting · Gestures │ │ Foundation │ ├───────────────────────────────────────────────────────────┤ │ Engine (C++) │ │ Impeller (rendu) · Dart runtime · texte · dart:ui │ ├───────────────────────────────────────────────────────────┤ │ Embedder (spécifique à la plateforme) │ │ Android (Java/Kotlin, C++) · iOS/macOS (Swift, ObjC) │ │ Windows, Linux (C++) · Web │ └───────────────────────────────────────────────────────────┘
  • Le framework est entièrement en Dart et se lit comme votre propre code : on peut ouvrir la source de Column ou de setState depuis l'IDE. C'est la meilleure documentation qui soit.
  • L'engine, en C++, fournit le rendu (Impeller), le runtime Dart, la mise en page du texte et expose des primitives bas niveau via dart:ui.
  • L'embedder intègre l'engine dans l'application hôte : surface de rendu, boucle d'événements, entrées, cycle de vie, accessibilité, plugins.
Nouveau en 2026

Material et Cupertino quittent progressivement le framework pour devenir des packages pub.dev (material_ui, cupertino_ui, version 1.0 en opt-in avec Flutter 3.47). Le découpage en couches reste le même : seule la distribution du haut de la pile change. Le détail est traité au chapitre 14.

Les trois arbres

Flutter maintient trois structures parallèles. Les confondre est l'erreur la plus fréquente en entretien.

ArbreNatureDurée de vieRôle
Widgetdescription immuable, légèrerecréé à chaque builddire ce qu'on veut afficher (configuration)
Elementinstance montée dans l'arbre, mutablelongue : survit aux rebuildsfaire le lien, porter le State, gérer les dépendances
RenderObjectobjet de rendu, mutable, coûteuxlonguelayout, paint, hit testing
Widget ──createElement()──▶ Element ──createRenderObject()──▶ RenderObject (plan, immuable) (instance, garde le State) (taille, position, pixels)

Un widget est un plan, pas un objet à l'écran. Créer des milliers de widgets par frame est bon marché, car ce sont de petits objets immuables. Ce qui coûte cher (le layout, le paint) se trouve dans les RenderObjects, et Flutter fait tout pour les réutiliser.

À chaque rebuild, l'Element compare l'ancien widget au nouveau avec Widget.canUpdate : même runtimeType et même key. Si c'est le cas, l'Element est conservé et simplement mis à jour avec la nouvelle configuration (updateRenderObject pour la partie rendu). Sinon, l'ancien Element est démonté et un nouveau est créé, avec perte de son State.

Tous les widgets ne créent pas de RenderObject. StatelessWidget et StatefulWidget produisent des ComponentElement qui ne font que composer d'autres widgets. Seuls les RenderObjectWidget (Padding, Opacity, RichText, Flex...) créent un RenderObject. Un Text est un StatelessWidget qui construit un RichText, qui lui crée un RenderParagraph.

À dire en entretien

« Le widget est une description immuable et jetable. L'Element est l'instance qui vit dans l'arbre : il garde le State et décide, au rebuild, s'il peut réutiliser l'existant en comparant le type et la key. Le RenderObject fait le travail coûteux : layout, paint et hit testing. Reconstruire des widgets est bon marché ; ce qu'il faut éviter, c'est de refaire du layout et du paint inutilement. »

BuildContext, c'est l'Element

BuildContext est une interface implémentée par Element. Le context reçu dans build est donc l'Element du widget courant, c'est-à-dire sa position dans l'arbre. Toutes les méthodes « of » (Theme.of, MediaQuery.sizeOf, Navigator.of) remontent l'arbre à partir de cette position.

Conséquence : un context ne voit que ses ancêtres. Le cas classique est un Scaffold.of(context) appelé dans le même build que celui qui crée le Scaffold : le contexte est au-dessus du Scaffold, la recherche échoue.

import 'package:flutter/material.dart';

class Ecran extends StatelessWidget {
  const Ecran({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      drawer: const Drawer(),
      body: TextButton(
        // erreur : ce context est celui d'Ecran, pas un descendant du Scaffold
        onPressed: () => Scaffold.of(context).openDrawer(),
        child: const Text('Menu'),
      ),
    );
  }
}
import 'package:flutter/material.dart';

class Ecran extends StatelessWidget {
  const Ecran({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      drawer: const Drawer(),
      body: Builder(
        builder: (inner) => TextButton(
          onPressed: () => Scaffold.of(inner).openDrawer(),
          child: const Text('Menu'),
        ),
      ),
    );
  }
}

Deuxième règle : un context n'est valide que tant que son Element est monté. Après un await, l'écran a pu être fermé. Le lint use_build_context_synchronously signale ce cas.

import 'package:flutter/material.dart';

Future<void> confirmer(BuildContext context, Future<void> Function() envoyer) async {
  await envoyer();
  if (!context.mounted) return; // l'écran a peut-être été fermé
  ScaffoldMessenger.of(context).showSnackBar(
    const SnackBar(content: Text('Transfert envoyé')),
  );
}

Cycle de vie d'un StatefulWidget

Un StatefulWidget est lui-même immuable. L'état mutable vit dans un objet State, créé une fois et conservé par l'Element tant que celui-ci reste dans l'arbre. C'est pourquoi un même State peut recevoir plusieurs widgets successifs au fil des rebuilds du parent.

import 'dart:async';

import 'package:flutter/material.dart';

class Minuteur extends StatefulWidget {
  const Minuteur({super.key, required this.secondes});
  final int secondes;

  @override
  State<Minuteur> createState() => _MinuteurState(); // 1. une seule fois
}

class _MinuteurState extends State<Minuteur> {
  late int _restant;
  Timer? _timer;

  @override
  void initState() {
    super.initState();                  // 2. une fois : init, abonnements
    _restant = widget.secondes;
    _timer = Timer.periodic(const Duration(seconds: 1), (_) {
      if (_restant == 0) return;
      setState(() => _restant--);
    });
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();      // 3. après initState, puis quand un
    // InheritedWidget dont on dépend change (Theme, MediaQuery, Locale...)
  }

  @override
  void didUpdateWidget(Minuteur oldWidget) {
    super.didUpdateWidget(oldWidget);   // le parent a fourni un nouveau widget
    if (oldWidget.secondes != widget.secondes) {
      _restant = widget.secondes;       // resynchroniser l'état dérivé
    }
  }

  @override
  Widget build(BuildContext context) => Text('$_restant s'); // 4. souvent

  @override
  void deactivate() {
    super.deactivate();                 // retiré de l'arbre, peut être réinséré
  }

  @override
  void dispose() {
    _timer?.cancel();                   // 5. définitif : libérer les ressources
    super.dispose();
  }
}
MéthodeAppeléeUsageInterdit
createStateune fois, à l'insertioncréer le Statelogique
initStateune foisinitialiser, s'abonner, créer les controllersdependOnInheritedWidgetOfExactType (donc Theme.of)
didChangeDependenciesaprès initState, puis à chaque changement d'un InheritedWidget utilisétravail dépendant du contextetravail coûteux non conditionné
buildsouvent, à chaque frame si nécessairedécrire l'UIeffets de bord, requêtes, setState
didUpdateWidgetquand le parent reconstruit avec un widget de même type et keyréagir au changement de paramètresoublier de comparer avec oldWidget
deactivateretrait de l'arbreraresupposer que c'est la fin
disposeune fois, retrait définitifannuler timers, abonnements, controllerssetState

mounted vaut true entre l'insertion du State et dispose. On le teste avant un setState qui suit un await, car appeler setState après dispose déclenche une erreur.

Piège d'entretien

« Pourquoi mon écran n'affiche-t-il pas le nouvel identifiant passé par le parent ? » Parce que l'état a été initialisé à partir de widget.x dans initState, qui ne s'exécute qu'une fois. Quand le parent passe une nouvelle valeur, le State est réutilisé et seul didUpdateWidget est appelé. Il faut y comparer oldWidget et widget, ou ne pas dupliquer la donnée dans l'état et lire widget.x directement dans build.

setState et quand build est appelé

setState ne reconstruit rien immédiatement. Il exécute la fonction passée (synchrone, qui ne doit pas renvoyer de Future), puis appelle markNeedsBuild() sur l'Element. Celui-ci est marqué « dirty » et ajouté à la liste des éléments à reconstruire du BuildOwner, et une frame est planifiée. Au prochain vsync, le framework reconstruit les éléments sales, du plus haut au plus bas. Dix setState dans le même événement donnent donc un seul rebuild.

build est appelé :

  • après initState et didChangeDependencies ;
  • après didUpdateWidget, quand le parent se reconstruit ;
  • après un setState ;
  • quand un InheritedWidget dont il dépend notifie un changement ;
  • après une réactivation (deactivate puis réinsertion).

Puisque build peut être appelé à chaque frame, il doit être pur et rapide : pas de requête réseau, pas de création de controller, pas de calcul lourd.

Const widgets et réutilisation

Quand un parent se reconstruit, tout son sous-arbre est reconstruit par défaut. Sauf pour un widget const : l'instance est canonicalisée (chapitre 01), donc identique à la précédente. L'Element constate que identical(ancien, nouveau) est vrai et ne reconstruit pas ce sous-arbre. Deux techniques en découlent : mettre const partout où c'est possible, et extraire les parties qui changent dans de petits widgets pour limiter la zone reconstruite par un setState.

// setState dans l'écran entier pour mettre à jour une horloge :
// l'en-tête, la liste et le pied de page sont reconstruits aussi.
import 'package:flutter/material.dart';

class Accueil extends StatelessWidget {
  const Accueil({super.key});

  @override
  Widget build(BuildContext context) {
    return const Column(
      children: [
        EnTete(),    // const : jamais reconstruit par l'horloge
        Horloge(),   // StatefulWidget isolé, il porte son propre setState
      ],
    );
  }
}

class EnTete extends StatelessWidget {
  const EnTete({super.key});
  @override
  Widget build(BuildContext context) => const Text('Wallet');
}

class Horloge extends StatefulWidget {
  const Horloge({super.key});
  @override
  State<Horloge> createState() => _HorlogeState();
}

class _HorlogeState extends State<Horloge> {
  @override
  Widget build(BuildContext context) => Text('${DateTime.now()}');
}
À dire en entretien

« setState exécute ma fonction de façon synchrone, puis marque l'Element comme dirty et planifie une frame. Le rebuild a lieu au prochain vsync, une seule fois même si j'appelle setState plusieurs fois. Je garde les setState au plus bas de l'arbre et j'utilise des widgets const pour que le framework court-circuite les sous-arbres inchangés. Je préfère extraire un widget plutôt qu'une méthode _buildX(), parce qu'un widget a son propre Element et peut être const. »

Keys

Par défaut, quand une liste d'enfants change, Flutter apparie anciens et nouveaux widgets par position et par type. Pour des widgets sans état, c'est sans conséquence. Pour des StatefulWidget, c'est un bug : le State reste attaché à la position, pas à la donnée.

import 'package:flutter/material.dart';

class Tuile extends StatefulWidget {
  const Tuile({super.key, required this.nom});
  final String nom;

  @override
  State<Tuile> createState() => _TuileState();
}

class _TuileState extends State<Tuile> {
  bool _favori = false; // état local à la tuile

  @override
  Widget build(BuildContext context) {
    return ListTile(
      title: Text(widget.nom),
      trailing: IconButton(
        icon: Icon(_favori ? Icons.star : Icons.star_border),
        onPressed: () => setState(() => _favori = !_favori),
      ),
    );
  }
}

// Liste [Amina, Karim, Sara], Amina marquée favori.
// On supprime Amina : Karim s'affiche... avec l'étoile pleine.
// Le premier State a été réutilisé pour le premier widget, quel qu'il soit.
Widget liste(List<String> noms) => Column(
      children: [for (final n in noms) Tuile(nom: n)],
    );
import 'package:flutter/material.dart';

class Beneficiaire {
  const Beneficiaire(this.id, this.nom);
  final String id;
  final String nom;
}

class Tuile extends StatelessWidget {
  const Tuile({super.key, required this.nom});
  final String nom;
  @override
  Widget build(BuildContext context) => ListTile(title: Text(nom));
}

Widget liste(List<Beneficiaire> items) => Column(
      children: [
        for (final b in items) Tuile(key: ValueKey(b.id), nom: b.nom),
      ],
    );
KeyIdentité fondée surUsage
ValueKey(v)l'égalité == de la valeurle cas courant : un identifiant métier stable
ObjectKey(o)l'identité de l'objet (identical)pas d'identifiant, mais des instances stables
UniqueKey()unique à chaque créationforcer la recréation d'un State (réinitialiser un formulaire, rejouer une animation)
GlobalKeyunique dans toute l'applicationaccéder au State (FormState), déplacer un widget dans l'arbre sans perdre son état

Les keys locales ne doivent être uniques qu'entre frères (siblings) d'un même parent. Elles se placent sur le widget le plus haut de l'élément de liste, celui qui est enfant direct de la collection. Une GlobalKey coûte plus cher (registre global, recherche) et doit rester stable : la créer dans build recrée l'état à chaque frame.

Piège d'entretien

« J'ai mis key: UniqueKey() sur mes éléments de liste pour corriger le bug. » C'est pire : chaque rebuild crée une nouvelle key, donc chaque élément perd son état et son RenderObject à chaque frame. De même, ValueKey(index) ne sert à rien, puisqu'elle reproduit l'appariement par position. La key doit venir de la donnée : ValueKey(transaction.id).

InheritedWidget

Un InheritedWidget rend une donnée accessible à tout son sous-arbre, en O(1), sans la passer de constructeur en constructeur. C'est la fondation de Theme, MediaQuery, Localizations, de Provider, et du ProviderScope de Riverpod, qui expose son conteneur à l'arbre par un InheritedWidget interne.

import 'package:flutter/widgets.dart';

class SessionScope extends InheritedWidget {
  const SessionScope({super.key, required this.nomClient, required super.child});

  final String nomClient;

  static SessionScope of(BuildContext context) {
    // enregistre une dépendance : ce context sera reconstruit si la donnée change
    final scope = context.dependOnInheritedWidgetOfExactType<SessionScope>();
    assert(scope != null, 'Aucun SessionScope au-dessus de ce context');
    return scope!;
  }

  @override
  bool updateShouldNotify(SessionScope oldWidget) =>
      nomClient != oldWidget.nomClient; // notifier seulement si utile
}

dependOnInheritedWidgetOfExactType fait deux choses : il trouve l'ancêtre le plus proche du type demandé (chaque Element garde une table des InheritedElements au-dessus de lui, d'où le coût constant), et il inscrit le contexte appelant comme dépendant. Quand l'InheritedWidget est reconstruit avec une nouvelle valeur et que updateShouldNotify renvoie true, tous les dépendants reçoivent didChangeDependencies puis un rebuild. getInheritedWidgetOfExactType lit sans s'abonner.

Une limite : tout dépendant est reconstruit, quelle que soit la partie de la donnée qu'il utilise. D'où InheritedModel et les accesseurs ciblés de MediaQuery : MediaQuery.sizeOf(context) ne reconstruit que si la taille change, alors que MediaQuery.of(context) reconstruit aussi à l'ouverture du clavier ou à un changement de luminosité. C'est le même principe que select en Riverpod.

À dire en entretien

« InheritedWidget propage une donnée vers le bas de l'arbre et permet aux descendants de s'y abonner via dependOnInheritedWidgetOfExactType. updateShouldNotify décide si les dépendants doivent être reconstruits. C'est le mécanisme sous Theme.of et MediaQuery, et la base de Provider. Riverpod l'utilise seulement pour exposer son conteneur : l'état des providers vit hors de l'arbre, ce qui le rend testable sans widgets. »

Layout : constraints go down, sizes go up

La règle tient en une phrase : les contraintes descendent, les tailles remontent, le parent fixe la position. Un parent transmet à chaque enfant des BoxConstraints (largeur et hauteur minimales et maximales). L'enfant choisit sa taille dans ces bornes et la renvoie. Le parent positionne ensuite l'enfant. Un widget ne connaît pas sa position à l'écran et ne peut pas choisir une taille hors de ses contraintes.

Parent │ contraintes : 0 ≤ largeur ≤ 400, 0 ≤ hauteur ≤ ∞ ▼ Enfant ── choisit sa taille : 400 × 56 │ ▲ taille remontée au parent Parent ── place l'enfant en (0, 120)

Les erreurs de layout se comprennent toutes avec cette règle.

Unbounded height : Column et ListView

Une Column donne à ses enfants non flexibles une hauteur maximale infinie (elle veut connaître leur taille naturelle). Une ListView veut occuper toute la hauteur disponible dans son axe de défilement. Infini contre « tout » : Flutter lève Vertical viewport was given unbounded height.

import 'package:flutter/material.dart';

Widget ecran(List<String> items) => Column(
      children: [
        const Text('Dernières transactions'),
        ListView(children: [for (final i in items) Text(i)]),
      ],
    );
import 'package:flutter/material.dart';

Widget ecran(List<String> items) => Column(
      children: [
        const Text('Dernières transactions'),
        Expanded(
          child: ListView.builder(
            itemCount: items.length,
            itemBuilder: (context, i) => Text(items[i]),
          ),
        ),
      ],
    );

shrinkWrap: true « corrige » aussi l'erreur, mais force la liste à mesurer tous ses enfants et supprime la virtualisation : à éviter sur une longue liste. Pour mélanger en-têtes et listes dans un seul défilement, la bonne réponse est CustomScrollView et les slivers (chapitre 04).

Expanded hors d'un Flex

Expanded et Flexible ne font que poser des parent data lues par un parent Flex (Row, Column). Placés sous un Container, un Stack ou un Padding, ils produisent Incorrect use of ParentDataWidget. Ils doivent être enfants directs du Row ou de la Column.

Autres classiques

  • RenderFlex overflowed : un Text long dans une Row reçoit une largeur infinie. Enveloppez-le dans Expanded ou Flexible et définissez overflow.
  • Container qui ignore sa taille : Container(width: 100) enfant direct de l'écran reçoit des contraintes serrées (tight) et prend tout l'écran. Un Center ou un Align relâche les contraintes.
  • Hauteur infinie dans un ListView horizontal : un ListView horizontal dans une Column doit recevoir une hauteur explicite (SizedBox(height: ...)).
Piège d'entretien

« Pourquoi mon SizedBox(width: 50) fait-il toute la largeur ? » Parce que le parent impose des contraintes serrées : minimum et maximum égaux à la largeur de l'écran. Un enfant ne peut pas sortir de ses contraintes. Le candidat qui répond par la règle « constraints go down, sizes go up » et propose un Align montre qu'il comprend le moteur de layout au lieu de tâtonner.

Pipeline d'une frame

Pour tenir 60 images par seconde, chaque frame dispose d'environ 16,7 ms (8,3 ms à 120 Hz). Le travail est réparti entre deux threads principaux.

UI thread (Dart) Raster thread (C++) ────────────────────────────────────────── ───────────────────────────── vsync ▶ animations ▶ build ▶ layout ▶ paint ─▶ layer tree ─▶ rasterisation (Impeller) ▶ GPU (tickers) (dirty (render (display (composition des layers, elements) objects) lists) exécution des commandes GPU)
  1. Animate : les Ticker avancent les animations.
  2. Build : les éléments dirty reconstruisent leurs widgets.
  3. Layout : les RenderObjects marqués needsLayout calculent tailles et positions.
  4. Paint : les RenderObjects enregistrent des commandes de dessin dans des layers.
  5. Compositing : l'arbre de layers est assemblé en une scène et envoyé à l'engine.
  6. Rasterization : sur le raster thread, Impeller transforme la scène en commandes GPU (Metal, Vulkan, OpenGL ES).

Le jank apparaît quand l'une des deux colonnes dépasse le budget : un build trop lourd ou un parsing sur l'UI thread côté Dart ; trop de saveLayer, d'opacités ou de flous côté raster. L'overlay de performance et DevTools affichent les deux graphes séparément, ce qui permet de savoir où chercher.

À retenir

Depuis Flutter 3.29, sur Android et iOS, le code Dart s'exécute sur le thread principal de l'application : il n'y a plus de thread UI séparé du thread plateforme. Un plugin qui bloque le thread principal côté natif fait donc perdre des frames. Le raster thread reste distinct.

Impeller

Avec Skia, les shaders étaient compilés à la volée, la première fois qu'un effet apparaissait à l'écran. Cette compilation pouvait prendre des dizaines de millisecondes : c'est le shader compilation jank, une saccade à la première animation, impossible à corriger proprement (le préchauffage par SkSL était fragile et propre à chaque appareil). Impeller a été conçu pour supprimer ce problème : un ensemble fini de shaders est compilé au moment du build de l'engine, et les pipelines sont préparés à l'avance. Le coût de rendu devient prévisible.

PlateformeImpeller
iOSpar défaut depuis Flutter 3.10, seul moteur depuis 3.29 (Skia retiré)
Androidpar défaut sur API 29+ (Vulkan, repli OpenGL ES) ; Skia retiré sur Android 10+ en 3.44
macOS, Windows, Linuxpar défaut depuis Flutter 3.47
Webnon : les renderers web reposent sur Skia (CanvasKit, skwasm)
À dire en entretien

« Impeller remplace Skia pour supprimer le jank de compilation des shaders : les shaders sont compilés à l'avance plutôt qu'à la première utilisation. Il est seul moteur sur iOS, par défaut sur Android récent et, depuis 3.47, sur desktop. En pratique, je teste les effets coûteux (flous, ombres, clips) sur des appareils Android d'entrée de gamme en mode profile, parce que c'est là que les écarts de rendu et de performance se voient. »

Hot reload et hot restart

En mode debug, le code Dart tourne sur une VM avec compilation JIT. Le hot reload injecte les bibliothèques modifiées dans la VM en cours d'exécution, puis le framework reconstruit l'arbre. L'état est préservé : les objets State, les variables globales déjà initialisées, la navigation.

  • Ce qui n'est pas réexécuté : main(), initState(), les initialiseurs de variables globales et statiques déjà évalués. Modifier l'un d'eux semble « sans effet ».
  • Ce qui exige un hot restart : changement d'un enum en classe ou inversement, modification de types génériques, certains changements de signature, modification du code natif.

Le hot restart recompile et relance l'application Dart depuis main() : tout l'état Dart est perdu, mais le processus natif continue. Un changement natif (Kotlin, Swift, AndroidManifest.xml, ajout d'un plugin) exige une recompilation complète.

Modes debug, profile et release

ModeCompilationCaractéristiquesUsage
DebugJITasserts actifs, hot reload, service extensions, DevTools complet, lentdéveloppement
ProfileAOTperformances proches du release, traçage et DevTools Performance disponiblesmesurer la performance, sur appareil réel
ReleaseAOTasserts retirés, tree shaking, binaire optimisé, aucun outil de debugproduction
Piège d'entretien

« L'app saccade en debug, Flutter n'est pas performant. » Le mode debug utilise le JIT et exécute des vérifications supplémentaires : ses performances ne disent rien de la production. On mesure toujours en mode profile, sur un appareil physique, idéalement d'entrée de gamme. Inversement, un assert qui protège un invariant ne s'exécute pas en release : il ne doit jamais contenir de logique métier.

Questions d'entretien

Quelle différence entre StatelessWidget et StatefulWidget ?

Les deux sont immuables. Un StatelessWidget construit son UI uniquement à partir de sa configuration et de ses InheritedWidgets. Un StatefulWidget crée un objet State, conservé par l'Element entre les rebuilds, qui porte un état mutable et un cycle de vie (initState, dispose). On choisit Stateful pour un état local éphémère ou des ressources à libérer (controllers, abonnements) ; l'état métier partagé va dans un gestionnaire d'état.

Qu'est-ce qu'un BuildContext ?

C'est l'Element du widget, exposé via l'interface BuildContext : sa position dans l'arbre. Les méthodes of remontent les ancêtres depuis cette position. Un contexte ne voit pas ses descendants, et n'est utilisable que tant que l'Element est monté (vérifier context.mounted après un await).

Que se passe-t-il quand on appelle setState ?

La fonction passée s'exécute de façon synchrone, puis markNeedsBuild() marque l'Element comme dirty et planifie une frame. Au vsync suivant, le BuildOwner reconstruit les éléments dirty. Le nouvel arbre de widgets est comparé à l'ancien : Elements et RenderObjects sont réutilisés quand le type et la key correspondent, puis layout et paint ne sont refaits que là où c'est nécessaire.

Expliquez les trois arbres.

Widget : description immuable, recréée à chaque build. Element : instance durable qui relie widget et rendu, garde le State et les dépendances. RenderObject : objet coûteux qui fait layout, paint et hit testing. Les widgets sont jetables pour que les Elements et RenderObjects puissent être réutilisés.

Pourquoi et quand utiliser des keys ?

Quand des StatefulWidgets (ou des widgets avec RenderObject à état, comme un champ de texte) de même type sont réordonnés, insérés ou supprimés dans une collection. Sans key, l'appariement se fait par position et le State reste sur la mauvaise ligne. On utilise ValueKey avec un identifiant métier. GlobalKey pour accéder à un State ou déplacer un widget sans perdre son état, avec parcimonie.

Dans quel ordre sont appelées les méthodes du cycle de vie ?

createState, initState, didChangeDependencies, build. Ensuite, selon les événements : didUpdateWidget puis build quand le parent reconstruit ; didChangeDependencies puis build quand un InheritedWidget change ; build après setState. À la fin : deactivate puis dispose.

Pourquoi ne peut-on pas appeler Theme.of(context) dans initState ?

Parce que Theme.of enregistre une dépendance via dependOnInheritedWidgetOfExactType, ce qui n'est pas autorisé dans initState : si la dépendance changeait, il n'y aurait pas de mécanisme pour réexécuter initState. On fait ce travail dans didChangeDependencies ou dans build.

Comment fonctionne InheritedWidget ?

Il expose une donnée à son sous-arbre. Un descendant appelle dependOnInheritedWidgetOfExactType, trouve l'ancêtre en temps constant et s'inscrit comme dépendant. Quand l'InheritedWidget est remplacé et que updateShouldNotify renvoie true, les dépendants sont reconstruits. Theme, MediaQuery et Provider reposent dessus.

Expliquez « constraints go down, sizes go up, parent sets position ».

Le parent impose des contraintes minimales et maximales à l'enfant, l'enfant choisit sa taille dans ces bornes et la renvoie, puis le parent le positionne. Un widget ne peut pas dépasser ses contraintes et ne connaît pas sa position. Cela explique l'erreur unbounded height d'une ListView dans une Column, ou le SizedBox qui prend toute la largeur sous des contraintes serrées.

Qu'est-ce qu'Impeller et pourquoi a-t-il remplacé Skia ?

Le moteur de rendu de Flutter. Skia compilait les shaders à la première utilisation, ce qui provoquait des saccades. Impeller compile un ensemble fini de shaders à l'avance et prépare ses pipelines, d'où un rendu prévisible. Seul moteur sur iOS, par défaut sur Android API 29+, et sur desktop depuis 3.47 ; le web reste sur Skia.

Quelle différence entre hot reload et hot restart ?

Le hot reload injecte le code modifié dans la VM et reconstruit l'arbre en conservant l'état ; main, initState et les initialiseurs globaux ne sont pas réexécutés. Le hot restart relance l'app Dart depuis main et perd tout l'état. Un changement natif exige une recompilation complète.

Pourquoi extraire un widget plutôt qu'une méthode _buildHeader() ?

Un widget extrait a son propre Element : il peut être const, être court-circuité au rebuild, avoir son propre setState et son propre contexte. Une méthode est réexécutée à chaque build du parent et partage son contexte. La méthode reste acceptable pour un fragment trivial, mais le widget est le bon réflexe pour la performance et la lisibilité.

Exercices

Exercice 1 · Sans IA · 20 min

Reproduisez le bug de la liste sans keys : une Column de trois tuiles StatefulWidget avec une étoile de favori, et un bouton qui supprime le premier élément. Observez le favori qui change de ligne, puis corrigez avec une ValueKey.

Critères : vous savez expliquer le bug en citant Widget.canUpdate ; vous montrez pourquoi ValueKey(index) et UniqueKey() ne conviennent pas.

Exercice 2 · Sans IA · 20 min

Écrivez un StatefulWidget qui affiche dans la console chaque méthode du cycle de vie. Placez-le dans un parent qui change un paramètre, puis le retire de l'arbre. Prédisez la séquence sur papier avant de lancer.

Critères : séquence prédite exacte, y compris didUpdateWidget et didChangeDependencies après un changement de thème.

Exercice 3 · Sans IA · 15 min

Provoquez volontairement trois erreurs de layout : ListView dans Column, Expanded dans un Container, Text long dans une Row. Pour chacune, lisez le message d'erreur en entier, expliquez-le avec la règle des contraintes et corrigez-le.

Critères : aucune correction par shrinkWrap ; explication orale de 30 secondes par erreur.

Projet fil rouge · étape 03

Posez le squelette de l'app Wallet (voir le projet), avec des données factices construites à partir des modèles de l'étape 01. Pas encore de réseau, de go_router ni de Riverpod.

  • Arborescence feature-first du contrat : features/wallet/presentation/, features/transactions/presentation/, etc.
  • Écran Accueil : carte de solde (Money.formater()), cinq dernières transactions, rangée d'actions rapides (Transfert, Bénéficiaires, Historique).
  • Écran Historique : ListView.builder de 200 transactions factices avec une ValueKey(transaction.id) par ligne, et un filtre par TransactionType en ChoiceChip.
  • Découpage en petits widgets const : BalanceCard, TransactionTile, QuickActions. Aucune méthode _buildX() de plus de quelques lignes.
  • Écrans Détail, Transfert, Bénéficiaires et Profil en simple Scaffold avec un titre, reliés par Navigator.push pour l'instant.

Critères : flutter analyze propre avec prefer_const_constructors ; aucune erreur de layout en portrait et paysage ; avec l'option « Highlight repaints » de DevTools, changer le filtre ne repeint pas la carte de solde.

Quiz

12 questions. Corrigez, relisez les explications, refaites-le 48 h plus tard.

Pour aller plus loin