Aller au contenu principal

Offline et Persistance

Pourquoi la persistance ?

  • Résilience réseau : mode avion, zones à faible débit, offline-first
  • Expérience fluide : cache, préchargement, restauration d'état
  • Performance : éviter les requêtes API répétées
  • Fidélité utilisateur : app responsive même sans internet

Cache léger (clé/valeur) avec SharedPreferences

Installation et utilisation basique

main.dart
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';

void main() {
runApp(const MyApp());
}

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


Widget build(BuildContext context) {
return MaterialApp(
title: 'SharedPreferences Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const PreferencesDemo(),
);
}
}

class PreferencesDemo extends StatefulWidget {
const PreferencesDemo({super.key});


State<PreferencesDemo> createState() => _PreferencesDemoState();
}

class _PreferencesDemoState extends State<PreferencesDemo> {
String _theme = 'light';
int _version = 0;
bool _firstLaunch = true;


void initState() {
super.initState();
_loadPreferences();
}

Future<void> _loadPreferences() async {
final prefs = await SharedPreferences.getInstance();
setState(() {
_theme = prefs.getString('theme') ?? 'light';
_version = prefs.getInt('version') ?? 0;
_firstLaunch = prefs.getBool('firstLaunch') ?? true;
});
}

Future<void> _savePreferences() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('theme', _theme);
await prefs.setInt('version', _version);
await prefs.setBool('firstLaunch', _firstLaunch);
}


Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Démonstration SharedPreferences'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Thème: $_theme'),
Text('Version: $_version'),
Text('Premier lancement: $_firstLaunch'),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () async {
setState(() {
_theme = _theme == 'light' ? 'dark' : 'light';
});
await _savePreferences();
},
child: const Text('Changer le thème'),
),
],
),
),
);
}
}

Cas d'usage

  • Préférences utilisateur (thème, langue, taille police)
  • Flags (first launch, tutorials shown)
  • Cache simple (last sync time, counters)
  • Données non sensibles

Limitations

  • Max ~5 MB par app
  • Clé/valeur seulement (pas de structure)
  • Pas de chiffrement (données en clair)

Stockage sécurisé avec flutter_secure_storage

Installation et utilisation

main.dart
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

void main() {
runApp(const MyApp());
}

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


Widget build(BuildContext context) {
return MaterialApp(
title: 'Secure Storage Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
useMaterial3: true,
),
home: const SecureStorageDemo(),
);
}
}

class SecureStorageDemo extends StatefulWidget {
const SecureStorageDemo({super.key});


State<SecureStorageDemo> createState() => _SecureStorageDemoState();
}

class _SecureStorageDemoState extends State<SecureStorageDemo> {
final storage = const FlutterSecureStorage();
String _token = '';
final _tokenController = TextEditingController();

Future<void> _saveToken() async {
await storage.write(
key: 'auth_token',
value: _tokenController.text,
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Token sauvegardé de manière sécurisée')),
);
}

Future<void> _loadToken() async {
final token = await storage.read(key: 'auth_token');
setState(() {
_token = token ?? 'Aucun token trouvé';
});
}

Future<void> _deleteToken() async {
await storage.delete(key: 'auth_token');
setState(() {
_token = '';
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Token supprimé')),
);
}


Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Stockage Sécurisé'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _tokenController,
decoration: const InputDecoration(
labelText: 'Token d\'authentification',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _saveToken,
child: const Text('Sauvegarder le token'),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _loadToken,
child: const Text('Charger le token'),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _deleteToken,
child: const Text('Supprimer le token'),
),
const SizedBox(height: 20),
Text(
'Token chargé: $_token',
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
),
);
}
}

Cas d'usage

  • Tokens d'authentification (JWT, OAuth)
  • Mots de passe
  • Clés API sensibles
  • Données personnelles (email, téléphone)

Avantages

  • Chiffrement automatique (AES)
  • Stockage sécurisé du système (Keychain iOS, Keystore Android)
  • Données inaccessibles même si device rooté/jailbreaké

Données structurées avec sqflite (SQLite)

Setup basique

database_helper.dart
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';

class DatabaseHelper {
static final DatabaseHelper _instance = DatabaseHelper._internal();
static Database? _database;

factory DatabaseHelper() {
return _instance;
}

DatabaseHelper._internal();

Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDatabase();
return _database!;
}

Future<Database> _initDatabase() async {
final dbPath = await getDatabasesPath();
final path = join(dbPath, 'app_database.db');

return await openDatabase(
path,
version: 1,
onCreate: (db, version) {
db.execute('''
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT,
email TEXT,
createdAt TEXT
)
''');
},
);
}
}
main.dart
import 'package:flutter/material.dart';
import 'database_helper.dart';

void main() {
runApp(const MyApp());
}

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


Widget build(BuildContext context) {
return MaterialApp(
title: 'SQLite Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
home: const UserListPage(),
);
}
}

class UserListPage extends StatefulWidget {
const UserListPage({super.key});


State<UserListPage> createState() => _UserListPageState();
}

class _UserListPageState extends State<UserListPage> {
final DatabaseHelper _dbHelper = DatabaseHelper();
List<Map<String, dynamic>> _users = [];
final _nameController = TextEditingController();
final _emailController = TextEditingController();


void initState() {
super.initState();
_loadUsers();
}

Future<void> _loadUsers() async {
final users = await _dbHelper.getUsers();
setState(() {
_users = users;
});
}

Future<void> _addUser() async {
if (_nameController.text.isNotEmpty && _emailController.text.isNotEmpty) {
await _dbHelper.insertUser({
'name': _nameController.text,
'email': _emailController.text,
'createdAt': DateTime.now().toIso8601String(),
});
_nameController.clear();
_emailController.clear();
await _loadUsers();
}
}

Future<void> _deleteUser(int id) async {
await _dbHelper.deleteUser(id);
await _loadUsers();
}


Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Gestion des utilisateurs (SQLite)'),
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Nom',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 8),
TextField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _addUser,
child: const Text('Ajouter un utilisateur'),
),
],
),
),
Expanded(
child: ListView.builder(
itemCount: _users.length,
itemBuilder: (context, index) {
final user = _users[index];
return ListTile(
title: Text(user['name']),
subtitle: Text(user['email']),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () => _deleteUser(user['id']),
),
);
},
),
),
],
),
);
}


void dispose() {
_nameController.dispose();
_emailController.dispose();
super.dispose();
}
}

CRUD operations

// Create
Future<int> insertUser(Map<String, dynamic> user) async {
final db = await database;
return await db.insert('users', user);
}

// Read
Future<List<Map>> getUsers() async {
final db = await database;
return await db.query('users');
}

// Update
Future<int> updateUser(int id, Map<String, dynamic> user) async {
final db = await database;
return await db.update('users', user, where: 'id = ?', whereArgs: [id]);
}

// Delete
Future<int> deleteUser(int id) async {
final db = await database;
return await db.delete('users', where: 'id = ?', whereArgs: [id]);
}

Migrations et versionning

// Augmenter version lors de changements de schéma
Future<Database> _initDatabase() async {
return await openDatabase(
path,
version: 2, // Nouvelle version
onUpgrade: (db, oldVersion, newVersion) {
if (oldVersion < 2) {
db.execute('ALTER TABLE users ADD COLUMN phone TEXT');
}
},
);
}

Stratégies offline

0. Détection de connectivité

Avant d'implémenter les stratégies offline, il faut pouvoir détecter l'état du réseau.

main.dart
import 'package:flutter/material.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'dart:async';

void main() {
runApp(const MyApp());
}

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


Widget build(BuildContext context) {
return MaterialApp(
title: 'Connectivity Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.cyan),
useMaterial3: true,
),
home: const ConnectivityDemo(),
);
}
}

class ConnectivityDemo extends StatefulWidget {
const ConnectivityDemo({super.key});


State<ConnectivityDemo> createState() => _ConnectivityDemoState();
}

class _ConnectivityDemoState extends State<ConnectivityDemo> {
final Connectivity _connectivity = Connectivity();
late StreamSubscription<ConnectivityResult> _connectivitySubscription;
String _connectionStatus = 'Vérification...';
bool _isOnline = false;


void initState() {
super.initState();
_checkConnectivity();
_connectivitySubscription = _connectivity.onConnectivityChanged.listen(_updateConnectionStatus);
}

Future<void> _checkConnectivity() async {
final result = await _connectivity.checkConnectivity();
_updateConnectionStatus(result);
}

void _updateConnectionStatus(ConnectivityResult result) {
setState(() {
if (result == ConnectivityResult.none) {
_connectionStatus = 'OFFLINE - Aucune connexion';
_isOnline = false;
} else if (result == ConnectivityResult.mobile) {
_connectionStatus = 'ONLINE - Données mobiles';
_isOnline = true;
} else if (result == ConnectivityResult.wifi) {
_connectionStatus = 'ONLINE - WiFi';
_isOnline = true;
} else {
_connectionStatus = 'ONLINE - ${result.name}';
_isOnline = true;
}
});
}


void dispose() {
_connectivitySubscription.cancel();
super.dispose();
}


Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Détection de connectivité'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
_isOnline ? Icons.wifi : Icons.wifi_off,
size: 100,
color: _isOnline ? Colors.green : Colors.red,
),
const SizedBox(height: 20),
Text(
_connectionStatus,
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 40),
ElevatedButton(
onPressed: _checkConnectivity,
child: const Text('Vérifier la connexion'),
),
],
),
),
);
}
}

1. Cache puis réseau (Cache-first)

Principe : Afficher immédiatement les données du cache local, puis rafraîchir en arrière-plan si connecté.

Comment ça marche :

  1. L'app cherche les données en cache d'abord
  2. Si trouvées, elle les affiche tout de suite (expérience fluide)
  3. Parallèlement, elle télécharge les nouvelles données depuis l'API
  4. Une fois reçues, elle met à jour le cache et affiche les données fraîches

Cas d'usage :

  • Applications de news, blogs, réseaux sociaux (données peuvent être légèrement obsolètes)
  • Catalogues de produits (mise à jour moins critique)
  • Listes de favoris, commentaires
  • Tout contexte où la réactivité prime sur la fraîcheur

Avantages :

  • Excellente UX : interface disponible immédiatement
  • Fonctionne parfaitement offline
  • Économe en bande passante (rafraîchir seulement si nécessaire)

Inconvénients :

  • Les utilisateurs voient d'abord les données obsolètes
  • Besoin d'un système de versioning pour savoir si le cache est périmé
Future<List<Product>> getProducts({bool forceRefresh = false}) async {
// Afficher le cache immédiatement
final cached = await _localStore.getProducts();
if (cached.isNotEmpty && !forceRefresh) {
unawaited(_refreshInBackground()); // Rafraîchir en arrière-plan
return cached;
}

// Sinon, fetch depuis l'API
final fresh = await _api.fetchProducts();
await _localStore.saveProducts(fresh);
return fresh;
}

Future<void> _refreshInBackground() async {
try {
final fresh = await _api.fetchProducts();
await _localStore.saveProducts(fresh);
} catch (e) {
print('Refresh failed: $e');
}
}

2. Queue des actions (Action queuing)

Principe : Mettre en attente les actions utilisateur (création, modification) quand offline, puis les exécuter dès que la connexion revient.

Comment ça marche :

  1. L'utilisateur effectue une action (créer un commentaire, aimer un post)
  2. L'app teste si elle est connectée
  3. Si oui, elle envoie l'action directement au serveur
  4. Si non, elle l'ajoute à une file d'attente persistée (base de données)
  5. Dès que la connexion revient, elle traite la file d'attente
  6. Si l'app crash, au redémarrage les actions non complétées sont relancées

Important : Les actions doivent être persistées (sauvegardées en base de données), pas juste en mémoire. Sinon, si l'app ferme avant la synchro, les actions sont perdues.

Cas d'usage :

  • Applications avec beaucoup de modifications utilisateur (todos, notes, messages)
  • Réseaux sociaux (posts, commentaires, likes)
  • Applications collaboratives
  • Tout contexte où les modifications doivent être synchronisées

Avantages :

  • L'app fonctionne normalement même offline
  • Aucune donnée n'est perdue (même si crash)
  • Transparence : utilisateur peut continuer à travailler
  • Récupération automatique au redémarrage

Inconvénients :

  • Complexité : gérer les conflits et les erreurs de sync
  • Stockage de la file d'attente (peut grandir rapidement)
  • Peut créer beaucoup d'actions en attente
class PendingAction {
final int id;
final String type; // 'create', 'update', 'delete'
final Map<String, dynamic> data;
final DateTime createdAt;

PendingAction({
required this.id,
required this.type,
required this.data,
required this.createdAt,
});

// Convertir en JSON pour la base de données
Map<String, dynamic> toJson() => {
'id': id,
'type': type,
'data': jsonEncode(data),
'createdAt': createdAt.toIso8601String(),
};
}

class ActionQueue {
final DatabaseHelper _db = DatabaseHelper();
final Connectivity _connectivity = Connectivity();
late StreamSubscription<ConnectivityResult> _connectivitySubscription;

ActionQueue() {
_connectivitySubscription = _connectivity.onConnectivityChanged.listen((result) {
if (result != ConnectivityResult.none) {
_processQueue();
}
});
}

// Ajouter une action à la file d'attente persistée
Future<void> queueAction(PendingAction action) async {
// 1. Sauvegarder localement d'abord
await _db.savePendingAction(action);

// 2. Essayer de synchroniser immédiatement si connecté
final result = await _connectivity.checkConnectivity();
if (result != ConnectivityResult.none) {
await _processQueue();
}
}

// Traiter toutes les actions en attente
Future<void> _processQueue() async {
final actions = await _db.getPendingActions();

for (final action in actions) {
try {
// Exécuter l'action sur le serveur
await _executeAction(action);

// Si succès, supprimer de la file d'attente
await _db.deletePendingAction(action.id);
} catch (e) {
// Si erreur, laisser en attente pour prochaine tentative
print('Failed to sync action ${action.id}: $e');
}
}
}

Future<void> _executeAction(PendingAction action) async {
switch (action.type) {
case 'create':
await _api.createItem(action.data);
break;
case 'update':
await _api.updateItem(action.data);
break;
case 'delete':
await _api.deleteItem(action.data['id']);
break;
}
}

void dispose() {
_connectivitySubscription.cancel();
}
}

Au démarrage de l'app :


void initState() {
super.initState();

// Relancer les actions non complétées depuis le dernier redémarrage
_actionQueue.processQueue();
}

Schéma de base de données :

CREATE TABLE pending_actions (
id INTEGER PRIMARY KEY,
type TEXT NOT NULL, -- 'create', 'update', 'delete'
data TEXT NOT NULL, -- JSON stringifiée
createdAt TEXT NOT NULL,
status TEXT DEFAULT 'pending' -- 'pending', 'syncing'
);

3. Sync automatique

Principe : Synchroniser régulièrement les données entre l'app et le serveur (dans les deux sens).

Comment ça marche :

  1. À intervalles réguliers (toutes les 5 min par exemple)
  2. L'app vérifie la connectivité
  3. Si connectée, elle :
    • Envoie les modifications locales au serveur
    • Télécharge les modifications du serveur
    • Résout les conflits potentiels
  4. Continue à fonctionner pendant que la sync se fait en arrière-plan

Cas d'usage :

  • Applications avec données partagées (équipes, documents collaboratifs)
  • Calendriers, contacts partagés
  • Bases de données distribuées
  • Services où la cohérence est importante

Avantages :

  • Tous les appareils restent synchronisés
  • Fonctionne automatiquement, l'utilisateur ne doit rien faire
  • Idéal pour données changeantes fréquemment

Inconvénients :

  • Consommation batterie et data (sync régulière)
  • Complexité de gestion des conflits
  • Lag avant que les changements se propagent
class SyncManager {
final Duration syncInterval = const Duration(minutes: 5);

void startAutoSync() {
Timer.periodic(syncInterval, (_) async {
final connectivity = Connectivity();
final result = await connectivity.checkConnectivity();

if (result != ConnectivityResult.none) {
await syncData();
}
});
}

Future<void> syncData() async {
// Télécharger les nouvelles données
// Uploader les données locales
// Résoudre les conflits
}
}

Comparaison des stratégies

StratégieRéactivitéFraîcheurComplexitéCas d'usage
Cache-firstTrès rapideFaibleBasseLecture seule, news
Action queuingModéréeTrès bonneHauteModifications utilisateur
Sync autoModéréeTrès bonneHauteDonnées partagées

Gestion des conflits

Stratégies simples

// 1. Client wins (données locales prioritaires)
Future<void> syncData() async {
final local = await _localStore.getData();
final remote = await _api.getData();

if (local.version > remote.version) {
await _api.updateData(local); // Upload local
} else {
await _localStore.saveData(remote); // Use remote
}
}

// 2. Server wins (données serveur prioritaires)
Future<void> syncData() async {
final remote = await _api.getData();
await _localStore.saveData(remote);
}

// 3. Merge (fusion intelligente)
Future<void> syncData() async {
final local = await _localStore.getData();
final remote = await _api.getData();
final merged = _mergeData(local, remote);

await _localStore.saveData(merged);
await _api.updateData(merged);
}

Bonnes pratiques

  1. Toujours chiffrer les données sensibles (secure storage, pas SharedPreferences)
  2. Implémenter une versioning des données pour migrations propres
  3. Nettoyer les anciens caches régulièrement
  4. Tester les scénarios offline (désactiver WiFi, mode avion)
  5. Utiliser des indices de base de données pour performances
  6. Monitorer la taille du cache pour ne pas remplir le device
  7. Implémenter un système de retry avec exponential backoff
  8. Afficher clairement l'état de sync à l'utilisateur (syncing, synced, error)