重走Flutter状态管理之路—Riverpod入门篇

2022-05-17 09:27:15 浏览数 (1)

熟悉我的朋友应该都知道,我好几年前写过一个「Flutter状态管理之路」系列,那个时候介绍的是Provider,这也是官方推荐的状态管理工具,但当时没有写完,因为写着写着,觉得有很多地方不尽人意,用着很别扭,所以在写了7篇文章之后,就暂时搁置了。

一晃时间过了这么久,Flutter内部依然没有一个能够碾压一切的状态管理框架,GetX可能是,但是我觉得不是,InheritedWidget系的状态管理,才应该是正统的状态管理。

最近在留意Provider的后续进展时,意外发现了一个新的库——Riverpod,号称是新一代的状态管理工具,仔细一看,嘿,居然还是Provider的作者,好家伙,这是搬起石头砸自己的脚啊。

就像作者所说,Riverpod就是对Provider的重写,可不是吗,字母都没变,就换了个顺序,这名字也是取的博大精深。

其实Provider在使用上已经非常不错了,只不过随着Flutter的更加深入,大家对它的需求也就越来越高,特别是对Provider中因为InheritedWidget层次问题导致的异常和BuildContext的使用这些问题诟病很多,而Riverpod,正是在Provider的基础上,探索出了一条心的状态管理之路。

大家可以先把官方文档看一看 https://riverpod.dev ,看完之后发现还是一脸懵逼,那就对了,Riverpod和Provider一样,有很多类型的Provider,分别用于不同的场景,所以,理清这些Provider的不同作用和使用场景,对于我们用好Riverpod是非常有帮助的。

官网的文档,虽然是作者精心编写的,但它的教程,站在的是一个创作者的角度,所以很多入门的初学者看上去会有点摸不清方向,所以,这才有了这个系列的文章。

我将在这个系列中,带领大家对文档进行一次精读,进行一次赏析,本文不全是对文档的翻译,而且讲解的顺序也不一样,所以,如果你想入门Riverpod进行状态管理,那么本文一定是你的最佳选择。

Provider第一眼

首先,我们为什么要进行状态管理,状态管理是解决申明式UI开发,关于数据状态的一个处理操作,例如Widget A依赖于同级的Widget B的数据,那么这个时候,就只能把数据状态上提到它们的父类,但是这样比较麻烦,Riverpod和Provider这样的状态管理框架,就是为了解决类似的问题而产生的。

将一个state包裹在一个Provider中可以有下面一些好处。

  • 允许在多个位置轻松访问该状态。Provider可以完全替代Singletons、Service Locators、依赖注入或InheritedWidgets等模式
  • 简化了这个状态与其他状态的结合,你有没有为,如何把多个对象合并成一个而苦恼过?这种场景可以直接在Provider内部实现
  • 实现了性能优化。无论是过滤Widget的重建,还是缓存昂贵的状态计算;Provider确保只有受状态变化影响的部分才被重新计算
  • 增加了你的应用程序的可测试性。使用Provider,你不需要复杂的setUp/tearDown步骤。此外,任何Provider都可以被重写,以便在测试期间有不同的行为,这可以轻松地测试一个非常具体的行为
  • 允许与高级功能轻松集成,如logging或pull-to-refresh

首先,我们通过一个简单的例子,来感受下,Riverpod是怎么进行状态管理的。

Provider是Riverpod应用程序中最重要的部分。Provider是一个对象,它封装了一个state并允许监听该state。Provider有很多变体形式,但它们的工作方式都是一样的。

最常见的用法是将它们声明为全局常量,例如下面这样。

代码语言:javascript复制
final myProvider = Provider((ref) {
  return MyValue();
});

❝不要被Provider的全局变量所吓倒。Provider是完全final的。声明一个Provider与声明一个函数没有什么不同,而且Provider是可测试和可维护的。 ❞

这段代码由三个部分组成。

  • final myProvider,一个变量的声明。这个变量是我们将来用来读取我们Provider的状态的。Provider应该始终是final的
  • Provider,我们决定使用的Provider类型。Provider是所有Provider类型中最基本的。它暴露了一个永不改变的对象。我们可以用其他Provider如StreamProvider或StateNotifierProvider来替换Provider,以改变值的交互方式
  • 一个创建共享状态的函数。该函数将始终接收一个名为ref的对象作为参数。这个对象允许我们读取其他Provider,在我们Provider的状态将被销毁时执行一些操作,以及其它一些事情

传递给Provider的函数返回的对象的类型,取决于所使用的Provider。例如,一个Provider的函数可以创建任何对象。另一方面,StreamProvider的回调将被期望返回一个Stream。

你可以不受限制地声明你想要的多个Provider。与使用package:provider不同的是,Riverpod允许创建多个暴露相同 "类型 "的状态的provider。

代码语言:javascript复制
final cityProvider = Provider((ref) => 'London');
final countryProvider = Provider((ref) => 'England');

两个Provider都创建了一个字符串,但这并没有任何问题。

为了使Provider发挥作用,您必须在Flutter应用程序的根部添加ProviderScope。

代码语言:javascript复制
void main() {
  runApp(ProviderScope(child: MyApp()));
}

以上就是Riverpod最简单的使用,我们看下完整的示例代码。

代码语言:javascript复制
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// We create a "provider", which will store a value (here "Hello world").
// By using a provider, this allows us to mock/override the value exposed.
final helloWorldProvider = Provider((_) => 'Hello world');

void main() {
  runApp(
    // For widgets to be able to read providers, we need to wrap the entire
    // application in a "ProviderScope" widget.
    // This is where the state of our providers will be stored.
    ProviderScope(
      child: MyApp(),
    ),
  );
}

// Extend ConsumerWidget instead of StatelessWidget, which is exposed by Riverpod
class MyApp extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final String value = ref.watch(helloWorldProvider);

    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('Example')),
        body: Center(
          child: Text(value),
        ),
      ),
    );
  }
}

可以发现,Riverpod的使用比package:Provider还要简单,申明一个全局变量来管理状态数据,然后就可以在任意地方获取数据了。

如何读取Provider的状态值

在有了一个简单的了解后,我们先来了解下关于状态中的「读」。

在Riverpod中,我们不像package:Provider那样需要依赖BuildContext,取而代之的是一个「ref」变量。这个东西,就是联系存取双方的纽带,这个对象允许我们与Provider互动,不管是来自一个Widget还是另一个Provider。

从Provider中获取ref

所有Provider都有一个 "ref "作为参数。

代码语言:javascript复制
final provider = Provider((ref) {
  // use ref to obtain other providers
  final repository = ref.watch(repositoryProvider);

  return SomeValue(repository);
})

这个参数可以安全地传递给其它Provider或者类,来获取所需要的值。

例如,一个常见的用例是将Provider的 "ref "传递给一个StateNotifier。

代码语言:javascript复制
final counterProvider = StateNotifierProvider<Counter, int>((ref) {
  return Counter(ref);
});

class Counter extends StateNotifier<int> {
  Counter(this.ref): super(0);

  final Ref ref;

  void increment() {
    // Counter can use the "ref" to read other providers
    final repository = ref.read(repositoryProvider);
    repository.post('...');
  }
}

这样做,可以使我们的Counter类能够读取Provider。

❝这种方式是联系组件和Provider的一个重要方式。 ❞

从Widget中获取ref

Widgets自然没有一个ref参数。但是Riverpod提供了多种解决方案来从widget中获得这个参数。

扩展ConsumerWidget

在widget树中获得一个ref的最常见的方法是用ConsumerWidget代替StatelessWidget。

ConsumerWidget在使用上与StatelessWidget相同,唯一的区别是它的构建方法上有一个额外的参数:"ref "对象。

一个典型的ConsumerWidget看起来像这样。

代码语言:javascript复制
class HomeView extends ConsumerWidget {
  const HomeView({Key? key}): super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // use ref to listen to a provider
    final counter = ref.watch(counterProvider);
    return Text('$counter');
  }
}
扩展ConsumerStatefulWidget

与ConsumerWidget类似,ConsumerStatefulWidget和ConsumerState相当于一个带有状态的StatefulWidget,不同的是,state有一个 "ref "对象。

这一次,"ref "不是作为构建方法的参数传递,而是作为ConsumerState对象的一个属性。

代码语言:javascript复制
class HomeView extends ConsumerStatefulWidget {
  const HomeView({Key? key}): super(key: key);

  @override
  HomeViewState createState() => HomeViewState();
}

class HomeViewState extends ConsumerState<HomeView> {
  @override
  void initState() {
    super.initState();
    // "ref" can be used in all life-cycles of a StatefulWidget.
    ref.read(counterProvider);
  }

  @override
  Widget build(BuildContext context) {
    // We can also use "ref" to listen to a provider inside the build method
    final counter = ref.watch(counterProvider);
    return Text('$counter');
  }
}

通过ref来获取状态

现在我们有了一个 "ref",我们可以开始使用它。

ref "有三个主要用途。

  • 获得一个Provider的值并监听变化,这样,当这个值发生变化时,这将重建订阅该值的Widget或Provider。这是通过ref.watch完成的
  • 在一个Provider上添加一个监听器,以执行一个action,如导航到一个新的页面或在该Provider发生变化时执行一些操作。这是通过 ref.listen 完成的
  • 获取一个Provider的值,同时忽略它的变化。当我们在一个事件中需要一个Provider的值时,这很有用,比如 "点击操作"。这是通过ref.read完成的

❝只要有可能,最好使用 ref.watch 而不是 ref.read 或 ref.listen 来实现一个功能。通过依赖ref.watch,你的应用程序变得既是反应式的又是声明式的,这使得它更容易维护。 ❞

通过ref.watch观察Provider的状态

ref.watch在Widget的构建方法中使用,或者在Provider的主体中使用,以使得Widget/Provider可以监听另一个Provider。

例如,Provider可以使用 ref.watch 来将多个Provider合并成一个新的值。

一个例子是过滤一个todo-list,我们需要两个Provider。

  • filterTypeProvider,一个暴露当前过滤器类型的Provider(None,表示只显示已完成的任务)
  • todosProvider,一个暴露整个任务列表的Provider

通过使用ref.watch,我们可以制作第三个Provider,结合这两个Provider来创建一个过滤后的任务列表。

代码语言:javascript复制
final filterTypeProvider = StateProvider<FilterType>((ref) => FilterType.none);
final todosProvider = StateNotifierProvider<TodoList, List<Todo>>((ref) => TodoList());

final filteredTodoListProvider = Provider((ref) {
  // obtains both the filter and the list of todos
  final FilterType filter = ref.watch(filterTypeProvider);
  final List<Todo> todos = ref.watch(todosProvider);

  switch (filter) {
    case FilterType.completed:
      // return the completed list of todos
      return todos.where((todo) => todo.isCompleted).toList();
    case FilterType.none:
      // returns the unfiltered list of todos
      return todos;
  }
});

有了这段代码,filteredTodoListProvider现在就可以管理过滤后的任务列表。

如果过滤器或任务列表发生变化,过滤后的列表也会自动更新。同时,如果过滤器和任务列表都没有改变,过滤后的列表将不会被重新计算。

类似地,一个Widget可以使用ref.watch来显示来自Provider的内容,并在该内容发生变化时更新用户界面。

代码语言:javascript复制
final counterProvider = StateProvider((ref) => 0);

class HomeView extends ConsumerWidget {
  const HomeView({Key? key}): super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // use ref to listen to a provider
    final counter = ref.watch(counterProvider);

    return Text('$counter');
  }
}

这段代码显示了一个Widget,它监听了一个存储计数的Provider。如果该计数发生变化,该Widget将重建,用户界面将更新以显示新的值。

❝ref.watch方法不应该被异步调用,比如在ElevatedButton的onPressed中。也不应该在initState和其他State的生命周期内使用它。在这些情况下,考虑使用 ref.read 来代替。 ❞

通过ref.listen监听Provider的变化

与ref.watch类似,可以使用ref.listen来观察一个Provider。

它们之间的主要区别是,如果被监听的Provider发生变化,使用ref.listen不会重建widget/provider,而是会调用一个自定义函数。

这对于在某个变化发生时执行某些操作是很有用的,比如在发生错误时显示一个snackbar。

ref.listen方法需要2个参数,第一个是Provider,第二个是当状态改变时我们要执行的回调函数。回调函数在被调用时将被传递2个值,即先前状态的值和新状态的值。

ref.listen方法也可以在Provider的体内使用。

代码语言:javascript复制
final counterProvider = StateNotifierProvider<Counter, int>((ref) => Counter(ref));

final anotherProvider = Provider((ref) {
  ref.listen<int>(counterProvider, (int? previousCount, int newCount) {
    print('The counter changed $newCount');
  });
  // ...
});

或在一个Widget的Build方法中使用。

代码语言:javascript复制
final counterProvider = StateNotifierProvider<Counter, int>((ref) => Counter(ref));

class HomeView extends ConsumerWidget {
  const HomeView({Key? key}): super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    ref.listen<int>(counterProvider, (int? previousCount, int newCount) {
      print('The counter changed $newCount');
    });
    
    return Container();
  }
}

❝ref.listen也不应该被异步调用,比如在ElevatedButton的onPressed中。也不应该在initState和其他State的生命周期内使用它。 ❞

通过ref.read来读取Provider的状态

ref.read方法是一种在不监听的情况下获取Provider的状态的方法。

它通常用于由用户交互触发的函数中。例如,当用户点击一个按钮时,我们可以使用ref.read来增加一个计数器的值。

代码语言:javascript复制
final counterProvider = StateNotifierProvider<Counter, int>((ref) => Counter(ref));

class HomeView extends ConsumerWidget {
  const HomeView({Key? key}): super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          // Call `increment()` on the `Counter` class
          ref.read(counterProvider.notifier).increment();
        },
      ),
    );
  }
}

❝应该尽可能地避免使用ref.read,因为它不是响应式的。 它存在于使用watch或listen会导致问题的情况下。如果可以的话,使用watch/listen几乎总是更好的,尤其是watch。 ❞

关于ref.read到底什么时候用

首先,永远不要在Widget的build函数中直接使用ref.read。

你可能很想使用ref.read来优化一个Widget的性能,例如通过下面的代码来实现。

代码语言:javascript复制
final counterProvider = StateProvider((ref) => 0);

Widget build(BuildContext context, WidgetRef ref) {
  // use "read" to ignore updates on a provider
  final counter = ref.read(counterProvider.notifier);
  return ElevatedButton(
    onPressed: () => counter.state  ,
    child: const Text('button'),
  );
}

但这是一种非常糟糕的做法,会导致难以追踪的错误。

以这种方式使用 ref.read 通常与这样的想法有关:"Provider所暴露的值永远不会改变,所以使用'ref.read'是安全的"。这个假设的问题是,虽然今天该Provider可能确实从未更新过它的值,但不能保证明天也是如此。

软件往往变化很大,而且很可能在未来,一个以前从未改变的值需要改变。

如果你使用ref.read,当这个值需要改变时,你必须翻阅整个代码库,将ref.read改为ref.watch--这很容易出错,而且你很可能会忘记一些情况。

如果你一开始就使用ref.watch,你在重构时就会减少问题。

但是如果我想用ref.read来减少我的widget重构的次数呢?

虽然这个目标值得称赞,但需要注意的是,你可以用ref.watch代替来达到完全相同的效果(减少构建的次数)。

Provider提供了各种方法来获得一个值,同时减少重建的次数,你可以用这些方法来代替。

例如下面的代码(bad)。

代码语言:javascript复制
final counterProvider = StateProvider((ref) => 0);

Widget build(BuildContext context, WidgetRef ref) {
  StateController<int> counter = ref.read(counterProvider.notifier);
  return ElevatedButton(
    onPressed: () => counter.state  ,
    child: const Text('button'),
  );
}

我们可以这样改。

代码语言:javascript复制
final counterProvider = StateProvider((ref) => 0);

Widget build(BuildContext context, WidgetRef ref) {
  StateController<int> counter = ref.watch(counterProvider.notifier);
  return ElevatedButton(
    onPressed: () => counter.state  ,
    child: const Text('button'),
  );
}

这两个片段代码都达到了同样的效果:当计数器增加时,我们的按钮将不会重建。

另一方面,第二种方法支持计数器被重置的情况。例如,应用程序的另一部分可以调用。

代码语言:javascript复制
ref.refresh(counterProvider);

这将重新创建StateController对象。

如果我们在这里使用ref.read,我们的按钮仍然会使用之前的StateController实例,而这个实例已经被弃置,不应该再被使用。

而使用ref.watch则可以正确地重建按钮,使用新的StateController。

关于ref.read可以读哪些值

根据你想监听的Provider,你可能有多个可能的值可以监听。

作为一个例子,考虑下面的StreamProvider。

代码语言:javascript复制
final userProvider = StreamProvider<User>(...);

当读取这个userProvider时,你可以像下面这样。

  • 通过监听userProvider本身同步读取当前状态。
代码语言:javascript复制
Widget build(BuildContext context, WidgetRef ref) {
  AsyncValue<User> user = ref.watch(userProvider);

  return user.when(
    loading: () => const CircularProgressIndicator(),
    error: (error, stack) => const Text('Oops'),
    data: (user) => Text(user.name),
  );
}
  • 通过监听userProvider.stream来获得相关的Stream。
代码语言:javascript复制
Widget build(BuildContext context, WidgetRef ref) {
  Stream<User> user = ref.watch(userProvider.stream);
}
  • 通过监听userProvider.future获得一个Future,该Future以最新发出的值进行解析。
代码语言:javascript复制
Widget build(BuildContext context, WidgetRef ref) {
  Future<User> user = ref.watch(userProvider.future);
}

其他Provider可能提供不同的替代值。

欲了解更多信息,请查阅API参考资料,参考每个Provider的API文档。

通过select来控制精确的读范围

最后要提到的一个与读取Provider有关的功能是,能够减少Widget/Provider从ref.watch重建的次数,或者ref.listen执行函数的频率的功能。

这一点很重要,因为默认情况下,监听一个Provider会监听整个对象的状态。但有时,一个Widget/Provider可能只关心一些属性的变化,而不是整个对象。

例如,一个Provider可能暴露了一个User对象。

代码语言:javascript复制
abstract class User {
  String get name;
  int get age;
}

但一个Widget可能只使用用户名。

代码语言:javascript复制
Widget build(BuildContext context, WidgetRef ref) {
  User user = ref.watch(userProvider);
  return Text(user.name);
}

如果我们简单地使用ref.watch,当用户的年龄发生变化时,这将重建widget。

解决方案是使用select来明确地告诉Riverpod我们只想监听用户的名字属性。

更新后的代码将是这样。

代码语言:javascript复制
Widget build(BuildContext context, WidgetRef ref) {
  String name = ref.watch(userProvider.select((user) => user.name));
  return Text(name);
}

通过使用select,我们能够指定一个函数来返回我们关心的属性。

每当用户改变时,Riverpod将调用这个函数并比较之前和新的结果。如果它们是不同的(例如当名字改变时),Riverpod将重建Widget。然而,如果它们是相等的(例如当年龄改变时),Riverpod将不会重建Widget。

这个场景也可以使用select和ref.listen。

代码语言:javascript复制
ref.listen<String>(
      userProvider.select((user) => user.name),
          (String? previousName, String newName) {
         print('The user name changed $newName');
      }
  );

这样做也将只在名称改变时调用listener。

另外,你不一定要返回对象的一个属性。任何覆盖==的值都可以使用。例如,你可以这样做。

代码语言:javascript复制
final label = ref.watch(userProvider.select((user) => 'Mr ${user.name}'));

读取状态,是一个非常重要的部分,什么时候用什么样的方式来读,都会有不同的效果。

ProviderObserver

ProviderObserver可以监听一个ProviderContainer的变化。

要使用它,你可以扩展ProviderObserver类并覆盖你想使用的方法。ProviderObserver有三个方法。

  • didAddProvider:在每次初始化一个Provider时被调用
  • didDisposeProvider:在每次销毁Provider的时候被调用
  • didUpdateProvider:每次在Provider更新时都会被调用

ProviderObserver的一个简单用例是通过覆盖didUpdateProvider方法来记录Provider的变化。

代码语言:javascript复制
// A Counter example implemented with riverpod with Logger

class Logger extends ProviderObserver {
  @override
  void didUpdateProvider(
    ProviderBase provider,
    Object? previousValue,
    Object? newValue,
    ProviderContainer container,
  ) {
    print('''
{
  "provider": "${provider.name ?? provider.runtimeType}",
  "newValue": "$newValue"
}''');
  }
}

void main() {
  runApp(
    // Adding ProviderScope enables Riverpod for the entire project
    // Adding our Logger to the list of observers
    ProviderScope(observers: [Logger()], child: const MyApp()),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(home: Home());
  }
}

final counterProvider = StateProvider((ref) => 0, name: 'counter');

class Home extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Counter example')),
      body: Center(
        child: Text('$count'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => ref.read(counterProvider.notifier).state  ,
        child: const Icon(Icons.add),
      ),
    );
  }
}

现在,每当我们的Provider的值被更新时,logger将记录它。

代码语言:javascript复制
I/flutter (16783): {
I/flutter (16783):   "provider": "counter",
I/flutter (16783):   "newValue": "1"
I/flutter (16783): }

❝对于诸如StateController(StateProvider.state的状态)和ChangeNotifier等可改变的状态,previousValue和newValue将是相同的。因为它们引用的是同一个StateController / ChangeNotifier。 ❞

这些是对Riverpod的最基本了解,但是却是很重要的部分,特别是如何对状态值进行读取,这是我们用好Riverpod的核心。

向大家推荐下我的网站 https://xuyisheng.top/

专注 Android-Kotlin-Flutter 欢迎大家访问

本文原创公众号:群英传,授权转载请联系微信(Tomcat_xu),授权后,请在原创发表24小时后转载。

< END >

作者:徐宜生

更文不易,点个“三连”支持一下

0 人点赞