flutter状态管理:Riverpod 高级

作者: xuyisheng 来源:juejin.cn 更新时间:2023-05-25 21:55


最后一篇文章,我们在掌握了如何读取状态值,并知道如何根据不同场景选择不同类型的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传递多个值的方法。另一方面,这个值可以是任何东西(只要它符合前面提到的限制)。

这包括下面这些类型。

下面是一个对多个参数使用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/