Onboarding : créer une introduction intuitive avec Flutter

En tant que développeur, l'un des défis constants auxquels nous sommes confrontés est la gestion efficace des données au sein de nos applications. Que ce soit pour stocker des fichiers locaux, utiliser une base de données SQL ou noSQL pour des opérations plus complexes, ou simplement stocker des paires clé-valeur sur le disque, la persistance des données est cruciale.

Dans cet article, je vous présenterai les différentes manières d’y parvenir et nous allons créer un cas concret pour explorer la profondeur d’un aspect essentiel de la persistance des données dans Flutter, en nous inspirant de l’une des plus belles applications poussée par Google : Wonderous de la team Gskinner.

Pour ceci, nous allons créer ensemble un exemple d’onBoarding dans une application. L'onBoarding dans une application désigne le processus par lequel les utilisateurs sont guidés à travers les fonctionnalités clés lors de leur première utilisation. Il vise à familiariser rapidement les nouveaux utilisateurs avec l'interface, les caractéristiques et les avantages de l'application, favorisant ainsi une expérience utilisateur positive.

Gif d'un exemple d'onboarding sur mobile

Présentation des possibilités de persistance de données

Lecture et écriture de fichiers dans Flutter

L'un des moyens les plus simples, mais essentiels pour stocker des données dans une application Flutter est la manipulation de fichiers.

Pour enregistrer des fichiers sur disque dans des applications Flutter, combinez le plugin path_provider avec la bibliothèque dart:io. Voici l’exemple de la lecture et l’écriture d’un Int dans un fichier local.

class CounterStorage {
  Future<String> get _localPath async {
    // from path_provider
    final directory = await getApplicationDocumentsDirectory(); 
    return directory.path;
  }

  Future<File> get _localFile async {
    final path = await _localPath;
    // from dart:io
    return File('$path/counter.txt');
  }
  
  Future<int> readCounter() async {
    try {
      final file = await _localFile;

      // Lire le fichier 
      final contents = await file.readAsString();

      return int.parse(contents);
    } catch (e) {
      // Si il attrape une erreur, retourne 0
      return 0;
    }
  }
}
Exemple d'utilisation du package path_provider et dart:io pour le stockage local

Stockage de données clé-valeur sur le disque

Pour des besoins plus simples, où une base de données complète pourrait être excessive, le stockage de données clé-valeur sur le disque est une option rapide et efficace.

En utilisant le package shared_preferences, vous pouvez stocker facilement des paires clé-valeur de manière persistante. Cela s'avère particulièrement utile pour les préférences utilisateur, les configurations d'application, ou toute autre donnée simple dont la persistance est nécessaire.

// Charger et obtenir l'instance du cache pour cette application
final prefs = await SharedPreferences.getInstance();

// Sauvegarde de la valeur du compteur dans le stockage persistant
// sous la clé 'counter'
await prefs.setInt('counter', counter);

// Essayer de lire la valeur du compteur à partir 
// de la mémoire persistante.
// S'il n'est pas présent, null est renvoyé, 
// la valeur par défaut est 0
final counter = prefs.getInt('counter') ?? 0;

// Retirer la paire clé-valeur du compteur du stockage persistant
await prefs.remove('counter');
Exemple d'utilisation de shared_preferences pour le stockage dans le cache

Bien que le stockage clé-valeur fourni par shared_preferences soit facile et pratique à utiliser, il présente des limites :

  • Seuls les types primitifs peuvent être utilisés : int, double, bool, String et List<String>.
  • Il n'est pas conçu pour stocker de grandes quantités de données.
  • Il n'y a aucune garantie que les données seront conservées après le redémarrage de l'application.

Persistance de données complexe avec SQLite

Lorsque vos besoins en matière de persistance de données deviennent plus complexes, la transition vers une base de données SQLite peut être la solution idéale.

Le package sqflite couvre la création de bases de données, l'insertion, la mise à jour, et la récupération de données.

// Obtenir un chemin à l'aide de getDatabasesPath
var databasesPath = await getDatabasesPath();
String path = join(databasesPath, 'demo.db');

// Supprimer la base de donnée
await deleteDatabase(path);

// Ouvrir la base de donnée
Database database = await openDatabase(path, version: 1,
    onCreate: (Database db, int version) async {
  // Lors de la création de la base de données, créer la table
  await db.execute(
      'CREATE TABLE Test (id INTEGER PRIMARY KEY, name TEXT, value INTEGER, num REAL)');
});

// Insérer des records dans une transaction
await database.transaction((txn) async {
  int id1 = await txn.rawInsert(
      'INSERT INTO Test(name, value, num) VALUES("some name", 1234, 456.789)');
  int id2 = await txn.rawInsert(
      'INSERT INTO Test(name, value, num) VALUES(?, ?, ?)',
      ['another name', 12345678, 3.1416]);
});

// Mise à jour de record
int count = await database.rawUpdate(
    'UPDATE Test SET name = ?, value = ? WHERE name = ?',
    ['updated name', '9876', 'some name']);

// Obtenir les records
List<Map> list = await database.rawQuery('SELECT * FROM Test');
List<Map> expectedList = [
  {'name': 'updated name', 'id': 1, 'value': 9876, 'num': 456.789},
  {'name': 'another name', 'id': 2, 'value': 12345678, 'num': 3.1416}
];
assert(const DeepCollectionEquality().equals(list, expectedList));

// Compter les records
count = Sqflite
    .firstIntValue(await database.rawQuery('SELECT COUNT(*) FROM Test'));
assert(count == 2);

// Supprimer un record .
count = await database
    .rawDelete('DELETE FROM Test WHERE name = ?', ['another name']);
assert(count == 1);

// Fermer la base de données
await database.close();
Exemple d'utilisation pour une base de données complexe avec le package sqflite

Création d’un onBoarding grâce à la persistance locale

Présentation du contenu de base

Nous allons tirer parti du package shared_preferences pour faire comprendre à l’application que l’utilisateur est déjà passé par le processus d’onboarding, et qu’il n’a pas besoin de l’afficher. Nous possédons déjà la HomePage et la OnBoardingPage qui s'occupent respectivement de l’accueil de l’utilisateur et de l’onboarding.

HomePage
OnBoardingPage

Nous avons un main.dart qui est présenté comme tel.

void main() async {
  runApp(const MainApp());
}

class MainApp extends StatelessWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context) {
	return MaterialApp(
  	// theme, debugShowCheckedModeBanner, etc ..
  	   home: OnBoardingPage(),
	);
  }
}
Fonction main() basique pour lancer l'application Flutter

Création du router

Nous allons utiliser la librairie go_router poussée par Google pour la navigation intra-application. Nous créons un fichier router.dart qui permettra de centraliser et d’avoir une vision claire sur la navigation dans notre application.

/// Chemins partagés / urls utilisés dans l'application
class ScreenPaths {
  static String splash = '/';
  static String intro = '/welcome';
  static String home = '/home';
}

/// Table du routeur, fait correspondre les chemins d'accès aux écrans de l'interface utilisateur, analyse éventuellement les paramètres des chemins d'accès
final appRouter = GoRouter(
 /// Rediriger l'utilisateur s'il est arrivé au splash
  redirect: (_, state) =>
  	state.uri.path == ScreenPaths.splash ? ScreenPaths.home : null,
  routes: [
	ShellRoute(
  	  builder: (context, state, child) => child,
  	  routes: [
	 // Ici, AppRoute() est une sous-classe GoRoute personnalisée pour faciliter la lecture de la déclaration du routeur
    	 // Celle-ci sera cachée
    	  AppRoute(ScreenPaths.splash, Container(color: Colors.purple)),
    	  AppRoute(ScreenPaths.intro, const OnBoardingPage()),
    	  AppRoute(ScreenPaths.home, const HomePage()),
  	],
      ),
  ],
);
Création du routeur pour gérer la navigation dans l'application

Notre MainApp() dans main.dart utilise maintenant MaterialApp.router pour faire fonctionner ce nouveau routeur.

class MainApp extends StatelessWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      // theme, debugShowCheckedModeBanner, etc ..
      routeInformationProvider: appRouter.routeInformationProvider,
      routeInformationParser: appRouter.routeInformationParser,
      routerDelegate: appRouter.routerDelegate,
    );
  }
}
Utilisation du nouveau routeur dans la fonction main de l'application

Nous pouvons donc ajouter au clic du bouton Done la navigation vers la HomePage.

void _validateOnBoarding(BuildContext context) {
	context.go(ScreenPaths.home);
}
Méthode de OnBoardingPage qui permet de naviguer jusqu'à la HomePage
Navigation fonctionnelle pour depuis OnBoardingPage jusqu'à HomePage

Création de la logique

Nous créons AppLogic qui servira de gestionnaire de tous les services de l’application et s’occupera de les initialiser. Puis nous créons le service SettingsLogic qui s’occupera de toutes les préférences utilisateurs, dont s'il a complété le onboarding ou non.

class AppLogic {
  /// Initialise l'application et tous les acteurs principaux.
  /// Charge les paramètres, met en place les services, etc.
  Future<void> bootstrap() async {
	// Chargement de la vue initiale (remplace la vue initiale vide qui est couverte par un écran d'accueil natif)
	bool showIntro = settingsLogic.hasCompletedOnboarding == false;
// Utiliser le routeur pour diriger l'utilisateur vers la bonne page
	if (showIntro) {
  		appRouter.go(ScreenPaths.intro);
	} else {
  		appRouter.go(ScreenPaths.home);
	}
  }
}

class SettingsLogic {
// Indique si l'onboarding a été effectué.
	bool hasCompletedOnboarding = false
}
Bloc de logique pour la gestion de l'application

Nous allons utiliser le package get_it qui permet d'accéder à l'implémentation concrète à partir de n'importe quel endroit de votre application. Cela répond au paterne d’injection de dépendances et offre une belle modularité dans la gestion des services et des classes de logiques.

Notre main.dart ressemble maintenant à ceci.

void main() async {
  // Démarrer l'application
  registerSingletons();

  runApp(const MainApp());
  await appLogic.bootstrap();
}

/// Créer des singletons (logique et services) qui peuvent être partagés dans l'application.
void registerSingletons() {
  // Contrôleur d'application de premier niveau
  GetIt.I.registerLazySingleton<AppLogic>(() => AppLogic());
  // Paramètres
  GetIt.I.registerLazySingleton<SettingsLogic>(() => SettingsLogic());
}

/// Ajouter du sucre syntaxique pour accéder rapidement aux principaux contrôleurs "logiques" de l'application.
/// Nous ne créons délibérément pas de raccourcis pour les services, afin de décourager leur utilisation directement dans la couche vue/widget.
AppLogic get appLogic => GetIt.I.get<AppLogic>();
SettingsLogic get settingsLogic => GetIt.I.get<SettingsLogic>();
Changement de la fonction principale avec les nouveaux blocs de logique et création de singleton pour faciliter l'accès intra-application

Nous avons un début d’architecture qui nous permet d’accéder à la navigation et aux paramètres de l’application n’importe où dans le code. Nous pouvons donc compléter notre fonction _validateOnBoarding dans la OnBoardingPage pour qu’elle notifie le service que l’onboarding s’est bien terminé.

void _validateOnBoarding(BuildContext context) {
	context.go(ScreenPaths.home);
	// Notifier au service que l'onboarding est terminé.
	settingsLogic.hasCompletedOnboarding = true;
}
Ajout du changement de paramètre dans la méthode dans OnBoardingPage pour notifier le service qu'on a bien complété l'onboarding

Sauf que l’information n’est pas gardée au redémarrage de l’application, il faut trouver un moyen de le faire facilement.

Au redémarrage, l'application revient sur OnBoardingPage

Faire persister l’information du service au démarrage

Passons au plus important : garder l’information entre chaque démarrage de l’application. Nous aurons deux armes importantes. Une première classe JsonPrefsFile qui permettra d’utiliser l’instance du shared_preferences et transformer n’importe quel objet JSON en String afin de pouvoir le stocker dans une référence clé-valeur sur le disque. Un second mixin SaveLoadMixin qui sera utilisé pour la sérialisation des informations des services en JSON et fera appel au JsonPrefsFile afin de stocker l’information.

class JsonPrefsFile {
  JsonPrefsFile(this.name);
  final String name;
	
  Future<Map<String, dynamic>> load() async {
	final p = (await SharedPreferences.getInstance()).getString(name);
	return Map<String, dynamic>.from(jsonDecode(p ?? '{}'));
  }

  Future<void> save(Map<String, dynamic> data) async {
	await (await SharedPreferences.getInstance())
    	.setString(name, jsonEncode(data));
  }
}
Classe qui s'occupe de l'enregistrement d'un bloc d'information dans le cache
mixin SaveLoadMixin {
  late final _file = JsonPrefsFile(fileName);

  Future<void> load() async {
	final results = await _file.load();
	try {
  		copyFromJson(results);
	} on Exception catch (_) {
  		// Exception caught
	}
  }

  Future<void> save() async {
	try {
  		await _file.save(toJson());
	} on Exception catch (_) {
  		// Exception caught
	}
  }
  
  /// Serialization
  String get fileName;
  Map<String, dynamic> toJson();
  void copyFromJson(Map<String, dynamic> value);
}
Mixin qui va accompagner les blocs de logiques afin de pouvoir les enregister dans le cache

JsonPrefsFile possède deux méthodes load() et save() qui permettent respectivement de manipuler le JSON et de l’enregistrer ou de le récupérer. SaveLoadMixin sera utile sur nos services, car il permettra d’avoir des méthodes pour serialiser l’information (toJson, copyFromJson) et deux méthodes pour enregistrer et récupérer l’information (save, load).

Notre SettingsLogic va maintenant pouvoir utiliser ces méthodes. On transforme aussi hasCompletedOnboarding avec des getter et des setters, qui permettront que lorsque hasCompletedOnboarding change, on appele le save() hérité qui enregistre la donnée.

class SettingsLogic with SaveLoadMixin {
 /// Nom de la clé utilisée pour stocker les informations
  @override
  String get fileName => 'settings.dat';

  bool _hasCompletedOnboarding = false;
  
  bool get hasCompletedOnboarding => _hasCompletedOnboarding;
  
  set hasCompletedOnboarding(bool value) {
	_hasCompletedOnboarding = value;
	// Chaque fois que nous appelons
    // settingsLogic.hasCompletedOnboarding = value
    // save est également appelée
	save();
  }

  @override
  void copyFromJson(Map<String, dynamic> value) {
    _hasCompletedOnboarding = value['hasCompletedOnboarding'] ?? false;
  }

  @override
  Map<String, dynamic> toJson() {
	return {
  	  'hasCompletedOnboarding': _hasCompletedOnboarding,
	};
  }
}
Ajout du SaveLoadMixin dans le SettingsLogic afin de persister ses informations

L’information est maintenant enregistrée à chaque modification du service, il nous reste simplement à ajouter dans notre boostrap() du AppLogic() qu’il faut initialiser le settingsLogic en appelant load()qui va récupérer l’information stockée.

class AppLogic {
  Future<void> bootstrap() async {
	// Recherche de paramètres dans les données locales.
	await settingsLogic.load();

	// Chargement de la vue initiale, etc.
    	// .....
  }
}
Initialisation de toutes les logiques dans le boostrap du bloc de logique principal

Bonus : garder le splashScreen pendant le bootstrap de l’application

Le package flutter_native_splash nous permet de personnaliser l'écran d'accueil blanc par défaut de Flutter avec une couleur d'arrière-plan et une image d'accueil. Il nous permet aussi de retenir cet écran le temps qu’on souhaite, dans notre cas, le temps que l’AppLogic fasse son boostrap. Cela donne une dernière modification pour notre `main`  .

void main() async {
  WidgetsBinding widgetsBinding = WidgetsFlutterBinding.ensureInitialized();
  // Maintenir l'écran de démarrage natif jusqu'à ce que l'application ait fini de démarrer.
  FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);
  // Démarrer l'app
  registerSingletons();

  runApp(const MainApp());
  await appLogic.bootstrap();

  // Suppression de l'écran de démarrage lorsque le bootstrap est terminé
  FlutterNativeSplash.remove();
}
Utilisation du package flutter_native_splash afin de garder l'écran de démarrage pendant que l'application démarre et charge toutes les informations
Parcours correct d'onboarding d'application

Conclusion

Nous avons vu comment conserver l’information du onboarding et comment créer une logique de services pour gérer les préférences d’une application avec l’utilisation des packages get_it, go_router, shared_preferences, flutter_native_splash.

Schéma du fonctionnement de l'application

Les sources du projet sont présentes sur ce repository. J'espère que ces informations vous seront utiles dans vos projets de développement Flutter, n’hésitez pas à partager cet article.

N'oubliez pas que le monde du code est un terrain de jeu infini, et chaque ligne que vous écrivez peut être une aventure en soi. Happy coding!

Liens utiles :