Développement Mobile

Persistance locale en Flutter : Hive et Drift pas à pas

15 دقائق للقراءة

La persistance locale est l’un des choix structurants d’une application mobile. Bien faite, elle rend l’application utilisable hors-ligne, instantanée à l’ouverture et résistante aux crashs. Mal faite, elle devient un cimetière de données corrompues, de migrations cassées et de bugs intermittents. Ce tutoriel implémente deux stratégies de persistance complémentaires sur le même projet Flutter : Hive pour un cache clé-valeur simple, et Drift pour une vraie base relationnelle SQLite. À la fin, vous saurez laquelle choisir et comment monter chacune proprement.

📖 Guide principal : Flutter 3 et Dart 3 pour le développement mobile cross-platform — pour la vue d’ensemble des choix de stockage, commencez par cette page.

Prérequis

  • Flutter SDK 3.41+ installé (guide d’installation).
  • Bases de Dart et Flutter — widgets, async/await, Future.
  • Aucune connaissance SQL préalable n’est requise, mais elle aide.
  • Niveau : intermédiaire. Temps estimé : 90 minutes.

Quand choisir Hive et quand choisir Drift

Avant le code, posons le critère de décision — c’est le sujet qui fait perdre le plus de temps quand on attaque un projet sans cadre. Le bon choix dépend de la forme de vos données et de la complexité des requêtes que vous comptez faire.

Hive est une base clé-valeur : vous rangez des objets sous une clé, vous les relisez par clé, vous les supprimez. C’est rapide à mettre en place (cinq minutes pour le premier put), c’est très performant en écriture, et l’API tient sur une page. C’est l’option indiquée quand vos données ressemblent à : cache produits, paramètres utilisateur, derniers résultats de recherche, brouillon de formulaire, JWT et metadata de session. Limite : pas de requêtes croisées, pas de jointures, pas de transactions multi-objets. Au moment de cette rédaction, Hive 2.2.3 est la version stable et n’a pas reçu de mise à jour majeure depuis longtemps ; les mainteneurs (Isar.dev) travaillent sur Hive 4 en pré-release et recommandent Isar 3 pour les cas qui dépassent le clé-valeur pur.

Drift (anciennement moor) est une couche SQLite type-safe avec génération de code. Vous décrivez vos tables en Dart, le générateur crée la classe DataClass, les Companion, les requêtes typées et les migrations. C’est l’option indiquée quand vos données ressemblent à : catalogue produit normalisé, journal de transactions, historique horodaté, comptabilité locale, synchronisation différée avec backend. Limite : ça pèse plus, le build runner est obligatoire, et il faut comprendre les bases de SQL pour aller au-delà des opérations simples. La branche Drift 2.x est stable et reçoit des mises à jour régulières — vérifiez la dernière mineure disponible sur pub.dev.

Pour ce tutoriel on installe les deux, parce que dans une vraie application elles cohabitent souvent : Drift pour le métier, Hive pour le cache léger.

Étape 1 — Préparer le projet et installer les deux libs

Partons d’un projet Flutter vierge :

flutter create persistance_demo
cd persistance_demo

# Hive
flutter pub add hive hive_flutter
flutter pub add --dev hive_generator build_runner

# Drift — installation moderne via drift_flutter
flutter pub add drift drift_flutter path_provider
flutter pub add --dev drift_dev build_runner

Au moment de cette rédaction, les versions résolues sont : hive: ^2.2.3, hive_flutter: ^1.1.0, drift: ^2.x (dernière mineure stable), drift_flutter: ^0.3.0, path_provider: ^2.1.5. Vérifiez votre pubspec.yaml. Le paquet drift_flutter est l’extension officielle qui simplifie l’ouverture de la base sur toutes les plateformes — Android, iOS, desktop, web — en une seule commande driftDatabase(). Il remplace l’ancien duo sqlite3_flutter_libs + setup manuel ; ce dernier est désormais marqué EOL par son auteur (version 0.6.0+eol) et n’est plus à utiliser pour les nouveaux projets.

Étape 2 — Initialiser Hive et ouvrir une première Box

Hive doit être initialisé une fois au démarrage de l’application, avant tout appel d’ouverture de Box. Une Box est l’unité de stockage : chaque box est un fichier indépendant sur disque, indexé en mémoire pour des lookups O(1).

Ouvrez lib/main.dart et ajoutez :

import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Hive.initFlutter();
  await Hive.openBox<String>('settings');
  runApp(const MyApp());
}

L’appel Hive.initFlutter() détermine où ranger les fichiers — getApplicationDocumentsDirectory() sur mobile, isolé par application. openBox('settings') ouvre (ou crée si absent) une box typée String. Vous pouvez ouvrir n’importe quel type primitif (int, double, bool, String, List, Map) sans configuration.

Pour lire et écrire :

final settings = Hive.box<String>('settings');
await settings.put('theme', 'dark');
final theme = settings.get('theme'); // 'dark'
await settings.delete('theme');
final keys = settings.keys.toList();

C’est tout. Pas de SQL, pas de schéma, pas de migration tant que vous restez sur des primitifs.

Étape 3 — Stocker des objets typés avec un TypeAdapter Hive

Pour stocker autre chose qu’un primitif — un User, un Product, un CartItem — Hive a besoin d’un TypeAdapter. Le générateur peut l’écrire pour vous via une annotation. Créez lib/models/product.dart :

import 'package:hive/hive.dart';
part 'product.g.dart';

@HiveType(typeId: 1)
class Product {
  Product({required this.id, required this.name, required this.price});

  @HiveField(0)
  final String id;

  @HiveField(1)
  final String name;

  @HiveField(2)
  final double price;
}

Notez le typeId: 1 — chaque classe Hive doit avoir un identifiant unique entre 0 et 223. Tenez un registre dans un fichier doc/hive_type_ids.md pour ne pas dupliquer accidentellement. Notez aussi le @HiveField(0), @HiveField(1)… Ces indices sont la colonne vertébrale du format binaire : si vous renommez un champ, l’adapter trouvera quand même la donnée tant que l’index n’a pas changé. À l’inverse, si vous changez un index, vous cassez vos boxes existantes — donc on n’enlève jamais un index, on en ajoute des nouveaux.

Générez l’adapter :

dart run build_runner build --delete-conflicting-outputs

Le fichier product.g.dart apparaît avec une classe ProductAdapter. Enregistrez-la avant d’ouvrir la box correspondante :

Hive.registerAdapter(ProductAdapter());
await Hive.openBox<Product>('products');

Et utilisez la box comme avant : await box.put('p1', Product(...)), box.get('p1'), box.values.toList().

Étape 4 — Réagir aux changements avec ValueListenableBuilder

Une box Hive expose un ValueListenable pour observer ses changements et reconstruire l’UI automatiquement. C’est l’équivalent du Stream de Drift pour le cas clé-valeur.

import 'package:hive_flutter/hive_flutter.dart';

ValueListenableBuilder(
  valueListenable: Hive.box<Product>('products').listenable(),
  builder: (context, Box<Product> box, _) {
    final products = box.values.toList();
    return ListView.builder(
      itemCount: products.length,
      itemBuilder: (c, i) => ListTile(
        title: Text(products[i].name),
        subtitle: Text('${products[i].price} €'),
      ),
    );
  },
);

Chaque put ou delete sur la box déclenche un rebuild. Pour des cas plus complexes (filtre, tri, agrégation), branchez ce ValueListenable dans un Notifier Riverpod plutôt que directement dans le widget.

Étape 5 — Déclarer une base Drift

Passons à Drift pour des données relationnelles. Créez lib/db/app_database.dart :

import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'package:path_provider/path_provider.dart';

part 'app_database.g.dart';

class Todos extends Table {
  IntColumn  get id        => integer().autoIncrement()();
  TextColumn get title     => text().withLength(min: 1, max: 200)();
  BoolColumn get done      => boolean().withDefault(const Constant(false))();
  DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
}

@DriftDatabase(tables: [Todos])
class AppDatabase extends _$AppDatabase {
  AppDatabase([QueryExecutor? executor]) : super(executor ?? _open());

  @override
  int get schemaVersion => 1;
}

QueryExecutor _open() {
  return driftDatabase(
    name: 'app',
    native: const DriftNativeOptions(
      databaseDirectory: getApplicationSupportDirectory,
    ),
  );
}

Trois éléments à comprendre. La classe Todos extends Table décrit le schéma : nom, type et contraintes des colonnes. L’annotation @DriftDatabase(tables: [...]) branche la table à la base. Et schemaVersion est le compteur de migrations : tant qu’il reste à 1, aucune migration n’est appliquée ; quand vous le passez à 2, vous devez fournir la logique onUpgrade qui transforme l’ancien schéma en nouveau.

Générez les fichiers manquants :

dart run build_runner build --delete-conflicting-outputs

Drift crée app_database.g.dart avec la classe Todo (DataClass immuable), TodosCompanion (pour les insertions partielles) et toute la plomberie de connexion.

Étape 6 — Faire des insertions, requêtes et streams réactifs

Une fois la base générée, l’API est essentiellement type-safe. Voici les opérations courantes :

final db = AppDatabase();

// INSERT
final newId = await db.into(db.todos).insert(TodosCompanion.insert(
  title: 'Acheter du pain',
));

// SELECT * FROM todos
final all = await db.select(db.todos).get();

// SELECT * WHERE done = false ORDER BY created_at DESC
final pending = await (db.select(db.todos)
      ..where((t) => t.done.equals(false))
      ..orderBy([(t) => OrderingTerm.desc(t.createdAt)]))
    .get();

// UPDATE
await (db.update(db.todos)..where((t) => t.id.equals(newId)))
    .write(const TodosCompanion(done: Value(true)));

// DELETE
await (db.delete(db.todos)..where((t) => t.id.equals(newId))).go();

Le vrai bénéfice de Drift apparaît avec les streams. Toute requête peut être convertie en Stream qui ré-émet automatiquement à chaque changement de la table sous-jacente :

Stream<List<Todo>> watchAll() {
  return db.select(db.todos).watch();
}

Branchez ce stream dans un StreamProvider Riverpod et votre UI se met à jour à chaque insert, update ou delete — pas besoin d’invalidate ou de cache manuel.

Étape 7 — Migration de schéma

Tôt ou tard, vous ajouterez une colonne. Drift gère les migrations de façon explicite. Disons que vous voulez ajouter une colonne priority à la table Todos :

class Todos extends Table {
  IntColumn  get id        => integer().autoIncrement()();
  TextColumn get title     => text()();
  BoolColumn get done      => boolean().withDefault(const Constant(false))();
  DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
  IntColumn get priority   => integer().withDefault(const Constant(0))();
}

@DriftDatabase(tables: [Todos])
class AppDatabase extends _$AppDatabase {
  AppDatabase() : super(_openConnection());

  @override
  int get schemaVersion => 2;

  @override
  MigrationStrategy get migration => MigrationStrategy(
    onCreate: (m) async {
      await m.createAll();
    },
    onUpgrade: (m, from, to) async {
      if (from == 1) {
        await m.addColumn(todos, todos.priority);
      }
    },
  );
}

Régénérez avec build_runner. Au prochain lancement, Drift détecte que la version stockée (1) est plus ancienne que la version compilée (2) et exécute onUpgrade. C’est non-destructif : les données existantes sont préservées, seule la nouvelle colonne est ajoutée avec sa valeur par défaut.

En production, testez systématiquement les migrations avec drift_dev schema qui peut générer des tests automatiques de migration entre versions. Une migration cassée en prod est l’une des erreurs les plus difficiles à corriger — l’application des utilisateurs ne démarre plus.

Étape 8 — Choisir entre les deux dans une vraie app

Dans le projet réel que vous démarrerez après ce tutoriel, voici la décision pratique :

  • Paramètres utilisateur, dernier écran visité, JWT → Hive (ou shared_preferences qui est encore plus simple pour quelques scalaires).
  • Cache d’une liste d’objets reçus d’une API, avec invalidation TTL → Hive.
  • Catalogue produit avec recherche, tri, filtres → Drift.
  • Historique de commandes, journal d’évènements, audit → Drift.
  • Données synchronisées avec backend (CRUD complet hors-ligne, puis push différé) → Drift.

Et si vous hésitez encore : commencez par Hive. C’est tellement rapide à mettre en place que vous pouvez basculer vers Drift plus tard si vous découvrez que vos requêtes deviennent complexes. L’inverse — passer de Drift à Hive — n’arrive jamais.

Comparaison de performance : où ça coûte vraiment

Les benchmarks publiés sur les forums donnent des chiffres bruts qui ne disent pas grand-chose en pratique. Ce qui compte sur un téléphone, c’est la latence perçue : le temps entre le geste de l’utilisateur et l’affichage du résultat. Quelques observations qui valent dans la vraie vie.

Pour des lectures de quelques objets par clé, Hive est imbattable. Une box ouverte garde son index en RAM ; un get prend moins d’une milliseconde même sur entrée de gamme. Drift, lui, doit traverser SQLite — vous êtes typiquement à 2 à 5 millisecondes par requête simple, ce qui reste imperceptible mais s’additionne quand vous faites cent appels dans une boucle. Préférez toujours une seule requête qui ramène N résultats à N requêtes successives.

Pour de l’écriture, Drift est plus prévisible. SQLite gère les transactions ACID nativement : si l’application crash pendant un INSERT dans une transaction, soit tout est écrit, soit rien. Hive utilise un format append-only avec compaction périodique ; en cas de crash en pleine écriture, l’entrée corrompue est ignorée au prochain démarrage mais le mécanisme n’est pas aussi strict qu’une vraie transaction SQL.

Côté taille du fichier sur disque, Hive est plus compact pour des objets simples (50 % d’overhead environ), Drift plus efficace dès qu’il y a des relations (les jointures évitent la duplication de données).

Patterns réactifs : Stream et ValueListenable côte à côte

Une application Flutter productive consomme la donnée locale comme une source temps réel. C’est ce qui donne l’impression d’instantanéité : on ne refresh pas une liste, on l’écoute. Drift et Hive offrent tous deux ce pattern, avec deux APIs différentes.

Côté Drift, le Stream est le moyen idiomatique. Il s’intègre directement avec StreamBuilder, StreamProvider Riverpod, ou le pattern async* de Dart. Avantage : un Stream peut subir transformations (map, where, distinct), ce qui n’est pas possible avec un ValueListenable.

Côté Hive, box.watch() donne un Stream<BoxEvent> qui émet à chaque modification. Vous pouvez l’utiliser directement, mais l’usage le plus pratique est box.listenable(keys: ['user']) qui crée un ValueListenable filtré sur certaines clés — utile pour ne pas tout reconstruire à chaque écriture.

Dans les deux cas, n’oubliez pas la distinct() ou distinctUnique() à la fin du pipeline si votre UI ne doit reconstruire que quand la valeur change réellement, pas à chaque event de la couche storage. C’est une optimisation gratuite qui économise des rebuilds inutiles.

Erreurs fréquentes

Erreur Cause Solution
HiveError: Did you forget to register an adapter? Box typée ouverte avant registerAdapter. Toujours appeler Hive.registerAdapter(...) AVANT openBox.
Conflit de typeId entre deux classes Hive Deux @HiveType(typeId: X) identiques. Tenir un registre des typeId dans doc/hive_type_ids.md.
build_runner bloqué sur « Conflicting outputs » Fichiers générés obsolètes. dart run build_runner build --delete-conflicting-outputs.
SqliteException: no such table au premier lancement Drift Suppression manuelle de la base sans incrémenter schemaVersion. Désinstaller l’app sur l’émulateur ou incrémenter schemaVersion et fournir onUpgrade.
Stream Drift qui n’émet pas après insert Vous avez créé une seconde instance d’AppDatabase. Toujours partager une seule instance via un Provider Riverpod ou un singleton.

Tutoriels associés

Foire aux questions

Hive est-il abandonné ?

Non, mais Hive 2.2.3 n’a plus reçu de mise à jour majeure depuis 2022. Hive 4 est en pré-release (4.0.0-dev) et les mainteneurs concentrent leurs efforts sur Isar 3, qui est l’évolution conseillée pour des besoins plus avancés. Pour un nouveau projet en 2026, Hive 2 reste un choix sûr pour du clé-valeur simple, mais évaluez aussi Isar 3 si vous voulez des requêtes plus riches.

Drift ou sqflite ?

sqflite est la couche brute SQLite sans génération de code — vous écrivez vos requêtes SQL à la main, le résultat est Map<String, dynamic>. C’est OK pour deux ou trois requêtes, mais le manque de type-safety devient pénible à grande échelle. Drift apporte la sécurité de typage et la réactivité par stream sans renoncer à la puissance de SQL.

Quelle base recommandez-vous pour le chiffrement ?

Hive supporte le chiffrement AES-256 natif via HiveAesCipher. Pour Drift, le chiffrement passait historiquement par sqlcipher_flutter_libs, désormais marqué EOL (version 0.7.0+eol) en parallèle de la migration vers package:sqlite3 3.x. Le chiffrement de la base reste possible via les builds SQLCipher distribués avec sqlite3 3.x ou des paquets plus récents — consultez la documentation Drift à jour pour la méthode active. Dans les deux cas, la clé doit être stockée dans le Keystore Android ou le Keychain iOS via flutter_secure_storage, pas en clair dans les préférences.

Comment migrer des données existantes ?

Pour Hive, écrivez un script à exécuter au premier lancement après mise à jour : ouvrir l’ancienne box, transformer chaque entrée, écrire dans la nouvelle, fermer puis supprimer l’ancienne. Pour Drift, utilisez MigrationStrategy.onUpgrade avec des SQL ALTER TABLE ou des transformations programmatiques.

Quelle est la taille maximale supportée par Hive et Drift ?

Hive tient confortablement jusqu’à quelques dizaines de milliers d’entrées dans une box ; au-delà, le coût mémoire au démarrage devient sensible (toutes les clés sont indexées en RAM). Drift, basé sur SQLite, tient sans peine plusieurs millions de lignes — vous serez limité par l’espace disque de l’appareil bien avant.

Ressources officielles

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité