Salta al contenuto principale

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

Gif showcasing the application, opening the detail page and refreshing the activity.

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.

lib/src/main.dart
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).

lib/src/detail_screen.dart

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(ActivityRef 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(ActivityRef 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.

info

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(ActivityRef 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(ActivityRef 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));
}