Respingere (Debouncing)/Cancellare richieste di rete
Man mano che le applicazioni diventano più complesse, è comune avere più richieste di rete attive contemporaneamente. Per esempio, un utente potrebbe digitare in una casella di testo e triggerare una nuova richiesta per ogni tasto premuto. Se l'utente digita velocemente, l'applicazione potrebbe avere tante richieste attive in parallelo.
Oppure, un utente potrebbe causare una richiesta, per poi navigare in una pagina differente prima che la richiesta sia completata. In questo caso, l'applicazione potrebbe avere una richiesta attiva di cui non abbiamo più bisogno.
Per ottimizzare le performance in queste situazioni ci sono delle tecniche che puoi usare:
- "Respingere" (Debouncing) le richieste. Significa che si aspetta finchè l'utente non ha terminato di digitare per un certo periodo di tempo prima di inviare la richiesta. Questo assicura di inviare una sola richiesta per un input fornito, anche se l'utente digita velocemente.
- "Cancellare" le richieste. Significa che puoi cancellare una richiesta se l'utente esce dalla pagina prima che la richiesta sia completata. Questo assicura di non sprecare tempo nel processare una risposta che l'utente non vedrà mai.
In Riverpod, entrambe le tecniche possono essere implementate in modo simile.
La chiave è utilizzare ref.onDispose
insieme al meccansimo di "distruzione automatica" o
ref.watch
per ottenere il comportamento desiderato.
Per dimostrarlo, creeremo una semplice applicazione con due pagine:
- Un pagina home, con un bottone che apre una nuova pagina
- Una pagina di dettaglio, che mostra un'attività casuale dalle Bored API, con la possibilità di aggiornare l'attività. Consulta Pull to refresh (trascina per aggiornare) per informazioni riguardo a come implementare un meccanismo di pull-to-refresh.
Implementeremo poi i seguenti comportamenti:
- Se l'utente apre la pagina di dettaglio e poi naviga indietro immediatamente, cancelleremo la richiesta dell'attività.
- Se l'utente riaggiorna l'attività molte volte di fila, respingeremo le richieste in modo tale di mandare una sola richiesta dopo che l'utente smette di riaggiornare.
L'applicazione
Per prima cosa, creiamo l'applicazione senza nessun meccanismo di debouncing o di cancellazione.
Non useremo niente di particolare in questo caso e ci atterremo ad un semplice FloatingActionButton
con un Navigator.push
per aprire la pagina di dettaglio.
Cominciamo col definire la nostra pagina di home. Come sempre,
non ci dimentichiamo di specificare ProviderScope
alla radice della nostra applicazione.
void main() => runApp(const ProviderScope(child: MyApp()));
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
routes: {
'/detail-page': (_) => const DetailPageView(),
},
home: const ActivityView(),
);
}
}
class ActivityView extends ConsumerWidget {
const ActivityView({super.key});
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(title: const Text('Home screen')),
body: const Center(
child: Text('Click the button to open the detail page'),
),
floatingActionButton: FloatingActionButton(
onPressed: () => Navigator.of(context).pushNamed('/detail-page'),
child: const Icon(Icons.add),
),
);
}
}
Successivamente, definiamo la nosta pagina di dettaglio. Per ottenere l'attività ed implementare il meccanismo di pull-to-refresh, riferirsi all'argomento di studio Pull to refresh (trascina per aggiornare).
class Activity with _$Activity {
factory Activity({
required String activity,
required String type,
required int participants,
required double price,
}) = _Activity;
factory Activity.fromJson(Map<String, dynamic> json) =>
_$ActivityFromJson(json);
}
Future<Activity> activity(Ref ref) async {
final response = await http.get(
Uri.https('www.boredapi.com', '/api/activity'),
);
final json = jsonDecode(response.body) as Map;
return Activity.fromJson(Map.from(json));
}
class DetailPageView extends ConsumerWidget {
const DetailPageView({super.key});
Widget build(BuildContext context, WidgetRef ref) {
final activity = ref.watch(activityProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Detail page'),
),
body: RefreshIndicator(
onRefresh: () => ref.refresh(activityProvider.future),
child: ListView(
children: [
switch (activity) {
AsyncValue(:final valueOrNull?) => Text(valueOrNull.activity),
AsyncValue(:final error?) => Text('Error: $error'),
_ => const Center(child: CircularProgressIndicator()),
},
],
),
),
);
}
}
Cancellare le richieste
Ora che abbiamo un applicazione funzionante, implementiamo la logica di cancellazione.
Per fare ciò, useremo ref.onDispose
per cancellare la richiesta quando l'utente
esce dalla pagina. Perché funzioni, è importante che la distruzione automatica dei provider
sia abilitata.
L'esatto codice necessario per cancellare la richiesta dipenderà dal client HTTP.
In questo esempio useremo package:http
, ma lo stesso principio si applica
agli altri client.
La chiave qui è che ref.onDispose
verrà chiamato quando l'utente naviga al di fuori.
Questo perchè il nostro provider non è più usato e quindi distrutto grazie alla distruzione automatica.
Perciò possiamo utilizzare questa callback per cancellare la richiesta. Se usiamo package:http
,
possiamo chiudere il nostro client HTTP.
Future<Activity> activity(Ref ref) async {
// We create an HTTP client using package:http
final client = http.Client();
// On dispose, we close the client.
// This will cancel any pending request that the client might have.
ref.onDispose(client.close);
// We now use the client to make the request instead of the "get" function.
final response = await client.get(
Uri.https('www.boredapi.com', '/api/activity'),
);
// The rest of the code is the same as before
final json = jsonDecode(response.body) as Map;
return Activity.fromJson(Map.from(json));
}
Respingere (Debouncing) le richieste
Ora che abbiamo implementato la cancellazione, implementiamo il meccanismo di debouncing. Al momento, se l'utente riaggiorna l'attività diverse volte di fila, manderemo una richiesta per ogni aggiornamento richiesto.
Tecnicamente parlando, ora che abbiamo implementato la cancellazione, questo non è un problema. Poichè, se l'utente riaggiorna l'attività molte volte di fila, la richiesta precedente sarà cancellata quando una nuova richiesta verrà eseguita.
Tuttavia, questo non è l'ideale. Stiamo comunque mandando richieste multiple e sprecando banda e risorse del server. Ciò che potremmo fare invece è ritardare le nostre richieste finché l'utente non smette di aggiornare l'attività per un periodo di tempo prestabilito.
La logica qui è molto simile a quella della cancellazione. Useremo di nuovo
ref.onDispose
. Tuttavia, l'idea in questo caso è, invece di chiudere il client HTTP,
di affidarci alla callback onDispose
per abortire la richiesta prima ancora che parta.
Aspetteremo poi arbitrariamente 500ms prima di inviare la richiesta.
Quindi, se l'utente riaggiorna l'attività di nuovo prima che i 500ms siano trascorsi,
onDispose
sarà invocata, abortendo la richiesta.
Per abortire le richieste, una pratica comune è di generare volontariamente un'eccezione. È sicuro generare un'eccezione all'interno dei provider dopo che il provider è stato distrutto. L'eccezione sarà catturata da Riverpod ed ignorata.
Future<Activity> activity(Ref ref) async {
// We capture whether the provider is currently disposed or not.
var didDispose = false;
ref.onDispose(() => didDispose = true);
// We delay the request by 500ms, to wait for the user to stop refreshing.
await Future<void>.delayed(const Duration(milliseconds: 500));
// If the provider was disposed during the delay, it means that the user
// refreshed again. We throw an exception to cancel the request.
// It is safe to use an exception here, as it will be caught by Riverpod.
if (didDispose) {
throw Exception('Cancelled');
}
// The following code is unchanged from the previous snippet
final client = http.Client();
ref.onDispose(client.close);
final response = await client.get(
Uri.https('www.boredapi.com', '/api/activity'),
);
final json = jsonDecode(response.body) as Map;
return Activity.fromJson(Map.from(json));
}
Andando oltre: fare entrambe le cose contemporaneamente
Ora sappiamo come usare il debounce e cancellare le richieste. Ma al momento, se vogliamo effettuare un'altra richiesta, abbiamo bisogno di copiare e incollare la stessa logica in posti diversi. E questo non è l'ideale.
Niente panico, possiamo andare avanti ed implementare un'utilità riutilizzabile per fare entrambe le cose in un momento solo.
L'idea è di implementare un metodo di estensione su Ref
che
gestirà sia la cancellazione che il debouncing in un singolo metodo.
extension DebounceAndCancelExtension on Ref {
/// Aspetta per la [duration] (di default a 500ms) e poi ritorna un [http.Client]
/// con il quale possiamo effettuare una richiesta.
///
/// Il client sarà chiuso automaticamente quando il provider verrà distrutto.
Future<http.Client> getDebouncedHttpClient([Duration? duration]) async {
// Per prima cosa gestiamo il debouncing.
var didDispose = false;
onDispose(() => didDispose = true);
// Ritardiamo la richiesta di 500ms per aspettare che l'utente finisca di aggiornare.
await Future<void>.delayed(duration ?? const Duration(milliseconds: 500));
// Se il provider è stato distrutto durante il ritardo, significa che l'utente
// ha aggiornato di nuovo. Generiamo un'eccezione per cancellare la richiesta.
// È sicuro generare un'eccezione qui dato che sarà catturata da Riverpod.
if (didDispose) {
throw Exception('Cancelled');
}
// Ora possiamo creare il client e chiuderlo quando il provider viene distrutto.
final client = http.Client();
onDispose(client.close);
// Infine, restituiamo il client per permettere al nostro provider di effettuare la richiesta.
return client;
}
}
Possiamo infine utilizzare questo metodo di estensione nei nostri provider come segue:
Future<Activity> activity(Ref ref) async {
// We obtain an HTTP client using the extension we created earlier.
final client = await ref.getDebouncedHttpClient();
// We now use the client to make the request instead of the "get" function.
// Our request will naturally be debounced and be cancelled if the user
// leaves the page.
final response = await client.get(
Uri.https('www.boredapi.com', '/api/activity'),
);
final json = jsonDecode(response.body) as Map;
return Activity.fromJson(Map.from(json));
}