网络请求的去抖动或取消
随着应用程序变得越来越复杂,同时处理多个网络请求是很常见的。 例如,用户可能在搜索框中键入内容并为每次击键触发新的请求。 如果用户打字速度很快,应用程序可能会同时处理许多请求。
或者,用户可能会触发请求,然后在请求完成之前导航到不同的页面。 在这种情况下,应用程序可能有一个不再需要的正在运行的请求。
要在这些情况下优化性能,您可以使用以下几种技术:
- “去抖动”请求。这意味着您要等到用户停止输入一段时间后再发送请求。 这可确保即使用户键入速度很快,您也只会针对给定输入发送一个请求。
- “取消”请求。这意味着如果用户在请求完成之前离开页面,您将取消请求。 这可确保您不会浪费时间处理用户永远不会看到的响应。
在 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),
),
);
}
}
然后,让我们定义我们的详细信息页面。 要获取活动并实施下拉刷新, 请参阅下拉刷新应用案例。
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));
}