Perché il caching HTTP conta più di quanto sembri
In molte app Flutter il collo di bottiglia non è il rendering, ma la rete: schermate che rifanno la stessa GET a ogni apertura, liste che si ricaricano da zero dopo un pop, utenti in metropolitana che vedono solo spinner. La soluzione più spesso adottata è scrivere una cache "a mano" in SharedPreferences o in un database locale, con chiavi stringa e logica di scadenza sparsa nei repository.
Esiste però un livello già standardizzato, testato da decenni e gratuito: la cache HTTP. Se il backend espone gli header giusti, il client può riutilizzare le risposte senza nemmeno interrogare il server, oppure validarle con una richiesta leggerissima che restituisce 304 Not Modified. In Flutter possiamo innestare questo comportamento direttamente in Dio con dio_cache_interceptor, senza toccare i repository.
Le basi: freshness e validazione
Il protocollo HTTP distingue due meccanismi complementari.
Freshness (Cache-Control): il server dichiara per quanto tempo la risposta è considerata valida.
Cache-Control: max-age=300, stale-while-revalidate=86400
Per 300 secondi la risposta è "fresca" e il client può servirla senza contattare il server. Nelle 24 ore successive può servirla comunque (stale) mentre aggiorna in background.
Validazione (ETag / Last-Modified): quando la risposta è scaduta, il client non riscarica tutto. Invia una richiesta condizionale:
GET /api/articles HTTP/1.1
If-None-Match: "a1b2c3d4"
Se nulla è cambiato il server risponde 304 Not Modified con corpo vuoto: si risparmiano banda e parsing JSON, non il round-trip.
Altri valori utili di Cache-Control lato risposta: no-store (non memorizzare mai, obbligatorio per dati sensibili), no-cache (memorizza ma rivalida sempre), private (solo cache client).
Installazione e setup minimo
dependencies:
dio: ^5.7.0
dio_cache_interceptor: ^3.5.0
dio_cache_interceptor_hive_store: ^3.2.2
path_provider: ^2.1.4
Il pacchetto separa due concetti: le opzioni (CacheOptions, cosa e per quanto memorizzare) e lo store (dove scrivere). Gli store disponibili includono MemCacheStore (solo RAM), HiveCacheStore, FileCacheStore e store basati su Drift.
import 'package:dio/dio.dart';
import 'package:dio_cache_interceptor/dio_cache_interceptor.dart';
import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
import 'package:path_provider/path_provider.dart';
Future<Dio> buildDio() async {
final dir = await getApplicationCacheDirectory();
final store = HiveCacheStore('${dir.path}/http_cache');
final cacheOptions = CacheOptions(
store: store,
// Rispetta gli header del server, con fallback se mancano
policy: CachePolicy.request,
// Validità massima se il server non dice nulla
maxStale: const Duration(days: 7),
// Serve la cache se la rete fallisce, tranne su questi status
hitCacheOnErrorExcept: const [401, 403],
priority: CachePriority.normal,
keyBuilder: CacheOptions.defaultCacheKeyBuilder,
allowPostMethod: false,
);
final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com'));
dio.interceptors.add(DioCacheInterceptor(options: cacheOptions));
return dio;
}
Da questo momento ogni GET passa dall'interceptor: se esiste una entry fresca viene restituita senza rete, altrimenti parte una richiesta condizionale con If-None-Match/If-Modified-Since e, in caso di 304, l'interceptor ricostruisce la risposta dalla cache.
Nota sulle versioni: nelle release più recenti del pacchetto
hitCacheOnErrorExceptè affiancato/sostituito dahitCacheOnNetworkFailure. Controlla il changelog della versione che blocchi nelpubspec.yaml.
Le policy: scegliere il comportamento giusto
CachePolicy è il parametro che determina tutto. In sintesi:
request(default consigliato): rispetta gli header HTTP del server; se mancano usamaxStale.forceCache: usa la cache se disponibile e non scaduta secondomaxStale, ignorando le direttive del server. Utile per endpoint quasi statici (configurazioni, cataloghi, tassonomie).refresh: forza la chiamata di rete ma aggiorna la cache. È la policy del pull-to-refresh.refreshForceCache: chiama sempre la rete e, in caso di errore, ricade sulla cache.noCache: richiesta sempre eseguita e risposta non memorizzata. Obbligatoria per endpoint sensibili (profilo, saldo, token).
Le opzioni si possono sovrascrivere per singola richiesta:
class ArticleApi {
ArticleApi(this._dio, this._cacheOptions);
final Dio _dio;
final CacheOptions _cacheOptions;
Future<List<Article>> fetchArticles({bool forceRefresh = false}) async {
final options = _cacheOptions
.copyWith(
policy: forceRefresh ? CachePolicy.refresh : CachePolicy.request,
)
.toOptions();
final response = await _dio.get<List<dynamic>>('/articles', options: options);
return response.data!
.map((json) => Article.fromJson(json as Map<String, dynamic>))
.toList();
}
Future<AppConfig> fetchConfig() async {
final options = _cacheOptions
.copyWith(
policy: CachePolicy.forceCache,
maxStale: const Nullable(Duration(hours: 12)),
)
.toOptions();
final response = await _dio.get<Map<String, dynamic>>('/config', options: options);
return AppConfig.fromJson(response.data!);
}
}
Il wrapper Nullable serve perché copyWith deve poter distinguere "non specificato" da "impostato a null".
Sapere da dove arriva una risposta
Per debug e UI ("dati aggiornati 5 minuti fa") l'interceptor popola gli extra della risposta:
final response = await dio.get('/articles');
final fromCache = response.extra[CacheResponse.cacheKey] != null;
final cachedAt = response.extra[CacheResponse.fromNetwork] == false;
debugPrint('status: ${response.statusCode}, servita da cache: $fromCache');
Un interceptor di logging dedicato aiuta a verificare che il backend stia davvero emettendo ETag e Cache-Control: senza quegli header la cache funziona solo grazie a maxStale, cioè "a tempo", senza validazione.
Invalidazione: la parte difficile
La regola pratica è: dopo ogni mutazione, invalida le risorse che dipendono da quella mutazione. Con un interceptor dedicato si evita di sparpagliare la logica nei repository.
class CacheInvalidationInterceptor extends Interceptor {
CacheInvalidationInterceptor(this._store, this._options);
final CacheStore _store;
final CacheOptions _options;
static const _rules = <String, List<String>>{
'/articles': ['/articles', '/feed'],
'/profile': ['/profile'],
};
@override
Future<void> onResponse(Response response, ResponseInterceptorHandler handler) async {
final method = response.requestOptions.method.toUpperCase();
final isMutation = method == 'POST' || method == 'PUT' ||
method == 'PATCH' || method == 'DELETE';
if (isMutation && response.statusCode != null && response.statusCode! < 300) {
final path = response.requestOptions.path;
final targets = _rules.entries
.where((e) => path.startsWith(e.key))
.expand((e) => e.value);
for (final target in targets) {
await _store.deleteFromPath(RegExp('.*$target.*'));
}
}
handler.next(response);
}
}
CacheStore espone anche clean() per svuotare tutto (utile al logout) e delete(key) per una singola entry, dove la chiave è quella prodotta dal keyBuilder. Attenzione: al logout la pulizia della cache non è opzionale, altrimenti l'utente successivo potrebbe vedere dati altrui.
Future<void> onLogout() async {
await store.clean(priorityOrBelow: CachePriority.high);
// più eventuali altre pulizie (secure storage, db locale)
}
Cosa non mettere mai in cache
- Endpoint di autenticazione, token, refresh token.
- Dati personali o finanziari che non devono persistere su disco: usa
CachePolicy.noCache, oppure uno store in memoria dedicato. - Risposte che variano in base a header non inclusi nella chiave (es.
Accept-Language, tenant ID). In questi casi personalizza ilkeyBuilderincludendo gli header rilevanti, oppure assicurati che il server inviiVary.
String myKeyBuilder(RequestOptions request) {
final lang = request.headers['Accept-Language'] ?? 'it';
final base = CacheOptions.defaultCacheKeyBuilder(request);
return '$base|$lang';
}
La firma di defaultCacheKeyBuilder è cambiata tra le major del pacchetto (prima accettava url e headers): adatta il codice alla versione in uso.
Cache e stati offline
Il caching HTTP non sostituisce un'architettura offline-first con database locale e sincronizzazione, ma copre molto bene il caso "lettura che deve funzionare anche senza rete". La combinazione tipica è:
hitCacheOnErrorExceptattivo, così unDioExceptiondi tipoconnectionErrorrestituisce comunque dati.- In UI, mostrare un banner quando la risposta arriva da cache e la rete non è disponibile.
- Un
maxStaleragionato per endpoint: ore per i contenuti editoriali, minuti per i dati operativi.
try {
final articles = await api.fetchArticles();
emit(ArticlesLoaded(articles, stale: !api.lastResponseFromNetwork));
} on DioException catch (e) {
if (e.type == DioExceptionType.connectionError) {
emit(const ArticlesError('Nessuna connessione e nessun dato in cache'));
} else {
rethrow;
}
}
Checklist finale
- Verifica con uno strumento HTTP (curl, Charles, Proxyman) che il backend invii
Cache-ControledETag. Senza di questi, la cache client è cieca. - Usa
CachePolicy.requestcome default e deroga solo dove serve. - Imposta sempre un
maxStaledi sicurezza: evita entry immortali. - Invalida dopo le mutazioni e svuota al logout.
- Scegli lo store in base ai dati:
MemCacheStoreper sessioni brevi, Hive/Drift per persistenza fra avvii. - Misura: il vero indicatore di successo è il calo delle richieste di rete e del tempo al primo frame utile, non il numero di righe di codice risparmiate.
Con poche decine di righe di configurazione si ottiene un comportamento che, scritto a mano, richiederebbe un modulo intero: meno traffico, apertura delle schermate quasi istantanea e un'app che continua a essere utilizzabile quando la connessione viene meno.