原文链接:Debouncing/Cancelling network requests | Riverpod
pub:riverpod | Dart Package (flutter-io.cn)
译时版本: 2.4.9
之前翻译过 Riverpod 的官方文档,现在随着版本更新,官方文档又多了很多新内容,所以再补充翻译一下。
之前翻译过的内容,现在官方文档有中文了。
Flutter状态管理库Riverpod官方文档翻译汇总 - 掘金 (juejin.cn)
网络请求的防抖/取消
因为应用会越来越复杂,所以同时有多个网络请求在执行是很常见的。 例如,一个用户可能在搜索框里输入时,每敲击一个按键就会触发一个新的请求。 如果用户输入地很快,应用就可能会同时发出去多个请求。
另外一种情况,用户可能触发了一个请求,然后在请求完成之前,导航到了一个不同的页面。 这种情况下,应用就会有一个正在发生的请求,并且已经不再需要这个请求。
要优化这些情况下的性能,这里有一些可用的技巧:
-
请求"防抖"。这意味着在发送请求前要一直等待用户停止输入一段特定时间。
这能确保对于给定的输入,只会发送一次请求,即使用户输入得很快。
-
"取消"请求。这意味着如果用户在请求完成前导航到了另外的页面,可以取消请求。这能确保不会为了用户永远看不到的响应浪费时间去处理。
在 Riverpod 中,这两种技巧的实现方式很相似。关键是使用 ref.watch
或用 "automatic disposal" 绑定的 ref.onDispose
来完成期望的行为。
要说明一下该做法,下面会编写一个有两个页面的简单应用:
然后会实现下面的行为:
- 如果用户打开了详情页然后立即返回,会取消请求 activity 。
- 如果用户一下子多次刷新 activity ,会进行请求防抖,这样就只会在用户停止刷新后发送一次请求。
应用
首先,创建不带防抖或取消的应用。
这里不会用什么特别的处理,只是一个普通的 FloatingActionButton
,用 Navigator.push
打开详情页。
首先,开始定义主页。和平常一样,别忘了在应用的根节点指定 ProviderScope
。
lib/src/main.dart
dart
void main() => runApp(const ProviderScope(child: MyApp()));
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
routes: {
'/detail-page': (_) => const DetailPageView(),
},
home: const ActivityView(),
);
}
}
class ActivityView extends ConsumerWidget {
const ActivityView({super.key});
@override
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),
),
);
}
}
然后,定义详细页。如何获取 activity 和实现下拉刷新,参考 下拉刷新 的场景学习。
lib/src/detail_screen.dart
dart
@freezed
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);
}
@riverpod
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});
@override
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
以取消请求。要使其起作用,重要的是 provider 的自动清除是可用的。
其它取消请求所需要的代码就取决于 HTTP client 了。该示例中,会使用 package:http
,对于其它客户端也是同样的原理。
这里的关键是 ref.onDispose
会在用户导航出去时被调用。这是因为 provider 不再被使用了,由于是自动清除,因此 provider 会被清除掉。
因此可以使用该回调取消请求。使用 package:http
时,可以通过关闭 HTTP client 来做到。
dart
@riverpod
Future<Activity> activity(ActivityRef ref) async {
// 使用 package:http 创建 HTTP 客户端
final client = http.Client();
// 清除时关闭客户端。
// 这会取消 client 所有还未发出的请求。
ref.onDispose(client.close);
// 现在用 client 创建请求代替 "get" 函数
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));
}
请求防抖
现在已经实现了取消,开始实现防抖。 这时,如果用户一下子多次刷新 activity ,会为每个刷新发送一次请求。
就技术上来说,现在已经实现了取消,那这也就不是问题。 如果用户一下子多次刷新 activity ,前面的请求会被取消,然后会创建新的请求。
尽管如此,这也是不理想的。仍然会发送多次请求,并且浪费带宽和服务器资源。
一个替代方案是延迟请求直到用户停止刷新 activity 一个固定的时长。
这里的逻辑和取消逻辑很相似。会再次使用 ref.onDispose
。尽管如此,这里的想法是取代关闭 HTTP client ,依赖 onDispose
放弃尚未开始的请求。
然后会强行在发送请求之前等待 500 毫秒。然后如果用户在 500 毫秒计时结束之前再次刷新 acitivity 的话,就会调用 onDispose
放弃请求。
信息
要放弃请求,通常的做法是自发抛出异常。
在 provider 被清除前在 provider 内部抛出异常是安全的。 异常会被 Riverpod 捕获并忽略。
dart
@riverpod
Future<Activity> activity(ActivityRef ref) async {
// 判断 provider 当前是否已被清除。
var didDispose = false;
ref.onDispose(() => didDispose = true);
// 延迟请求 500 毫秒,等待用户停止刷新。
await Future<void>.delayed(const Duration(milliseconds: 500));
// 如果 provider 在延迟途中被清除了,这意味着用户进行了刷新。
// 这时报出异常以取消请求。
// 这里使用异常是安全的,因为它可以被 Riverpod 捕获。
if (didDispose) {
throw Exception('Cancelled');
}
// 以下代码和之前的代码片段没有变化
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
实现一个扩展方法,该方法在单个方法里同时处理取消和防抖。
dart
extension DebounceAndCancelExtension on Ref {
/// 等待 [时长] (默认是 500 毫秒),然后返回一个可用来进行请求的 [http.Client]。
///
/// 该 client 会在 provider 被清除时自动关闭。
Future<http.Client> getDebouncedHttpClient([Duration? duration]) async {
// 首先,处理防抖
var didDispose = false;
onDispose(() => didDispose = true);
// 延迟请求 500 毫秒,等待用户停止刷新。
await Future<void>.delayed(duration ?? const Duration(milliseconds: 500));
// 如果在延迟途中 provider 被清除了,这意味着用户再次刷新了。
// 这时抛出异常以取消请求。
// 这里使用异常是安全的,因为它可以被 Riverpod 捕获。
if (didDispose) {
throw Exception('Cancelled');
}
// 现在创建 client 并在 provider 被清除时关闭它。
final client = http.Client();
onDispose(client.close);
// 最后,返回 client 使 provider 可以创建请求。
return client;
}
}
We can then use this extension method in our providers as followed:
然后可以如下使用 provider 中的扩展方法:
dart
@riverpod
Future<Activity> activity(ActivityRef ref) async {
// 得到一个使用前面创建的扩展方法的 HTTP client 。
final client = await ref.getDebouncedHttpClient();
// 现在使用该 client 代替 "get" 函数创建请求。
// 请求会在用户离开页面时自己就防抖或取消。
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));
}