跳到主要内容

网络请求的去抖动或取消

随着应用程序变得越来越复杂,同时处理多个网络请求是很常见的。 例如,用户可能在搜索框中键入内容并为每次击键触发新的请求。 如果用户打字速度很快,应用程序可能会同时处理许多请求。

或者,用户可能会触发请求,然后在请求完成之前导航到不同的页面。 在这种情况下,应用程序可能有一个不再需要的正在运行的请求。

要在这些情况下优化性能,您可以使用以下几种技术:

  • “去抖动”请求。这意味着您要等到用户停止输入一段时间后再发送请求。 这可确保即使用户键入速度很快,您也只会针对给定输入发送一个请求。
  • “取消”请求。这意味着如果用户在请求完成之前离开页面,您将取消请求。 这可确保您不会浪费时间处理用户永远不会看到的响应。

在 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

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