網路請求的去抖動或取消
隨著應用程式變得越來越複雜,同時處理多個網路請求是很常見的。 例如,使用者可能在搜尋框中鍵入內容併為每次擊鍵觸發新的請求。 如果使用者打字速度很快,應用程式可能會同時處理許多請求。
或者,使用者可能會觸發請求,然後在請求完成之前導航到不同的頁面。 在這種情況下,應用程式可能有一個不再需要的正在執行的請求。
要在這些情況下最佳化效能,您可以使用以下幾種技術:
- “去抖動”請求。這意味著您要等到使用者停止輸入一段時間後再發送請求。 這可確保即使使用者鍵入速度很快,您也只會針對給定輸入傳送一個請求。
- “取消”請求。這意味著如果使用者在請求完成之前離開頁面,您將取消請求。 這可確保您不會浪費時間處理使用者永遠不會看到的響應。
在 Riverpod 中,這兩種技術都可以以類似的方式實現。
關鍵是使用 ref.onDispose
方法與“自動處置”或 ref.watch
結合來實現所需的行為。
為了展示這一點,我們將製作一個包含兩個頁面的簡單應用程式:
然後我們將實現以下行為:
- 如果使用者開啟詳細資訊頁面然後立即導航回來, 我們將取消該活動的請求。
- 如果使用者連續多次重新整理活動,我們將對請求進行去抖動, 以便在使用者停止重新整理後僅傳送一個請求。
應用

首先,讓我們建立應用程式,不進行任何去抖動或取消操作。
我們不會在這裡使用任何花哨的東西,而是堅持使用普通的 FloatingActionButton
和 Navigator.push
來開啟詳細資訊頁面。
首先,讓我們從定義主螢幕開始。像往常一樣,
我們不要忘記在應用程式的根元件上指定 ProviderScope
。
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),
),
);
}
}
然後,讓我們定義我們的詳細資訊頁面。 要獲取活動並實施下拉重新整理, 請參閱下拉重新整理應用案例。
sealed 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()),
},
],
),
),
);
}
}
取消請求
現在我們有了一個可以執行的應用程式,讓我們實現取消邏輯。
為此,我們將在使用者離開頁面時使用 ref.onDispose
取消請求。
為了使其運作,啟用提供者程式的自動處置非常重要。
取消請求所需的確切程式碼取決於 HTTP 客戶端。
在此示例中,我們將使用 package:http
,
但相同的原則也適用於其他客戶端。
這裡的關鍵點是當用戶離開時將呼叫 ref.onDispose
。
這是因為我們的提供者程式不再使用,因此透過自動處置進行了處置。
因此,我們可以使用此回撥來取消請求。
當使用 package:http
時,可以透過關閉 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));
}
請求去抖
現在我們已經實現了取消,讓我們實現去抖動。
目前,如果使用者連續多次重新整理活動,
我們將為每次刷新發送一個請求。
從技術上來說,既然我們已經實行了取消,這就不成問題了。 如果使用者連續多次重新整理活動, 則當發出新請求時,先前的請求將被取消。
然而,這並不理想。我們仍然傳送多個請求,
浪費頻寬和伺服器資源。
相反,我們可以做的是延遲我們的請求,
直到使用者在固定的時間內停止重新整理活動。
這裡的邏輯和取消邏輯非常相似。
我們將再次使用 ref.onDispose
。然而,這裡的想法是,
我們將依靠 onDispose
在請求開始之前中止請求,
而不是關閉 HTTP 客戶端。
然後我們會任意等待 500ms,然後再發送請求。
然後,如果使用者在 500 毫秒過去之前再次重新整理活動,
將呼叫 onDispose
並中止請求。
要中止請求,常見的做法是主動丟擲。
在提供者程式被處置後,將提供者程式內部丟擲異常是安全的。
該異常自然會被 Riverpod 捕獲並被忽略。
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));
}
更進一步:同時做這兩件事
我們現在知道如何取消和取消請求。
但目前,如果我們想做另一個請求,
我們需要將相同的邏輯複製貼上到多個位置。這並不理想。
然而,我們可以更進一步,實現一個可重用的實用程式來同時完成這兩個任務。
這裡的想法是在 Ref
上實現一個擴充套件方法,
該方法將在單個方法中處理取消和去抖。
extension DebounceAndCancelExtension on Ref {
/// 等待 [duration](預設為 500ms),
/// 然後返回一個 [http.Client],用於發出請求。
///
/// 當提供者程式被處置時,該客戶端將自動關閉。
Future<http.Client> getDebouncedHttpClient([Duration? duration]) async {
// 首先,我們要處理去抖問題。
var didDispose = false;
onDispose(() => didDispose = true);
// 我們將請求延遲 500 毫秒,以等待使用者停止重新整理。
await Future<void>.delayed(duration ?? const Duration(milliseconds: 500));
// 如果在延遲期間處理了提供者程式,則意味著使用者再次重新整理了請求。
// 我們會丟擲一個異常來取消請求。
// 在這裡使用異常是安全的,因為它會被 Riverpod 捕捉到。
if (didDispose) {
throw Exception('Cancelled');
}
// 現在我們建立客戶端,並在處理提供者程式時關閉客戶端。
final client = http.Client();
onDispose(client.close);
// 最後,我們返回客戶端,讓我們的提供者程式提出請求。
return client;
}
}
然後我們可以在我們的提供者程式中使用此擴充套件方法,如下所示:
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));
}