Article original : How to Build Scalable and Performant Flutter Applications: A Handbook for Devs
Flutter est rapidement devenu l'un des Frameworks les plus populaires pour créer des applications multiplateformes. Sa capacité à fournir des applications fluides, compilées nativement sur iOS, Android, le web et le bureau à partir d'une seule base de code le rend attrayant tant pour les startups que pour les entreprises.
Mais construire une application Flutter qui non seulement fonctionne, mais qui évolue et reste performante face à une demande croissante, nécessite plus que l'écriture de widgets et la connexion d'API. Vous devrez également adopter les meilleures pratiques architecturales, optimiser les performances et gérer l'état de manière efficace.
Dans cet article, nous passerons en revue les meilleures pratiques fondamentales pour créer des applications Flutter évolutives et performantes. Chaque section comprend des explications, des exemples de code et des conseils exploitables que vous pouvez appliquer immédiatement à vos propres projets.
Table des matières
Utiliser Flex (Row/Column) et LayoutBuilder pour les règles responsives
Minimiser les reconstructions de widgets : Techniques et Patterns
Code Splitting et Lazy Loading : Réduire le poids initial de l'application
Gestion de la mémoire : Éviter les fuites et la croissance incontrôlée
Distribution de l'application et optimisation de la taille du build
Prérequis
Avant de commencer, vous devriez avoir :
Des connaissances de base du langage de programmation Dart
Une compréhension des concepts de widgets Flutter (
StatelessWidget,StatefulWidget)Une familiarité avec la programmation asynchrone utilisant
Future,async, etawaitDe l'expérience dans l'exécution et le débogage d'applications Flutter à l'aide de DevTools
Si vous débutez avec Flutter, assurez-vous de l'avoir installé en suivant le guide d'installation officiel de Flutter.
Lors de la création d'applications Flutter destinées à croître en termes de fonctionnalités, d'utilisateurs et de complexité, le respect des meilleures pratiques est essentiel. Ces pratiques améliorent non seulement les performances, mais rendent également votre base de code plus propre, plus facile à maintenir et plus résiliente au fil du temps.
Vous trouverez ci-dessous les stratégies clés que tout développeur Flutter devrait adopter pour garantir que ses applications restent évolutives, efficaces et pérennes.
Widgets et Layouts efficaces : les bases de la performance
La performance de l'UI de Flutter consiste à minimiser la quantité de travail que le Framework doit effectuer pour reconstruire votre UI, à éviter les allocations de mémoire inutiles et à toujours choisir le widget de mise en page le plus approprié pour la tâche. Un arbre de widgets peu profond, bien factorisé et utilisant const lorsque cela est possible est tout simplement moins coûteux à rendre et plus facile à maintenir.
Exemple : Utilisation de Stateless + const
import 'package:flutter/material.dart';
class Greeting extends StatelessWidget {
final String name;
const Greeting({super.key, required this.name});
@override
Widget build(BuildContext context) {
return Text(
'Hello, $name',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
);
}
}
Voici ce qui se passe dans ce code :
import 'package:flutter/material.dart';: importe l'API des widgets material de Flutter, qui nous donne accès à des widgets commeText.class Greeting extends StatelessWidget {: déclare un widget qui n'a pas d'état mutable. LesStatelessWidgetsont généralement moins coûteux à maintenir et à reconstruire que lesStatefulWidget.final String name;: Déclare une propriété immuable. Lenameest stocké une seule fois lors de la construction du widgetGreetinget ne peut plus être modifié par la suite.const Greeting({super.key, requiredthis.name});: Le constructeur est marquéconst. Cela permet à Flutter de créer des instances canoniques deGreetingau moment de la compilation lorsque ses entrées sont également des constantes de compilation.@override Widget build(BuildContext context) {: La méthodebuildest l'endroit où vous définissez le sous-arbre de widgets que ce widgetGreetingva rendre.return Text('Hello, $name',: Retourne un widgetTextqui affiche la salutation en utilisant lenamefourni.style: const TextStyle(...),: LeTextStyleest également marquéconst. C'est crucial car cela indique à Flutter que cet objet de style ne changera jamais. En le marquantconst, Flutter évite de créer un nouvel objetTextStyleau moment de l'exécution à chaque reconstruction du widgetGreeting, économisant ainsi de la mémoire et des cycles CPU.
Pourquoi c'est important : L'utilisation de const réduit considérablement les allocations au moment de l'exécution et le coût de reconstruction des widgets. Utilisez toujours const pour tout widget et ses sous-objets dont on sait qu'ils ne changeront jamais.
Évitez les arbres de Container profondément imbriqués : préférez les primitives de composition à la place.
Voici un exemple de code moins optimal :
Container(
margin: const EdgeInsets.all(12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(children: [
Container(child: Text('X')),
]),
),
)
Et voici une meilleure approche :
Padding(
padding: const EdgeInsets.all(12),
child: Column(children: [
Padding(padding: const EdgeInsets.symmetric(horizontal: 8), child: const Text('X')),
]),
)
Les widgets comme Padding, Align, SizedBox, Row et Column sont très légers et expriment clairement leur intention. Vous devriez toujours préférer utiliser ces primitives de composition dédiées au lieu d'imbriquer des widgets Container lorsque vous n'avez besoin que d'effets simples comme le padding, l'alignement ou le dimensionnement. Un Container est un widget puissant qui peut faire beaucoup de choses, mais l'utiliser uniquement pour le padding ajoute une surcharge inutile.
Utiliser Flex (Row/Column) et LayoutBuilder pour les règles responsives
LayoutBuilder est un outil fantastique car il vous donne les contraintes du widget parent. Cela vous permet de prendre des décisions de mise en page intelligentes et responsives sans avoir à dépendre de MediaQuery.of(context).size partout, ce qui peut déclencher des reconstructions inutiles.
Exemple :
Widget responsiveHeader(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
if (constraints.maxWidth > 600) {
return Row(children: [Expanded(child: Text('Wide header'))]);
} else {
return Column(children: [Text('Narrow header')]);
}
});
}
Dans ce code, LayoutBuilder lit les contraintes de mise en page (spécifiquement maxWidth dans ce cas) et construit intelligemment uniquement le sous-arbre approprié – soit une Row pour les écrans larges, soit une Column pour les écrans étroits. C'est plus efficace que de construire les deux versions et de simplement en cacher une, car cela évite la création de widgets et les calculs de mise en page inutiles.
Gestion d'état : Source unique de vérité et isolation
À mesure que les applications grandissent, elles ont besoin d'un flux de données prévisible, d'une séparation claire entre l'UI et la logique métier sous-jacente, et de la capacité de tester cette logique indépendamment de la présentation visuelle. Choisir la bonne approche de gestion d'état est crucial et dépend souvent de la taille de votre équipe et de la complexité de votre application.
Voici un aperçu rapide :
Applications de petite à moyenne taille : Provider ou Riverpod sont d'excellents choix pour leur simplicité et leur facilité d'utilisation.
Applications de taille moyenne à grande avec une logique pilotée par les événements : BLoC (flutter_bloc) offre une solution robuste et hautement testable, particulièrement lors de la gestion de flux asynchrones complexes et de transitions événement-état claires.
Pour une réactivité fine et une sécurité au moment de la compilation : Riverpod (surtout avec son modificateur
family) offre une alternative moderne, puissante et très testable à Provider, offrant une sécurité au moment de la compilation et facilitant la gestion des dépendances sans dépendre duBuildContext.
Exemple Provider (mises à jour d'état simples et explicites)
Le package Provider est une solution largement utilisée pour l'injection de dépendances et la gestion d'état. Il est construit au-dessus d' InheritedWidget mais le rend beaucoup plus facile à utiliser, offrant un moyen simple de fournir et de consommer des valeurs (y compris des objets d'état) dans l'arbre des widgets. Il est particulièrement adapté aux mises à jour d'état explicites.
Voici un exemple d'un simple compteur utilisant ChangeNotifier et Provider :
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class CounterModel with ChangeNotifier {
int _count = 0;
int get count => _count;
void increment() {
_count++;
notifyListeners(); // Indique aux widgets à l'écoute de se reconstruire
}
}
void main() {
runApp(
// ChangeNotifierProvider rend CounterModel disponible pour ses enfants
ChangeNotifierProvider(
create: (_) => CounterModel(),
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(home: CounterScreen());
}
}
class CounterScreen extends StatelessWidget {
const CounterScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Provider Counter')),
body: Center(
// Consumer se reconstruit uniquement lorsque CounterModel change
child: Consumer<CounterModel>(builder: (context, model, child) {
return Text('Count: ${model.count}');
})
),
floatingActionButton: FloatingActionButton(
// context.read lit le modèle sans s'abonner aux reconstructions
onPressed: () => context.read<CounterModel>().increment(),
child: const Icon(Icons.add),
),
);
}
}
Voici ce qui se passe dans ce code :
import 'package:flutter/material.dart';: Importe le package UI principal de Flutter.import 'package:provider/provider.dart';: Importe le packageprovider, essentiel pour l'injection de dépendances et la réactivité.class CounterModel with ChangeNotifier {: Définit notre objet d'état. En utilisant le mixinChangeNotifier,CounterModelacquiert la capacité de notifier les écouteurs lorsque son état interne change.int _count = 0;: Une variable privée pour contenir la valeur réelle du compteur.int get count => _count;: Un getter public qui permet aux widgets de lire le compte actuel, mais pas de modifier directement_count.void increment() { _count++; notifyListeners(); }: Cette méthode met à jour le compteur puis appellenotifyListeners(). Cet appel crucial indique à tous les widgets qui "surveillent" ceCounterModelque quelque chose a changé et qu'ils pourraient avoir besoin de se reconstruire.void main() { runApp(ChangeNotifierProvider(create: (_) => CounterModel(), child: const MyApp(),),); }: Ici, nous enveloppons notreMyAppavec unChangeNotifierProvider. Cela rend une instance deCounterModeldisponible pour tous les widgets du sous-arbreMyApp. La fonctioncreatefournit l'instance initiale de notre modèle d'état.class MyApp extends StatelessWidget { ... }: C'est la structure principale de notre application.MaterialApp(home: CounterScreen());: Notre application utiliseCounterScreencomme contenu principal.Consumer<CounterModel>(builder: (context, model, child) { return Text('Count: ${model.count}'); }): Le widgetConsumerest la façon dont nous "écoutons" les changements deCounterModel. Crucialement,Consumerreconstruit uniquement la partie de l'arbre de widgets définie dans son callbackbuilderlorsquenotifyListeners()est appelé dansCounterModel. Cela aide à minimiser les mises à jour UI inutiles.floatingActionButton: FloatingActionButton(onPressed: () =>context.read<CounterModel>().increment(), ...):: Pour le callbackonPresseddu bouton, nous utilisonscontext.read<CounterModel>().increment(). La méthodereadrécupère l'instance deCounterModelsans que le bouton ne s'abonne à ses changements. C'est important car le bouton lui-même n'a pas besoin de se reconstruire lorsque le compte change ; il a seulement besoin d'appeler une méthode sur le modèle.
Pourquoi utilisons-nous Consumer + context.read ? Nous utilisons context.read pour appeler des méthodes ou accéder à des valeurs d'un provider sans provoquer la reconstruction du widget appelant lorsque le provider notifie des changements. Nous utilisons Consumer (ou context.watch) lorsqu'un widget doit se reconstruire pour afficher des données mises à jour du provider. Cette distinction nous permet de réduire la portée des reconstructions et d'optimiser les performances.
Exemple BLoC (flutter_bloc) : séparer les événements UI de la logique d'état
BLoC (Business Logic Component) est un pattern qui aide à séparer la logique métier de l'UI en utilisant des événements et des états. Le package flutter_bloc fournit une implémentation robuste de ce pattern. Il est particulièrement puissant pour les applications complexes où vous souhaitez une séparation claire, explicite et testable entre la façon dont un utilisateur interagit avec l'UI (événements) et la façon dont l'état de l'application change (états). Cette "séparation événement-état" garantit que votre logique métier est pure et indépendante de l'UI.
Compteur simplifié utilisant BLoC :
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
// 1. Définir les événements : Actions utilisateur ou déclencheurs externes
abstract class CounterEvent {}
class Increment extends CounterEvent {} // Un événement spécifique pour incrémenter le compteur
// 2. Définir l'état : Représente l'état actuel de l'UI
class CounterState {
final int value;
CounterState(this.value);
// Optionnel : Pour les vérifications d'égalité dans BlocBuilder
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is CounterState && other.value == value);
@override
int get hashCode => value.hashCode;
}
// 3. Définir le BLoC : Gère les événements et émet de nouveaux états
class CounterBloc extends Bloc<CounterEvent, CounterState> {
// L'état initial du compteur est 0
CounterBloc() : super(CounterState(0)) {
// Enregistrer le gestionnaire d'événement pour l'événement Increment
on<Increment>((event, emit) {
// Lorsque l'événement Increment se produit, émettre un nouvel état avec la valeur incrémentée
emit(CounterState(state.value + 1));
});
}
}
void main() {
runApp(
// BlocProvider rend le CounterBloc disponible
BlocProvider(create: (_) => CounterBloc(), child: const MyApp()));
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp(home: CounterPage());
}
class CounterPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('BLoC Counter')),
body: Center(
// BlocBuilder se reconstruit uniquement lorsque CounterBloc émet un nouvel état
child: BlocBuilder<CounterBloc, CounterState>(builder: (context, state) {
return Text('Value: ${state.value}');
})
),
floatingActionButton: FloatingActionButton(
// Ajouter un événement au BLoC
onPressed: () => context.read<CounterBloc>().add(Increment()),
child: const Icon(Icons.add),
),
);
}
}
Dans ce code :
Imports :
materialpour l'UI etflutter_blocpour le pattern BLoC.abstract class CounterEvent {}etclass Increment extends CounterEvent {}: Nous définissons une base abstraiteCounterEventet un événement concretIncrement. Les événements sont des intentions ou des actions qui se produisent dans l'application (par exemple, un clic sur un bouton).class CounterState { final int value; CounterState(this.value); }: C'est notre modèle d'état immuable. Chaque objetCounterStateenveloppe simplement lavalueactuelle du compteur. En rendant l'état immuable, nous garantissons que chaque changement crée un nouvel état, rendant les transitions d'état claires et débogables.class CounterBloc extends Bloc<CounterEvent, CounterState> { ... }: C'est le cœur du BLoC. Il étendBloc, spécifiant qu'il gère desCounterEventet émet desCounterState.CounterBloc() : super(CounterState(0)) { on<Increment>((event, emit) => emit(CounterState(state.value + 1))); }: Dans le constructeur, nous définissons l'état initial àCounterState(0). Ensuite, nous enregistrons un gestionnaire d'événement viaon<Increment>. Cela dit au BLoC : "Lorsqu'un événementIncrementarrive, prends lastate.valueactuelle, ajoute 1, etemitun nouveauCounterStateavec cette valeur mise à jour."BlocProvider(create: (_) => CounterBloc(), child: const MyApp()): Semblable àChangeNotifierProvider,BlocProviderinjecte une instance de notreCounterBlocdans l'arbre des widgets, la rendant accessible aux widgets enfants.BlocBuilder<CounterBloc, CounterState>: Ce widget est utilisé pour reconstruire des parties de l'UI spécifiquement lorsque leCounterBlocémet un nouvel étatCounterState. Il écoute automatiquement le BLoC et fournit le dernier état à son callbackbuilder.context.read<CounterBloc>().add(Increment()): Lorsque le bouton est pressé, nous ne modifions pas directement l'état. Au lieu de cela, nous lisons leCounterBloc(encore une fois,readne s'abonne pas aux reconstructions) et lui ajoutons un événementIncrement(). Le BLoC traite ensuite cet événement et émet un nouvel état.
Pourquoi BLoC ? BLoC rend la gestion d'état hautement explicite, prévisible et incroyablement testable. Il est préféré pour les applications pilotées par les événements avec des flux complexes, où la séparation des actions UI de la logique métier est critique pour la maintenabilité et la collaboration.
Exemple court Riverpod (moderne, testable, sans contexte)
Riverpod est une autre bibliothèque de gestion d'état qui vise à résoudre certaines complexités de Provider, notamment sa dépendance au BuildContext pour accéder aux providers.
Riverpod est sûr au moment de la compilation, ce qui facilite la détection précoce des erreurs, et il est conçu dès le départ pour être hautement testable sans mocking. Il est souvent considéré comme une alternative moderne et puissante, offrant une grande flexibilité et sécurité.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// 1. Définir un provider pour notre état
// StateNotifierProvider est bon pour l'état mutable qui notifie les écouteurs
final counterProvider = StateNotifierProvider<CounterNotifier, int>((ref) => CounterNotifier());
// 2. Définir notre contrôleur d'état (Notifier)
class CounterNotifier extends StateNotifier<int> {
CounterNotifier(): super(0); // L'état initial est 0
void increment() => state = state + 1; // Met à jour l'état et notifie les écouteurs
}
void main() => runApp(const ProviderScope(child: MyApp())); // ProviderScope est requis pour Riverpod
class MyApp extends ConsumerWidget { // ConsumerWidget peut accéder aux providers
const MyApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) { // WidgetRef remplace BuildContext pour l'accès au provider
final count = ref.watch(counterProvider); // Surveille le provider pour reconstruire quand l'état change
return MaterialApp(
home: Scaffold(
body: Center(child: Text('Count: $count')),
floatingActionButton: FloatingActionButton(
// Lit le notifier pour appeler ses méthodes (ne reconstruit pas ce widget)
onPressed: () => ref.read(counterProvider.notifier).increment(),
child: const Icon(Icons.add)
)
)
);
}
}
Dans ce code :
Riverpod change fondamentalement la façon dont on accède aux providers. Au lieu de dépendre de
BuildContext, il introduitWidgetRef(passé à la méthodebuilddeConsumerWidget) pour interagir avec les providers.ref.watch: abonne le widget aux changements decounterProvideret provoque sa reconstruction lorsque l'état (count) change.ref.read(counterProvider.notifier): utilisé pour obtenir l'instance deCounterNotifieret appeler sa méthodeincrementsans provoquer la reconstruction duFloatingActionButton. Ce pattern supprime la dépendance auBuildContextpour l'accès à l'état et améliore la testabilité et la sécurité.
Minimiser les reconstructions de widgets : Techniques et Patterns
La force principale de Flutter est son UI réactive, où les widgets se reconstruisent lorsque leur état change. Bien que Flutter soit incroyablement efficace pour cela, des reconstructions inutiles peuvent toujours entraîner des goulots d'étranglement de performance, en particulier dans les UI complexes comportant de nombreux widgets. Chaque reconstruction implique de comparer le nouvel arbre de widgets avec l'ancien (diffing), de calculer la mise en page et potentiellement de repeindre.
Minimiser ce travail signifie que votre application sera plus fluide, utilisera moins de CPU et consommera moins de batterie. L'objectif n'est pas d'arrêter toutes les reconstructions, mais de s'assurer que seules les parties nécessaires de votre UI se reconstruisent lorsque leurs données sous-jacentes changent.
Techniques
Il existe diverses techniques que vous pouvez utiliser pour y parvenir.
Premièrement, vous pouvez utiliser des constructeurs const et des sous-objets const. C'est l'optimisation la plus simple et la plus puissante. Si un widget et tous ses enfants (et leurs propriétés) sont véritablement immuables et connus au moment de la compilation, marquez-les avec const. Flutter peut alors réutiliser ces instances de widgets au lieu de les reconstruire, économisant ainsi des ressources importantes.
Vous pouvez également réduire la portée de la reconstruction avec Selector, Consumer ou ValueListenableBuilder. Au lieu de permettre à un écran entier de se reconstruire lorsqu'une petite donnée change, utilisez ces widgets spécialisés pour écouter des données spécifiques. Ils isolent les reconstructions au seul sous-arbre nécessaire, laissant le reste de l'UI intact.
Une autre approche consiste à éviter setState dans les widgets parents de haut niveau. Vous pouvez utiliser des objets d'état localisés ou des contrôleurs : setState déclenche une reconstruction du StatefulWidget actuel et de tout son sous-arbre. Si vous avez un appel setState haut dans votre arbre de widgets, cela peut provoquer la reconstruction de nombreux widgets non liés. Essayez plutôt de pousser les StatefulWidget et les appels setState aussi bas que possible dans l'arbre, ou utilisez des solutions de gestion d'état qui localisent les changements d'état.
Et vous pouvez utiliser shouldRebuild/shouldRepaint/shouldUpdate là où des délégués/peintures personnalisés sont utilisés. Pour les scénarios avancés impliquant CustomPainter, SliverChildBuilderDelegate ou des RenderObject, vous pourriez implémenter ces méthodes pour fournir un contrôle précis sur le moment où vos composants personnalisés se mettent à jour ou se repeignent. Il s'agit d'une optimisation plus avancée pour des cas d'utilisation très spécifiques.
Réduire la portée de la reconstruction avec ValueListenableBuilder
ValueListenableBuilder est un excellent exemple de widget conçu pour réduire la portée de la reconstruction. Il écoute un ValueNotifier (un simple objet observable qui contient une seule valeur) et reconstruit uniquement sa fonction builder lorsque cette valeur change.
final ValueNotifier<int> counter = ValueNotifier<int>(0);
Widget build(BuildContext context) {
return ValueListenableBuilder<int>(
valueListenable: counter, // C'est ce que nous écoutons
builder: (context, value, child) {
// Seul ce widget Text se reconstruit lorsque counter.value change
return Text('value: $value');
},
);
}
Voici ce qui se passe dans ce code :
ValueNotifier<int> counter = ValueNotifier<int>(0);: Cela crée un objet observable léger (ValueNotifier) qui contient une valeur entière, initialement0. Vous pouvez changer sa valeur en appelantcounter.value = newValue;.ValueListenableBuilder<int>: Ce widget s'abonne à notrecounterValueNotifier.valueListenable: counter,: Nous disons auValueListenableBuilderde surveiller notre objetcounter.builder: (context, value, child) { return Text('value: $value'); },: Cette fonctionbuilderest appelée chaque fois quecounter.valuechange. Crucialement, seul le widgetTextà l'intérieur de cebuilderse reconstruira. Le reste de votre arbre de widgets à l'extérieur duValueListenableBuilderrestera inchangé, ce qui conduit à des mises à jour plus efficaces.
Éviter les setState coûteux sur des écrans entiers
Imaginez que vous ayez un écran complexe avec de nombreux éléments d'UI différents. Si un petit champ de texte en bas change et que vous appelez setState dans le StatefulWidget qui représente l'écran entier, alors chaque widget sur cet écran (et ses enfants) sera potentiellement reconstruit. C'est souvent du gaspillage.
La solution est de gérer l'état plus localement. Si seul un petit composant doit changer, soit faites de ce composant son propre StatefulWidget et gérez son état en interne, soit utilisez une solution de gestion d'état (comme Provider, Riverpod ou BLoC dont nous avons discuté plus haut) qui vous permet de limiter les mises à jour d'état à de plus petits sous-arbres en utilisant des widgets comme Consumer, Selector ou BlocBuilder. Ces outils garantissent que seules les parties affectées de votre UI sont reconstruites, gardant votre application rapide et réactive.
Optimisation du code : Idiomes et exemples
Au-delà de la gestion des reconstructions de widgets, il existe plusieurs idiomes de codage Dart et Flutter généraux qui contribuent à une application plus optimisée et efficace. Ces pratiques aident à réduire la surcharge de mémoire, à améliorer la lisibilité et à garantir une exécution fluide, en particulier lors de la gestion d'opérations asynchrones ou de calculs lourds.
Utiliser final et const correctement
Comprendre la différence entre final et const et les utiliser de manière appropriée est une optimisation fondamentale dans Dart et Flutter.
Une variable
finalne peut être définie qu'une seule fois. Sa valeur est déterminée au moment de l'exécution, mais une fois assignée, elle ne peut plus être modifiée. C'est parfait pour les variables dont les valeurs ne changent pas après l'initialisation. Par exemple,final DateTime currentTime = DateTime.now();.Une variable
constest une constante au moment de la compilation. Sa valeur doit être connue lors de la compilation. Cela signifie qu'elle est immuable et fixée avant même que votre application ne s'exécute. L'utilisation deconstpour les widgets, lesTextStyle, lesColoret d'autres objets chaque fois que possible permet à Flutter d'effectuer des optimisations agressives en réutilisant ces objets, économisant ainsi de la mémoire et des cycles CPU.
Voici quelques exemples :
// Utilisation de final :
final String username = 'Alice'; // username ne peut pas être réassigné après cette ligne
// username = 'Bob'; // Cela provoquerait une erreur à la compilation
// Utilisation de const pour des valeurs simples :
const double pi = 3.14159; // pi est une constante de compilation
// Utilisation de const pour les propriétés de widgets et les widgets eux-mêmes :
class MyConstantWidget extends StatelessWidget {
// Si le texte du titre est toujours le même, rendez-le const
final Widget title;
const MyConstantWidget({super.key, this.title = const Text('Default Title')});
@override
Widget build(BuildContext context) {
return Card(
// La Card et ses enfants sont immuables
child: const Padding(
padding: EdgeInsets.all(8.0),
child: Text(
'This text never changes.',
// Le TextStyle est également une constante de compilation
style: const TextStyle(fontSize: 16, color: Colors.blue),
),
),
);
}
}
// Autre exemple : une liste qui ne change jamais
const List<String> immutableColors = ['Red', 'Green', 'Blue'];
En appliquant systématiquement final et const là où c'est approprié, vous signalez au compilateur Dart et au Framework Flutter que ces objets sont immuables, ce qui permet une gestion de la mémoire plus efficace et empêche les modifications involontaires.
Patterns asynchrones : FutureBuilder et StreamBuilder
Les applications Flutter ont souvent besoin de récupérer des données sur Internet, de lire dans une base de données ou d'effectuer d'autres opérations qui prennent du temps. Ce sont des opérations asynchrones.
Lors de la gestion de telles tâches, vous ne voulez généralement pas bloquer le thread UI, ce qui ferait geler votre application. Flutter fournit des widgets puissants, FutureBuilder et StreamBuilder, qui gèrent gracieusement les données asynchrones et reconstruisent automatiquement votre UI lorsque de nouvelles données arrivent, sans que vous ayez besoin d'appeler manuellement setState.
FutureBuilder: Ce widget est parfait pour gérer des opérations asynchrones uniques qui retournent unFuture(comme la récupération de données une seule fois). Il vous permet de définir à quoi doit ressembler votre UI pendant le chargement duFuture, s'il se termine par une erreur ou lorsqu'il retourne des données avec succès.StreamBuilder: Si vous avez une source de données qui émet plusieurs valeurs au fil du temps (comme des mises à jour en temps réel d'une base de données ou une connexion WebSocket),StreamBuilderest votre allié. Il écoute unStreamet reconstruit son UI chaque fois qu'une nouvelle valeur est émise, offrant une interface réactive et dynamique.
Voici un exemple avec FutureBuilder :
Future<String> fetchData() async {
// Simuler un délai réseau
await Future.delayed(const Duration(seconds: 2));
// Simuler une récupération de données réussie
return 'Data loaded successfully!';
}
Widget build(BuildContext context) {
return FutureBuilder<String>(
future: fetchData(), // Le Future que nous observons
builder: (context, snapshot) {
// Vérifier l'état de connexion du Future
if (snapshot.connectionState == ConnectionState.waiting) {
// En attendant que le Future se termine, afficher un indicateur de chargement
return const CircularProgressIndicator();
} else if (snapshot.hasError) {
// Si le Future s'est terminé par une erreur, l'afficher
return Text('Error: ${snapshot.error}');
} else if (snapshot.hasData) {
// Si le Future s'est terminé avec succès avec des données, afficher les données
return Text('Result: ${snapshot.data}');
}
// Repli pour les cas où le snapshot n'a pas de données, d'erreur ou n'attend pas
return const Text('No data');
},
);
}
Ce FutureBuilder lie votre UI directement à l'état d'achèvement du Future retourné par fetchData(). Il gère automatiquement les différents états (attente, erreur, données) et reconstruit les éléments d'UI pertinents, vous libérant des appels manuels à setState et de la logique de rendu conditionnel complexe.
Utiliser compute / Isolates pour les tâches liées au CPU
Dart est monothread, ce qui signifie que tout votre code s'exécute sur une seule boucle d'événements. Si vous effectuez un calcul très lourd et long directement sur ce thread principal (également appelé thread UI), l'UI de votre application gèlera et deviendra non réactive – c'est ce que nous appelons des "saccades" (jank).
Pour éviter cela, Dart fournit des Isolates. Un Isolate est comme un processus Dart complètement séparé et indépendant avec sa propre mémoire et sa propre boucle d'événements. Les Isolates communiquent entre eux en passant des messages. Cela vous permet de décharger les tâches gourmandes en calcul vers un Isolate en arrière-plan, gardant votre thread UI libre et votre application fluide.
La fonction utilitaire compute du package package:flutter/foundation est une enveloppe pratique autour de l'API Isolate de Dart. Elle permet d'exécuter très facilement une fonction dans un Isolate en arrière-plan et d'en récupérer le résultat. C'est particulièrement utile pour des tâches comme l'analyse de très gros payloads JSON, le traitement d'images complexes ou les transformations de données lourdes qui bloqueraient autrement l'UI.
Voici un exemple utilisant compute :
import 'dart:convert'; // Pour jsonDecode
import 'package:flutter/foundation.dart'; // Pour compute
// Cette fonction s'exécutera dans un isolate séparé
List<int> parseLargeJson(String jsonString) {
// Simuler une tâche d'analyse lourde
final parsed = jsonDecode(jsonString) as List<dynamic>;
return parsed.map((e) => e as int).toList();
}
// Pour appeler ceci :
// Supposons que largeJsonString soit une très longue chaîne JSON comme '[1,2,3,...,1000000]'
Future<void> processData() async {
final largeJsonString = // ... récupérez votre chaîne JSON ...
print('Starting heavy JSON parsing...');
// compute lance un isolate et y exécute parseLargeJson
final result = await compute(parseLargeJson, largeJsonString);
print('Parsing finished. Result count: ${result.length}');
// Vous pouvez maintenant utiliser le résultat sans bloquer l'UI
}
Dans cet exemple, compute lance un Isolate en coulisses, exécute la fonction parseLargeJson avec largeJsonString comme argument, et retourne le résultat une fois la tâche en arrière-plan terminée. Vous pouvez l'utiliser pour toutes les tâches CPU lourdes afin de maintenir le thread UI fluide et réactif, évitant ainsi des ralentissements frustrants pour vos utilisateurs.
Listes, images et performance du défilement
Faire défiler de longues listes et afficher de nombreuses images sont des fonctionnalités courantes dans de nombreuses applications. Mais si elles ne sont pas gérées avec soin, elles peuvent rapidement devenir des goulots d'étranglement de performance majeurs, entraînant un défilement saccadé (jank) et une utilisation excessive de la mémoire.
Flutter propose des widgets et des techniques spécifiques pour garantir que vos listes et images restent performantes, même avec de vastes quantités de données.
Utilisation de ListView.builder, itemExtent, et cacheExtent
Les widgets ListView standard peuvent être inefficaces s'ils construisent tous leurs enfants en même temps, surtout pour les longues listes. ListView.builder est votre meilleur ami ici car il construit les éléments de manière "paresseuse" (lazy). Cela signifie qu'il ne crée les widgets que pour les éléments qui sont actuellement visibles à l'écran ou sur le point de le devenir.
Si tous les éléments de votre ListView ont la même hauteur exacte, définir itemExtent à cette hauteur est une optimisation énorme. Flutter peut alors calculer les métriques de défilement de manière beaucoup moins coûteuse, car il n'a pas besoin de mesurer chaque élément individuellement. Cela peut conduire à une amélioration notable de la fluidité du défilement.
La propriété cacheExtent détermine combien de pixels "hors écran" Flutter doit construire pour les éléments de la liste. Par défaut, elle est de 250 pixels. Augmenter cacheExtent peut aider à réduire les saccades lors d'un défilement très rapide, car plus d'éléments sont pré-construits. Mais attention à ne pas la rendre trop grande, car cela peut augmenter l'utilisation de la mémoire. Trouver le bon équilibre dépend de votre UI spécifique et de la complexité des éléments.
Exemple :
ListView.builder(
itemCount: items.length, // Le nombre total d'éléments
itemBuilder: (context, index) => ListTile(title: Text('Item ${items[index]}')),
itemExtent: 80, // Si chaque élément a la même hauteur fixe de 80 pixels logiques
cacheExtent: 1000, // Pré-construit les éléments 1000 pixels avant la position de défilement actuelle
)
Dans cet exemple, itemExtent permet à Flutter de calculer les métriques de défilement à moindre coût, et ListView.builder garantit que les éléments ne sont construits et rendus que de manière paresseuse au fur et à mesure qu'ils deviennent visibles.
Mise en cache et optimisation des images
Les images sont souvent les assets les plus lourds d'une application. Télécharger ou décoder des images de manière répétée peut consommer une bande passante réseau, de la mémoire et du CPU importants.
Vous pouvez utiliser le package populaire cached_network_image (ou une autre solution de mise en cache appropriée) pour éviter les téléchargements répétés et réduire les pics de mémoire. Ce package gère automatiquement le téléchargement, la mise en cache sur le disque et en mémoire, ainsi que l'affichage de widgets de remplacement (placeholder) ou d'erreur.
Voici un exemple de fonctionnement :
import 'package:cached_network_image/cached_network_image.dart';
// ... à l'intérieur d'une méthode build ...
CachedNetworkImage(
imageUrl: 'https://example.com/image.jpg', // L'URL de votre image
placeholder: (context, url) => const CircularProgressIndicator(), // Quoi afficher pendant le chargement
errorWidget: (context, url, error) => const Icon(Icons.error), // Quoi afficher si le chargement échoue
width: 100, // Spécifiez la largeur et la hauteur pour de meilleures performances
height: 100,
fit: BoxFit.cover, // Comment l'image doit s'adapter à ses limites
)
cached_network_image stocke intelligemment les images décodées en mémoire et sur le disque, les réutilisant à travers les widgets et les lancements ultérieurs de l'application. Cela améliore considérablement la performance perçue et réduit l'utilisation du réseau.
Précharger les images pour éviter les saccades lors de leur première apparition
Lorsqu'une image est chargée et affichée pour la toute première fois, elle peut avoir besoin d'être téléchargée, décodée, puis mise en page. Ces opérations peuvent prendre un moment, surtout pour les grandes images, provoquant potentiellement un bref gel ou un bégaiement dans votre UI.
precacheImage est une fonction qui télécharge et décode une image dans le cache d'images de Flutter avant qu'elle ne soit réellement nécessaire pour le premier rendu. Cela signifie que lorsque l'image apparaît enfin à l'écran, elle est déjà prête, évitant toute saccade soudaine. C'est particulièrement utile pour les images de type "hero", les images d'arrière-plan ou les images dans des listes que vous savez que l'utilisateur verra probablement très bientôt.
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Appeler precacheImage pour une image que vous prévoyez d'afficher bientôt
precacheImage(NetworkImage(imageUrl), context);
// Vous pourriez faire cela pour les images clés d'un écran qui se charge initialement
}
En appelant precacheImage, vous préchauffez efficacement le cache d'images, garantissant que l'image est téléchargée et décodée en mémoire avant que le Framework n'essaie de la rendre, ce qui conduit à une expérience utilisateur beaucoup plus fluide lors de la première apparition de l'image.
Code spécifique à la plateforme et intégration native
Bien que la force principale de Flutter soit le développement multiplateforme, il arrive que vous deviez plonger dans le code spécifique à la plateforme. Cela peut être pour accéder à une API native qui n'est pas encore disponible via un package Flutter, ou pour s'intégrer à des modules natifs existants ou à des bibliothèques hautement optimisées écrites en Kotlin/Java pour Android ou Swift/Objective-C pour iOS. Cette section présentera comment Flutter comble le fossé entre votre code Dart et la plateforme native sous-jacente.
Quand utiliser du code natif
Vous devriez envisager d'utiliser du code natif dans Flutter pour des scénarios spécifiques :
Accéder à des API de plateforme non exposées par l'écosystème de plugins : Parfois, vous avez besoin d'une fonctionnalité de plateforme très récente ou très spécifique qu'aucun package Flutter existant ne couvre.
Bibliothèques natives haute performance : Pour des tâches telles que le traitement audio avancé, la manipulation d'images en temps réel ou des calculs mathématiques complexes, les bibliothèques natives existantes (qui peuvent être du code C/C++ hautement optimisé) peuvent offrir des performances supérieures.
Intégration avec des modules natifs existants : Si vous ajoutez Flutter à une application native existante (une application "hybride"), vous pourriez avoir besoin de communiquer avec des bases de code natives existantes.
Exemple Platform Channels (Dart + Android)
Les "Platform Channels" sont le mécanisme principal de Flutter pour communiquer entre le code Dart et le code spécifique à la plateforme (Kotlin/Java sur Android, Swift/Objective-C sur iOS). Ils vous permettent d'invoquer des méthodes du côté natif depuis Dart, et vice versa. Considérez-les comme un pipeline de communication bien défini.
Regardons un exemple de la façon dont vous pourriez obtenir le niveau de batterie d'un appareil Android.
Côté Dart :
import 'package:flutter/services.dart'; // Services Flutter de base pour l'interaction avec la plateforme
class Battery {
// 1. Définir le MethodChannel avec un nom unique
// Ce nom doit correspondre des deux côtés, Dart et natif.
static const _channel = MethodChannel('com.example/battery');
// 2. Définir une méthode à invoquer du côté natif
static Future<int> getBatteryLevel() async {
try {
// Invoquer la méthode native nommée 'getBatteryLevel'
// Le résultat est un Future, que nous attendons.
final int batteryLevel = await _channel.invokeMethod('getBatteryLevel');
return batteryLevel;
} on PlatformException catch (e) {
// Gérer les erreurs potentielles du côté natif
print("Failed to get battery level: '${e.message}'.");
return -1; // Ou lancer une erreur
}
}
}
Dans ce code :
MethodChannel('com.example/battery'): Nous établissons un canal nommé. La chaîne'com.example/battery'est un identifiant unique. Il est crucial que cette chaîne exacte soit utilisée à la fois du côté Dart et du côté natif (Android/iOS) pour s'assurer qu'ils communiquent sur le même canal.invokeMethod('getBatteryLevel'): Cette ligne est la clé. Elle dit au moteur Flutter : "Du côté plateforme du canal nommé'com.example/battery', veuillez appeler une méthode également nommée'getBatteryLevel'." La méthode peut éventuellement passer des arguments et retournera unFutureavec le résultat du côté natif.
Côté Android (Kotlin) (exemple de code intégré) :
Ce code irait généralement dans votre fichier MainActivity.kt dans le projet Android.
import androidx.annotation.NonNull
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
class MainActivity: FlutterActivity() {
private val CHANNEL = "com.example/battery" // Doit correspondre au nom du canal côté Dart
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
// Créer un nouveau MethodChannel
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler {
// C'est ici que nous gérons les appels de méthode depuis Dart
call, result ->
if (call.method == "getBatteryLevel") {
val batteryLevel = getBatteryLevel() // Appeler notre fonction native
if (batteryLevel != -1) {
result.success(batteryLevel) // Retourner le succès avec le niveau de batterie
} else {
result.error("UNAVAILABLE", "Battery level not available.", null) // Retourner une erreur
}
} else {
// Si le nom de la méthode ne correspond pas, indiquer qu'elle n'est pas implémentée
result.notImplemented()
}
}
}
// Fonction utilitaire pour obtenir le niveau de batterie via les API Android
private fun getBatteryLevel(): Int {
val batteryLevel: Int
if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
val batteryManager = getSystemService(Context.BATTERY_SERVICE) as BatteryManager
batteryLevel = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
} else {
val intent = ContextWrapper(applicationContext).registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
batteryLevel = intent!!.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) * 100 / intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
}
return batteryLevel
}
}
Du côté Android, le MethodChannel reçoit les appels de méthode de Dart. Lorsque call.method == "getBatteryLevel" est vrai, il exécute la fonction native getBatteryLevel() en utilisant les API Android standard. Le résultat est ensuite renvoyé à Dart via result.success() ou result.error() si quelque chose ne va pas. Cette communication bidirectionnelle permet une intégration transparente avec les fonctionnalités spécifiques à la plateforme.
Bibliothèques natives avec dart:ffi
Si vous devez appeler directement des bibliothèques natives C/C++ pré-compilées (par exemple, .dll sur Windows, .so sur Linux/Android ou .dylib sur macOS), Dart propose dart:ffi (Foreign Function Interface) associé à DynamicLibrary. Il s'agit d'une approche de plus bas niveau et plus performante que les Platform Channels pour les scénarios où vous devez interagir directement avec des binaires de code natif existants. C'est particulièrement utile pour les chemins de code critiques en termes de performance et exclusivement natifs, comme les moteurs de rendu graphique ou les bibliothèques de traitement de données spécialisées. Vous trouverez généralement des exemples d'utilisation détaillés et des guides dans la documentation Dart pour DynamicLibrary.
Code Splitting et Lazy Loading : Réduire le poids initial de l'application
Les applications de grande taille peuvent devenir assez lourdes, ce qui entraîne des temps de téléchargement plus longs, une installation plus lente et une durée de démarrage accrue.
Pour lutter contre cela, des techniques comme le Code Splitting et le Lazy Loading sont inestimables. Elles vous permettent de différer le chargement de certaines parties du code et des assets de votre application jusqu'à ce qu'ils soient réellement nécessaires, réduisant ainsi considérablement le poids initial de l'application et accélérant la première expérience de l'utilisateur.
Exemple d'importation différée (Dart)
La syntaxe deferred as de Dart est un mécanisme intégré pour le Code Splitting. Elle indique au compilateur de placer le code d'une bibliothèque dans un fichier séparé qui peut être chargé à la demande au moment de l'exécution. C'est idéal pour les fonctionnalités qui ne sont pas critiques pour le démarrage immédiat ou qui sont utilisées peu fréquemment.
import 'package:flutter/material.dart';
import 'heavy_screen.dart' deferred as heavy; // Marque 'heavy_screen.dart' pour un chargement différé
Future<void> openHeavyScreen(BuildContext context) async {
// Cette ligne demande explicitement le chargement du code pour heavy_screen.dart
await heavy.loadLibrary();
Navigator.of(context).push(MaterialPageRoute(builder: (_) => heavy.HeavyScreen()));
}
// Dans main.dart, ou un autre fichier qui appelle ceci :
// ElevatedButton(
// onPressed: () => openHeavyScreen(context),
// child: const Text('Go to Heavy Feature'),
// )
Voici ce qui se passe :
import 'heavy_screen.dart' deferred as heavy;: Cette ligne est la clé. La clausedeferred as heavyindique au compilateur Dart de générer un fichier JavaScript séparé (pour le web) ou un morceau de code séparé (pour les plateformes natives) pourheavy_screen.dart. Le code deheavy_screen.dartn'est pas inclus initialement dans le code principal de votre application.await heavy.loadLibrary();: Cette ligne cruciale demande le chargement de la bibliothèque différée au moment de l'exécution. Lorsque cette ligne s'exécute, Flutter récupère et charge le paquet de code séparé. Cela se produit généralement de manière asynchrone.Une fois que
loadLibrary()est terminé, vous pouvez alors instancier en toute sécurité des classes et appeler des fonctions de la bibliothèque différée, commeheavy.HeavyScreen().
Les importations différées vous permettent de retarder l'intégration de modules volumineux ou rarement utilisés en mémoire jusqu'à ce que cela soit absolument nécessaire, réduisant directement le coût de démarrage et la taille de téléchargement initiale.
Pour les plateformes Android et web, Flutter propose également des "composants différés" plus avancés (Android App Bundles et chargement différé web) pour télécharger des paquets entiers de code et d'assets au besoin. Vous pouvez trouver plus de détails complets dans la documentation officielle de Flutter sur les composants différés et leurs exigences spécifiques à la plateforme.
Lazy Loading au niveau des routes
Vous pouvez combiner les importations différées avec la stratégie de navigation de votre application pour implémenter le Lazy Loading au niveau des routes. Cela signifie qu'un module de fonctionnalité entier (par exemple, un écran de paramètres complexe, un flux d'onboarding ou un tableau de bord analytique rarement consulté) n'est chargé que lorsque l'utilisateur navigue réellement vers sa route correspondante.
Exemple utilisant un Framework de navigation comme go_router (ou une logique similaire avec Navigator) :
// main.dart ou votre fichier de configuration du routeur
import 'package:go_router/go_router.dart';
import 'package:flutter/material.dart';
import 'heavy_feature_module.dart' deferred as heavy_feature; // Marquer pour chargement différé
final _router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/heavy-feature',
// Le builder pour la fonctionnalité lourde charge la bibliothèque à la demande
pageBuilder: (context, state) => CustomTransitionPage(
key: state.pageKey,
child: FutureBuilder(
future: heavy_feature.loadLibrary(), // Charger la bibliothèque avant d'afficher la page
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return heavy_feature.HeavyFeatureScreen(); // Afficher l'écran après le chargement
}
return const LoadingScreen(); // Afficher un indicateur de chargement
},
),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(opacity: animation, child: child);
},
),
),
],
);
// Dans votre HomeScreen ou tout autre endroit pour naviguer :
// ElevatedButton(
// onPressed: () => GoRouter.of(context).go('/heavy-feature'),
// child: const Text('Go to Heavy Feature'),
// )
Dans cette configuration, le code de HeavyFeatureScreen et tout ce qui est importé par heavy_feature_module.dart ne fera pas partie du bundle initial de l'application. Il ne sera téléchargé et chargé que lorsque l'utilisateur naviguera vers la route /heavy-feature, affichant un LoadingScreen entre-temps.
Packages et bibliothèques utilitaires
La communauté Flutter a également développé divers packages pour aider aux stratégies de Lazy Loading. Vous pouvez trouver des packages sur pub.dev (le dépôt officiel de packages Dart et Flutter) qui offrent des utilitaires pour construire paresseusement des sous-arbres d'UI ou gérer des éléments dans de grandes piles, tels que flutter_lazy_loading ou lazy_indexed_stack. Vérifiez toujours pub.dev pour les solutions existantes avant d'implémenter des mécanismes de Lazy Loading complexes à partir de zéro.
Profilage de performance et outils
Garantir que votre application Flutter fonctionne de manière fluide ne consiste pas seulement à appliquer les meilleures pratiques. Il s'agit également de mesurer ses performances pour identifier et corriger les goulots d'étranglement. Flutter est livré avec une excellente suite d'outils de profilage essentiels pour diagnostiquer et résoudre les problèmes de performance.
Outils clés et comment les utiliser
Flutter DevTools : C'est votre arme principale pour le profilage de performance. Il s'agit d'une suite d'outils de débogage et de profilage basée sur le web qui s'intègre parfaitement à votre application Flutter.
Inspector : Vous aide à comprendre votre arbre de widgets et votre mise en page.
Timeline : Crucial pour identifier les saccades (jank) de l'UI. Il visualise le travail effectué par Flutter image par image (threads GPU et UI). Vous chercherez les images qui dépassent 16 ms (pour les écrans 60 Hz) ou 33 ms (pour les écrans 30 Hz). Si une image prend plus de temps, cela signifie que votre application perd des images et paraîtra saccadée à l'utilisateur.
Memory : Aide à suivre l'utilisation de la mémoire, à identifier les fuites et à analyser les allocations d'objets.
CPU Profiler : Vous montre où votre application dépense ses cycles CPU.
Widget Rebuild Profiler : Identifie quels widgets se reconstruisent et à quelle fréquence. C'est vital pour repérer les reconstructions inutiles.
flutter run --profile: Exécutez toujours votre application en mode profil (ou mode release) lors du profilage. Le mode debug inclut de nombreuses assertions et aides au débogage qui peuvent impacter considérablement les performances, rendant les résultats du profilage inexacts.Observatory / VM service : Il s'agit d'un outil de bas niveau qui fournit des informations approfondies sur la VM Dart, l'utilisation de la mémoire et les performances. DevTools est construit au-dessus du service VM, vous interagissez donc souvent avec lui indirectement.
Comment aborder le profilage : Un flux de travail
Passons en revue un flux de travail de profilage typique :
Tout d'abord, vous devrez reproduire le problème sur un appareil réel. Bien que les simulateurs soient utiles, les appareils réels ont souvent des caractéristiques de performance différentes (CPU, GPU, mémoire). Confirmez toujours les problèmes de performance sur du matériel réel.
Ensuite, utilisez la Timeline pour trouver les saccades. Ouvrez DevTools, allez dans l'onglet "Performance" et commencez l'enregistrement. Interagissez avec la partie de votre application qui semble lente. Recherchez les images rouges ou celles qui dépassent le temps cible (16 ms ou 33 ms).
Ensuite, inspectez le Widget Rebuild Profiler. Si vous soupçonnez que des reconstructions excessives sont en cause, utilisez la section "Widget rebuilds" de l'onglet "Performance" pour voir quels widgets se reconstruisent trop souvent. Combinez cela avec la Timeline pour voir si ces reconstructions correspondent aux saccades.
Après cela, analysez la mémoire et le CPU. Si vous avez des avertissements de mémoire ou des pics de CPU, utilisez les onglets "Memory" et "CPU Profiler" pour localiser les allocations excessives, les fuites de mémoire ou le travail synchrone coûteux en calcul.
Il est alors temps de corriger les points chauds que vous avez trouvés. Une fois que vous avez identifié un goulot d'étranglement (par exemple, un calcul synchrone lourd bloquant le thread UI, trop de reconstructions de widgets inutiles ou un décodage d'image lent), appliquez la technique d'optimisation appropriée (par exemple,
computepour le travail CPU,constpour les widgets,cached_network_imagepour les images).Enfin, assurez-vous de mesurer à nouveau. L'optimisation des performances est un processus itératif. Après avoir effectué des changements, re-profilez pour confirmer que vos modifications ont réellement amélioré les performances et n'ont pas introduit de nouveaux problèmes.
Optimisation réseau
Les requêtes réseau sont une source courante de retards de performance et peuvent consommer une autonomie de batterie et des données importantes. Optimiser la façon dont votre application Flutter interagit avec les ressources réseau est critique pour une expérience utilisateur rapide, réactive et efficace.
Utiliser un client HTTP robuste : Exemple avec Dio
Bien que le package http intégré de Flutter convienne aux cas d'utilisation simples, les applications de niveau production bénéficient souvent d'un client HTTP plus riche en fonctionnalités. Dio est un choix populaire et puissant qui prend en charge les intercepteurs, l'annulation de requêtes, les adaptateurs personnalisés, la configuration globale, et plus encore. Il vous permet de centraliser les préoccupations réseau communes comme l'authentification, la journalisation et la gestion des erreurs.
Voici un exemple avec une configuration de base, incluant un squelette d'intercepteur pour la journalisation et l'ajout d'en-têtes d'authentification :
import 'package:dio/dio.dart'; // Importer le package Dio
// Créer une instance Dio, souvent comme singleton ou fournie via la gestion d'état
final dio = Dio(BaseOptions(
baseUrl: 'https://api.example.com', // Votre URL de base API
connectTimeout: const Duration(seconds: 5), // Délai de connexion
receiveTimeout: const Duration(seconds: 3), // Délai de réception
));
class ApiClient {
ApiClient() {
// Ajouter des intercepteurs à l'instance Dio
dio.interceptors.add(LogInterceptor(responseBody: true, requestBody: true)); // Log les requêtes et réponses
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
// Exemple : ajouter des en-têtes d'authentification si disponibles
final token = 'votre_token_ici'; // Récupérer du stockage sécurisé ou du service d'auth
if (token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
return handler.next(options); // Continuer la requête
},
onError: (DioException error, handler) async {
// Exemple : gérer l'expiration du token, le rafraîchissement ou la logique de réessai
if (error.response?.statusCode == 401) {
// Tenter de rafraîchir le token ou de se ré-authentifier
print("Erreur d'authentification, tentative de rafraîchissement...");
// ... logique de rafraîchissement ...
// si rafraîchi, réessayer la requête : return handler.resolve(await dio.fetch(error.requestOptions));
}
return handler.next(error); // Passer l'erreur
},
));
}
// Exemple de méthode pour récupérer des éléments
Future<Response> getItems() async {
return dio.get('/items'); // Effectue une requête GET vers /items
}
// Exemple de méthode pour poster des données
Future<Response> postData(Map<String, dynamic> data) async {
return dio.post('/data', data: data);
}
}
Dans ce code :
Dio(BaseOptions(...)): Nous créons un clientDioavec des configurations communes comme une URL de base et des délais d'attente.LogInterceptor: Cet intercepteur est incroyablement utile pendant le développement, affichant les détails des requêtes et des réponses dans la console.InterceptorsWrapper: Cela vous permet de définir une logique personnalisée qui s'exécute avant (onRequest), après (onResponse) ou en cas d'erreur (onError) de toute requête réseau. Ici, nous démontrons l'ajout d'en-têtes d'authentification, ce qui centralise une préoccupation transversale à tous les appels API.
Mise en cache et compression
Au-delà d'un client robuste, une utilisation intelligente de la mise en cache et de la compression peut considérablement améliorer les performances réseau et l'expérience utilisateur.
Compression côté serveur (Gzip) : Assurez-vous toujours que votre serveur backend est configuré pour utiliser la compression (comme gzip) pour les réponses. Cela réduit considérablement la taille des données transférées sur le réseau. Votre client
Dio(et la plupart des clients HTTP) inclura automatiquement les en-têtesAccept-Encoding: gzip, deflate, br, indiquant au serveur qu'il peut accepter des réponses compressées.Stratégies de mise en cache HTTP : Tirez parti des en-têtes de mise en cache HTTP standard comme
Cache-Control,ETagetLast-Modified.Cache-Control: Indique aux clients (et aux proxys) combien de temps une réponse peut être mise en cache et si elle nécessite une revalidation.ETag/Last-Modified: Permettent des requêtes conditionnelles. Si le client a une version en cache, il peut envoyer les en-têtesIf-None-Match(avec l'ETag) ouIf-Modified-Since(avecLast-Modified). Si la ressource n'a pas changé, le serveur peut répondre par un304 Not Modified, économisant de la bande passante en n'envoyant pas à nouveau toute la réponse.
Caches côté client pour un comportement hors ligne/résilient : Pour les données critiques, envisagez de stocker les réponses dans une base de données locale (comme
sqfliteouHive) ou d'utiliser un package de cache dédié. Cela permet un accès immédiat aux données même hors ligne et réduit les requêtes réseau lors des lancements ultérieurs.
// Exemple utilisant Dio pour une requête conditionnelle (simplifié)
Future<Response> getCachedItems() async {
final storedETag = await _getLocalETagForItems(); // Récupérer l'ETag du stockage local
try {
final response = await dio.get(
'/items',
options: Options(
headers: {
if (storedETag != null) 'If-None-Match': storedETag, // Envoyer l'ETag pour un GET conditionnel
},
),
);
if (response.statusCode == 304) {
print("Éléments non modifiés, service depuis le cache.");
// Retourner les données précédemment mises en cache
return _getLocalCachedItems(); // Récupérer de la DB locale/cache
} else {
// Les données ont été modifiées, stocker le nouvel ETag et les données
await _saveLocalETagForItems(response.headers.value('etag'));
await _saveLocalCachedItems(response.data);
return response;
}
} on DioException catch (e) {
if (e.response?.statusCode == 304) {
print("Éléments non modifiés (chemin d'erreur), service depuis le cache.");
return _getLocalCachedItems();
}
rethrow;
}
}
// Emplacements pour les opérations réelles de stockage local/DB
Future<String?> _getLocalETagForItems() async => null;
Future<void> _saveLocalETagForItems(String? etag) async {}
Future<Response> _getLocalCachedItems() async => Response(requestOptions: RequestOptions(path: '/items'), data: []);
Future<void> _saveLocalCachedItems(dynamic data) async {}
Pour les payloads JSON très volumineux, analysez-les toujours dans un Isolate en utilisant compute pour éviter de bloquer le thread UI, comme discuté dans la section Optimisation du code.
Processus en arrière-plan et tâches de longue durée
Les applications ont souvent besoin d'effectuer des tâches qui prennent beaucoup de temps ou qui doivent continuer même lorsque l'utilisateur ne regarde pas activement l'application. Gérer ces "processus en arrière-plan" efficacement est crucial pour une expérience utilisateur fluide et une utilisation efficace des ressources.
Dans Dart et Flutter, les deux principaux moyens de gérer le travail asynchrone et de longue durée sont les Futures et les Isolates.
Isolates vs Futures
Futures / async / await sont destinés aux tâches liées aux E/S (I/O-bound). Cela signifie des tâches qui impliquent d'attendre quelque chose d'externe, comme la fin d'une requête réseau, la lecture d'un fichier sur le disque ou la fin d'une requête de base de données. Pendant ce temps d'attente, la boucle d'événements Dart peut traiter d'autres tâches, gardant votre UI réactive.
Les Futures ne s'exécutent pas sur un thread séparé. Ils permettent simplement au thread principal de rester non bloqué pendant qu'il attend qu'une opération se termine.
Exemple : Récupérer des données d'une API ou lire un gros fichier sur le disque.
Isolates (ou compute) sont destinés aux tâches liées au CPU (CPU-bound). Cela signifie des tâches qui nécessitent beaucoup de puissance de calcul et qui bloqueraient le thread Dart principal si elles y étaient exécutées. Les Isolates exécutent des boucles d'événements Dart entièrement séparées avec leur propre mémoire, garantissant que les calculs lourds ne gèlent pas votre UI.
Exemple : Analyser un énorme fichier JSON, manipulation d'images complexes, calculs mathématiques lourds.
Exemple d'Isolate
Bien que compute soit un utilitaire pratique pour de nombreuses tâches liées au CPU, vous avez parfois besoin d'un contrôle plus précis sur les Isolates, comme la mise en place d'une communication continue ou la gestion de plusieurs messages. Cela implique d'utiliser directement la bibliothèque dart:isolate.
import 'dart:isolate'; // Pour l'API Isolate
import 'package:flutter/material.dart'; // Juste pour le contexte de l'exemple
// C'est le point d'entrée pour le nouvel isolate.
// Il doit s'agir d'une fonction de haut niveau ou statique.
void heavyTaskEntryPoint(SendPort sendPort) {
// Un ReceivePort pour que cet isolate écoute les messages de l'isolate principal
final receivePort = ReceivePort();
// Envoyer le SendPort du nouvel isolate à l'isolate principal
sendPort.send(receivePort.sendPort);
// Écouter les messages de l'isolate principal
receivePort.listen((message) {
if (message is String && message == 'start') {
print('Isolate reçu commande start, exécution du travail lourd...');
// Simuler un travail lourd lié au CPU ici
final result = List.generate(50000000, (i) => i).reduce((a, b) => a + b);
sendPort.send(result); // Renvoyer le résultat à l'isolate principal
print('Isolate travail lourd terminé.');
} else if (message is String && message == 'stop') {
print('Isolate reçu commande stop, arrêt de l\'isolate.');
receivePort.close(); // Fermer le port de réception
Isolate.current.kill(); // Terminer l'isolate
}
});
print('Isolate prêt.');
}
Future<int> runHeavyTask() async {
final receivePort = ReceivePort(); // ReceivePort de l'isolate principal
// Lancer un nouvel isolate, en lui passant le SendPort de notre ReceivePort
final isolate = await Isolate.spawn(heavyTaskEntryPoint, receivePort.sendPort);
// Attendre que le nouvel isolate nous renvoie son propre SendPort
final SendPort? isolateSendPort = await receivePort.first as SendPort?;
if (isolateSendPort == null) {
throw Exception('Échec de récupération du SendPort de l\'isolate.');
}
// Envoyer un message 'start' au nouvel isolate
isolateSendPort.send('start');
// Attendre le résultat de la tâche lourde
final result = await receivePort.first as int;
// Envoyer un message 'stop' pour terminer l'isolate après avoir obtenu le résultat
isolateSendPort.send('stop');
// Nettoyer l'isolate
isolate.kill(priority: Isolate.immediate);
receivePort.close();
return result;
}
// Exemple d'utilisation dans un widget Flutter
class MyIsolateWidget extends StatefulWidget {
const MyIsolateWidget({super.key});
@override
State<MyIsolateWidget> createState() => _MyIsolateWidgetState();
}
class _MyIsolateWidgetState extends State<MyIsolateWidget> {
String _taskStatus = 'Idle';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Isolate Demo')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Statut de la tâche : $_taskStatus'),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () async {
setState(() => _taskStatus = 'Exécution de la tâche lourde...');
try {
final sum = await runHeavyTask();
setState(() => _taskStatus = 'Tâche terminée ! Somme : $sum');
} catch (e) {
setState(() => _taskStatus = 'Tâche échouée : $e');
}
},
child: const Text('Lancer la tâche lourde'),
),
],
),
),
);
}
}
Dans ce code :
ReceivePort(): Crée un port dans l'Isolate principal pour recevoir des messages du nouvel Isolate.Isolate.spawn(heavyTaskEntryPoint, receivePort.sendPort): Cette ligne crée un nouvel Isolate Dart indépendant et y exécute immédiatement la fonctionheavyTaskEntryPoint. Nous passonsreceivePort.sendPortau nouvel Isolate pour qu'il sache comment renvoyer des messages à l'Isolate principal.receivePort.first: CeFutureattend que le premier message arrive sur leReceivePort. Dans notre exemple, le premier message du nouvel Isolate sera sonSendPort, que nous utilisons ensuite pour envoyer des commandes ('start','stop') à l'Isolate.isolate.kill(): Après avoir obtenu le résultat, il est de bonne pratique de terminer l'Isolate s'il n'est plus nécessaire, afin de libérer des ressources.
Pour les tâches en arrière-plan au niveau de la plateforme (comme la planification de travaux même lorsque votre application est fermée, en utilisant Android WorkManager ou iOS background fetch), vous devrez généralement utiliser des plugins de plateforme ou des packages qui enveloppent ces mécanismes de planification natifs, car les Isolates Dart ne s'exécutent que tant que le processus de votre application Flutter est actif.
Tests : Portes de qualité pour l'évolutivité
Les tests sont une pratique critique pour garantir l'évolutivité, la maintenabilité et la fiabilité à long terme de votre application Flutter. Une base de code bien testée vous donne la confiance nécessaire pour refactoriser, ajouter de nouvelles fonctionnalités et garantir que les optimisations de performance ne cassent pas les fonctionnalités existantes. Flutter offre un support robuste pour différents types de tests.
Tests unitaires
Les tests unitaires se concentrent sur les plus petites parties testables de votre application (fonctions individuelles, classes ou composants de logique métier) de manière isolée, sans aucune UI. Ils sont rapides à exécuter et aident à garantir que votre logique de base se comporte comme prévu.
Exemple :
import 'package:flutter_test/flutter_test.dart'; // Requis pour test et expect
// Une fonction simple à tester
int add(int a, int b) => a + b;
void main() {
// Définir un groupe de tests ou un test unique
test('la fonction add devrait additionner correctement deux nombres', () {
// Utiliser expect pour affirmer le résultat attendu
expect(add(1, 2), 3); // Cas de test 1 : nombres positifs
expect(add(-1, 5), 4); // Cas de test 2 : positif et négatif
expect(add(0, 0), 0); // Cas de test 3 : zéros
});
}
Les tests unitaires valident la logique pure, indépendamment de l'UI. Ils sont le type de test le plus rapide et forment la base d'une base de code fiable.
Tests de widgets
Les tests de widgets, également appelés tests de composants, vérifient qu'un seul widget ou un petit sous-arbre de widgets s'affiche et se comporte comme prévu. Ils s'exécutent dans un environnement simulé, simulant le navigateur ou l'appareil, vous permettant d'interagir avec vos widgets et de vérifier leur rendu et leurs changements d'état.
Exemple : Tester la fonctionnalité d'incrémentation d'un simple widget de compteur.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:your_app/main.dart'; // Supposant que MyApp est dans main.dart
void main() {
testWidgets('Le compteur s\'incrémente quand on appuie sur le FAB', (WidgetTester tester) async {
// Construire notre application et déclencher une image.
await tester.pumpWidget(const MyApp()); // Rendre le widget MyApp
// Vérifier que notre compteur commence à 0.
expect(find.text('0'), findsOneWidget); // Trouver un widget Text affichant '0'
expect(find.text('1'), findsNothing); // S'assurer que '1' n'est pas encore présent
// Appuyer sur l'icône '+'.
await tester.tap(find.byIcon(Icons.add)); // Simuler un appui sur le bouton d'ajout
// Reconstruire le widget après le changement d'état.
await tester.pump(); // Déclencher une reconstruction pour refléter le changement d'état
// Vérifier que notre compteur a été incrémenté.
expect(find.text('0'), findsNothing); // '0' ne devrait plus être présent
expect(find.text('1'), findsOneWidget); // '1' devrait maintenant être affiché
});
}
Les tests de widgets vérifient le comportement de l'UI dans un environnement simulé. Ils sont cruciaux pour s'assurer que vos composants d'UI s'affichent correctement et répondent aux interactions de l'utilisateur comme prévu.
Tests d'intégration
Les tests d'intégration vérifient des flux entiers ou plusieurs modules de votre application travaillant ensemble, s'exécutant sur un appareil réel ou un émulateur. Ils couvrent les parcours utilisateurs, garantissant que les différentes parties de votre application s'intègrent correctement. Flutter fournit le package integration_test pour cela.
Bien qu'un exemple complet soit trop long ici, conceptuellement, vous devriez :
Utiliser
flutter_driverou le packageintegration_test.Écrire des tests qui simulent des interactions utilisateur sur plusieurs écrans (par exemple, se connecter, naviguer vers une liste de produits, ajouter au panier, passer à la caisse).
Affirmer l'état final de l'UI ou des données.
Les tests d'intégration sont précieux pour vérifier les flux utilisateurs de bout en bout sur des appareils réels et dans des environnements CI, capturant des problèmes qui pourraient n'apparaître que lorsque toutes les parties de l'application sont connectées.
Gestion de la mémoire : Éviter les fuites et la croissance incontrôlée
Une gestion efficace de la mémoire est vitale pour des applications évolutives et performantes. Une mauvaise gestion de la mémoire peut entraîner des performances ralenties, des plantages de l'application et une expérience utilisateur frustrante.
Dans Flutter, éviter les fuites de mémoire et la croissance incontrôlée revient en grande partie à gérer avec diligence le cycle de vie de vos objets, en particulier ceux qui détiennent des ressources ou écoutent des événements.
Voici quelques points clés à garder à l'esprit :
Toujours libérer les contrôleurs et les abonnements dans dispose()
Les widgets utilisent souvent TextEditingController pour les champs de texte, AnimationController pour les animations et StreamSubscription pour écouter des flux. Ces objets détiennent souvent des ressources natives ou créent des écouteurs qui doivent être explicitement libérés lorsque le widget est retiré de l'arbre. Ne pas le faire entraînera des fuites de mémoire. La méthode dispose() d'un StatefulWidget est l'endroit idéal pour nettoyer ces ressources.
Éviter de conserver de grands graphes d'objets dans des singletons à moins de gérer explicitement les cycles de vie
Les singletons (objets qui n'ont qu'une seule instance pendant toute la durée de vie de l'application) peuvent être pratiques, mais ils vivent pendant toute la durée de l'application. Si un singleton détient des références à de grands objets (comme des images, de grandes structures de données ou même des écrans entiers), ces objets ne seront jamais collectés par le ramasse-miettes (garbage collector), ce qui peut entraîner une utilisation excessive de la mémoire.
Si vous devez utiliser des singletons, assurez-vous que toutes les données volumineuses ou temporaires qu'ils détiennent sont explicitement effacées lorsqu'elles ne sont plus nécessaires.
Utiliser des références faibles ou vider les caches lors de l'arrivée d'avertissements de mémoire faible
Sur les plateformes mobiles, le système d'exploitation peut envoyer des avertissements de mémoire faible. Bien que le ramasse-miettes de Dart gère la majeure partie de la gestion de la mémoire, dans les applications très gourmandes en mémoire, vous pourriez vouloir écouter ces avertissements et vider explicitement les caches non critiques (par exemple, les caches d'images, les données temporaires) pour libérer de la mémoire et empêcher l'OS de tuer votre application. Il s'agit d'une optimisation avancée.
Voici un exemple démontrant une libération correcte dans un StatefulWidget :
class MyForm extends StatefulWidget {
const MyForm({super.key});
@override
State<MyForm> createState() => _MyFormState();
}
class _MyFormState extends State<MyForm> {
// 1. Déclarer les contrôleurs qui nécessitent une libération
final TextEditingController _textController = TextEditingController();
// Exemple : un abonnement à un flux
// StreamSubscription? _mySubscription;
@override
void initState() {
super.initState();
// 2. Initialiser les contrôleurs et les abonnements
// _mySubscription = someStream.listen((data) { ... });
}
@override
void dispose() {
// 3. Crucialement, libérez vos contrôleurs et abonnements ici
_textController.dispose();
// _mySubscription?.cancel(); // Annuler les abonnements aux flux
print('MyFormState libéré, ressources relâchées.');
super.dispose(); // Toujours appeler super.dispose() en dernier
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _textController,
decoration: const InputDecoration(labelText: 'Entrez du texte'),
);
}
}
Voici ce qui se passe dans ce code :
La méthode dispose() est invoquée lorsque le StatefulWidget est définitivement retiré de l'arbre des widgets. En appelant _textController.dispose() ici, nous nous assurons que les ressources natives associées à la saisie de texte sont libérées, empêchant ainsi une fuite de mémoire. Ne pas libérer ces objets signifie qu'ils continuent d'occuper de la mémoire et de consommer potentiellement des ressources système même après la disparition de l'élément d'UI. Appeler toujours super.dispose() comme dernière ligne garantit que la classe parente peut également nettoyer ses ressources.
Optimisation des images et des assets
Les images et autres assets (comme les polices, les fichiers JSON) constituent souvent une partie importante de la taille d'une application et peuvent impacter les performances s'ils ne sont pas optimisés. Une gestion réfléchie de ces ressources est la clé d'une application légère et rapide.
Préférer les graphiques vectoriels (SVG) lorsque c'est approprié
Pour les logos, les icônes et les illustrations qui doivent être redimensionnés sans perte de qualité, les graphiques vectoriels comme le SVG (Scalable Vector Graphics) sont idéaux. Des packages comme flutter_svg vous permettent d'utiliser les SVG efficacement. Ils se traduisent souvent par des tailles de fichiers plus petites par rapport à de multiples assets d'images matricielles pour différentes densités d'écran et offrent un rendu net sur tous les appareils.
Compresser les images matricielles et fournir plusieurs résolutions si nécessaire
Pour les images photographiques ou les graphiques complexes qui doivent être matriciels (PNG, JPG, WebP), compressez-les toujours. Il existe divers outils que vous pouvez utiliser pour réduire considérablement la taille du fichier sans perte notable de qualité visuelle.
Vous pouvez également envisager de fournir des images à différentes résolutions (1.0x, 2.0x, 3.0x) dans votre dossier d'assets pubspec.yaml. Flutter choisira automatiquement la résolution la plus appropriée pour la densité de pixels de l'appareil, empêchant le chargement de grandes images sur de petits écrans (ce qui gaspille de la mémoire et du CPU) et garantissant des images nettes sur les écrans haute densité.
Utiliser des assets différés quand c'est possible et precacheImage pour les visuels clés
Tout comme pour le code, vous pouvez différer le chargement de grands bundles d'assets jusqu'à ce qu'ils soient nécessaires, en particulier pour les fonctionnalités qui ne sont pas consultées immédiatement. Pour les images critiques pour l'expérience utilisateur initiale (comme les images hero ou les arrière-plans d'écran initiaux), utilisez precacheImage (comme discuté précédemment) pour vous assurer qu'elles sont téléchargées et décodées en mémoire avant d'être rendues, évitant ainsi les saccades visuelles.
Supprimer les assets inutilisés et auditer avec des outils de taille de build
Au fil du temps, les projets accumulent des assets inutilisés. Auditez régulièrement vos dossiers d'assets et votre pubspec.yaml pour supprimer tout ce qui n'est plus nécessaire. Des outils comme flutter_launcher_icons (bien que principalement pour les icônes d'application) et les outils généraux d'analyse de taille de build peuvent aider à identifier les assets contribuant de manière significative à la taille finale de votre application.
Distribution de l'application et optimisation de la taille du build
Une taille de téléchargement d'application importante peut décourager les utilisateurs et augmenter les coûts de données. Optimiser la taille de votre application pour la distribution est une partie cruciale du cycle de vie du développement, garantissant une portée plus large et des installations plus rapides.
Vous pouvez utiliser flutter build apk --split-per-abi ou flutter build appbundle pour réduire la taille du téléchargement.
Pour Android, vous devriez toujours préférer flutter build appbundle. Cela génère un Android App Bundle, que Google Play utilise pour générer des APK optimisés pour la configuration de l'appareil de chaque utilisateur (ABI, langue, DPI). Cela signifie que les utilisateurs ne téléchargent que le code et les ressources pertinents pour leur appareil.
Si vous devez générer des APK directement, flutter build apk --split-per-abi génère des APK séparés pour chaque architecture (par exemple, armeabi-v7a, arm64-v8a). Cela permet aux utilisateurs de ne télécharger que l'APK compatible avec le CPU de leur appareil, plutôt qu'un APK "gras" contenant du code pour toutes les architectures.
Vérifier le tree shaking et élaguer les dépendances
Le tree shaking de Dart supprime automatiquement le code inutilisé pendant la compilation. Mais vous devriez tout de même revoir régulièrement votre pubspec.yaml pour vous assurer que vous n'incluez pas de packages volumineux et inutiles.
Chaque dépendance ajoute à la taille de votre application. Si vous n'avez besoin que d'un petit utilitaire d'un grand package, envisagez de trouver une alternative plus légère ou de l'implémenter vous-même si c'est faisable.
Utiliser des composants différés pour les fonctionnalités optionnelles afin de réduire la taille d'installation initiale
Comme discuté dans le Code Splitting, les composants différés de Flutter (et les importations différées de Dart) sont des moyens puissants de déplacer les fonctionnalités rarement utilisées vers des bundles d'assets séparés qui ne sont téléchargés que lorsqu'ils sont activés. Cela maintient votre taille d'installation initiale minimale. Reportez-vous à la documentation Flutter pour les composants différés pour une implémentation détaillée.
Checklist de production pour la taille du build :
Supprimer les packages réservés au debug dans les builds de release : Assurez-vous que les packages utilisés uniquement pour le développement ou le débogage (par exemple, certains packages de journalisation, moniteurs de performance) ne sont pas inclus dans vos builds de release. Utilisez
dev_dependenciesdans votrepubspec.yaml.Exécuter
flutter build --releaseet analyser la taille avec DevTools : Analysez toujours la taille de votre build de release. Flutter DevTools dispose d'un onglet "App Size" qui peut vous donner une ventilation de ce qui contribue à la taille de votre application (code, assets, bibliothèques).Utiliser la CI pour exécuter des vérifications de taille et bloquer les PR qui augmentent la taille au-delà des seuils : Intégrez des vérifications de taille d'application dans votre pipeline d'Intégration Continue (CI). Faites échouer automatiquement les pull requests si elles augmentent la taille de l'application au-delà d'un seuil acceptable prédéfini, encourageant les développeurs à être attentifs aux implications sur la taille.
Bonnes pratiques de sécurité
La sécurité est primordiale dans toute application, et Flutter ne fait pas exception. Protéger les données des utilisateurs, la logique de l'application et les communications backend nécessite une approche proactive. Voici quelques bonnes pratiques à suivre et des techniques à essayer :
Utiliser HTTPS pour toutes les communications réseau : N'utilisez jamais de HTTP non chiffré pour toute transmission de données sensibles. Utilisez toujours HTTPS pour chiffrer les données en transit, les protégeant ainsi de l'écoute clandestine et de l'altération.
Stocker les secrets et les tokens de manière sécurisée : Ne codez pas en dur les clés API, les tokens d'authentification ou d'autres identifiants sensibles directement dans votre code source. Pour stocker de petits morceaux de données utilisateur sensibles (comme les tokens de connexion) sur l'appareil, utilisez
flutter_secure_storagequi exploite les mécanismes de stockage sécurisé spécifiques à la plateforme (Keychain sur iOS, Encrypted SharedPreferences sur Android). Pour les clés API, envisagez des variables d'environnement pendant le temps de build ou récupérez-les auprès d'un service backend sécurisé.Utiliser le certificate pinning si vous devez vous protéger contre le MITM pour les applications à haut risque : Pour les applications traitant des données hautement sensibles (par exemple, les applications bancaires), le certificate pinning ajoute une couche de sécurité supplémentaire. Cela consiste à intégrer la clé publique ou le certificat d'un serveur dans votre application. De cette façon, votre application ne communiquera qu'avec les serveurs dont le certificat correspond à celui épinglé, empêchant les attaques de l'homme du milieu (MITM) où un attaquant tente d'usurper l'identité de votre serveur avec un certificat frauduleux. C'est une fonctionnalité complexe à implémenter et à maintenir.
Assainir et valider les entrées provenant du réseau et des fichiers : Ne faites jamais confiance aux entrées utilisateur, aux réponses réseau ou aux données lues à partir de fichiers. Assainissez toujours (supprimez les caractères potentiellement dangereux) et validez (vérifiez par rapport aux formats et contraintes attendus) toutes les données entrantes pour prévenir les attaques par injection (comme l'injection SQL ou le cross-site scripting) et les dépassements de tampon.
Faire pivoter les clés API et éviter d'expédier des identifiants dans le code : Implémentez une stratégie pour faire pivoter régulièrement vos clés API. Si une clé API est compromise, faites-la pivoter immédiatement. Évitez d'intégrer des clés API ou des secrets directement dans le code de votre application qui est expédié aux utilisateurs. Utilisez des variables d'environnement pendant votre processus CI/CD, ou mieux encore, récupérez-les auprès d'un backend sécurisé au moment de l'exécution.
Voici un exemple utilisant flutter_secure_storage :
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
// Créer une instance de stockage (souvent comme singleton ou via injection de dépendances)
final FlutterSecureStorage storage = FlutterSecureStorage();
// Fonction pour écrire un token
Future<void> saveAuthToken(String token) async {
await storage.write(key: 'auth_token', value: token);
print('Auth token sauvegardé de manière sécurisée.');
}
// Fonction pour lire un token
Future<String?> getAuthToken() async {
final token = await storage.read(key: 'auth_token');
print('Auth token récupéré : $token');
return token;
}
// Fonction pour supprimer un token
Future<void> deleteAuthToken() async {
await storage.delete(key: 'auth_token');
print('Auth token supprimé.');
}
Dans ce code, flutter_secure_storage fournit une API facile à utiliser pour stocker des paires clé-valeur chiffrées. Sur iOS, il utilise Keychain, tandis que sur Android, il utilise Encrypted SharedPreferences. Cela garantit que les informations sensibles sont stockées dans le stockage le plus sécurisé disponible sur la plateforme, ce qui rend l'accès beaucoup plus difficile pour les acteurs malveillants.
Analytique et suivi des erreurs
Comprendre comment les utilisateurs interagissent avec votre application et identifier et résoudre rapidement les erreurs sont des éléments critiques pour une amélioration continue et le maintien d'une expérience utilisateur de haute qualité. L'intégration d'outils d'analytique et de suivi des erreurs dès le départ fournit des informations inestimables.
Certaines options populaires sont :
Firebase Analytics pour le suivi des événements : Firebase Analytics est un outil gratuit et puissant pour suivre l'engagement et le comportement des utilisateurs. Vous pouvez enregistrer des événements personnalisés (par exemple, 'item_added_to_cart', 'feature_x_used'), suivre les vues d'écran et analyser la démographie des utilisateurs. Ces données vous aident à comprendre l'utilisation des fonctionnalités, les flux utilisateurs et à identifier les domaines à améliorer.
Firebase Crashlytics pour le rapport de plantage : Crashlytics est un service de rapport de plantage robuste et en temps réel qui vous aide à suivre, prioriser et corriger les problèmes de stabilité. Il collecte automatiquement des rapports de plantage détaillés, y compris les traces de pile et les informations sur l'appareil, vous permettant de diagnostiquer rapidement les problèmes.
Pour un suivi des erreurs plus riche, envisagez Sentry (ou équivalent) : Bien que Crashlytics soit excellent pour les plantages, des services comme Sentry offrent un suivi des erreurs plus complet, incluant les erreurs non fatales, les fil d'Ariane (breadcrumbs - une trace des événements menant à une erreur) et des informations contextuelles sur l'utilisateur. Cela peut être inestimable pour déboguer des problèmes subtils qui ne provoquent pas un plantage complet.
Suivre l'utilisation des fonctionnalités, les métriques de performance et les flux utilisateurs : Au-delà du rapport de plantage de base, utilisez votre plateforme d'analytique pour suivre des métriques de performance spécifiques (par exemple, les temps de chargement pour les écrans critiques) et cartographier les flux utilisateurs. Cela vous aide à identifier les opportunités d'optimisation à fort impact et à comprendre où les utilisateurs pourraient abandonner ou rencontrer des frictions.
Initialisation (squelette Crashlytics) :
import 'package:firebase_core/firebase_core.dart'; // Requis pour Firebase.initializeApp()
import 'package:firebase_crashlytics/firebase_crashlytics.dart'; // Requis pour Crashlytics
import 'package:flutter/foundation.dart'; // Requis pour FlutterError.onError
import 'package:flutter/material.dart'; // Pour runApp et WidgetsFlutterBinding.ensureInitialized
void main() async {
// S'assurer que le moteur Flutter est initialisé avant tout appel Firebase
WidgetsFlutterBinding.ensureInitialized();
// Initialiser Firebase
await Firebase.initializeApp();
// Relier les erreurs Flutter à Crashlytics
// Cela capture toutes les erreurs du Framework Flutter, y compris celles au démarrage
FlutterError.onError = (errorDetails) {
FirebaseCrashlytics.instance.recordFlutterFatalError(errorDetails);
};
// Se brancher également sur les erreurs de plateforme en dehors du Framework Flutter (ex: erreurs asynchrones)
PlatformDispatcher.instance.onError = (error, stack) {
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
return true; // Indique que l'erreur a été gérée
};
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
// Widget racine de votre application
return MaterialApp(
title: 'Analytics & Error Demo',
home: Scaffold(
appBar: AppBar(title: const Text('Hello !')),
body: Center(
child: Column(
children: [
ElevatedButton(
onPressed: () {
// Exemple d'enregistrement d'un événement personnalisé
// FirebaseAnalytics.instance.logEvent(name: 'button_tapped', parameters: {'button_name': 'hello_button'});
throw Exception('Test Crash !'); // Déclencher un plantage pour le test
},
child: const Text('Tap Me !'),
),
],
),
),
),
);
}
}
En reliant FlutterError.onError à FirebaseCrashlytics.instance.recordFlutterFatalError (et PlatformDispatcher.instance.onError pour les erreurs non-Flutter) tôt dans votre fonction main, vous vous assurez que même les plantages au démarrage ou les erreurs inattendues qui se produisent en dehors d'un bloc try-catch sont capturés et signalés à Crashlytics. Cela fournit un filet de sécurité robuste pour surveiller la stabilité de votre application.
CI/CD, contrôle de version et pratiques d'équipe
Pour tout projet Flutter sérieux, en particulier ceux impliquant des équipes, des pratiques de développement robustes centrées sur le contrôle de version et l'Intégration Continue/Déploiement Continu (CI/CD) sont non négociables. Ces pratiques garantissent la qualité du code, la cohérence et une collaboration efficace.
Voici quelques conseils pour vous aider à renforcer votre flux de travail :
Utiliser Git avec une stratégie de branchement (feature branches + PR + code reviews)
Git est le standard pour le contrôle de version. Adoptez une stratégie de branchement claire (par exemple, Git Flow, GitHub Flow) où les nouvelles fonctionnalités ou les corrections de bugs sont développées sur des branches de fonctionnalités dédiées. Ces branches ne sont fusionnées dans la branche de développement principale (main ou develop) qu'après des revues de code approfondies et la réussite des tests via un processus de Pull Request (PR).
Imposer des linters et des formateurs (dart format, dart analyze, flutter analyze)
La cohérence du style de code et la détection précoce des problèmes potentiels sont essentielles.
dart format .: Formate automatiquement votre code Dart selon le guide de style Dart.dart analyze/flutter analyze: Outils d'analyse statique qui vérifient les avertissements, les erreurs et le respect des meilleures pratiques dans votre code. Intégrez-les dans votre IDE et votre pipeline CI.
Configurer la CI (GitHub Actions/GitLab CI) pour exécuter flutter analyze, les tests unitaires, les tests de widgets et les vérifications de taille
Un pipeline d'Intégration Continue (CI) est un système automatisé qui construit et teste votre code chaque fois que des modifications sont poussées vers votre dépôt. Cela garantit que chaque nouveau changement n'introduit pas de régressions ou ne casse pas les fonctionnalités existantes. Incluez des étapes pour exécuter l'analyse statique, tous les types de tests (unitaires, widgets, intégration) et même des vérifications de la taille de l'application.
Automatiser les builds et la signature des releases dans la CI pour réduire les erreurs manuelles
Pour les builds de release, automatisez l'ensemble du processus, y compris la signature de vos APK/App Bundles Android et IPA iOS, au sein de votre pipeline CI/CD. Les étapes de signature manuelle sont sujettes aux erreurs et consomment un temps précieux pour les développeurs.
Exemple d'étape GitHub Action (partiel) :
name: Flutter CI
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3 # Récupérer le code du dépôt
- name: Install Flutter
uses: subosito/flutter-action@v2 # Action pour configurer l'environnement Flutter
with:
flutter-version: 'stable' # Utiliser la dernière version stable de Flutter
channel: 'stable'
- name: Get Flutter dependencies
run: flutter pub get # Récupérer toutes les dépendances de packages
- name: Run analyzer
run: flutter analyze # Exécuter l'analyse statique pour vérifier les avertissements/erreurs
- name: Run tests
run: flutter test # Exécuter tous les tests unitaires et de widgets
# Optionnellement ajouter --coverage pour générer des rapports de couverture
# run: flutter test --coverage
# Optionnel : Construire un APK pour Android
# - name: Build Android APK
# run: flutter build apk --release
# # télécharger l'artefact
# uses: actions/upload-artifact@v3
# with:
# name: app-release-apk
# path: build/app/outputs/flutter-apk/app-release.apk
Ce flux de travail GitHub Actions partiel démontre comment configurer les étapes de base de la CI. Tout push ou pull request vers la branche main déclenchera ce flux de travail, garantissant la qualité du code et la couverture des tests avant la fusion. Vous pouvez en savoir plus sur le processus ici.
Internationalisation (i18n)
Rendre votre application accessible à un public mondial nécessite souvent de prendre en charge plusieurs langues. Ce processus, connu sous le nom d'internationalisation (i18n), consiste à concevoir votre application pour qu'elle s'adapte à différentes langues, formats régionaux et conventions culturelles.
Vous voudrez planifier l'i18n tôt. Intégrer l'internationalisation dès le début de votre projet est beaucoup plus facile que d'essayer de l'adapter ultérieurement dans une application existante codée en dur.
Vous pouvez utiliser le package intl et les fichiers ARB ou l'outil gen_l10n de Flutter pour ce faire. Flutter fournit d'excellents outils pour l'i18n. L'approche recommandée utilise des fichiers Application Resource Bundle (ARB), qui sont de simples fichiers de type JSON contenant des paires clé-valeur pour les chaînes traduites.
L'outil gen_l10n de Flutter (faisant partie du SDK) génère automatiquement du code Dart à partir de ces fichiers ARB, vous donnant un accès fortement typé à vos chaînes localisées. Le package intl fournit des fonctionnalités de localisation avancées comme la pluralisation et le formatage des dates/nombres.
C'est également une bonne idée de structurer les textes de l'UI via des fichiers de ressources plutôt que des chaînes littérales. Évitez de coder en dur des chaînes directement dans vos widgets d'UI. Au lieu de cela, définissez tout le texte destiné à l'utilisateur dans vos fichiers ARB. Cela facilite la traduction et garantit la cohérence.
Configuration minimale de gen_l10n (dans pubspec.yaml) :
flutter:
generate: true # Active la génération de code de Flutter pour l'i18n
uses-material-design: true
# Configuration pour la localisation
localizations:
arb-dir: lib/l10n # Répertoire où se trouvent vos fichiers ARB
template-arb-file: app_en.arb # Le fichier ARB de base, généralement en anglais
output-localization-file: app_localizations.dart # Le nom du fichier Dart généré
Après avoir configuré cela et exécuté flutter pub get, Flutter générera un fichier app_localizations.dart (ou le nom que vous lui avez donné) qui fournit des classes comme AppLocalizations.of(context).helloWorld pour un accès fortement typé et sensible au contexte à vos chaînes localisées. Cette approche garantit que votre application peut basculer de manière transparente entre les langues.
Conseils pratiques supplémentaires (Quick Hits)
Voici quelques conseils rapides supplémentaires qui peuvent aider à améliorer les performances et la maintenabilité de votre application Flutter :
Utiliser
itemExtentetRepaintBoundaryde manière stratégique pour réduire les coûts de peinture : Nous avons discuté deitemExtentpourListView.builder.RepaintBoundaryest un autre widget puissant qui peut empêcher son enfant et ses descendants d'être repeints lorsque le widget parent se reconstruit. Utilisez-le autour de sous-arbres statiques complexes qui ne changent pas souvent mais qui ont des parents dynamiques.Pour les animations : préférez les animations implicites (AnimatedOpacity, AnimatedContainer) pour les cas courants. Utilisez
TweenAnimationBuilderouAnimationControllerpour les cas complexes : Flutter propose plusieurs façons d'animer. Les animations implicites (commeAnimatedOpacity,AnimatedContainer,AnimatedCrossFade) sont plus simples à utiliser pour les animations de base et courantes car elles gèrent l'AnimationControlleren interne. Pour les animations hautement personnalisées, enchaînées ou pilotées par des gestes, l'utilisation deTweenAnimationBuilderou directement d'AnimationControlleretTweenvous donne plus de contrôle.Éviter le travail synchrone important sur le thread UI (s'appuyer sur les isolates ou le code de plateforme) : C'est une règle d'or ! Toute opération qui prend plus de quelques millisecondes et s'exécute sur le thread UI principal provoquera des saccades. Déchargez les calculs lourds vers les Isolates (via
computeoudart:isolate) et utilisez les Platform Channels pour les opérations natives complexes.Préférer les API de streaming pour les mises à jour continues et débouncer les recherches déclenchées par l'utilisateur pour réduire le flux réseau : Pour les données en temps réel ou les mises à jour continues (ex: messages de chat, cours de la bourse),
StreamBuilderet les API de streaming sont plus efficaces que les interrogations répétées (polling). Pour les champs de recherche, implémentez le "debouncing" (attendre une courte période après que l'utilisateur a cessé de taper avant de faire une requête réseau). Cela empêche l'envoi d'une requête pour chaque frappe de touche.Utiliser des patterns de gestion d'exceptions cohérents et centraliser la logique de réessai/backoff dans les couches réseau : Implémentez une stratégie cohérente pour capturer et gérer les erreurs (par exemple, des blocs
try-catch, des typesEitherissus de la programmation fonctionnelle). Pour les requêtes réseau, centralisez la logique de réessai avec un backoff exponentiel pour les erreurs transitoires afin d'améliorer la résilience sans surcharger votre backend.
Exemple complet : Une petite application pour synthétiser
Ci-dessous se trouve un squelette compact et réaliste qui démontre bon nombre des bonnes pratiques dont nous avons discuté ici. Il combine la modularisation, Provider pour l'état, ListView.builder avec CachedNetworkImage pour un défilement et une gestion d'images efficaces, une route de fonctionnalité différée, un client réseau robuste avec Dio, et precacheImage pour un chargement d'images plus fluide.
Ce code est intentionnellement ciblé pour mettre en évidence ces concepts, et vous pouvez l'étendre selon les besoins spécifiques de votre application.
// main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; // Utilisation de Provider pour l'injection de dépendances
import 'api_client.dart'; // Notre client API personnalisé basé sur Dio
import 'models/item.dart'; // Modèle de données simple pour les éléments
import 'package:cached_network_image/cached_network_image.dart'; // Pour un chargement d'image efficace
import 'feature_screen.dart' deferred as feature; // Import différé pour une fonctionnalité chargée paresseusement
void main() {
runApp(
// MultiProvider permet de fournir plusieurs dépendances à la racine
MultiProvider(
providers: [
// Fournir ApiClient comme singleton pour toute l'application
Provider(create: (_) => ApiClient())
],
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Scalable App',
theme: ThemeData(primarySwatch: Colors.blue), // Thème de base
home: const HomeScreen(), // Notre écran principal
);
}
}
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
late Future<List<Item>> _itemsFuture; // Future pour contenir nos éléments récupérés
@override
void initState() {
super.initState();
// Récupérer les éléments lors de l'initialisation de l'écran
_itemsFuture = context.read<ApiClient>().fetchItems();
}
// Fonction pour ouvrir l'écran de fonctionnalité chargé paresseusement
Future<void> _openFeature() async {
// Attendre le chargement de la bibliothèque différée avant de naviguer
await feature.loadLibrary();
Navigator.of(context).push(MaterialPageRoute(builder: (_) => feature.FeatureScreen()));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Home Screen - Scalable App')),
floatingActionButton: FloatingActionButton(
onPressed: _openFeature,
child: const Icon(Icons.open_in_new), // Icône pour ouvrir la nouvelle fonctionnalité
),
body: FutureBuilder<List<Item>>(
future: _itemsFuture, // Surveiller notre future d'éléments
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator()); // Afficher le chargement
}
if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}')); // Afficher l'erreur
}
final items = snapshot.data ?? []; // Obtenir les données ou une liste vide
return ListView.builder(
itemCount: items.length,
itemExtent: 72, // En supposant une hauteur d'élément cohérente pour la performance
itemBuilder: (context, index) {
final item = items[index];
// Précharger l'image pour les premiers éléments pour réduire les saccades au défilement initial
if (index < 5) { // Exemple : précharger les 5 premiers
precacheImage(CachedNetworkImageProvider(item.imageUrl), context);
}
return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: ListTile(
leading: CachedNetworkImage(
imageUrl: item.imageUrl,
width: 56,
height: 56,
fit: BoxFit.cover,
placeholder: (context, url) => const CircularProgressIndicator(strokeWidth: 2),
errorWidget: (context, url, error) => const Icon(Icons.broken_image),
),
title: Text(item.title),
subtitle: Text(item.subtitle),
onTap: () {
// Gérer le clic sur l'élément
print('Tapped on: ${item.title}');
},
),
);
},
);
},
),
);
}
}
// api_client.dart
import 'package:dio/dio.dart';
import 'models/item.dart';
class ApiClient {
final Dio _dio = Dio(BaseOptions(
baseUrl: 'https://jsonplaceholder.typicode.com', // Une API de test publique
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 3),
));
ApiClient() {
_dio.interceptors.add(LogInterceptor(responseBody: true, requestBody: true));
}
Future<List<Item>> fetchItems() async {
final response = await _dio.get('/photos'); // Utilisation de /photos comme éléments
if (response.statusCode == 200) {
return (response.data as List).map((json) => Item.fromJson(json)).toList();
} else {
throw Exception('Failed to load items: ${response.statusCode}');
}
}
}
// models/item.dart
class Item {
final int id;
final String title;
final String imageUrl;
final String subtitle; // Ajouté pour plus de réalisme
Item({required this.id, required this.title, required this.imageUrl, required this.subtitle});
factory Item.fromJson(Map<String, dynamic> json) {
return Item(
id: json['id'],
title: json['title'],
imageUrl: json['thumbnailUrl'], // Utilisation de thumbnailUrl de JSONPlaceholder
subtitle: 'Album ID: ${json['albumId']}', // Exemple de sous-titre
);
}
}
// feature_screen.dart (ce fichier est chargé de manière différée)
import 'package:flutter/material.dart';
class FeatureScreen extends StatelessWidget {
const FeatureScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Lazy Loaded Feature')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.star, size: 100, color: Colors.amber),
const SizedBox(height: 20),
Text(
'This is a feature that was loaded on demand!',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 10),
const Text(
'It means its code was not part of the initial app bundle.',
textAlign: TextAlign.center,
),
],
),
),
);
}
}
Voici quelques points saillants sélectionnés de ce code :
deferred as feature+await feature.loadLibrary(): Ceci démontre le Lazy Loading deFeatureScreen. Son code ne fait pas partie du téléchargement initial de l'application et n'est récupéré que lorsque l'utilisateur appuie sur leFloatingActionButton.MultiProvider: Nous utilisonsMultiProviderà la racine pour rendre notreApiClientdisponible dans toute l'application, montrant une manière évolutive d'injecter des dépendances.FutureBuilder: Ce widget gère gracieusement le chargement asynchrone des éléments. Il gère automatiquement les étatswaiting,erroretdata, mettant à jour l'UI en conséquence sans nécessiter d'appels manuels àsetState.ListView.builder+CachedNetworkImage+precacheImage: Cette combinaison garantit une expérience de défilement incroyablement performante.ListView.builderconstruit les widgets de manière paresseuse,CachedNetworkImagegère efficacement le téléchargement et la mise en cache des images, etprecacheImage(pour les premiers éléments) aide à réduire toute saccade potentielle lors de la première apparition de ces images pendant le défilement initial.Modularisation : L'
ApiClient, le modèleItemetFeatureScreensont dans des fichiers séparés, favorisant une base de code plus propre, plus organisée et plus facile à maintenir.
Checklist de production
Voici une checklist que vous pouvez utiliser lors de la création de vos applications et de leur préparation au déploiement en production. Elle permet de s'assurer que vous avez pris en compte tous les aspects clés pour une application robuste, performante et sécurisée.
Performance des widgets et de l'UI
Utiliser des constructeurs
constet des sous-objetsconstlà où c'est approprié.Auditer les arbres de widgets pour les reconstructions inutiles à l'aide du profiler "Widget rebuilds" de DevTools.
Utiliser
ValueListenableBuilder,ConsumerouSelectorpour réduire la portée de la reconstruction.Employer
ListView.builderavecitemExtentetcacheExtentpour des listes efficaces.Envisager
RepaintBoundarypour les sous-arbres d'UI statiques et complexes.precacheImagepour les images hero critiques ou les premiers éléments de liste afin d'éviter les saccades.
Gestion d'état
Choisir et standardiser une approche de gestion d'état (par exemple, Provider, Riverpod, BLoC) et documenter les patterns pour votre équipe.
Assurer une séparation claire entre l'UI, la logique métier et les couches de données.
Qualité du code et optimisation
Utiliser
finaletconstcorrectement pour l'immuabilité et les constantes de compilation.Gérer les opérations asynchrones gracieusement avec
FutureBuilderetStreamBuilder.Décharger le travail lié au CPU (par exemple, analyse de gros JSON, traitement d'images) vers des Isolates en utilisant
computeoudart:isolate.Libérer tous les
AnimationController,TextEditingController,StreamSubscriptionet autres objets jetables dans les méthodesdispose().
Réseau et données
Implémenter un client HTTP robuste (comme Dio) avec des intercepteurs pour la journalisation, l'authentification, le réessai/backoff et la mise en cache.
S'assurer que la compression côté serveur (gzip) est activée et que le client utilise
Accept-Encoding.Implémenter des stratégies de mise en cache HTTP (
Cache-Control,ETag) et envisager des caches côté client pour la résilience/le support hors ligne.
Taille de l'application et distribution
Utiliser
flutter build appbundle(Android) ouflutter build ipa(iOS) pour les builds de release.Utiliser
flutter build apk --split-per-abisi vous distribuez des APK directement.Tirer parti des importations différées de Dart et des composants différés de Flutter pour les fonctionnalités volumineuses et rarement utilisées afin de réduire la taille d'installation initiale.
Vérifier le tree shaking et élaguer les dépendances inutiles de
pubspec.yaml.Supprimer les packages réservés au debug des builds de release.
Optimiser les images (compression, WebP) et utiliser des graphiques vectoriels (SVG) lorsque c'est approprié.
Exécuter
flutter build --releaseet analyser la taille de l'application avec DevTools.
Sécurité
Imposer le HTTPS pour toutes les communications réseau.
Stocker les clés et tokens sensibles de manière sécurisée en utilisant
flutter_secure_storageou les keystores de plateforme.Assainir et valider toutes les entrées utilisateur et les données réseau.
Éviter de coder en dur des identifiants sensibles dans le code source.
Envisager le certificate pinning pour les applications à haute sécurité (si l'expertise est disponible).
Suivi et analytique
Intégrer Firebase Analytics pour le suivi des événements et les informations sur le comportement des utilisateurs.
Configurer Firebase Crashlytics pour le rapport de plantage en temps réel.
Envisager des solutions de suivi des erreurs plus riches comme Sentry pour les erreurs non fatales et les informations contextuelles.
Relier
FlutterError.onErroretPlatformDispatcher.instance.onErrorà votre rapporteur de plantage.
Tests et CI/CD
Implémenter des tests unitaires, de widgets et d'intégration complets.
Utiliser Git avec une stratégie de branchement claire (feature branches, PR, revues de code).
Imposer le style de code avec
dart formatet l'analyse statique avecflutter analyze.Configurer un pipeline CI (par exemple, GitHub Actions, GitLab CI) pour exécuter automatiquement les tests, l'analyse et les étapes de build.
Automatiser les builds de release et la signature au sein de votre pipeline CI/CD.
Internationalisation (i18n)
Planifier l'i18n tôt dans le cycle de développement.
Utiliser les outils
gen_l10nde Flutter avec des fichiers ARB pour gérer les traductions.Éviter de coder en dur les chaînes destinées à l'utilisateur directement dans les widgets.
Conclusion
Ce guide vous a, nous l'espérons, aidé à transformer vos plans de base en un plan d'action approfondi pour créer des applications Flutter évolutives et performantes. Nous avons couvert les architectures recommandées, des patterns de code concrets, des techniques de performance essentielles et des pratiques prêtes pour la production.
N'oubliez pas que l'optimisation des performances et de l'évolutivité est un voyage continu, pas une tâche ponctuelle. Vous pouvez commencer par appliquer un changement par sprint : d'abord réduire les reconstructions avec const et ValueListenableBuilder, puis introduire une gestion d'état appropriée, puis profiler et optimiser les chemins critiques, par exemple.
La clé est de mesurer, changer et mesurer à nouveau. Avec ces pratiques, vous serez bien équipé pour construire des applications Flutter qui non seulement ravissent les utilisateurs, mais résistent également à l'épreuve du temps et de la croissance.
Références et lectures complémentaires
Composants différés et chargement différé dans Flutter (docs officielles)
Code splitting et importations différées dans Flutter/Dart (guides communautaires)
Packages pour le lazy loading et les stratégies d'UI paresseuses sur pub.dev (par exemple,
flutter_lazy_loading,lazy_indexed_stack)Package Dio sur pub.dev et d'autres packages largement utilisés sur pub.dev.