flutter状态管理:Riverpod 高级
最后一篇文章,我们在掌握了如何读取状态值,并知道如何根据不同场景选择不同类型的Provider,以及如何对Provider进行搭配使用之后,再来了解一下它的一些其它特性,看看它们是如何帮助我们更好的进行状态管理的。
Provider Modifiers
所有的Provider都有一个内置的方法来为你的不同Provider添加额外的功能。
它们可以为 ref 对象添加新的功能,或者稍微改变Provider的consume方式。Modifiers可以在所有Provider上使用,其语法类似于命名的构造函数。
final myAutoDisposeProvider = StateProvider.autoDispose<int>((ref) => 0);
final myFamilyProvider = Provider.family<String, int>((ref, id) => '$id');
目前,有两个Modifiers可用。
- .autoDispose,这将使Provider在不再被监听时自动销毁其状态
- .family,它允许使用一个外部参数创建一个Provider
一个Provider可以同时使用多个Modifiers。
final userProvider = FutureProvider.autoDispose.family<User, int>((ref, userId) async {
return fetchUser(userId);
});
.family
.family修饰符有一个目的:根据外部参数创建一个独特的Provider。family的一些常见用例是下面这些。
- 将FutureProvider与.family结合起来,从其ID中获取一个Message对象
- 将当前的Locale传递给Provider,这样我们就可以处理国际化
family的工作方式是通过向Provider添加一个额外的参数。然后,这个参数可以在我们的Provider中自由使用,从而创建一些状态。
例如,我们可以将family与FutureProvider结合起来,从其ID中获取一个Message。
final messagesFamily = FutureProvider.family<Message, String>((ref, id) async {
return dio.get('http://my_api.dev/messages/$id');
});
当使用我们的 messagesFamily Provider时,语法会略有不同。
像下面这样的通常语法将不再起作用。
Widget build(BuildContext context, WidgetRef ref) {
// Error – messagesFamily is not a provider
final response = ref.watch(messagesFamily);
}
相反,我们需要向 messagesFamily 传递一个参数。
Widget build(BuildContext context, WidgetRef ref) {
final response = ref.watch(messagesFamily('id'));
}
我们可以同时使用一个具有不同参数的变量。
例如,我们可以使用titleFamily来同时读取法语和英语的翻译。
@override Widget build(BuildContext context, WidgetRef ref) { final frenchTitle = ref.watch(titleFamily(const Locale('fr'))); final englishTitle = ref.watch(titleFamily(const Locale('en'))); return Text('fr: $frenchTitle en: $englishTitle'); }
参数限制
为了让families正确工作,传递给Provider的参数必须具有一致的hashCode和==。
理想情况下,参数应该是一个基础类型(bool/int/double/String),一个常数(Provider),或者一个重写==和hashCode的不可变的对象。
当参数不是常数时,更倾向于使用autoDispose
你可能想用family来传递一个搜索字段的输入,给你的Provider。但是这个值可能会经常改变,而且永远不会被重复使用。这可能导致内存泄漏,因为在默认情况下,即使不再使用,Provider也不会被销毁。
同时使用.family和.autoDispose就可以修复这种内存泄漏。
final characters = FutureProvider.autoDispose.family<List<Character>, String>((ref, filter) async {
return fetchCharacters(filter: filter);
});
给family传递多重参数
family没有内置支持向一个Provider传递多个值的方法。另一方面,这个值可以是任何东西(只要它符合前面提到的限制)。
这包括下面这些类型。
- tuple类型,类似Python的元组,pub.dev/packages/tu…
- 用Freezed或build_value生成的对象,pub.dev/packages/fr…
- 使用equatable的对象,pub.dev/packages/eq…
下面是一个对多个参数使用Freezed或equatable的例子。
@freezed
abstract class MyParameter with _$MyParameter {
factory MyParameter({
required int userId,
required Locale locale,
}) = _MyParameter;
}
final exampleProvider = Provider.autoDispose.family<Something, MyParameter>((ref, myParameter) {
print(myParameter.userId);
print(myParameter.locale);
// Do something with userId/locale
});
@override
Widget build(BuildContext context, WidgetRef ref) {
int userId; // Read the user ID from somewhere
final locale = Localizations.localeOf(context);
final something = ref.watch(
exampleProvider(MyParameter(userId: userId, locale: locale)),
);
...
}
.autoDispose
它的一个常见的用例是,当一个Provider不再被使用时,要销毁它的状态。
这样做的原因有很多,比如下面这些场景。
- 当使用Firebase时,要关闭连接并避免不必要的费用
- 当用户离开一个屏幕并重新进入时,要重置状态
Provider通过.autoDisposeModifiers内置了对这种使用情况的支持。
要告诉Riverpod当它不再被使用时销毁一个Provider的状态,只需将.autoDispose附加到你的Provider上即可。
final userProvider = StreamProvider.autoDispose<User>((ref) {
});
就这样了。现在,userProvider的状态将在不再使用时自动被销毁。
注意通用参数是如何在autoDispose之后而不是之前传递的--autoDispose不是一个命名的构造函数。
如果需要,你可以将.autoDispose与其他Modifiers结合起来。
final userProvider = StreamProvider.autoDispose.family<User, String>((ref, id) {
});
ref.keepAlive
用autoDispose标记一个Provider时,也会在ref上增加了一个额外的方法:keepAlive。
keep函数是用来告诉Riverpod,即使不再被监听,Provider的状态也应该被保留下来。
它的一个用例是在一个HTTP请求完成后,将这个标志设置为true。
final myProvider = FutureProvider.autoDispose((ref) async {
final response = await httpClient.get(...);
ref.keepAlive();
return response;
});
这样一来,如果请求失败,UI离开屏幕然后重新进入屏幕,那么请求将被再次执行。但如果请求成功完成,状态将被保留,重新进入屏幕将不会触发新的请求。
示例:当Http请求不再使用时自动取消
autoDisposeModifiers可以与FutureProvider和ref.onDispose相结合,以便在不再需要HTTP请求时轻松取消。
我们的目标是:
- 当用户进入一个屏幕时启动一个HTTP请求
- 如果用户在请求完成前离开屏幕,则取消HTTP请求
- 如果请求成功,离开并重新进入屏幕不会启动一个新的请求
在代码中,这将是下面这样。
final myProvider = FutureProvider.autoDispose((ref) async {
// An object from package:dio that allows cancelling http requests
final cancelToken = CancelToken();
// When the provider is destroyed, cancel the http request
ref.onDispose(() => cancelToken.cancel());
// Fetch our data and pass our `cancelToken` for cancellation to work
final response = await dio.get('path', cancelToken: cancelToken);
// If the request completed successfully, keep the state
ref.keepAlive();
return response;
});
异常
当使用.autoDispose时,你可能会发现自己的应用程序无法编译,出现类似下面的错误。
The argument type 'AutoDisposeProvider' can't be assigned to the parameter type 'AlwaysAliveProviderBase'
不要担心! 这个错误是正常的。它的发生是因为你很可能有一个bug。
例如,你试图在一个没有标记为.autoDispose的Provider中监听一个标记为.autoDispose的Provider,比如下面的代码。
final firstProvider = Provider.autoDispose((ref) => 0);
final secondProvider = Provider((ref) {
// The argument type 'AutoDisposeProvider<int>' can't be assigned to the
// parameter type 'AlwaysAliveProviderBase<Object, Null>'
ref.watch(firstProvider);
});
这是不可取的,因为这将导致firstProvider永远不会被dispose。
为了解决这个问题,可以考虑用.autoDispose标记secondProvider。
final firstProvider = Provider.autoDispose((ref) => 0);
final secondProvider = Provider.autoDispose((ref) {
ref.watch(firstProvider);
});
provider状态关联与整合
我们之前已经看到了如何创建一个简单的Provider。但实际情况是,在很多情况下,一个Provider会想要读取另一个Provider的状态。
要做到这一点,我们可以使用传递给我们Provider的回调的ref对象,并使用其watch方法。
作为一个例子,考虑下面的Provider。
final cityProvider = Provider((ref) => 'London');
我们现在可以创建另一个Provider,它将消费我们的cityProvider。
final weatherProvider = FutureProvider((ref) async {
// We use `ref.watch` to listen to another provider, and we pass it the provider
// that we want to consume. Here: cityProvider
final city = ref.watch(cityProvider);
// We can then use the result to do something based on the value of `cityProvider`.
return fetchWeather(city: city);
});
这就是了。我们已经创建了一个依赖另一个Provider的Provider。
这个其实在前面的例子中已经讲到了,ref是可以连接多个不同的Provider的,这是Riverpod非常灵活的一个体现。
FAQ
What if the value being listened to changes over time?
根据你正在监听的Provider,获得的值可能会随着时间的推移而改变。例如,你可能正在监听一个StateNotifierProvider,或者被监听的Provider可能已经通过使用ProviderContainer.refresh/ref.refresh强制刷新。
当使用watch时,Riverpod能够检测到被监听的值发生了变化,并将在需要时自动重新执行Provider的创建回调。
这对计算的状态很有用。例如,考虑一个暴露了todo-list的StateNotifierProvider。
class TodoList extends StateNotifier<List<Todo>> {
TodoList(): super(const []);
}
final todoListProvider = StateNotifierProvider((ref) => TodoList());
一个常见的用例是让用户界面过滤todos的列表,只显示已完成/未完成的todos。
实现这种情况的一个简单方法是。
- 创建一个StateProvider,它暴露了当前选择的过滤方法。
enum Filter {
none,
completed,
uncompleted,
}
final filterProvider = StateProvider((ref) => Filter.none);
- 做一个单独的Provider,把过滤方法和todo-list结合起来,暴露出过滤后的todo-list。
final filteredTodoListProvider = Provider<List<Todo>>((ref) {
final filter = ref.watch(filterProvider);
final todos = ref.watch(todoListProvider);
switch (filter) {
case Filter.none:
return todos;
case Filter.completed:
return todos.where((todo) => todo.completed).toList();
case Filter.uncompleted:
return todos.where((todo) => !todo.completed).toList();
}
});
然后,我们的用户界面可以监听filteredTodoListProvider来监听过滤后的todo-list。使用这种方法,当过滤器或todo-list发生变化时,用户界面将自动更新。
要看到这种方法的作用,你可以看一下Todo List例子的源代码。
这种行为不是特定于Provider的,它适用于所有的Provider。
例如,你可以将watch与FutureProvider结合起来,实现一个支持实时配置变化的搜索功能。
// The current search filter final searchProvider = StateProvider((ref) => ''); /// Configurations which can change over time final configsProvider = StreamProvider<Configuration>(...); final charactersProvider = FutureProvider<List<Character>>((ref) async { final search = ref.watch(searchProvider); final configs = await ref.watch(configsProvider.future); final response = await dio.get('${configs.host}/characters?search=$search'); return response.data.map((json) => Character.fromJson(json)).toList(); });
这段代码将从服务中获取一个字符列表,并在配置改变或搜索查询改变时自动重新获取该列表。
Can I read a provider without listening to it?
有时,我们想读取一个Provider的内容,但在获得的值发生变化时不需要重新创建值。
一个例子是一个 Repository,它从另一个Provider那里读取用户token用于认证。
我们可以使用观察并在用户token改变时创建一个新的 Repository,但这样做几乎没有任何用处。
在这种情况下,我们可以使用read,这与listen类似,但不会导致Provider在获得的值改变时重新创建它的值。
在这种情况下,一个常见的做法是将ref.read传递给创建的对象。然后,创建的对象将能够随时读取Provider。
final userTokenProvider = StateProvider<String>((ref) => null);
final repositoryProvider = Provider((ref) => Repository(ref.read));
class Repository {
Repository(this.read);
/// The `ref.read` function
final Reader read;
Future<Catalog> fetchCatalog() async {
String token = read(userTokenProvider);
final response = await dio.get('/path', queryParameters: {
'token': token,
});
return Catalog.fromJson(response.data);
}
}
你也可以把ref而不是ref.read传给你的对象。
final repositoryProvider = Provider((ref) => Repository(ref)); class Repository { Repository(this.ref); final Ref ref; }
传递ref.read带来的唯一区别是,它略微不那么冗长,并确保我们的对象永远不会使用ref.watch。
但是,永远不要像下面这样做。
final myProvider = Provider((ref) {
// Bad practice to call `read` here
final value = ref.read(anotherProvider);
});
如果你使用read作为尝试去避免太多的刷新重建,可以参考后面的FAQ
How to test an object that receives read as a parameter of its constructor?
如果你正在使用《我可以在不监听Provider的情况下读取它吗》中描述的模式,你可能想知道如何为你的对象编写测试。
在这种情况下,考虑直接测试Provider而不是原始对象。你可以通过使用ProviderContainer类来做到这一点。
final repositoryProvider = Provider((ref) => Repository(ref.read));
test('fetches catalog', () async {
final container = ProviderContainer();
addTearOff(container.dispose);
Repository repository = container.read(repositoryProvider);
await expectLater(
repository.fetchCatalog(),
completion(Catalog()),
);
});
My provider updates too often, what can I do?
如果你的对象被重新创建得太频繁,你的Provider很可能在监听它不关心的对象。
例如,你可能在监听一个配置对象,但只使用host属性。
通过监听整个配置对象,如果host以外的属性发生变化,这仍然会导致你的Provider被重新评估--这可能是不希望的。
这个问题的解决方案是创建一个单独的Provider,只公开你在配置中需要的东西(所以是host)。
应当避免像下面的代码一样,对整个对象进行监听。
final configProvider = StreamProvider<Configuration>(...);
final productsProvider = FutureProvider<List<Product>>((ref) async {
// Will cause productsProvider to re-fetch the products if anything in the
// configurations changes
final configs = await ref.watch(configProvider.future);
return dio.get('${configs.host}/products');
});
当你只需要一个对象的单一属性时,更应该使用select。
final configProvider = StreamProvider<Configuration>(...);
final productsProvider = FutureProvider<List<Product>>((ref) async {
// Listens only to the host. If something else in the configurations
// changes, this will not pointlessly re-evaluate our provider.
final host = await ref.watch(configProvider.selectAsync((config) => config.host));
return dio.get('$host/products');
});
这将只在host发生变化时重建 productsProvider。
通过这三篇文章,相信大家已经能熟练的对Riverpod进行使用了,相比package:Provider,Riverpod的使用更加简单和灵活,这也是我推荐它的一个非常重要的原因,在入门之后,大家可以根据文档中作者提供的示例来进行学习,充分的了解Riverpod在实战中的使用技巧。
推荐原作者博客: xuyisheng.top/