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: any

Internationaliser 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: true

Internationaliser 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.dart

Internationaliser 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.com

Et 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 :

  1. créer un widget à part
     
  2. 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