Flutter avancé
Présentation du cours
Présentation personnelle
Présentation personnelle
Règles générales concernant :
- les retards
- l'attention en cours
- les supports de cours
- la notation
- un TP en solitaire
- un projet à faire en équipe
- je prends vos feedbacks !
Prérequis
Ce cours est la suite directe du premier Cours de Flutter que je donne et dont vous pourrez retrouver les slides sur cette plateforme.
Il est donc important d'avoir bien suivi et compris ce premier cours avant d'aller plus loin avec celui-ci
Au programme
- gestion d'état global de base (InheritedWidget) et via packages
- Flavors / Schema
- variables d'environnements
- thèmes personnalisés
- Repository Pattern pour les appels réseaux
- bien architecturer ses widgets
- comment internationaliser son app ?
- comprendre ce qu'est le BuildContext
- comprendre ce que sont les Keys
- Unitary test
- Widgets test
- Integration tests
- ...
Petit échauffement
Petit échauffement
Pour commencer, nous allons réaliser une mini app qui va nous servir d'exemple pour comprendre la problématique de la gestion d'état global.
Petit échauffement




Internationaliser son app
Internationaliser son app
Pendant longtemps, il n'y a pas vraiment eu de solution "officielle" pour internationaliser son app en Flutter
Il y a donc de NOMBREUSES manières de le faire et beaucoup de packages externes
- etc.
Internationaliser son app
Nous allons nous intéresser à la version officielle pour le moment (car il y en finalement eu une)
Tout d'abord, on ajoute les packages Flutter nécessaires :
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
intl: anyInternationaliser son app
Ensuite, nous allons modifier notre MaterialApp en lui spécifiant :
- les locales que nous prenons en compte
- des "stocks" de traductions déjà faites pour les composants de base
@override
Widget build(BuildContext context) {
return const MaterialApp(
title: 'Flutter Demo',
localizationsDelegates: [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: [
Locale('en'), // English
Locale('es'), // Spanish
],
home: MyHomePage(title: 'Flutter Demo Home Page'),
);
}Internationaliser son app
Ajoutons maintenant nos traductions
On va devoir faire de la génération de code, il faut donc l'autoriser dans le pubspec.yaml :
# The following section is specific to Flutter packages.
flutter:
generate: trueInternationaliser son app
Ensuite, nous allons déclarer au package où seront situés nos fichiers de traductions
À la racine du projet, créer un fichier I18n.yaml (c'est un i majuscule) avec ce contenu :
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dartInternationaliser son app
On créé ensuite notre dossier /lib/I18n
Et dedans, notre premier fichier de trad anglaise app_en.arb avec par exemple ce contenu :
{
"helloWorld": "Hello World!",
"@helloWorld": {
"description": "The conventional newborn programmer greeting"
}
}Internationaliser son app
Votre IDE vous propose sûrement de télécharger un Plugin pour mieux modifier les fichiers arb, n'hésitez pas à le prendre, ça peut aider !
On peut ensuite créer tous les fichiers de trads qu'on veut, par exemple celui en français (/lib/I10n/app_fr.arb) :
{
"helloWorld": "Salut le monde !"
}Internationaliser son app
Nous pouvons désormais générer notre code !
Pour ce faire, on lance un petit :
flutter gen-l10nÇa a normalement dû générer le code en question dans .dart_tool/flutter_gen/gen_I10n/
Internationaliser son app
On peut désormais y faire référence en l'important :
import 'package:flutter_gen/gen_l10n/app_localizations.dart';Dans un premier temps il faut l'ajouter comme nouveau Delegate de langues, dans la MaterialApp :
localizationsDelegates: [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],Internationaliser son app
Et on peut également s'en servir pour définir les langues supportées :
supportedLocales: AppLocalizations.supportedLocales,Ne pas oublier, côté iOS, de préciser les langues dans le Info.plist :
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>fr</string>
</array>Internationaliser son app
C'est bon ! Vous pouvez maintenant y faire référence dans votre code et tenter de changer la langue :
Text(
AppLocalizations.of(context).helloWorld,
),Créer un Theme custom
Créer des Flavors/Scheme
Créer des Flavors/Scheme
Il peut être très intéressant pour une app d'avoir plusieurs Scheme (iOS) et plusieurs Flavors (Android)
On peut voir des Flavors/Scheme comme des configurations de builds dans laquelle nous allons pouvoir changer certaines valeurs comme (pour ne citer que ça) :
- l'identifiant de l'app
- le nom de l'app
- etc.
Créer des Flavors/Scheme
Intérêts :
- on peut avoir les apps de dev, preprod et prod sur un même appareil
- on peut parfaitement séparer nos environnements
Intérêts :
Inconvénients :
- Beaucoup plus de configurations
Créer des Flavors/Scheme
Regardons les étapes côté iOS
Tout se passe côté XCode (en ouvrant, via XCode, le fichier XCworkspace)
Créer des Flavors/Scheme

On commence par "dupliquer" les configurations en cliquant sur le bouton "+" et en leur donnant un nom correspondant au Scheme
Créer des Flavors/Scheme

Product > Scheme > New Scheme
Créer des Flavors/Scheme
Product > Scheme > New Scheme

Créer des Flavors/Scheme

On clique sur le Scheme > Edit Scheme
Créer des Flavors/Scheme
Ici, on change toutes les configurations pour mettre la nouvelle !

Créer des Flavors/Scheme

Créer des Flavors/Scheme
Enfin, on va pouvoir changer le nom de l'app et son bundle ID :

Créer des Flavors/Scheme
Et on oublie pas d'afficher le PRODUCT_NAME comme nom de l'app :

Créer des Flavors/Scheme
Passons au côté Android maintenant !
Créer des Flavors/Scheme
La config est plus simple, ça se passe dans android/app/build.gradle
flavorDimensions "default"
productFlavors {
dev {
dimension "default"
resValue "string", "app_name", "My App Dev"
applicationIdSuffix ".dev"
}
preprod {
dimension "default"
resValue "string", "app_name", "My App Preprod"
applicationIdSuffix ".preprod"
}
prod {
dimension "default"
resValue "string", "app_name", "My App"
}
}Créer des Flavors/Scheme
Et on oublie pas de prendre en compte le nom de l'app dans le Manifest :

Variables d'environnement
Variables d'envrionnement
Il y a plusieurs manières de mettre en place des variables d'environnement en Flutter :
- package spécifique : https://pub.dev/packages/flutter_dotenv
- la version native Flutter : --dart-define
Variables d'envrionnement
Au lancement de l'app via flutter run, on peut en effet donner des variables de cette manière :
flutter run --dart-define=API_BASE_URL=http://www.monapi.comEt les récupérer de cette manière :
const apiBaseUrl = String.fromEnvironment('API_BASE_URL', defaultValue: 'coucou');Qu'est-ce que le BuildContext
Le BuildContext
import 'package:flutter/material.dart';
void main() {
runApp(const ContextApp());
}
class ContextApp extends StatelessWidget {
const ContextApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('Context')),
body: Container(),
floatingActionButton: FloatingActionButton(
onPressed: () => _showSnackBar(context),
child: const Icon(Icons.add),
),
),
);
}
void _showSnackBar(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Hello!')));
}
}Pourquoi ça ne fonctionne pas ?
Le BuildContext

Jetons un oeil à l'erreur en elle-même
Le BuildContext
Pour comprendre ce comportement, il faut comprendre ce qu'est un BuildContext
Le comprendre vous permettra de comprendre BEAUCOUP de choses
Tout d'abord, il faut comprendre quelque chose.... il n'y a pas que des Widgets dans Flutter...
Le BuildContext

Le BuildContext
Nous avons en fait 3 arbres différents :
- les widgets : qui ne sont finalement que des configurations
- les éléments, qui sont créés par les widgets, qui sont ceux qui tiennent le tout
- les RenderObject qui sont responsables du dessin sur le Canvas
Le BuildContext
Si on regarde les StateLess et Stateful widgets, les deux sont des descendants de la classe Widget :
abstract class StatefulWidget extends Widget {
// ....abstract class StatelessWidget extends Widget {
// ...Et dans la classe Widget :
abstract class Widget extends DiagnosticableTree {
//...
@protected
@factory
Element createElement();
//...Le BuildContext
Les Widgets ne sont finalement que des schémas (Blueprints en anglais) et les Element associés sont eux les vrais instances, détenteurs d'informations
Et le BuildContext dans tout ça ?
Le BuildContext
abstract class StatelessWidget extends Widget {
// ...
@override
StatelessElement createElement() => StatelessElement(this);
//.../// An [Element] that uses a [StatelessWidget] as its configuration.
class StatelessElement extends ComponentElement {
/// Creates an element that uses the given widget as its configuration.
StatelessElement(StatelessWidget super.widget);
@override
Widget build() => (widget as StatelessWidget).build(this);
// ...Le BuildContext
abstract class Element extends DiagnosticableTree implements BuildContext {
//...Les Elements sont en fait des BuildContext !
Ce qu'il faut comprendre : Lorsqu'un Widget est créé, le BuildContext qui lui est envoyé correspond en fait à l'Element dans l'arbre des éléments.
Le BuildContext

Le BuildContext
Le BuildContext porte donc bien son nom : il s'agit de l'emplacement exact de l'élément dans l'arbre : le "contexte" de celui-ci.
On peut donc remonter tous les ancêtres à partir d'un seul BuildContext, et c'est d'ailleurs la logique des InheritedWidget
Theme.of(context);Ici, l'arbre va être remonté jusqu'à trouver une instance de Theme.
Le BuildContext
import 'package:flutter/material.dart';
void main() {
runApp(const ContextApp());
}
class ContextApp extends StatelessWidget {
const ContextApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('Context')),
body: Container(),
floatingActionButton: FloatingActionButton(
onPressed: () => _showSnackBar(context),
child: const Icon(Icons.add),
),
),
);
}
void _showSnackBar(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Hello!')));
}
}Reprenons l'exemple initial :
Le BuildContext
Le BuildContext auquel nous faisons référence dans notre affichage de SnackBar fait référence à l'emplacement de la ContextApp !

Le BuildContext
Flutter ira alors chercher les ancêtres à partir de ContextApp et ne verra ni la MaterialApp ni Scaffold !
D'où notre erreur !

Le BuildContext
Pour régler ça, il y a 2 solutions :
- créer un widget à part
- utiliser un Builder
Le BuildContext
Créer un widget à part :
class CustomFloatingButton extends StatelessWidget {
const CustomFloatingButton({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return FloatingActionButton(
onPressed: () => _showSnackBar(context),
child: const Icon(Icons.add),
);
}
void _showSnackBar(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Hello!')));
}
}Le BuildContext
Utiliser un Builder :
class ContextApp extends StatelessWidget {
const ContextApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Builder(
builder: (context) {
return Scaffold(
appBar: AppBar(title: const Text('Context')),
body: Container(),
floatingActionButton: FloatingActionButton(
onPressed: () => _showSnackBar(context),
child: const Icon(Icons.add),
),
);
}
),
);
}
void _showSnackBar(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Hello!')));
}
}Le BuildContext
Dans les deux cas, nous recevons le BuildContext (et donc l'élément) correspondant à notre position !
Le BuildContext
Comprendre le cas spécial de la Navigation :

Les différents OverlayEntry n'ont rien en commun à part la MaterialApp !
Leur BuildContext communs sera forcément celui de la MaterialApp ou au dessus
Les Keys
Les Keys
Pour comprendre l'intérêt des Keys, nous allons mettre en place un petit exemple :
import 'package:flutter/material.dart';
class StatelessColoredTile extends StatelessWidget {
final Color color;
const StatelessColoredTile({
Key? key,
required this.color,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
height: 200,
width: 100,
color: color,
);
}
}Imaginons ce widget StateLess :
Les Keys
Imaginons maintenant qu'on les place dans une Row avec un bouton pour pouvoir changer leur ordre :
class KeysApp extends StatefulWidget {
const KeysApp({Key? key}) : super(key: key);
@override
State<KeysApp> createState() => _KeysAppState();
}
class _KeysAppState extends State<KeysApp> {
final List<Widget> _coloredTiles = const [
StatelessColoredTile(color: Colors.red),
StatelessColoredTile(color: Colors.blue),
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Keys'),
),
body: Row(
children: _coloredTiles,
),
floatingActionButton: FloatingActionButton(
onPressed: () {
setState(() {
_coloredTiles.insert(0, _coloredTiles.removeAt(1));
});
},
child: const Icon(Icons.refresh),
),
);
}
}Les Keys
Si on switch, voici le résultat :

Jusque là, tout est normal.
Tentons maintenant de les convertir en boutons stateful :
Les Keys
import 'dart:math';
import 'package:flutter/material.dart';
class ColoredTile extends StatefulWidget {
const ColoredTile({Key? key}) : super(key: key);
@override
State<ColoredTile> createState() => _ColoredTileState();
}
class _ColoredTileState extends State<ColoredTile> {
Color _color = Colors.amber;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _changeColor,
child: Container(
height: 200,
width: 100,
color: _color,
),
);
}
void _changeColor() {
final colors = [
Colors.amber,
Colors.blue,
Colors.brown,
Colors.cyan,
Colors.deepOrange,
Colors.deepPurple,
Colors.green,
Colors.indigo,
Colors.lime,
Colors.pink,
];
setState(() {
_color = colors[Random().nextInt(colors.length)];
});
}
}Il s'agit simplement d'une cellule avec une couleur qui peut changer au clique
Les Keys
Changez maintenant les cellules dans le widget parent en mettant celles-ci :

class _KeysAppState extends State<KeysApp> {
final List<Widget> _coloredTiles = const [
ColoredTile(),
ColoredTile(),
];
//...Les Keys
Les cellules ne changent plus de place.
Pour comprendre cela, nous devons comprendre comment Flutter fonctionne pour re-dessiner les widgets
Les Keys

Pour rappel, voici une représentation des Widgets concernés et de leurs Elements associés
Les Keys
Que se passe-t-il si je change l'ordre des widgets ?

Les Keys
Flutter va alors parcourir l'arbre des Element et va vérifier si la structure des éléments est toujours la bonne.
Il va vérifier si l'ancien Widget et le nouveau sont les mêmes. Et c'est ici que tout se joue.
Pour vérifier si deux Widgets sont égaux, Flutter se base sur 2 informations :
- leur type
- leur key
Les Keys
Dans notre cas, les widgets sont toujours des StatelessTile, donc pour lui rien ne change.
Seulement, lorsqu'on est dans un StatelessWidget, l'information de la couleur est portée par le Widget en lui-même.
Bien que les Element n'aient pas été échangés, on a quand même les couleurs qui changent car l'info est récupérée depuis le Widget
Les Keys
Voici ce que ça donne par contre avec des StatefulWidget

Les Keys
Très important :
Dans ce cas, l'information de la couleur est portée par le State et est donc portée par l'Element.
Ce qui signifie que, lorsqu'on va échanger les Widgets, les Elements ne changeront pas de place et donc les couleurs affichées ne changerons jamais.
Les Keys
Pour rappel, Flutter compare deux widgets par rapport à :
- leur type
- leur keys
Dès lors que l'on veut pouvoir faire se déplacer, réordonner, etc. des Stateful widgets, nous allons donc être obligés de leur ajouter des Keys.
Les Keys
Testez simplement comme ceci :
class _KeysAppState extends State<KeysApp> {
final List<Widget> _coloredTiles = [
ColoredTile(key: UniqueKey(),),
ColoredTile(key: UniqueKey(),),
];
//..Le problème est réglé 🎉
Les Keys
Pourquoi ? Parce que nous avons des Keys maintenant :

Les Keys
Si on change l'ordre, alors les Elements ne correspondent plus !

Les Keys
Dans ce cas, Flutter cherche parmi les Elements un autre élément qui correspond (au niveau du type ET de la Key)


Les Keys
Quelles Keys utiliser alors ?
- UniqueKey => si on a pas forcément d'élément différenciant
- ValueKey => si on a une valeur (exemple : ID d'un article)
- ObjectKey => si on a un objet (exemple : User avec equals)
- GlobalKey => Key unique de manière globale dans l'app
Les Keys
À savoir :
UniqueKey, ValueKey et ObjectKey sont des LocalKey
Regardons ce qu'en dit la doc :
/// A key that is not a [GlobalKey].
///
/// Keys must be unique amongst the [Element]s with the same parent. By
/// contrast, [GlobalKey]s must be unique across the entire app.
///
/// See also:
///
/// * [Widget.key], which discusses how widgets use keys.
abstract class LocalKey extends Key {
//...Les Keys
Si vous avez bien compris ce que ça signifie, quel est le problème avec le code suivant ?
final List<Widget> _coloredTiles = [
Padding(
padding: const EdgeInsets.all(8.0),
child: ColoredTile(key: UniqueKey(),),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: ColoredTile(key: UniqueKey(),),
),
];Les Widget Test
Les widget test
Flutter avancé
By Ecalle Thomas
Flutter avancé
- 316