跳到主要內容

網路請求的去抖動或取消

隨著應用程式變得越來越複雜,同時處理多個網路請求是很常見的。 例如,使用者可能在搜尋框中鍵入內容併為每次擊鍵觸發新的請求。 如果使用者打字速度很快,應用程式可能會同時處理許多請求。

或者,使用者可能會觸發請求,然後在請求完成之前導航到不同的頁面。 在這種情況下,應用程式可能有一個不再需要的正在執行的請求。

要在這些情況下最佳化效能,您可以使用以下幾種技術:

  • “去抖動”請求。這意味著您要等到使用者停止輸入一段時間後再發送請求。 這可確保即使使用者鍵入速度很快,您也只會針對給定輸入傳送一個請求。
  • “取消”請求。這意味著如果使用者在請求完成之前離開頁面,您將取消請求。 這可確保您不會浪費時間處理使用者永遠不會看到的響應。

在 Riverpod 中,這兩種技術都可以以類似的方式實現。 關鍵是使用 ref.onDispose 方法與“自動處置”或 ref.watch 結合來實現所需的行為。

為了展示這一點,我們將製作一個包含兩個頁面的簡單應用程式:

  • 主螢幕,帶有開啟新頁面的按鈕
  • 詳細資訊頁面,顯示來自 Bored API 的隨機活動,並且能夠重新整理活動。 有關如何實現下拉重新整理的資訊, 請參閱下拉重新整理

然後我們將實現以下行為:

  • 如果使用者開啟詳細資訊頁面然後立即導航回來, 我們將取消該活動的請求。
  • 如果使用者連續多次重新整理活動,我們將對請求進行去抖動, 以便在使用者停止重新整理後僅傳送一個請求。

應用​

展示應用程式、開啟詳細頁面和重新整理活動的 Gif。

首先,讓我們建立應用程式,不進行任何去抖動或取消操作。
我們不會在這裡使用任何花哨的東西,而是堅持使用普通的 FloatingActionButtonNavigator.push 來開啟詳細資訊頁面。

首先,讓我們從定義主螢幕開始。像往常一樣, 我們不要忘記在應用程式的根元件上指定 ProviderScope

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),
),
);
}
}

然後,讓我們定義我們的詳細資訊頁面。 要獲取活動並實施下拉重新整理, 請參閱下拉重新整理應用案例。

lib/src/detail_screen.dart

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