Hors ligne et persistance
Choisir un stockage, maîtriser drift, chiffrer la base, puis concevoir une app qui fonctionne sans réseau : source de vérité locale, outbox, synchronisation, conflits. Les applications terrain des télécoms et du secteur public, comme les agents de paiement, travaillent dans des zones mal couvertes : la question « que se passe-t-il hors ligne ? » arrive dans presque tous les entretiens d'architecture mobile.
Objectifs
- Choisir le bon stockage pour chaque donnée et justifier ce choix, état de maintenance des packages compris.
- Écrire tables, DAO, requêtes réactives et migrations drift, et tester une migration.
- Chiffrer une base SQLite avec la chaîne d'outils actuelle (sqlite3 3.x et ses hooks).
- Concevoir une outbox idempotente et expliquer pourquoi un paiement rejoué n'est pas débité deux fois.
- Présenter les stratégies de synchronisation et de résolution de conflits, avec leurs compromis.
Choisir son stockage
Il n'y a pas « une » base pour l'app : chaque catégorie de donnée a son support. La question à se poser pour chacune : est-ce sensible, est-ce structuré et requêtable, faut-il l'observer, quel volume ?
| Option | Pour quoi | Limites et état (octobre 2026) |
|---|---|---|
shared_preferences | Préférences simples : thème, onboarding vu, dernier filtre | Clé-valeur non chiffré, pas pour des données métier ni sensibles |
flutter_secure_storage | Petits secrets : refresh token, clé de base | Lent (Keystore / Keychain), pas pour du volume (chapitre 10) |
sqflite | SQL brut, maintenu | Pas de typage, requêtes en chaînes, pas de streams réactifs intégrés |
drift | Données relationnelles, requêtes typées, streams, migrations | Génération de code ; recommandé par défaut |
| Hive | Clé-valeur rapide en Dart pur | Le package original n'a plus de version depuis 2022 ; un fork communautaire actif existe (hive_ce) |
| Isar | Base NoSQL rapide avec requêtes | Le package original n'a plus de version stable depuis 2023 ; reprise communautaire (isar_community), centrée sur la maintenance de la v3 |
| ObjectBox | Base objet très performante, synchronisation payante | Produit commercial (cœur open source), dépendance à un éditeur |
« Pourquoi pas Hive ou Isar, ils sont plus rapides ? » Répondre sur la performance seule est une erreur. Dans un grand compte, le critère premier est la pérennité : une app bancaire vit cinq à dix ans. Les auteurs originaux de Hive et Isar ont cessé de publier ; les forks communautaires sont actifs mais portés par peu de personnes. SQLite, lui, est un standard, chiffrable, requêtable, et drift est activement maintenu. Choisir drift, c'est réduire le risque projet.
shared_preferences et son API asynchrone
Le package propose désormais deux API qui remplacent l'historique SharedPreferences.getInstance() (appelée à être dépréciée) : SharedPreferencesAsync, sans cache, où chaque lecture interroge la plateforme et voit donc toujours la valeur à jour, y compris écrite par un autre isolate ou du code natif ; et SharedPreferencesWithCache, avec un cache mémoire synchrone limité à une liste blanche de clés. Sur Android, l'API asynchrone s'appuie par défaut sur Jetpack DataStore : les données écrites avec l'ancienne API ne sont pas visibles sans migration, et le package fournit un utilitaire pour cela.
import 'package:shared_preferences/shared_preferences.dart';
final prefs = SharedPreferencesAsync();
Future<void> saveTheme(String mode) => prefs.setString('theme_mode', mode);
Future<String?> readTheme() => prefs.getString('theme_mode');
/// Variante avec cache mémoire synchrone pour une liste blanche de clés.
Future<SharedPreferencesWithCache> openPrefsCache() => SharedPreferencesWithCache.create(
cacheOptions: const SharedPreferencesWithCacheOptions(allowList: {'theme_mode', 'onboarding_done'}),
);
drift en détail
drift est une couche de persistance réactive au-dessus de SQLite. Vous décrivez les tables en Dart (ou en SQL dans des fichiers .drift), build_runner génère les classes de lignes, les companions (objets d'insertion et de mise à jour où chaque champ est optionnel), et l'API de requêtes typée. Une colonne renommée casse la compilation, pas la production.
dependencies:
drift: ^2.35.0
drift_flutter: ^0.3.1 # ouverture de la base, isolate de fond, chemins
sqlite3: ^3.7.0 # SQLite embarqué via les hooks (build hooks Dart)
dev_dependencies:
build_runner: ^2.16.0
drift_dev: ^2.35.0
hooks:
user_defines:
sqlite3:
source: sqlite3mc # SQLite3MultipleCiphers : base chiffrée
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
part 'wallet_database.g.dart';
/// Cache local de l'historique : la source de vérité de l'UI.
@DataClassName('TransactionRow')
class CachedTransactions extends Table {
TextColumn get id => text()();
TextColumn get type => text()(); // valeur API brute, y compris inconnue
IntColumn get amount => integer()(); // centimes
TextColumn get direction => text()();
TextColumn get counterparty => text()();
TextColumn get note => text().nullable()();
TextColumn get status => text()();
DateTimeColumn get createdAt => dateTime()();
DateTimeColumn get syncedAt => dateTime()(); // pour l'invalidation
@override
Set<Column<Object>> get primaryKey => {id};
}
enum OutboxStatus { pending, inFlight, failed }
/// File des écritures faites hors ligne (outbox pattern).
@DataClassName('OutboxEntry')
class Outbox extends Table {
IntColumn get id => integer().autoIncrement()(); // ordre de rejeu
TextColumn get idempotencyKey => text().unique()(); // UUID v4, figé
TextColumn get kind => text()(); // 'transfer'
TextColumn get payload => text()(); // JSON du corps
IntColumn get status => intEnum<OutboxStatus>()();
IntColumn get attempts => integer().withDefault(const Constant(0))();
TextColumn get lastError => text().nullable()();
DateTimeColumn get createdAt => dateTime()();
}
@DriftAccessor(tables: [CachedTransactions])
class TransactionsDao extends DatabaseAccessor<WalletDatabase>
with _$TransactionsDaoMixin {
TransactionsDao(super.db);
/// Stream réactif : réémet à chaque écriture dans la table.
Stream<List<TransactionRow>> watchLatest({String? type, int limit = 50}) {
final query = select(cachedTransactions)
..orderBy([(t) => OrderingTerm.desc(t.createdAt)])
..limit(limit);
if (type != null) query.where((t) => t.type.equals(type));
return query.watch();
}
/// Upsert d'une page reçue du serveur, en une transaction.
Future<void> upsertPage(List<CachedTransactionsCompanion> rows) =>
batch((b) => b.insertAllOnConflictUpdate(cachedTransactions, rows));
Future<int> deleteByIds(List<String> ids) =>
(delete(cachedTransactions)..where((t) => t.id.isIn(ids))).go();
}
@DriftDatabase(tables: [CachedTransactions, Outbox], daos: [TransactionsDao])
class WalletDatabase extends _$WalletDatabase {
WalletDatabase(super.e);
/// Ouverture chiffrée : la clé vient de flutter_secure_storage.
factory WalletDatabase.open(String key) => WalletDatabase(
driftDatabase(
name: 'wallet',
native: DriftNativeOptions(
// Exécuté dans l'isolate de la base, avant toute requête drift.
setup: (db) {
// La clé doit être une String capturée (envoyable entre isolates).
db.execute("PRAGMA key = '${key.replaceAll("'", "''")}';");
// Garde-fou : échoue si la lib SQLite n'est pas une build chiffrée.
assert(db.select('PRAGMA cipher;').isNotEmpty);
},
),
),
);
@override
int get schemaVersion => 2;
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) => m.createAll(),
onUpgrade: (m, from, to) async {
if (from < 2) {
// v2 : ajout de l'outbox.
await m.createTable(outbox);
}
},
beforeOpen: (details) async {
await customStatement('PRAGMA foreign_keys = ON');
},
);
}
Ce qu'il faut savoir expliquer
- Tables : une classe par table, une méthode getter par colonne.
@DataClassNamenomme la classe de ligne générée. La clé primaire est déclarée avecprimaryKeyouautoIncrement(). - DAO (Data Access Object) : regroupe les requêtes d'une fonctionnalité, ce qui évite une classe de base de données de mille lignes et s'aligne sur l'architecture feature-first.
- Requêtes typées :
select,where,orderBy,limit, jointures, agrégats.get()renvoie unFuture,watch()unStream. - Streams réactifs :
watch()réémet chaque fois qu'une écriture touche les tables concernées, à condition de passer par la même instance de base (ou une connexion partagée entre isolates). Une écriture SQL faite par un autre moyen n'est pas vue. - Transactions :
transaction(() async { ... })rend un groupe d'écritures atomique ;batchregroupe beaucoup d'insertions en une seule opération, beaucoup plus rapide qu'une boucle deinsert. Les streams ne réémettent qu'à la fin de la transaction. - Isolate :
drift_flutterouvre la base dans un isolate de fond ; les requêtes lourdes ne bloquent pas l'UI.
Ne stockez pas l'enum Dart, stockez la valeur brute de l'API ('transfer_in') : une valeur inconnue aujourd'hui sera peut-être connue après une mise à jour de l'app, et vous ne voulez pas l'avoir perdue en la convertissant en unknown au moment de l'écriture.
Migrations de schéma et tests
Chaque modification de schéma incrémente schemaVersion et ajoute une étape dans onUpgrade. Une migration ratée en production, c'est une app qui plante au démarrage pour tous les utilisateurs qui se mettent à jour, ou qui perd leurs données hors ligne non synchronisées : le pire scénario pour une app terrain.
drift outille ce risque. La commande make-migrations sauvegarde un instantané JSON du schéma à chaque version, génère des helpers stepByStep (chaque étape reçoit le schéma exact de sa version, ce qui empêche d'utiliser par erreur une colonne qui n'existera que plus tard) et des tests de migration.
targets:
$default:
builders:
drift_dev:
options:
databases:
wallet_db: lib/core/storage/wallet_database.dart
schema_dir: drift_schemas/
test_dir: test/drift/
# après chaque changement de schéma (et incrément de schemaVersion)
dart run drift_dev make-migrations
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) => m.createAll(),
onUpgrade: stepByStep(
from1To2: (m, schema) async {
await m.createTable(schema.outbox);
},
),
);
test('migration v1 vers v2', () async {
final verifier = SchemaVerifier(GeneratedHelper());
final connection = await verifier.startAt(1); // base au schéma v1
final db = WalletDatabase(connection);
await verifier.migrateAndValidate(db, 2); // compare au schéma v2 attendu
await db.close();
});
Testez aussi l'intégrité des données : insérer des lignes au schéma v1, migrer, vérifier qu'elles sont toujours là et correctes. Le fichier généré contient un exemple de ce test pour la première migration.
Chiffrer la base
Une base SQLite en clair dans le répertoire de l'app se lit en une commande sur un appareil rooté ou depuis une sauvegarde extraite. Pour des données de clients, de paiements ou de citoyens, le chiffrement au repos est attendu (MASVS-STORAGE-1).
Le package sqlite3 3.x embarque SQLite via les build hooks Dart. sqlite3_flutter_libs et sqlcipher_flutter_libs sont en fin de vie (versions +eol sans fonctionnalité) : on les retire des dépendances. Le chiffrement se choisit dans pubspec.yaml avec hooks: user_defines: sqlite3: source: : sqlite3mc (SQLite3MultipleCiphers, recommandé par drift, disponible aussi sur le web) ou sqlcipher. drift n'a plus besoin de sqlcipher_flutter_libs depuis sa version 2.32, et drift_flutter 0.3 utilise sqlite3 3.x. Licences : SQLite est dans le domaine public, SQLite3MultipleCiphers et SQLCipher ont leurs propres licences, à faire valider.
Le principe est toujours le même : exécuter PRAGMA key avant la première requête, ce que permet le callback setup de DriftNativeOptions (voir WalletDatabase.open plus haut). Le callback est envoyé à l'isolate de la base : il ne doit capturer que des valeurs transférables, comme une String. Le garde-fou PRAGMA cipher échoue en debug si la bibliothèque chargée n'est pas une build chiffrante : sans lui, PRAGMA key est silencieusement ignoré par un SQLite standard et la base reste en clair.
import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'providers.dart';
import 'wallet_database.dart';
/// Clé de base générée sur l'appareil au premier lancement (256 bits),
/// conservée par le Keystore / Keychain. Jamais dans le code.
Future<String> loadOrCreateDbKey(FlutterSecureStorage storage) async {
const name = 'db_key_v1';
final existing = await storage.read(key: name);
if (existing != null) return existing;
final random = Random.secure();
final key = base64Url.encode(List<int>.generate(32, (_) => random.nextInt(256)));
await storage.write(key: name, value: key);
return key;
}
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final key = await loadOrCreateDbKey(const FlutterSecureStorage());
final db = WalletDatabase.open(key);
runApp(
ProviderScope(
overrides: [walletDatabaseProvider.overrideWithValue(db)],
child: const MaterialApp(home: Placeholder()),
),
);
}
- La clé est générée aléatoirement sur l'appareil et protégée par le Keystore / Keychain. Pas de clé dérivée d'un identifiant d'appareil, pas de clé en dur.
- Une base existante en clair se migre par
VACUUM INTOvers une copie, puisPRAGMA rekey; une base créée avec SQLCipher s'ouvre avec SQLite3MultipleCiphers moyennantPRAGMA cipher = 'sqlcipher'etPRAGMA legacy = 4avant la clé. - Si la clé est perdue (réinstallation, restauration sur un autre appareil), la base est irrécupérable : c'est voulu. Prévoyez la reconstruction depuis le serveur, et dites-le en entretien.
- Vérification simple : l'en-tête d'une base SQLite en clair commence par
SQLite format 3; celui d'une base chiffrée est illisible.
Stratégies offline-first
Offline-first ne veut pas dire « fonctionne parfois sans réseau ». Cela veut dire que l'UI lit toujours la base locale, et que le réseau sert à la remplir et à la synchroniser. L'écran ne sait pas si la donnée vient d'arriver ou date d'hier ; il affiche, et peut indiquer la fraîcheur.
| Stratégie | Principe | Quand |
|---|---|---|
| Network-first | Réseau, cache en repli si échec | Données qui doivent être fraîches (solde avant paiement) |
| Cache-first | Cache s'il existe, réseau sinon | Référentiels qui changent rarement |
| Cache-then-network / stale-while-revalidate | Afficher le cache immédiatement, rafraîchir en fond, l'UI se met à jour | Listes, historiques, tableaux de bord : le cas par défaut |
import 'package:drift/drift.dart';
import '../model/transaction.dart';
import '../model/transactions_api.dart';
import 'wallet_database.dart';
/// Source de vérité locale : l'UI lit la base, le réseau ne fait que la remplir.
class TransactionsRepository {
TransactionsRepository(this._db, this._api);
final WalletDatabase _db;
final TransactionsApi _api;
/// Stale-while-revalidate : on émet tout de suite le cache (même périmé),
/// puis on rafraîchit en arrière-plan ; le stream drift réémet tout seul.
Stream<List<TransactionRow>> watchLatest() {
refreshIfStale(); // volontairement non attendu
return _db.transactionsDao.watchLatest();
}
Future<void> refreshIfStale({Duration maxAge = const Duration(minutes: 2)}) async {
final newest = await (_db.selectOnly(_db.cachedTransactions)
..addColumns([_db.cachedTransactions.syncedAt.max()]))
.map((r) => r.read(_db.cachedTransactions.syncedAt.max()))
.getSingleOrNull();
if (newest != null && DateTime.now().toUtc().difference(newest) < maxAge) return;
try {
final page = await _api.fetchPage();
await _db.transactionsDao.upsertPage(page.items.map(_toRow).toList());
} on Exception {
// Hors ligne : on garde le cache, l'UI affiche « données du ... ».
}
}
CachedTransactionsCompanion _toRow(Transaction t) => CachedTransactionsCompanion.insert(
id: t.id,
type: t.type.name,
amount: t.amount,
direction: t.direction.name,
counterparty: t.counterparty,
note: Value(t.note),
status: t.status.name,
createdAt: t.createdAt,
syncedAt: DateTime.now().toUtc(),
);
}
Avec drift, le stale-while-revalidate est presque gratuit : le stream émet le cache, l'upsert du rafraîchissement déclenche une nouvelle émission. Pas de logique de fusion dans l'UI. Pensez à afficher la date de dernière synchronisation sur les écrans où la fraîcheur compte : un solde affiché hors ligne doit dire de quand il date.
« Je fais de la base locale la source de vérité : l'UI observe des streams drift, le repository rafraîchit en arrière-plan et fait des upserts. Les écritures hors ligne passent par une outbox persistée, rejouée dans l'ordre avec une clé d'idempotence par opération. Le réseau devient un détail d'implémentation pour l'écran, et l'app reste utilisable dans une zone blanche. »
Outbox pattern : écrire hors ligne
L'utilisateur valide un transfert dans le métro. Appeler l'API échouera ; perdre l'opération est inacceptable ; la rejouer naïvement risque un double débit. L'outbox résout les trois problèmes :
- Persister l'intention dans une table
outbox, dans la même transaction que la mise à jour locale éventuelle (afficher le transfert « en attente »). - Générer l'
Idempotency-Keyà l'enregistrement, une fois pour toutes, et la stocker avec l'opération. C'est le point central : chaque rejeu, même après un redémarrage de l'app, envoie la même clé. - Rejouer dans l'ordre (clé auto-incrémentée) au retour du réseau, au démarrage, et depuis une tâche de fond.
- Classer les erreurs : transitoire (réseau, 5xx, 429) : on garde l'opération et on s'arrête pour préserver l'ordre ; définitive (422 solde insuffisant, 404 bénéficiaire supprimé) : on la marque en échec, on prévient l'utilisateur, on passe à la suivante.
- Supprimer après succès, et mettre à jour la ligne locale avec la transaction renvoyée par le serveur.
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:drift/drift.dart';
import 'package:uuid/uuid.dart';
import 'wallet_database.dart';
const _uuid = Uuid();
class OutboxProcessor {
OutboxProcessor(this._db, this._dio);
final WalletDatabase _db;
final Dio _dio;
bool _running = false;
/// Enregistre l'intention de paiement. La clé est générée ICI, une seule fois.
Future<String> enqueueTransfer({
required String beneficiaryId,
required int amount,
String? note,
}) async {
final key = _uuid.v4();
await _db.into(_db.outbox).insert(
OutboxCompanion.insert(
idempotencyKey: key,
kind: 'transfer',
payload: jsonEncode({'beneficiaryId': beneficiaryId, 'amount': amount, 'note': ?note}),
status: OutboxStatus.pending,
createdAt: DateTime.now().toUtc(),
),
);
return key;
}
/// Rejoue la file dans l'ordre. Appelé au retour du réseau, au démarrage
/// et par la tâche de fond.
Future<void> drain() async {
if (_running) return; // pas deux vidages concurrents
_running = true;
try {
final entries = await (_db.select(_db.outbox)
..where((o) => o.status.isNotValue(OutboxStatus.failed.index))
..orderBy([(o) => OrderingTerm.asc(o.id)]))
.get();
for (final entry in entries) {
final keepGoing = await _send(entry);
if (!keepGoing) break; // erreur transitoire : on garde l'ordre
}
} finally {
_running = false;
}
}
Future<bool> _send(OutboxEntry entry) async {
await _mark(entry, OutboxStatus.inFlight);
try {
await _dio.post<Map<String, dynamic>>(
'/transfers',
data: jsonDecode(entry.payload),
// Même clé à chaque tentative : le serveur ne débite qu'une fois.
options: Options(headers: {'Idempotency-Key': entry.idempotencyKey}),
);
await (_db.delete(_db.outbox)..where((o) => o.id.equals(entry.id))).go();
return true;
} on DioException catch (e) {
final status = e.response?.statusCode;
final definitive = status != null && status >= 400 && status < 500 && status != 429;
await _mark(
entry,
definitive ? OutboxStatus.failed : OutboxStatus.pending,
error: e.response?.data?.toString() ?? e.type.name,
);
// Définitive (422 solde insuffisant...) : on signale et on continue.
// Transitoire (réseau, 5xx, 429) : on s'arrête et on réessaiera.
return definitive;
}
}
Future<void> _mark(OutboxEntry e, OutboxStatus s, {String? error}) =>
(_db.update(_db.outbox)..where((o) => o.id.equals(e.id))).write(
OutboxCompanion(
status: Value(s),
attempts: Value(e.attempts + (s == OutboxStatus.inFlight ? 1 : 0)),
lastError: Value(error),
),
);
}
- Une opération restée
inFlightaprès un crash est renvoyée au démarrage : sans danger, la clé est la même. - Dépendances entre opérations : si « ajouter un bénéficiaire » échoue définitivement, le transfert vers ce bénéficiaire qui suit dans la file doit échouer aussi. Pour des flux complexes, on regroupe les opérations dépendantes ou on stocke une référence de dépendance.
- Le drapeau
_runningévite deux vidages concurrents (retour réseau et tâche de fond en même temps) qui enverraient la même opération deux fois. Ce ne serait pas grave grâce à la clé, mais c'est du bruit. - Hors ligne, on ne connaît pas le solde réel : la validation définitive est serveur. L'UI doit montrer un statut « en attente d'envoi » et non « effectué ».
« Comment garantissez-vous qu'un paiement hors ligne n'est pas débité deux fois ? » La mauvaise réponse : « je vérifie que la requête n'a pas déjà été envoyée ». Le client ne peut pas savoir si une requête en timeout a été traitée. La bonne réponse : une Idempotency-Key générée à la création de l'intention, persistée avec l'opération dans l'outbox, renvoyée identique à chaque tentative ; le serveur enregistre la clé avec le résultat et renvoie la même transaction en cas de rejeu, sans débiter. Côté serveur, la clé est unique par client, a une durée de rétention, et une clé réutilisée avec un corps différent est rejetée. C'est exactement le comportement du backend du projet.
Détection de connectivité et ses limites
connectivity_plus indique le type d'interface active (Wi-Fi, mobile, Ethernet, VPN, aucune). Depuis la version 6, l'API renvoie une liste de ConnectivityResult, car plusieurs interfaces peuvent être actives à la fois.
import 'package:connectivity_plus/connectivity_plus.dart';
/// connectivity_plus dit quelle interface est active, pas si Internet répond.
Stream<bool> hasNetworkInterface() => Connectivity().onConnectivityChanged.map(
(results) => results.any((r) => r != ConnectivityResult.none),
);
Limite essentielle : une interface active ne garantit pas l'accès à Internet. Wi-Fi d'hôtel avec portail captif, réseau mobile saturé, VPN d'entreprise qui filtre, API gateway en panne : connectivity_plus dit « Wi-Fi », la requête échoue. Conséquences :
- Utilisez le changement de connectivité comme déclencheur d'une tentative (vider l'outbox), jamais comme vérité.
- La seule preuve d'accès est une requête réussie vers votre API. Des packages font un ping vers des hôtes publics (
internet_connection_checker_plus), mais votre back-end peut être injoignable alors qu'Internet fonctionne. - Ne bloquez pas une action parce que l'indicateur dit « hors ligne » : tentez, et basculez dans l'outbox en cas d'échec réseau.
Synchronisation
Recharger toutes les données à chaque ouverture ne passe pas à l'échelle pour une app terrain avec des milliers de fiches. La synchronisation incrémentale (delta sync) ne demande que ce qui a changé depuis la dernière fois.
- Par timestamp (
?updatedSince=2026-10-21T08:00:00Z) : simple, mais fragile. Horloges décalées, transactions longues côté serveur qui committent un horodatage passé après votre lecture, précision insuffisante : des modifications se perdent. Si on l'utilise, on prend une marge et on dédoublonne. - Par curseur serveur (numéro de séquence, version de changement, jeton opaque) : le serveur sait exactement ce qu'il vous a envoyé. C'est la méthode robuste.
- Tombstones (pierres tombales) : une suppression côté serveur ne peut pas apparaître dans « ce qui a changé » si la ligne n'existe plus. Le serveur conserve une trace « id X supprimé à telle version » et la renvoie dans le delta ; le client supprime localement. Les tombstones ont une rétention : un client absent plus longtemps doit refaire une synchronisation complète.
import 'package:dio/dio.dart';
import 'package:drift/drift.dart';
import 'wallet_database.dart';
/// Synchronisation incrémentale (delta sync) : le serveur renvoie
/// ce qui a changé depuis le curseur, suppressions comprises (tombstones).
class DeltaSync {
DeltaSync(this._db, this._dio, this._readCursor, this._saveCursor);
final WalletDatabase _db;
final Dio _dio;
final Future<String?> Function() _readCursor;
final Future<void> Function(String) _saveCursor;
Future<void> pull() async {
var cursor = await _readCursor();
var hasMore = true;
while (hasMore) {
final res = await _dio.get<Map<String, dynamic>>(
'/sync/transactions', // endpoint hypothétique, hors contrat Wallet
queryParameters: {'since': ?cursor},
);
final body = res.data!;
final changed = (body['changed'] as List).cast<Map<String, dynamic>>();
final deleted = (body['deleted'] as List).cast<String>(); // tombstones
// Appliquer la page ET avancer le curseur dans la même transaction :
// un crash au milieu ne laisse jamais un curseur en avance sur les données.
await _db.transaction(() async {
await _db.transactionsDao.upsertPage([
for (final j in changed)
CachedTransactionsCompanion.insert(
id: j['id'] as String,
type: j['type'] as String,
amount: j['amount'] as int,
direction: j['direction'] as String,
counterparty: j['counterparty'] as String,
note: Value(j['note'] as String?),
status: j['status'] as String,
createdAt: DateTime.parse(j['createdAt'] as String),
syncedAt: DateTime.now().toUtc(),
),
]);
await _db.transactionsDao.deleteByIds(deleted);
cursor = body['nextCursor'] as String;
await _saveCursor(cursor!);
});
hasMore = body['hasMore'] as bool;
}
}
}
Point clé : appliquer la page et avancer le curseur dans la même transaction. Sinon, un crash entre les deux laisse un curseur en avance et des données manquantes, pour toujours.
Résolution de conflits
Un conflit survient quand la même donnée est modifiée à deux endroits entre deux synchronisations : l'agent modifie une fiche client hors ligne pendant que le back-office la corrige. Il n'y a pas de solution universelle, il y a des stratégies et un choix métier.
| Stratégie | Principe | Risque | Quand |
|---|---|---|---|
| Last-write-wins (LWW) | La dernière écriture (horodatage) écrase l'autre | Perte silencieuse de données ; horloges des appareils fausses | Données peu critiques, un seul éditeur par donnée |
| Versionnement optimiste | Chaque enregistrement a un numéro de version ; une écriture basée sur une version périmée est refusée (409 ou 412) | Il faut gérer le refus : recharger, fusionner ou demander | Le défaut raisonnable pour des données métier |
| Merge métier | Fusion champ par champ selon des règles (notes concaténées, statut : le plus avancé gagne) | Règles à définir avec le métier, à tester | Fiches à plusieurs éditeurs |
| Résolution par l'utilisateur | Afficher les deux versions et laisser choisir | Charge cognitive, à réserver aux cas rares | Conflit sur un champ important |
| Opérations commutatives / CRDT | Envoyer des opérations (« +1 article ») plutôt que des états | Complexité ; peu courant en entreprise | Compteurs, éditions collaboratives |
import 'package:dio/dio.dart';
/// Versionnement optimiste : chaque fiche porte un numéro de version.
/// Le serveur refuse (409) une écriture basée sur une version périmée.
sealed class SaveResult {
const SaveResult();
}
final class Saved extends SaveResult {
const Saved(this.newVersion);
final int newVersion;
}
final class Conflict extends SaveResult {
const Conflict(this.server);
final Map<String, dynamic> server; // état serveur actuel, pour fusionner
}
Future<SaveResult> saveVisit(Dio dio, String id, Map<String, dynamic> local, int baseVersion) async {
try {
final res = await dio.put<Map<String, dynamic>>(
'/visits/$id',
data: {...local, 'version': baseVersion},
options: Options(headers: {'If-Match': '"$baseVersion"'}),
);
return Saved(res.data!['version'] as int);
} on DioException catch (e) {
if (e.response?.statusCode == 409 || e.response?.statusCode == 412) {
return Conflict(e.response!.data as Map<String, dynamic>);
}
rethrow;
}
}
/// Fusion métier champ par champ : on garde les champs modifiés localement
/// si le serveur ne les a pas touchés ; sinon, la règle métier tranche.
Map<String, dynamic> mergeVisit({
required Map<String, dynamic> base, // version commune connue
required Map<String, dynamic> local,
required Map<String, dynamic> server,
}) {
final merged = {...server};
for (final key in local.keys) {
final localChanged = local[key] != base[key];
final serverChanged = server[key] != base[key];
if (localChanged && !serverChanged) merged[key] = local[key];
if (localChanged && serverChanged && key == 'notes') {
merged[key] = '${server[key]}\n---\n${local[key]}'; // règle métier : concaténer
}
// autres champs en conflit : le serveur gagne, l'utilisateur est prévenu
}
return merged;
}
Remarquez que la fusion a besoin de la version de base (l'état commun connu des deux côtés) : sans elle, on ne sait pas qui a modifié quoi, et on retombe sur du last-write-wins. Il faut donc stocker localement la dernière version serveur connue, pas seulement l'état modifié.
Pour des transactions financières, on évite le conflit par conception : une transaction est immuable, on ne la modifie pas, on en crée une nouvelle (annulation, remboursement). Les conflits ne concernent que des données éditables : fiches, brouillons, préférences.
« Je commence par demander au métier quelles données peuvent être modifiées à deux endroits. Les données financières sont immuables, donc sans conflit. Pour le reste, j'utilise un numéro de version : le serveur refuse une écriture basée sur une version périmée, et le client fusionne champ par champ à partir de la version de base, avec des règles métier écrites et testées. Le last-write-wins, je le réserve aux préférences, parce qu'il perd des données en silence. »
Travail en arrière-plan
Vider l'outbox quand l'app est fermée suppose une exécution en arrière-plan, et les deux plateformes la limitent fortement pour préserver la batterie.
- Android :
workmanagers'appuie sur WorkManager de Jetpack. Tâches ponctuelles ou périodiques (15 minutes minimum), contraintes (réseau disponible, batterie non faible), survie au redémarrage. Le système décale l'exécution (mode Doze, buckets de veille), sans garantie d'horaire. - iOS :
BGTaskScheduler. Le système décide quand lancer la tâche selon l'usage de l'app, la batterie et le réseau, pour une durée courte. Aucune exécution si l'utilisateur a forcé la fermeture de l'app. Identifiants à déclarer dansInfo.plist(BGTaskSchedulerPermittedIdentifiers).background_fetchetworkmanagerenveloppent cette API. - La tâche s'exécute dans un nouvel isolate : providers, base, Dio et clé de chiffrement doivent être recréés. Le point d'entrée porte
@pragma('vm:entry-point'), sinon le tree shaking du build release le supprime.
import 'package:workmanager/workmanager.dart';
const drainOutboxTask = 'drain-outbox';
@pragma('vm:entry-point') // indispensable : appelé depuis le natif
void callbackDispatcher() {
Workmanager().executeTask((task, inputData) async {
// Nouvel isolate : rien n'est initialisé, il faut tout recréer
// (clé, base, Dio) puis vider l'outbox.
return true; // false demande au système de replanifier
});
}
Future<void> scheduleOutboxSync() async {
await Workmanager().initialize(callbackDispatcher);
await Workmanager().registerPeriodicTask(
drainOutboxTask,
drainOutboxTask,
frequency: const Duration(minutes: 15), // minimum Android
constraints: Constraints(networkType: NetworkType.connected),
existingWorkPolicy: ExistingPeriodicWorkPolicy.keep,
);
}
Conclusion à dire clairement : l'arrière-plan est un bonus opportuniste, pas un mécanisme fiable. Les vrais points de synchronisation sont le démarrage de l'app, le retour au premier plan et le retour du réseau. Si l'envoi doit être garanti dans un délai, cela ne peut pas reposer sur le mobile seul.
Intégration avec Riverpod
Un stream drift s'expose naturellement par un StreamProvider. L'UI fait ref.watch et reçoit un AsyncValue : chargement initial, données, erreur. Chaque écriture en base, qu'elle vienne d'une synchronisation, de l'outbox ou d'un autre écran, met à jour tous les écrans abonnés sans code supplémentaire.
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:drift/drift.dart';
import 'wallet_database.dart';
/// Surchargé dans main() une fois la clé lue (ProviderScope.overrides).
final walletDatabaseProvider = Provider<WalletDatabase>(
(ref) => throw UnimplementedError('walletDatabaseProvider non surchargé'),
);
/// L'UI écoute la base, pas le réseau : hors ligne, elle affiche le cache.
final latestTransactionsProvider = StreamProvider<List<TransactionRow>>((ref) {
final db = ref.watch(walletDatabaseProvider);
return db.transactionsDao.watchLatest(limit: 5);
});
final pendingOutboxCountProvider = StreamProvider<int>((ref) {
final db = ref.watch(walletDatabaseProvider);
final count = countAll();
final query = db.selectOnly(db.outbox)..addColumns([count]);
return query.map((row) => row.read(count) ?? 0).watchSingle();
});
La base est créée dans main() une fois la clé lue, puis injectée par overrides. En test, on surcharge avec WalletDatabase(NativeDatabase.memory()) : base en mémoire, rapide, isolée à chaque test. Les providers mis en pause par Riverpod 3 quand l'écran n'est plus visible suspendent aussi leur abonnement au stream.
Riverpod 3 introduit une offline persistence expérimentale (package:riverpod/experimental/persist.dart) : un Notifier peut persister son état dans un stockage, avec une implémentation riverpod_sqflite. C'est utile pour restaurer rapidement un état d'écran, mais l'API est marquée expérimentale : pour des données métier synchronisées, une base drift explicite reste le choix à défendre en entretien.
Cas pratique : application terrain multi-tenant hors ligne
Énoncé typique d'entretien d'architecture : « Une app pour des agents commerciaux d'un opérateur télécom, plusieurs distributeurs (tenants), visites clients en zone rurale sans réseau pendant des journées entières, données clients sensibles. Concevez la persistance. »
- Isolation des tenants : une base drift par couple tenant + utilisateur (fichier nommé d'après un identifiant non devinable), plutôt qu'une colonne
tenantIdpartout. Le changement de compte ferme une base et en ouvre une autre ; aucune requête oubliée ne peut mélanger deux distributeurs. Effacement simple au départ d'un agent. - Chiffrement : SQLite3MultipleCiphers, une clé aléatoire par base, stockée dans flutter_secure_storage sous un nom dérivé de la base. Optionnellement, clé liée à la biométrie pour l'ouverture le matin.
- Synchronisation descendante : delta sync par curseur serveur avec tombstones, par périmètre (le portefeuille clients de l'agent, pas tout le référentiel). Synchronisation complète initiale au Wi-Fi.
- Écritures : outbox ordonnée, une
Idempotency-Keypar opération, photos et signatures en fichiers chiffrés référencés par l'outbox, envoyées en multipart après les données. - Conflits : numéro de version sur les fiches client, fusion champ par champ, notes de visite en ajout seul (jamais de conflit), conflits restants listés dans un écran dédié.
- Expiration : au-delà de N jours sans synchronisation, l'app exige une connexion (refresh token expiré, révocation d'un agent parti), en préservant l'outbox non envoyée.
- Observabilité : nombre d'opérations en attente visible par l'agent, journal de synchronisation technique sans données personnelles, remontée des échecs définitifs au back-office.
« Et si l'agent désinstalle l'app avec 200 visites non synchronisées ? » Il n'y a pas de magie : elles sont perdues, puisque la clé de la base disparaît avec le Keystore. La réponse attendue porte sur la prévention : compteur d'opérations en attente toujours visible, avertissement avant déconnexion, synchronisation opportuniste dès qu'un réseau apparaît, politique MDM qui empêche la désinstallation sur les appareils de flotte.
Questions d'entretien
Comment gérez-vous un conflit entre une modification hors ligne et une modification serveur ?
D'abord, éviter les conflits par conception (données financières immuables, notes en ajout seul). Ensuite, versionnement optimiste : le client envoie la version de base, le serveur refuse si elle est périmée. Le client fusionne champ par champ à partir de la version de base selon des règles métier, et ne demande l'arbitrage de l'utilisateur que pour les cas restants. Last-write-wins seulement pour des données sans enjeu.
Comment garantir qu'un paiement hors ligne n'est pas débité deux fois ?
Outbox persistée et Idempotency-Key générée à la création de l'intention, stockée avec l'opération et renvoyée identique à chaque tentative. Le serveur mémorise la clé et le résultat ; un rejeu renvoie la même transaction sans débiter. Le client ne peut pas savoir si une requête en timeout a été traitée : seule la clé rend le rejeu sûr.
Pourquoi drift plutôt que Hive ou Isar ?
Données relationnelles et requêtables, typage généré, streams réactifs, migrations outillées et testables, chiffrement via SQLite3MultipleCiphers ou SQLCipher, et pérennité : SQLite est un standard et drift est activement maintenu, alors que les packages originaux de Hive et Isar ne publient plus et reposent sur des forks communautaires.
connectivity_plus indique Wi-Fi. Avez-vous Internet ?
Pas forcément : portail captif, réseau saturé, VPN filtrant, gateway en panne. L'indicateur sert à déclencher une tentative de synchronisation. La seule preuve est une requête réussie vers votre API.
Qu'est-ce que le stale-while-revalidate et comment l'implémentez-vous ?
Afficher immédiatement la donnée en cache, même périmée, et la rafraîchir en arrière-plan. Avec drift : l'UI observe un stream watch(), le repository lance un rafraîchissement et fait un upsert, le stream réémet. On affiche la date de dernière synchronisation quand la fraîcheur compte.
Comment testez-vous une migration de schéma ?
Avec drift_dev make-migrations : instantanés de schéma par version, helpers stepByStep et tests générés qui ouvrent une base à l'ancien schéma, migrent et comparent au schéma attendu. J'ajoute un test d'intégrité des données (lignes insérées en v1 toujours correctes après migration), exécuté en CI.
Comment chiffrez-vous une base drift aujourd'hui ?
sqlite3 3.x avec hooks: user_defines: sqlite3: source: sqlite3mc (ou sqlcipher) dans le pubspec, PRAGMA key dans le callback setup de l'ouverture, une vérification PRAGMA cipher en debug, et une clé aléatoire générée sur l'appareil, stockée dans flutter_secure_storage. Les anciens packages *_flutter_libs sont en fin de vie.
Comment gérez-vous les suppressions dans une synchronisation incrémentale ?
Avec des tombstones : le serveur garde une trace des suppressions et les renvoie dans le delta, le client supprime localement. Au-delà de la durée de rétention des tombstones, le client refait une synchronisation complète. Le curseur avance dans la même transaction que l'application des changements.
Peut-on compter sur l'exécution en arrière-plan pour synchroniser ?
Non. WorkManager diffère les tâches (Doze, 15 minutes minimum en périodique), iOS décide seul du moment et n'exécute rien après une fermeture forcée. C'est un bonus ; les synchronisations fiables ont lieu au démarrage, au retour au premier plan et au retour du réseau.
Où placez-vous la séparation entre tenants dans une app multi-tenant hors ligne ?
Une base par tenant et utilisateur, chacune avec sa clé. L'isolation est physique : pas de risque qu'une requête sans filtre tenantId mélange des données, et l'effacement d'un compte revient à supprimer un fichier et une clé.
Exercices
Écrivez de mémoire la base drift du projet : table de cache des transactions, table outbox, DAO avec watchLatest et upsertPage, migration v1 vers v2 qui ajoute l'outbox. Critères : build_runner passe, un test en mémoire vérifie que watchLatest réémet après un upsert.
Écrivez OutboxProcessor.drain et trois tests avec un adapter Dio factice : succès (l'entrée disparaît), 503 (l'entrée reste, la suivante n'est pas tentée), 422 (l'entrée passe en échec, la suivante est envoyée). Vérifiez que l'en-tête Idempotency-Key est identique entre deux tentatives.
Présentez en cinq minutes, sans support, la conception de persistance du cas pratique multi-tenant : stockage, chiffrement, synchronisation, conflits, limites. Enregistrez-vous et vérifiez que vous avez cité au moins une limite par choix.
Dans l'app Wallet (voir le projet) :
- Cache drift de l'historique : table
CachedTransactions(montants en centimes, type brut de l'API), DAO, base chiffrée avecsqlite3mcet clé dans flutter_secure_storage. L'accueil et l'historique lisent la base via desStreamProvider; le repository rafraîchit en stale-while-revalidate ; date de dernière synchronisation affichée. - Outbox des transferts : en cas d'échec réseau, le transfert est enregistré avec son
Idempotency-Keyet affiché « en attente d'envoi » ; vidage au retour du réseau (connectivity_pluscomme déclencheur), au démarrage et au retour au premier plan ;INSUFFICIENT_FUNDStraité comme échec définitif avec notification. - Déconnexion : suppression de la base et de sa clé (chapitre 10).
- Validation : mode avion, faire deux transferts, tuer l'app, la relancer, réactiver le réseau : deux transactions créées, dans l'ordre, et un seul débit chacune. Avec
X-Chaos-Fail-Rate: 0.3, rejouer plusieurs fois le vidage : aucun doublon côté backend. - Migration : passer le schéma en v2 (ajout d'une colonne), générer les tests avec
make-migrationset les faire passer en CI.
Quiz
12 questions. Corrigez, relisez les explications, refaites-le 48 h plus tard.
Pour aller plus loin
- drift sur pub.dev et drift_flutter
- sqlite3 (hooks, SQLite3MultipleCiphers, SQLCipher)
- Offline-first (guide d'architecture Flutter)
- Persistance SQLite (cookbook Flutter)
- shared_preferences (SharedPreferencesAsync, SharedPreferencesWithCache)
- connectivity_plus
- workmanager
- WorkManager (Android)
- BackgroundTasks (Apple)
- Riverpod (offline persistence expérimentale)