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.

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;
}
}
}
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');
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();
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.


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(),
);
}
}
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()),
],
),
],
);
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,
);
}
}
Nous pouvons donc ajouter au clic du bouton Done la navigation vers la HomePage.
void _validateOnBoarding(BuildContext context) {
context.go(ScreenPaths.home);
}

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
}
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>();
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;
}
Sauf que l’information n’est pas gardée au redémarrage de l’application, il faut trouver un moyen de le faire facilement.

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));
}
}
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);
}
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,
};
}
}
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.
// .....
}
}
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();
}

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
.

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 :