7

重走Flutter状态管理之路—Riverpod最终篇

 1 year ago
source link: https://xuyisheng.top/riverpod3/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

最后一篇文章,我们在掌握了如何读取状态值,并知道如何根据不同场景选择不同类型的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非常灵活的一个体现。

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在实战中的使用技巧。

向大家推荐下我的网站 https://xuyisheng.top/ 专注 Android-Kotlin-Flutter 欢迎大家访问

heroqr.png

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK