Réseau adaptatif
Qora fournit un comportement adaptatif au réseau pour les requêtes et les mutations. Quand la connectivité est perdue, Qora peut suspendre les fetchs, mettre en file les mutations et rejouer les opérations après la reconnexion.
Vue d'ensemble
La conscience du réseau dans Qora repose sur trois primitives orthogonales :
| Primitive | Ce qu'elle contrôle |
|---|---|
NetworkMode | Comment une requête ou mutation se comporte quand hors-ligne |
FetchStatus | Si un fetch est activement en cours, en pause ou inactif |
OfflineMutationQueue | File FIFO qui rejoue les écritures en file à la reconnexion |
Elles sont entièrement indépendantes. NetworkMode.online (pause des requêtes) peut être utilisé sans activer la file de mutations hors-ligne, ou la file peut être activée sur les mutations pendant que les requêtes utilisent NetworkMode.always.
NetworkMode
NetworkMode est un enum par requête / par mutation qui indique à Qora que faire quand l'appareil n'a pas de connectivité :
enum NetworkMode {
/// Par défaut. Suspend le fetch hors-ligne et le rejoue à la reconnexion.
/// L'état devient Loading(previousData: <donnée en cache ou null>) et
/// FetchStatus passe à paused jusqu'au retour de la connectivité.
online,
/// Toujours exécuter quelle que soit la connectivité.
/// Utile quand le fetcher utilise une base de données locale ou un cache.
always,
/// Servir les données en cache immédiatement, puis planifier une revalidation
/// en arrière-plan quand l'appareil est de nouveau en ligne.
offlineFirst,
}
Définir networkMode par requête
final user = await client.fetchQuery<User>(
key: ['users', userId],
fetcher: () => api.getUser(userId),
options: const QoraOptions(networkMode: NetworkMode.online), // défaut
);
Définir networkMode globalement
final client = QoraClient(
config: const QoraClientConfig(
defaultOptions: QoraOptions(
networkMode: NetworkMode.online,
),
),
);
Comportement par mode
| Mode | Comportement hors-ligne | À la reconnexion |
|---|---|---|
online (défaut) | Fetch suspendu, FetchStatus.paused émis | Rejoué automatiquement |
always | Fetch s'exécute : échoue sans connectivité | Pas de retry à la reconnexion |
offlineFirst | Données en cache retournées immédiatement | Revalidation en arrière-plan mise en file |
NetworkMode.always pour les requêtes soutenues par une base de données SQLite locale ou une box Hive : elles n'ont jamais besoin de connectivité et ne devraient jamais être suspendues.FetchStatus : le second axe
QoraState<T> décrit ce que vous avez (initial / loading / success / failure). FetchStatus décrit ce que Qora est en train de faire pour l'obtenir :
enum FetchStatus {
/// Une requête réseau est en cours.
fetching,
/// En file pour le fetch mais hors-ligne : reprendra à la reconnexion.
paused,
/// Pas de fetch actif (les données sont fraîches ou aucun fetch n'a démarré).
idle,
}
Ces deux valeurs forment une grille bidimensionnelle. Les combinaisons les plus utiles sont :
QoraState | FetchStatus | Signification |
|---|---|---|
Loading | fetching | Premier fetch en cours |
Loading (sans previousData) | paused | Hors-ligne, rien en cache : le fetch attend le réseau |
Loading (avec previousData) | paused | Hors-ligne, mode online : refetch en attente, donnée en cache toujours disponible |
Success | fetching | Revalidation en arrière-plan en cours |
Success | paused | Hors-ligne, mode offlineFirst : donnée en cache servie, refetch en file |
Success | idle | Données fraîches : rien en cours |
Failure | idle | Dernier fetch échoué, pas de retry |
Observer FetchStatus en Dart
client.watchFetchStatus(['users', userId]).listen((status) {
switch (status) {
case FetchStatus.fetching:
print('Récupération…');
case FetchStatus.paused:
print('Hors-ligne : réessaiera en ligne');
case FetchStatus.idle:
print('Terminé');
}
});
Observer FetchStatus dans Flutter
QoraBuilder reçoit FetchStatus comme troisième argument du builder :
QoraBuilder<User>(
queryKey: ['users', userId],
fetcher: () => api.getUser(userId),
builder: (context, state, fetchStatus) {
// Afficher un chip "en pause" quand hors-ligne et en attente de fetch
final isPaused = fetchStatus == FetchStatus.paused;
return switch (state) {
Initial() => const SizedBox.shrink(),
Loading() => isPaused
? const OfflineWaitingIndicator()
: const CircularProgressIndicator(),
Success(:final data) => Column(
children: [
UserCard(data),
if (isPaused) const RefreshingOfflineChip(),
],
),
Failure(:final error) => ErrorBanner(error),
};
},
)
File de mutations hors-ligne
L'OfflineMutationQueue maintient les opérations d'écriture qui n'ont pas pu être envoyées hors-ligne et les rejoue dans l'ordre FIFO quand l'appareil se reconnecte.
Activer la file
La file est automatiquement créée et attachée à QoraClient. Dans Flutter, QoraMutationBuilder l'injecte automatiquement. Il suffit d'opter par mutation via MutationOptions :
QoraMutationBuilder<void, Todo, void>(
mutator: (todo) => api.createTodo(todo),
options: MutationOptions(
// Opter cette mutation dans la file hors-ligne
offlineQueue: true,
// Fournir une réponse optimiste pour que l'UI se mette à jour immédiatement
optimisticResponse: (todo) => todo,
),
builder: (context, state, mutate) {
return AddTodoButton(
onTap: () => mutate(newTodo),
);
},
)
Flux hors-ligne : étape par étape
Quand offlineQueue: true et que l'appareil est hors-ligne :
mutate(variables)
│
├── offlineQueue: true & hors-ligne ?
│ OUI
│ ├── Mettre en file PendingMutation (FIFO)
│ ├── optimisticResponse fourni ?
│ │ OUI → émettre MutationSuccess(isOptimistic: true)
│ │ NON → rester MutationPending
│ └── ← retourner à l'appelant
│
└── NON (en ligne) → exécuter normalement → MutationSuccess(isOptimistic: false)
À la reconnexion, QoraClient rejoue la file :
reconnexion détectée
│
├── Rejouer les requêtes en pause (par lots, avec jitter)
└── Rejouer OfflineMutationQueue (FIFO)
│
├── chaque mutation s'exécute une fois, dans l'ordre
├── succès → onSuccess, invalidates, onSettled
└── échec → onError (rollback), onSettled, mutation suivante
isOptimistic : styliser les mutations non confirmées
Quand optimisticResponse est fourni, la mutation émet MutationSuccess(isOptimistic: true). Utiliser ce flag dans l'UI pour afficher un état visuel "en attente" (grisé, icône d'horloge, barré) pour les éléments qui n'ont pas encore été confirmés par le serveur :
QoraMutationBuilder<void, Todo, void>(
mutator: api.createTodo,
options: MutationOptions(
offlineQueue: true,
optimisticResponse: (todo) => todo,
onSuccess: (_, todo, __) async {
// S'exécute pour un succès RÉEL (isOptimistic: false) uniquement après confirmation serveur
context.qora.invalidate(['todos']);
},
),
builder: (context, state, mutate) {
// Vérifier isOptimistic sur l'état MutationSuccess
final isOptimistic = switch (state) {
MutationSuccess(:final isOptimistic) => isOptimistic,
_ => false,
};
return Column(
children: [
if (isOptimistic)
const Row(
children: [
Icon(Icons.schedule, size: 14),
SizedBox(width: 4),
Text('En file : synchronisation quand en ligne'),
],
),
SubmitButton(
onTap: state.isPending ? null : () => mutate(newTodo),
),
],
);
},
)
Accéder directement à la file
// Combien de mutations attendent la relecture ?
final count = context.qora.offlineMutationQueue.length;
// Inspecter les mutations en attente
for (final pending in context.qora.offlineMutationQueue.snapshot) {
print('${pending.mutatorId} mis en file à ${pending.enqueuedAt}');
}
// Déclencher manuellement une relecture (normalement automatique à la reconnexion)
final result = await context.qora.offlineMutationQueue.replay();
print('Succès : ${result.succeeded}, Échecs : ${result.failed}');
Quand une mutation rejouée échoue
Chaque mutation en file est envoyée une fois, dans l'ordre, par le même chemin qu'un appel en ligne. Quand le serveur en rejette une, onError reçoit le contexte renvoyé par onMutate au moment de la mise en file : le rollback écrit pour le cas en ligne couvre donc aussi le cas hors-ligne. Le rejeu continue ensuite avec la mutation suivante.
Un rejeu en échec n'est pas remis en file. Signalez-le depuis onError (un snackbar, un bouton réessayer) plutôt que de compter sur la prochaine reconnexion.
Gardez les mutations en file indépendantes quand c'est possible. Quand l'une dépend d'une autre (créer un todo, puis le cocher), la seconde échoue si la première a été rejetée, et son propre onError l'annule.
ReconnectStrategy : prévention du thundering herd
Quand la connectivité revient et que des dizaines de requêtes sont suspendues, les rejouer simultanément inonde le serveur. ReconnectStrategy introduit une concurrence contrôlée et du jitter :
final client = QoraClient(
config: QoraClientConfig(
reconnectStrategy: const ReconnectStrategy(
maxConcurrent: 5, // Rejouer 5 requêtes à la fois
jitter: Duration(milliseconds: 200), // Délai aléatoire entre les lots
),
),
);
Constructeurs nommés
// Défaut : 5 concurrent, 100 ms jitter
const ReconnectStrategy()
// Toutes les requêtes rejouées simultanément (utile pour les tests)
ReconnectStrategy.instant()
// Conservateur : 2 concurrent, 500 ms jitter
ReconnectStrategy.conservative()
Configuration Flutter
1. Installer connectivity_plus
dependencies:
connectivity_plus: ^6.0.0
2. Câbler QoraScope
import 'package:flutter/foundation.dart';
import 'package:qora_flutter/qora_flutter.dart';
void main() {
final client = QoraClient(
config: const QoraClientConfig(
defaultOptions: QoraOptions(
staleTime: Duration(minutes: 5),
networkMode: NetworkMode.online,
),
reconnectStrategy: ReconnectStrategy(
maxConcurrent: 5,
jitter: Duration(milliseconds: 100),
),
debugMode: kDebugMode,
),
);
runApp(
QoraScope(
client: client,
lifecycleManager: FlutterLifecycleManager(qoraClient: client),
// Pas de référence QoraClient requise : fournisseur de signal pur
connectivityManager: FlutterConnectivityManager(),
child: const MyApp(),
),
);
}
3. Ajouter NetworkStatusIndicator
Encapsuler l'application (ou tout sous-arbre) avec NetworkStatusIndicator pour afficher une bannière hors-ligne automatiquement :
QoraScope(
client: client,
connectivityManager: FlutterConnectivityManager(),
child: NetworkStatusIndicator(
child: const MyApp(),
),
)
Affiche une fine barre sombre en bas de l'écran indiquant "Mode hors-ligne" quand NetworkStatus.offline est détecté.
Bannière hors-ligne personnalisée
NetworkStatusIndicator(
offlineBanner: (context) => Container(
color: Colors.orange,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.wifi_off, color: Colors.white, size: 16),
SizedBox(width: 8),
Text('Pas de connexion', style: TextStyle(color: Colors.white)),
],
),
),
child: const MyApp(),
)
Contrôle total via builder
Utiliser builder quand une réaction à NetworkStatus.online ou NetworkStatus.unknown est nécessaire :
NetworkStatusIndicator(
builder: (context, status, child) {
return Stack(
children: [
child!,
if (status == NetworkStatus.offline)
const Positioned(
bottom: 0, left: 0, right: 0,
child: MyOfflineBanner(),
),
if (status == NetworkStatus.unknown)
const Positioned(
bottom: 0, left: 0, right: 0,
child: MyCheckingBanner(),
),
],
);
},
child: const MyApp(),
)
Exemple complet : application Todo offline-first
import 'package:flutter/material.dart';
import 'package:qora_flutter/qora_flutter.dart';
class TodoScreen extends StatelessWidget {
const TodoScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: QoraBuilder<List<Todo>>(
queryKey: const ['todos'],
fetcher: api.getTodos,
builder: (context, state, fetchStatus) {
// En pause → hors-ligne, en attente de charger pour la première fois
if (fetchStatus == FetchStatus.paused && state.dataOrNull == null) {
return const Center(child: Text('Hors-ligne : connectez-vous pour charger les todos'));
}
return switch (state) {
Initial() => const SizedBox.shrink(),
Loading() => const CircularProgressIndicator(),
Failure(:final error) => ErrorScreen(error),
Success(:final data) => TodoList(
todos: data,
isRefreshing: fetchStatus == FetchStatus.fetching,
isStale: fetchStatus == FetchStatus.paused,
),
};
},
),
floatingActionButton: QoraMutationBuilder<void, String, void>(
mutator: (title) => api.createTodo(title),
options: MutationOptions(
offlineQueue: true,
optimisticResponse: (title) => null,
onSuccess: (_, __, ___) async =>
context.qora.invalidate(const ['todos']),
),
builder: (context, state, mutate) {
final isOptimistic = state is MutationSuccess && state.isOptimistic;
return FloatingActionButton.extended(
onPressed: state.isPending ? null : () => mutate('Nouveau Todo'),
icon: isOptimistic
? const Icon(Icons.schedule)
: const Icon(Icons.add),
label: Text(
isOptimistic ? 'En file hors-ligne' : 'Ajouter un Todo',
),
);
},
),
);
}
}
Étapes suivantes
- NetworkStatusBuilder / Indicator : Widgets pour l'UI de connectivité
- QoraBuilder : API builder complète avec
FetchStatus - QoraMutationBuilder : File hors-ligne et
isOptimistic - QoraOptions : Référence du champ
networkMode - QoraClientConfig : Référence du champ
reconnectStrategy
Persistance
Persister les résultats des requêtes sur disque et les restaurer au démarrage : donnant à l'application une couche de données instantanée qui fonctionne entièrement hors-ligne.
Requêtes infinies
Implémenter la pagination avant et bidirectionnelle avec InfiniteQueryObserver : chargement au scroll, pull-to-refresh, pagination fenêtrée et APIs basées sur curseur.