Chapitre 04 · Semaines 1 et 2

UI et performance

Construire des écrans robustes (layout, thèmes, listes, formulaires, accessibilité, RTL) puis savoir expliquer pourquoi un écran saccade et comment le prouver avec DevTools. C'est la partie où un entretien senior distingue celui qui assemble des widgets de celui qui comprend ce qu'ils coûtent.

Durée 3 séances de 2 h Niveau fondamental à avancé Prérequis chapitre 03

Objectifs

  • Choisir entre Expanded, Flexible, Stack et LayoutBuilder en expliquant les contraintes en jeu.
  • Écrire sans IA une liste paginée avec en-têtes collants à base de slivers.
  • Mettre en place un thème Material 3 avec ThemeExtension, un formulaire validé et une UI qui fonctionne en arabe (RTL).
  • Diagnostiquer un jank en mode profile avec DevTools et justifier chaque optimisation par une mesure.

Layout : les briques courantes

Tout le layout Flutter repose sur une règle vue au chapitre 03 : les contraintes descendent, les tailles remontent, le parent positionne. Les widgets de layout ne sont que des façons différentes de distribuer des contraintes.

  • Row et Column sont des Flex. Ils donnent d'abord aux enfants non flexibles une contrainte non bornée sur l'axe principal, puis répartissent l'espace restant entre les enfants flexibles.
  • Expanded est un Flexible avec fit: FlexFit.tight : l'enfant doit occuper sa part. Flexible par défaut (FlexFit.loose) : l'enfant peut occuper jusqu'à sa part, mais peut être plus petit.
  • Stack superpose ses enfants. Les enfants non positionnés reçoivent des contraintes lâches ; Positioned (ou PositionedDirectional pour le RTL) les place par rapport aux bords.
  • LayoutBuilder donne accès aux contraintes du parent au moment du layout. C'est l'outil pour adapter un composant à la place qu'on lui donne, pas à la taille de l'écran.
// Ligne de transaction : le libellé prend l'espace restant et s'ellipse,
// le montant garde sa taille naturelle.
class TransactionRow extends StatelessWidget {
  const TransactionRow({super.key, required this.label, required this.amount});

  final String label;
  final String amount;

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        const Icon(Icons.swap_horiz),
        const SizedBox(width: 12),
        Expanded(
          child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis),
        ),
        const SizedBox(width: 12),
        Text(amount, style: Theme.of(context).textTheme.titleMedium),
      ],
    );
  }
}
Piège d'entretien

« Pourquoi ce ListView dans une Column lève-t-il Vertical viewport was given unbounded height ? » Parce que la Column donne une hauteur non bornée à ses enfants non flexibles, et qu'une liste veut toute la hauteur disponible. La bonne réponse est Expanded(child: ListView(...)). shrinkWrap: true fait disparaître l'erreur mais construit toute la liste d'un coup : c'est une régression de performance déguisée.

MediaQuery.sizeOf plutôt que MediaQuery.of

MediaQuery.of(context) abonne le widget à tout le MediaQueryData : taille, insets du clavier, padding système, échelle de texte, luminosité. Ouvrir le clavier change viewInsets et reconstruit donc tous ces widgets, à chaque frame de l'animation du clavier. Depuis Flutter 3.10, MediaQuery est un InheritedModel et expose des accesseurs ciblés : sizeOf, paddingOf, viewInsetsOf, textScalerOf, platformBrightnessOf. Le widget n'est reconstruit que si l'aspect qu'il lit change.

final width = MediaQuery.of(context).size.width; // reconstruit aussi à l'ouverture du clavier
final width = MediaQuery.sizeOf(context).width; // reconstruit seulement si la taille change

SafeArea et edge-to-edge (Android 15+)

Une app qui cible le SDK 35 ou plus est affichée en edge-to-edge (bord à bord) sur Android 15+ : le contenu passe sous la barre d'état et la barre de navigation, et l'opt-out n'est plus possible. Avec Flutter 3.47, le targetSdk par défaut est 36, donc c'est le cas général. Les widgets Material (Scaffold, AppBar, NavigationBar) consomment déjà les insets. Ce qui casse, ce sont les écrans custom : bouton collé en bas, contenu d'un Stack plein écran, bottom sheet maison.

// Bouton d'action en bas d'écran : SafeArea ajoute le padding système
// (barre de gestes) sans toucher au haut, déjà géré par l'AppBar.
Scaffold(
  appBar: AppBar(title: const Text('Transfert')),
  body: const TransferForm(),
  bottomNavigationBar: SafeArea(
    top: false,
    minimum: const EdgeInsets.all(16),
    child: FilledButton(onPressed: () {}, child: const Text('Continuer')),
  ),
);

Distinguez MediaQuery.paddingOf (zone occupée par l'UI système, réduite quand le clavier est ouvert) et MediaQuery.viewPaddingOf (zone physique, encoche comprise, qui ne change pas avec le clavier).

Responsive et adaptive

Deux notions à ne pas confondre en entretien. Responsive : la mise en page s'ajuste à l'espace (une colonne sur téléphone, deux sur tablette). Adaptive : le comportement s'ajuste à la plateforme et au mode d'entrée (souris, clavier, tactile ; conventions iOS ou Android). Material 3 définit des classes de fenêtre : compact sous 600 dp, medium de 600 à 839 dp, expanded à partir de 840 dp.

// Le composant s'adapte à la place reçue, pas à la taille de l'écran :
// il fonctionne aussi dans un panneau latéral ou un split-screen.
class HistoryLayout extends StatelessWidget {
  const HistoryLayout({super.key, required this.list, required this.detail});

  final Widget list;
  final Widget detail;

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        if (constraints.maxWidth < 840) return list;
        return Row(
          children: [
            SizedBox(width: 360, child: list),
            const VerticalDivider(width: 1),
            Expanded(child: detail),
          ],
        );
      },
    );
  }
}

Évitez de tester Platform.isIOS pour décider d'un layout : cela casse sur le web et ne dit rien de la taille. Pour l'adaptive, lisez Theme.of(context).platform (surchargeable en test) et les constructeurs .adaptive (Switch.adaptive, CircularProgressIndicator.adaptive).

Thèmes Material 3

Material 3 est le défaut depuis Flutter 3.16. Le point d'entrée est ColorScheme.fromSeed : une couleur de marque génère une palette tonale cohérente, en clair et en sombre, avec des contrastes calculés. Les widgets lisent les rôles (primary, onPrimary, surfaceContainer, error...) : on ne code jamais une couleur en dur dans un widget.

Pour les besoins hors Material (couleur crédit / débit, espacements de marque), on ajoute une ThemeExtension. Elle est typée, interpolée lors des transitions de thème grâce à lerp, et accessible via Theme.of(context).extension<T>().

import 'package:flutter/material.dart';

@immutable
class WalletColors extends ThemeExtension<WalletColors> {
  const WalletColors({required this.credit, required this.debit});

  final Color credit;
  final Color debit;

  @override
  WalletColors copyWith({Color? credit, Color? debit}) =>
      WalletColors(credit: credit ?? this.credit, debit: debit ?? this.debit);

  @override
  WalletColors lerp(WalletColors? other, double t) {
    if (other == null) return this;
    return WalletColors(
      credit: Color.lerp(credit, other.credit, t)!,
      debit: Color.lerp(debit, other.debit, t)!,
    );
  }
}

ThemeData buildTheme(Brightness brightness) {
  final scheme = ColorScheme.fromSeed(
    seedColor: const Color(0xFF0A62C2),
    brightness: brightness,
  );
  return ThemeData(
    colorScheme: scheme,
    extensions: [
      WalletColors(
        credit: brightness == Brightness.light ? const Color(0xFF1A7F37) : const Color(0xFF5CD27A),
        debit: scheme.error,
      ),
    ],
  );
}

// Dans un widget :
// final colors = Theme.of(context).extension<WalletColors>()!;
// Text(amount, style: TextStyle(color: colors.credit));

Pour la transparence, utilisez color.withValues(alpha: 0.6) : withOpacity est déprécié depuis le passage aux couleurs en virgule flottante (wide gamut).

Nouveau en 2026

Material et Cupertino sont gelés dans le framework depuis 3.44. En 3.47, ils existent en packages autonomes material_ui et cupertino_ui, en opt-in : on remplace import 'package:flutter/material.dart' par import 'package:material_ui/material_ui.dart', ou on lance dart fix --apply --code=migrate_design_widgets. Les noms de classes ne changent pas. La dépréciation formelle des bibliothèques du SDK est annoncée pour la stable de novembre 2026. Les exemples de ce chapitre utilisent encore l'import historique, toujours valide en 3.47.

Listes et slivers

ListView(children: [...]) construit tous les enfants immédiatement. ListView.builder ne construit que les éléments visibles (plus une marge de cache, cacheExtent) et recycle les éléments qui sortent de l'écran. Au-delà d'une vingtaine d'éléments, ou dès que la liste vient d'une API, le builder est la règle.

Deux paramètres réduisent encore le coût du layout. itemExtent fixe la hauteur de chaque élément : le viewport calcule directement quel index est visible sans mesurer les enfants, ce qui rend les sauts (jumpTo, scrollbar glissée) instantanés. prototypeItem fait la même chose quand la hauteur n'est pas connue à l'avance mais identique pour tous : Flutter mesure le prototype une fois. itemExtentBuilder couvre le cas de hauteurs variables mais calculables.

ListView.builder(
  itemCount: items.length,
  itemExtent: 72, // toutes les lignes font 72 dp : pas de mesure individuelle
  itemBuilder: (context, index) {
    final tx = items[index];
    // ValueKey sur l'id métier : l'état d'une ligne suit la transaction
    // si la liste est réordonnée ou filtrée (voir chapitre 03).
    return TransactionTile(key: ValueKey(tx.id), transaction: tx);
  },
);

Slivers

Un sliver est une portion de zone défilante. ListView n'est qu'un CustomScrollView avec un seul SliverList. Dès qu'un écran combine plusieurs zones dans un même défilement (en-tête qui se replie, grille puis liste, sections), on passe à CustomScrollView avec des slivers : un seul viewport, un seul scroll, et la paresse de construction est conservée pour chaque section. Imbriquer des ListView en shrinkWrap perd cette paresse.

  • SliverAppBar : barre qui se replie (pinned, floating, snap), variantes SliverAppBar.medium et .large en M3.
  • SliverList.builder, SliverGrid, SliverToBoxAdapter pour un widget simple.
  • SliverMainAxisGroup (3.13+) regroupe plusieurs slivers en une section. Un en-tête épinglé à l'intérieur reste collé jusqu'à la fin de son groupe, puis laisse la place au suivant : c'est exactement le comportement des en-têtes de date d'un historique bancaire.
class GroupedHistory extends StatelessWidget {
  const GroupedHistory({super.key, required this.groups});

  /// Transactions déjà groupées par jour, du plus récent au plus ancien.
  final List<({String label, List<Transaction> items})> groups;

  @override
  Widget build(BuildContext context) {
    return CustomScrollView(
      slivers: [
        const SliverAppBar.medium(title: Text('Historique'), pinned: true),
        for (final group in groups)
          SliverMainAxisGroup(
            key: ValueKey(group.label),
            slivers: [
              SliverPersistentHeader(
                pinned: true,
                delegate: _DayHeader(group.label),
              ),
              SliverList.builder(
                itemCount: group.items.length,
                itemBuilder: (context, i) => TransactionTile(
                  key: ValueKey(group.items[i].id),
                  transaction: group.items[i],
                ),
              ),
            ],
          ),
      ],
    );
  }
}

class _DayHeader extends SliverPersistentHeaderDelegate {
  const _DayHeader(this.label);

  final String label;

  @override
  double get minExtent => 40;
  @override
  double get maxExtent => 40;

  @override
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
    final scheme = Theme.of(context).colorScheme;
    return Container(
      color: scheme.surfaceContainerHighest,
      alignment: AlignmentDirectional.centerStart,
      padding: const EdgeInsetsDirectional.only(start: 16),
      child: Text(label, style: Theme.of(context).textTheme.labelLarge),
    );
  }

  @override
  bool shouldRebuild(_DayHeader oldDelegate) => oldDelegate.label != label;
}

Pagination infinie

Le principe : déclencher le chargement de la page suivante avant que l'utilisateur atteigne le bas, empêcher les appels concurrents, et afficher un indicateur ou une erreur réessayable en dernier élément. L'état (items, curseur, chargement en cours) vit dans un AsyncNotifier Riverpod, détaillé au chapitre 06 ; le widget ne fait que signaler le besoin.

class HistoryList extends ConsumerStatefulWidget {
  const HistoryList({super.key});

  @override
  ConsumerState<HistoryList> createState() => _HistoryListState();
}

class _HistoryListState extends ConsumerState<HistoryList> {
  final _controller = ScrollController();

  @override
  void initState() {
    super.initState();
    _controller.addListener(_onScroll);
  }

  void _onScroll() {
    // Moins de 600 px restants : on demande la page suivante.
    // Le notifier ignore l'appel si un chargement est déjà en cours
    // ou si nextCursor est null (fin de liste).
    if (_controller.position.extentAfter < 600) {
      ref.read(historyProvider.notifier).loadMore();
    }
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final history = ref.watch(historyProvider);
    return switch (history) {
      AsyncData(:final value) => ListView.builder(
          controller: _controller,
          itemCount: value.items.length + (value.hasMore ? 1 : 0),
          itemBuilder: (context, i) => i == value.items.length
              ? const Padding(
                  padding: EdgeInsets.all(16),
                  child: Center(child: CircularProgressIndicator()),
                )
              : TransactionTile(key: ValueKey(value.items[i].id), transaction: value.items[i]),
        ),
      AsyncError(:final error) => Center(child: Text('Erreur : $error')),
      _ => const Center(child: CircularProgressIndicator()),
    };
  }
}
À dire en entretien

« Pour une liste issue d'une API, j'utilise toujours un builder, avec itemExtent ou prototypeItem quand les lignes ont une hauteur fixe. Dès que l'écran mélange plusieurs zones dans un même défilement, je passe à un CustomScrollView avec des slivers plutôt que d'imbriquer des listes en shrinkWrap, qui construisent tout d'un coup. La pagination est pilotée par l'état : le widget signale qu'il approche du bas, le notifier décide s'il charge. »

Images

Une photo de 4000 x 3000 pixels décodée occupe environ 48 Mo en mémoire (4 octets par pixel), même affichée en vignette de 48 dp. Le décodage se fait à la taille source, sauf si on demande une taille cible. cacheWidth / cacheHeight (ou ResizeImage) font décoder l'image directement à la taille utile.

Widget avatar(BuildContext context, String url) {
  final dpr = MediaQuery.devicePixelRatioOf(context);
  return ClipOval(
    child: Image.network(
      url,
      width: 48,
      height: 48,
      fit: BoxFit.cover,
      // Décodage à la taille affichée en pixels physiques, pas à la taille source.
      cacheWidth: (48 * dpr).round(),
      errorBuilder: (context, error, stack) => const Icon(Icons.person),
    ),
  );
}

Image.network ne garde l'image qu'en cache mémoire (ImageCache). cached_network_image ajoute un cache disque, des placeholders et les paramètres memCacheWidth / memCacheHeight qui jouent le même rôle que cacheWidth. precacheImage(provider, context) charge une image en avance, par exemple dans didChangeDependencies de l'écran précédent, pour éviter le flash lors de la navigation.

Animations

Implicites : AnimatedContainer, AnimatedOpacity, AnimatedSwitcher, TweenAnimationBuilder. On change une valeur cible, le widget anime la transition. Aucun contrôleur à gérer : c'est le bon choix par défaut. Explicites : un AnimationController que l'on pilote (forward, reverse, repeat, arrêt sur un geste), combiné à des Tween et des transitions (FadeTransition, SlideTransition) ou à un AnimatedBuilder. On en a besoin pour les animations en boucle, chaînées ou pilotées par un geste.

Le contrôleur demande un vsync : un TickerProvider qui fournit un tick par frame et coupe les ticks quand le widget n'est pas visible (TickerMode désactivé, par exemple une route recouverte). Sans cela, une animation hors écran consommerait du CPU et de la batterie.

class PulseDot extends StatefulWidget {
  const PulseDot({super.key});

  @override
  State<PulseDot> createState() => _PulseDotState();
}

class _PulseDotState extends State<PulseDot> with SingleTickerProviderStateMixin {
  late final AnimationController _controller = AnimationController(
    vsync: this, // ticks coupés quand le widget n'est pas visible
    duration: const Duration(milliseconds: 900),
  )..repeat(reverse: true);

  @override
  void dispose() {
    _controller.dispose(); // sinon : fuite et exception "Ticker was not disposed"
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    // FadeTransition anime l'opacité sans reconstruire le sous-arbre.
    return FadeTransition(
      opacity: Tween<double>(begin: 0.3, end: 1).animate(_controller),
      child: const Icon(Icons.circle, size: 12),
    );
  }
}
Nouveau en 2026

Flutter 3.41 ajoute RepeatingAnimationBuilder : une animation en boucle sans StatefulWidget ni contrôleur. Paramètres : animatable, duration, curve, repeatMode (redémarrage ou aller-retour), paused, builder et child. Idéal pour un skeleton de chargement.

RepeatingAnimationBuilder<double>(
  animatable: Tween<double>(begin: 0.4, end: 1),
  duration: const Duration(milliseconds: 1200),
  curve: Curves.easeInOut,
  // child est construit une fois et réutilisé à chaque tick.
  child: const SkeletonTile(),
  builder: (context, opacity, child) => Opacity(opacity: opacity, child: child),
);

Formulaires

Un Form regroupe des TextFormField ; une GlobalKey<FormState> permet d'appeler validate(), save() ou reset() sur tous les champs. Chaque champ porte un validator qui renvoie un message d'erreur ou null. autovalidateMode: AutovalidateMode.onUserInteraction valide au fil de la saisie, après la première interaction.

Les TextEditingController et FocusNode sont des objets avec des listeners natifs : ils se créent dans initState (ou en late final) et se libèrent dans dispose. Les créer dans build les recrée à chaque rebuild, perd le texte et fuit.

class AmountForm extends StatefulWidget {
  const AmountForm({super.key, required this.onSubmit});

  final void Function(int amountMinor, String? note) onSubmit;

  @override
  State<AmountForm> createState() => _AmountFormState();
}

class _AmountFormState extends State<AmountForm> {
  final _formKey = GlobalKey<FormState>();
  final _amount = TextEditingController();
  final _note = TextEditingController();
  final _noteFocus = FocusNode();

  @override
  void dispose() {
    _amount.dispose();
    _note.dispose();
    _noteFocus.dispose();
    super.dispose();
  }

  String? _validateAmount(String? raw) {
    final value = int.tryParse((raw ?? '').trim());
    if (value == null) return 'Montant invalide';
    if (value <= 0) return 'Le montant doit être positif';
    return null;
  }

  void _submit() {
    if (!_formKey.currentState!.validate()) return;
    // Saisie en dirhams entiers, conversion en centimes : jamais de double.
    final minor = int.parse(_amount.text.trim()) * 100;
    final note = _note.text.trim();
    widget.onSubmit(minor, note.isEmpty ? null : note);
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      autovalidateMode: AutovalidateMode.onUserInteraction,
      child: Column(
        children: [
          TextFormField(
            controller: _amount,
            decoration: const InputDecoration(labelText: 'Montant (MAD)'),
            keyboardType: TextInputType.number,
            textInputAction: TextInputAction.next,
            validator: _validateAmount,
            onFieldSubmitted: (_) => _noteFocus.requestFocus(),
          ),
          TextFormField(
            controller: _note,
            focusNode: _noteFocus,
            decoration: const InputDecoration(labelText: 'Note (facultatif)'),
            maxLength: 140,
            textInputAction: TextInputAction.done,
            onFieldSubmitted: (_) => _submit(),
          ),
          FilledButton(onPressed: _submit, child: const Text('Valider')),
        ],
      ),
    );
  }
}
Piège d'entretien

« La validation côté client suffit-elle pour un montant de transfert ? » Non. Elle sert l'expérience utilisateur. Le backend revalide tout (plafond, solde, format), et l'app doit afficher proprement l'erreur métier renvoyée (VALIDATION_ERROR, INSUFFICIENT_FUNDS), idéalement sur le champ concerné.

Accessibilité

Pour le secteur public et les grands comptes, l'accessibilité figure souvent dans le cahier des charges (référentiels de type WCAG / RGAA). Flutter construit un semantics tree (arbre sémantique) parallèle, lu par TalkBack et VoiceOver. Les widgets Material produisent déjà une sémantique correcte ; le travail porte sur les widgets custom.

  • Semantics ajoute un libellé, un rôle (button, header), une valeur. MergeSemantics fusionne une ligne en une seule annonce. ExcludeSemantics masque la décoration.
  • Tailles de texte : ne bridez pas TextScaler globalement. Testez à 200 % ; les lignes de hauteur fixe (itemExtent) doivent alors s'agrandir ou laisser le texte passer sur deux lignes. MediaQuery.textScalerOf(context) remplace l'ancien textScaleFactor.
  • Contraste : 4,5:1 minimum pour le texte courant. Utiliser les paires de rôles du ColorScheme (primary / onPrimary) garantit en grande partie ce point.
  • Cibles tactiles d'au moins 48 x 48 dp ; ne pas transmettre une information par la seule couleur (crédit en vert : ajouter aussi un signe « + »).
MergeSemantics(
  child: Semantics(
    // Une seule annonce claire au lieu de trois fragments lus séparément.
    label: 'Transfert reçu de Salma, plus 150 dirhams, 12 octobre',
    excludeSemantics: true,
    button: true,
    child: TransactionTile(transaction: tx),
  ),
);

Internationalisation et RTL

Une app marocaine doit souvent gérer français, arabe et parfois anglais. L'arabe impose le right-to-left (RTL). Flutter inverse automatiquement Row, ListTile et les icônes directionnelles quand la locale est RTL, à condition d'utiliser les variantes directionnelles : EdgeInsetsDirectional (start / end au lieu de left / right), AlignmentDirectional, PositionedDirectional, BorderRadiusDirectional. Le widget Directionality fixe le sens pour un sous-arbre (rarement nécessaire : MaterialApp le déduit de la locale).

Les textes passent par des fichiers ARB et le générateur gen-l10n : flutter: generate: true dans le pubspec, un l10n.yaml, et une classe AppLocalizations typée est générée. Les formats ICU gèrent les pluriels (l'arabe a six formes) et les placeholders typés.

arb-dir: lib/l10n
template-arb-file: app_fr.arb
output-localization-file: app_localizations.dart
{
  "@@locale": "fr",
  "historyTitle": "Historique",
  "pendingCount": "{count, plural, =0{Aucune opération en attente} =1{1 opération en attente} other{{count} opérations en attente}}",
  "@pendingCount": {
    "placeholders": { "count": { "type": "int" } }
  }
}
MaterialApp.router(
  routerConfig: router,
  supportedLocales: AppLocalizations.supportedLocales, // fr, ar, en
  // Avec l'import historique : délégués générés (Material, Cupertino, Widgets inclus).
  localizationsDelegates: AppLocalizations.localizationsDelegates,
);

// Dans un widget :
// Text(AppLocalizations.of(context)!.pendingCount(3));
// Padding(padding: const EdgeInsetsDirectional.only(start: 16), child: ...);
Nouveau en 2026

Avec la migration vers material_ui (3.47), flutter_localizations est dégroupé pour les composants de design : les délégués Material, Cupertino et Widgets s'obtiennent en une fois avec GlobalMaterialLocalizations.delegates, auquel on ajoute AppLocalizations.delegate. Point à vérifier sur votre version : la forme exacte du code généré par gen-l10n après migration.

Piège d'entretien

« Votre écran est traduit en arabe, il est donc prêt pour le RTL ? » Non. Il faut aussi que les marges soient directionnelles, que les icônes « retour » ou « suivant » soient miroir (Icons.arrow_back l'est, une image PNG non), que les montants et numéros de téléphone restent lisibles (chiffres latins ou arabes selon le choix produit, formatés par intl), et tester réellement l'app avec la locale ar.

Performance : pourquoi un écran saccade

À 60 Hz, chaque frame dispose de 16,6 ms ; à 120 Hz, de 8,3 ms. Une frame passe par deux threads : le UI thread exécute le code Dart (build, layout, paint, c'est-à-dire la production d'une liste de commandes de dessin) et le raster thread transforme ces commandes en pixels via le moteur de rendu (Impeller). Un jank (saccade) est une frame qui dépasse le budget sur l'un des deux threads. Le diagnostic commence toujours par savoir lequel.

Vsync ──▶ UI thread : build ▸ layout ▸ paint (Dart) ──▶ Raster thread : composition ▸ GPU (Impeller) trop lent = trop de widgets reconstruits, trop lent = saveLayer, clips, ombres, calculs dans build, layout coûteux images énormes, effets plein écran

Causes côté UI thread

  • Rebuilds trop larges. Un setState en haut d'un écran reconstruit tout le sous-arbre. Une horloge qui tique chaque seconde à la racine reconstruit l'écran chaque seconde.
  • Travail dans build. Tri, filtrage, parsing de dates, NumberFormat recréé pour chaque ligne. build peut être appelé à chaque frame : il doit rester une fonction pure et bon marché.
  • Travail lourd sur l'isolate principal. Décoder un JSON de plusieurs Mo, chiffrer un fichier : passer par Isolate.run (chapitre 02).
  • Listes non paresseuses (shrinkWrap, Column dans un SingleChildScrollView avec des centaines d'éléments).

Réduire les rebuilds

  • const : un widget const est canonicalisé ; lors d'un rebuild du parent, Flutter voit que l'instance est identique et ne reconstruit pas ce sous-arbre.
  • Découper en petits widgets : l'état qui change doit être le plus bas possible dans l'arbre. Seul le petit widget qui écoute est reconstruit.
  • Préférer des classes aux méthodes qui retournent des widgets. Une méthode _buildHeader() s'exécute à chaque build du parent, ne peut pas être const, n'a pas son propre Element ni son propre BuildContext, et n'apparaît pas comme widget dans l'inspecteur.
  • Côté Riverpod, ref.watch(provider.select((s) => s.balance)) ne reconstruit le widget que si la valeur sélectionnée change (détails au chapitre 06).
  • Paramètre child de AnimatedBuilder, ValueListenableBuilder, Consumer : la partie statique est construite une fois et réinjectée.
class HomeScreen extends ConsumerWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final wallet = ref.watch(walletProvider); // tout l'écran dépend du solde
    return Column(children: [
      _buildBalance(wallet),   // méthode : reconstruite à chaque build
      _buildQuickActions(),    // statique, mais reconstruite quand même
      _buildRecent(ref),
    ]);
  }
}
class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return const Column(children: [
      BalanceCard(),    // seul widget qui watch le solde
      QuickActions(),   // const : jamais reconstruit par le parent
      RecentTransactions(),
    ]);
  }
}

class BalanceCard extends ConsumerWidget {
  const BalanceCard({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final balance = ref.watch(walletProvider.select((w) => w.value?.balance));
    return Card(child: Text(balance == null ? '...' : formatMinor(balance)));
  }
}

Causes côté raster thread

  • saveLayer : certaines opérations obligent à dessiner un sous-arbre dans une texture intermédiaire puis à la composer. Déclencheurs typiques : Opacity sur un sous-arbre complexe, ShaderMask, ColorFilter, BackdropFilter, Clip.antiAliasWithSaveLayer.
  • Opacité : pour une couleur unie, utiliser color.withValues(alpha: ...) plutôt qu'un widget Opacity. Pour animer, FadeTransition ou AnimatedOpacity plutôt que Opacity reconstruit à chaque frame. Pour une image, le paramètre opacity de Image.
  • Clips : un ClipRRect autour d'un conteneur peut souvent être remplacé par BoxDecoration(borderRadius: ...). Le défaut Clip.hardEdge ou antiAlias suffit presque toujours.
  • Ombres et flous nombreux dans une liste, images décodées trop grandes.

RepaintBoundary isole un sous-arbre dans sa propre couche : quand une petite zone anime (indicateur, graphique temps réel), le reste n'est pas repeint. À l'inverse, en mettre partout multiplie les couches et la mémoire. ListView en ajoute déjà un autour de chaque élément (addRepaintBoundaries: true). On l'ajoute quand l'option Highlight repaints de DevTools montre une zone repeinte inutilement.

Avec Impeller, défaut sur iOS et Android, les shaders sont précompilés : le jank de « première animation » dû à la compilation de shaders, fréquent avec Skia, a pratiquement disparu. Les conseils de shader warm-up que l'on trouve dans d'anciens articles sont obsolètes.

DevTools : mesurer avant d'optimiser

Règle absolue : on profile en mode profile, sur un appareil réel (flutter run --profile). Le mode debug exécute le Dart en JIT avec des assertions et des services de debug : il est souvent plusieurs fois plus lent et ne dit rien de la production. Le mode release retire les outils de mesure. Préférez un appareil d'entrée de gamme : c'est là que vos utilisateurs saccadent.

Outil DevToolsCe qu'il montreQuestion à laquelle il répond
Performance view, frame chartUne barre par frame, UI et raster séparés, frames lentes en rougeEst-ce le UI thread ou le raster thread qui dépasse ?
Timeline eventsDétail d'une frame : Build, Layout, Paint, RasterQuelle phase coûte ?
Rebuild stats (Track widget rebuilds)Nombre de rebuilds par widgetQuels widgets sont reconstruits sans raison ?
CPU profilerÉchantillons de pile, bottom-up et call treeQuelle fonction Dart consomme le temps ?
MemoryHeap, allocations, snapshots, diffY a-t-il une fuite (controller non libéré, écran retenu) ?
Highlight repaints, Highlight oversized imagesZones repeintes, images décodées trop grandesFaut-il un RepaintBoundary ou un cacheWidth ?
À dire en entretien

« Je ne commence pas par mettre des const partout. Je reproduis la saccade en mode profile sur un appareil modeste, j'ouvre le frame chart pour savoir si c'est le UI thread ou le raster thread, puis je descends : rebuild stats et CPU profiler côté UI, saveLayer, clips et images côté raster. Je corrige une cause, je remesure, et je garde la trace avant / après. Dans un contexte grand compte, je peux même ajouter un test de performance automatisé avec integration_test pour éviter la régression. »

Piège d'entretien

« L'app saccade en debug sur l'émulateur, que faites-vous ? » Rien de définitif tant que ce n'est pas reproduit en mode profile sur un vrai appareil. Optimiser sur une mesure en debug, c'est corriger un problème qui n'existe peut-être pas, et rendre le code plus complexe sans gain prouvé.

Questions d'entretien

Quelle différence entre Expanded et Flexible ?

Expanded est un Flexible avec fit: FlexFit.tight : l'enfant est forcé à occuper exactement sa part de l'espace restant. Flexible en loose lui permet d'être plus petit. Les deux répartissent l'espace selon flex, après le dimensionnement des enfants non flexibles.

Pourquoi préférer MediaQuery.sizeOf à MediaQuery.of(context).size ?

MediaQuery est un InheritedModel. of abonne le widget à toutes les propriétés, donc il est reconstruit quand le clavier s'ouvre, quand l'échelle de texte change, etc. sizeOf n'abonne qu'à la taille. Sur un écran avec un champ texte, la différence se voit pendant l'animation du clavier.

ListView vs ListView.builder : quand utiliser lequel, et que changent itemExtent et prototypeItem ?

Le constructeur par défaut construit tous les enfants : acceptable pour une poignée d'éléments statiques. Le builder construit à la demande les éléments visibles. itemExtent (hauteur fixe connue) et prototypeItem (hauteur identique mesurée une fois) évitent de mesurer chaque enfant, ce qui accélère le layout et rend les sauts de position instantanés.

Qu'est-ce qu'un sliver et quand en avez-vous besoin ?

Une portion d'un viewport défilant, avec son propre protocole de layout (SliverConstraints). On en a besoin dès qu'un écran combine plusieurs zones dans un seul défilement : app bar repliable, sections avec en-têtes collants (SliverMainAxisGroup + en-tête épinglé), grille puis liste. Chaque section reste paresseuse, contrairement à des listes imbriquées en shrinkWrap.

Pourquoi éviter les méthodes qui retournent des widgets ?

Une méthode s'exécute à chaque build du parent et ne peut pas être const. Elle n'a ni Element ni BuildContext propre, donc impossible de limiter un rebuild à cette partie, et un Theme.of(context) y lit le contexte du parent. Une classe StatelessWidget peut être const, court-circuitée par Flutter, et apparaît dans l'inspecteur et les rebuild stats.

Animation implicite ou explicite : comment choisissez-vous ?

Implicite par défaut : on change une valeur cible et le widget anime, sans cycle de vie à gérer. Explicite quand il faut contrôler l'animation : boucle, séquence, arrêt ou inversion, pilotage par un geste. Cela implique un AnimationController, un vsync et un dispose. Pour une simple boucle, RepeatingAnimationBuilder (3.41) évite le contrôleur.

À quoi sert vsync sur un AnimationController ?

Il fournit un Ticker synchronisé sur les frames. Via TickerMode, les tickers sont coupés quand le widget n'est pas visible (route recouverte, onglet inactif), ce qui évite de consommer CPU et batterie pour une animation que personne ne voit.

Comment diagnostiquez-vous un écran qui saccade ?

Reproduction en mode profile sur un appareil réel. Frame chart pour identifier le thread en cause. UI thread : rebuild stats, CPU profiler, recherche de travail dans build ou de calcul lourd sur l'isolate principal. Raster thread : saveLayer (opacité, filtres), clips, ombres, images trop grandes. Une correction à la fois, remesure, comparaison avant / après.

Quand utiliser RepaintBoundary ?

Quand une zone qui se repeint souvent (animation, graphique temps réel) force le repaint de voisins statiques, ou l'inverse. Le widget crée une couche séparée. Il a un coût mémoire et de composition : on l'ajoute après avoir vu le problème avec Highlight repaints, pas par principe. Les éléments de ListView en ont déjà un.

Comment rendez-vous une app compatible avec l'arabe ?

Locale ar dans supportedLocales, textes en ARB générés par gen-l10n avec pluriels ICU, widgets directionnels (EdgeInsetsDirectional, AlignmentDirectional, PositionedDirectional), icônes et images directionnelles vérifiées, formats de nombres et dates via intl, et tests réels en RTL, y compris des golden tests en arabe.

Quels points d'accessibilité vérifiez-vous sur un écran de paiement ?

Libellés sémantiques pour les icônes et les lignes custom (Semantics, MergeSemantics), lecture correcte des montants, ordre de lecture logique, cibles de 48 dp, contraste 4,5:1, information non portée par la seule couleur, comportement à 200 % de taille de texte, focus clavier. Et des tests automatisés avec les matchers de sémantique (chapitre 12).

Exercices

Exercice 1 · Sans IA · 45 min

Écran « Historique » : à partir d'une liste en dur de 200 transactions (dates sur 30 jours, montants en centimes), afficher une liste groupée par jour avec en-têtes collants (« Aujourd'hui », « Hier », puis la date), un SliverAppBar.medium et les montants colorés via une ThemeExtension.

Critères : un seul CustomScrollView ; SliverMainAxisGroup par jour ; aucune liste en shrinkWrap ; ValueKey sur l'id ; regroupement fait hors de build (ou mémorisé) ; marges en EdgeInsetsDirectional ; l'écran reste correct en forçant Locale('ar') et à 200 % de taille de texte.

Exercice 2 · Sans IA · 30 min · diagnostic

Lisez le code ci-dessous, sans l'exécuter. Listez au moins cinq causes probables de saccade ou de surconsommation, classez-les par thread (UI ou raster), et proposez pour chacune une correction et la mesure DevTools qui la confirmerait.

class FeedScreen extends StatefulWidget {
  const FeedScreen({super.key, required this.items});
  final List<Transaction> items;
  @override
  State<FeedScreen> createState() => _FeedScreenState();
}

class _FeedScreenState extends State<FeedScreen> {
  DateTime now = DateTime.now();

  @override
  void initState() {
    super.initState();
    Timer.periodic(const Duration(seconds: 1), (_) => setState(() => now = DateTime.now()));
  }

  @override
  Widget build(BuildContext context) {
    final sorted = [...widget.items]..sort((a, b) => b.createdAt.compareTo(a.createdAt));
    final width = MediaQuery.of(context).size.width;
    return SingleChildScrollView(
      child: Column(children: [
        Text('Mis à jour à $now'),
        for (final tx in sorted)
          Opacity(
            opacity: tx.status == 'pending' ? 0.5 : 1,
            child: ClipRRect(
              borderRadius: BorderRadius.circular(12),
              child: Row(children: [
                Image.network(tx.avatarUrl, width: 40),
                Text(NumberFormat.currency(locale: 'fr_MA', symbol: 'MAD').format(tx.amount / 100)),
                SizedBox(width: width / 3),
              ]),
            ),
          ),
      ]),
    );
  }
}

Critères : au moins le timer à la racine (et jamais annulé), le tri dans build, la liste non paresseuse, MediaQuery.of, Opacity sur un sous-arbre, ClipRRect par ligne, image décodée sans cacheWidth, NumberFormat recréé par ligne.

Exercice 3 · Sans IA · 30 min

Formulaire de bénéficiaire (nom, téléphone marocain 06/07 sur 10 chiffres) avec validation à l'interaction, passage de focus au champ suivant, bouton désactivé pendant l'envoi. Critères : controllers et FocusNode libérés ; aucun objet créé dans build ; messages d'erreur lus par TalkBack ; validator testable en isolation (fonction pure).

Projet fil rouge · étapes 03 et 04

Écrans Accueil et Historique de l'app Wallet (voir le projet). Accueil : carte de solde (seul widget qui écoute le solde, via select), 5 dernières transactions, actions rapides en const. Historique : liste paginée par curseur (GET /transactions?cursor=&limit=20&type=), en-têtes collants par jour, filtre par type, pull-to-refresh (RefreshIndicator), indicateur de chargement et erreur réessayable en bas de liste. Montants en centimes formatés à l'affichage seulement, ValueKey(tx.id) partout. Validation : profil en mode profile avec X-Chaos-Latency: 2000, aucune frame rouge en défilement rapide sur 200 éléments, écran utilisable en arabe.

Quiz

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

Pour aller plus loin