Aller au contenu principal

Performance et Profiling

Objectifs

  • Minimiser les jank (frames 16 ms, 60 FPS)
  • Réduire la consommation mémoire
  • Rendre la navigation et le scroll fluides
  • Optimiser la batterie

C'est quoi le jank ?

Le jank est un ralentissement ou bégaiement visuel qui se produit quand l'application ne peut pas maintenir une fréquence de rafraîchissement fluide.

Explications techniques

  • Un smartphone moderne fonctionne à 60 FPS (60 images par seconde)
  • Chaque frame doit être complétée en environ 16 ms (1 000 ms ÷ 60 ≈ 16,67 ms)
  • Quand une frame prend plus de 16 ms à renderer, l'écran doit attendre avant d'afficher la suivante
  • Cet à-coup visuel perceptible par l'utilisateur = jank

Exemple concret

  • Scroll fluide : chaque frame < 16 ms → 60 FPS → expérience smooth
  • Scroll saccadé : certaines frames > 16 ms → frames perdues → jank visible

Impact utilisateur

  • Expérience perçue comme "laggy" ou "non responsive"
  • Même quelques frames perdues nuisent à la fluidité perçue
  • Cible à viser : < 1% de jank pendant l'utilisation normale

Réduire la consommation de mémoire

Une app gourmande en mémoire ralentit l'appareil entier et risque d'être tuée par le système.

Causes principales de fuite mémoire

  • Images non optimisées : charger une image 4K en entier gaspille des MB inutilement
  • Rebuilds inutiles : chaque rebuild créé des objets temporaires
  • Listes infinies : charger 10 000 items en mémoire au lieu de paginer
  • Controllers non disposés : ScrollController, TextEditingController qui ne sont pas fermés

Solutions

  • Cacher les images : cacheHeight: 400, cacheWidth: 400 réduit la résolution en mémoire
  • Utiliser const widgets : évite la création d'objets dupliqués
  • Pagination : charger par batches (20 items à la fois) plutôt que tout
  • Toujours disposer : les Controllers doivent être fermés dans dispose()

Cible : < 150 MB pour une app moyenne

Rendre la navigation et le scroll fluides

Un scroll saccadé = mauvaise UX, même s'il ne consomme pas beaucoup de mémoire.

Causes du scroll saccadé

  • Calculs lourds pendant le scroll : trier une liste pendant le scroll = jank
  • Rebuilds inutiles : des éléments non visibles se reconstruisent
  • Animations complexes : trop d'animations simultanées ralentissent le GPU

Solutions

  • Séparation des widgets : factoriser les ListItems dans des widgets séparés
  • RepaintBoundary : isoler les zones stables pour éviter le redessin complet
  • Debounce : attendre que le scroll s'arrête avant de faire des calculs
  • Pagination : charger les items sous demande (lazy loading)
  • const widgets : moins de rebuilds = moins de travail GPU

Exemple : avec pagination, seuls les ~10 items visibles sont en mémoire. Au scroll, on ajoute les nouveaux items graduellement au lieu de tout recalculer.

Optimiser la batterie

La batterie se décharge rapidement si l'app :

  • Garde le CPU actif inutilement (calculs constants)
  • Fait des appels réseau fréquents (très gourmand)
  • Utilise les animations constantes

Solutions

  • Debounce sur input : attendre 500ms au lieu d'appeler l'API à chaque caractère
  • Pagination + lazy loading : limiter les appels réseau et la fréquence
  • Éviter les animations infinies : une animation qui tourne 24/7 = batterie rapidement vide
  • Profiler en production : détecter les appels API qui s'exécutent trop souvent
  • Const widgets : moins de CPU pour les rebuilds

Impact : une app qui fait 100 appels API par minute au lieu de 10 peut diviser l'autonomie par 10.

Outils de debugging

Flutter DevTools

flutter pub global activate devtools
devtools

Performance overlay

flutter run --profile --show-performance-overlay

Affiche :

  • GPU timeline (jaune)
  • UI timeline (bleu)
  • FPS actuel (haut à droite)

Widget Inspector

flutter run
# Appuyer sur `w` pour ouvrir Widget Inspector

Permet de :

  • Inspecter la hiérarchie de widgets
  • Détecter les rebuilds inutiles
  • Vérifier les constraints

Règles clés de performance

1. Const partout possible

// MAUVAIS - rebuild à chaque parent rebuild
class MyWidget extends StatelessWidget {

Widget build(BuildContext context) {
return Container(
child: Icon(Icons.home),
);
}
}

// BON - Icon est const
class MyWidget extends StatelessWidget {

Widget build(BuildContext context) {
return Container(
child: const Icon(Icons.home),
);
}
}

Ce que ça apporte :

  • Mauvais : À chaque rebuild du parent, Icon est recréé en mémoire et redessiné
  • Bon : const Icon est compilé une seule fois. Le compilateur réutilise la même instance → 0 rebuild, 0 redessinage
  • Impact : Moins d'allocations mémoire, moins de travail GPU = scroll plus fluide, batterie préservée

2. Séparation des widgets et clés

// MAUVAIS - ListItem rebuild pour tous les éléments
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
return ListTile(title: Text(items[index].name));
},
)

// BON - Factoriser ListItem dans un widget séparé
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
return ListItemWidget(item: items[index]);
},
)

// BON - Utiliser key si ordre peut changer
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
return ListItemWidget(key: ValueKey(items[index].id), item: items[index]);
},
)

Ce que ça apporte :

  • Mauvais : Tout le ListTile se rebuild quand n'importe quel item change → toute la liste rebuild
  • Bon (v1) : Créer un widget séparé = Flutter peut tracker chaque item indépendamment → seul l'item modifié rebuild
  • Bon (v2) : Ajouter une key unique = Flutter sait quel item c'est même si l'ordre change (ex: suppression, tri) → évite les bugs visuels
  • Impact : Avec 100 items, au lieu de 100 rebuilds, seul 1 item rebuild → scroll fluide même avec liste longue

3. Images optimisées

// MAUVAIS - charge full résolution
Image.network(url)

// BON - cache dimensions pour réduire mémoire
Image.network(
url,
cacheHeight: 400,
cacheWidth: 400,
)

// BON - placeholder avec FadeInImage
FadeInImage.assetNetwork(
placeholder: 'assets/placeholder.png',
image: imageUrl,
fadeInDuration: Duration(milliseconds: 300),
)

// BON - utiliser WebP ou AVIF pour taille réduite

Ce que ça apporte :

  • Mauvais : Charger une image 4K (3MB) pour l'afficher en 400x400px = 8MB en mémoire gaspillés
  • Bon (v1) : cacheHeight/cacheWidth = Flutter redimensionne et cache à la résolution demandée (perte de ~90% mémoire)
  • Bon (v2) : FadeInImage = show placeholder rapido pendant téléchargement → UX plus fluide, pas de blanc qui clignote
  • Bon (v3) : WebP/AVIF = 30-50% plus petits que JPG → telé réseau réduit, batterie économisée
  • Impact : Une liste de 20 images en 4K = 160MB en mémoire / Avec optimisation = 15MB

4. Listes avec pagination

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

State<ProductsPage> createState() => _ProductsPageState();
}

class _ProductsPageState extends State<ProductsPage> {
final _scroll = ScrollController();
final _items = <Product>[];
bool _loading = false;
int _page = 1;


void initState() {
super.initState();
_loadMore();
_scroll.addListener(() {
// Charger plus quand proche de la fin
if (_scroll.position.pixels >=
_scroll.position.maxScrollExtent - 200 && !_loading) {
_loadMore();
}
});
}

Future<void> _loadMore() async {
setState(() => _loading = true);
final next = await fetchPage(_page++);
setState(() => _items.addAll(next));
setState(() => _loading = false);
}


void dispose() {
_scroll.dispose();
super.dispose();
}


Widget build(BuildContext context) {
return ListView.builder(
controller: _scroll,
itemCount: _items.length + (_loading ? 1 : 0),
itemBuilder: (context, index) {
if (index == _items.length) {
return const Center(child: CircularProgressIndicator());
}
return ListTile(title: Text(_items[index].name));
},
);
}
}

Ce que ça apporte :

  • Sans pagination : Charger 10 000 items d'un coup = app freeze pendant 5s, mémoire saturée à 500MB → crash
  • Avec pagination : Charger 20 items → user scrolle → détecte position (ligne_scroll.addListener) → charge 20 de plus
  • Lazy loading : Seuls les ~10 items visibles sont en mémoire + les ~20 prêts à appear
  • Dispose : Fermer le ScrollController évite la memory leak
  • Impact : 10 000 items chargés graduellement vs tout à la fois = opérationnel vs inutilisable

5. RepaintBoundary pour zones stables

// MAUVAIS - tout redessine
Column(
children: [
AnimatedContainer(...), // Qui change souvent
ExpensiveWidget(), // Stable mais redessine quand même
],
)

// BON - isoler la zone stabile
Column(
children: [
AnimatedContainer(...),
RepaintBoundary(
child: ExpensiveWidget(),
),
],
)

Ce que ça apporte :

  • Mauvais : AnimatedContainer change chaque frame (60x par seconde) → toute la Column redessine → ExpensiveWidget() redessine 60x/sec aussi inutilement
  • Bon : RepaintBoundary = créé un cache GPU séparé pour ExpensiveWidget → ignore les animations du parent → redessine 0x si contenu inchangé
  • Cas typique : Page avec animation de header + liste de produits stable → RepaintBoundary sur liste → scroll/animation fluides
  • Impact : Sur une page complexe, 40-50% réduction du travail GPU = moins de jank, moins de batterie

6. Debounce sur input/scroll

// MAUVAIS - setState à chaque keystroke
TextField(
onChanged: (value) {
setState(() => searchQuery = value);
search(value); // Appel API à chaque char
},
)

// BON - debounce avec Timer
Timer? _debounce;

void _onSearchChanged(String query) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 500), () {
search(query);
});
}

TextField(
onChanged: _onSearchChanged,
)

Ce que ça apporte :

  • Mauvais : Taper "flutter" = 7 appels API simultanés pour "f", "fl", "flu", etc. → serveur surchargé, batterie vide, réseau saturé
  • Bon : _debounce?.cancel() annule le timer précédent → attendre 500ms après dernier keypress → 1 seul appel pour "flutter"
  • Bonus : setState appelé 5x au lieu de 7x → CPU économisé, UI ne refresh que pour le résultat final
  • Cible : Débounce 300-500ms pour search/input utilisateur
  • Impact : Au lieu de 100 appels API pour chercher → 1 appel = 99x moins de réseau, batterie x10 mieux

7. Memoization et pré-calcul

// MAUVAIS - recalcule à chaque build

Widget build(BuildContext context) {
final sortedList = items.sorted(); // Recalcul coûteux!
return ListView(children: sortedList);
}

// BON - pré-calculer
late List<Item> _sorted;


void initState() {
super.initState();
_sorted = items.sorted();
}


Widget build(BuildContext context) {
return ListView(children: _sorted);
}

Ce que ça apporte :

  • Mauvais : items.sorted() trié 1000 éléments à chaque rebuild = 10ms CPU utilisé → 60 FPS impossible si rebuild fréquent
  • Bon : Trier une seule fois en initState = coût payé au démarrage (2ms OK) → build() appelle _sorted directement (0ms)
  • Quand l'utiliser : Calculs coûteux (sort, filter, map complexe, regex) qui ne changent pas à chaque frame
  • Bonus : Meilleure UX = pas de lag pendant scroll car rien à calculer
  • Impact : 1000 items triés à chaque frame vs 1x à l'initialisation = 60 FPS maintenus vs 15 FPS saccadé

Monitoring et métadonnées

Profiler en production

import 'package:firebase_performance/firebase_performance.dart';

// Tracer custom
final trace = FirebasePerformance.instance.newTrace('my_trace');
await trace.start();
// ... code à profiler ...
await trace.stop();

Metrics à monitorer

  • FPS (cible : 60 FPS sur tous les appareils)
  • Mémoire (cible : < 150 MB pour app moyenne)
  • Startup time (cible : < 2s pour cold start)
  • Jank percentage (cible : < 1%)