跳到主要内容

动机

这篇深入的文章旨在说明为什么需要 Riverpod 的动机。

特别是,本节应回答以下问题:

  • 既然 Provider 广受欢迎,为什么要迁移到 Riverpod?
  • 我能获得哪些具体优势?
  • 如何迁移到 Riverpod?
  • 我可以增量迁移吗?
  • 等等……

在本节结束时,您应该确信 Riverpod 优先于 Provider。

与 Provider 相比,Riverpod 确实是一种更现代、更推荐和更可靠的方法。

Riverpod 提供更好的状态管理功能、更好的缓存策略和简化的响应式模型。
然而,Provider 目前在许多领域都缺乏,没有前进的道路。

Provider 的局限性​

Provider 存在根本问题是由于受到 InheritedWidget API 的限制。
从本质上讲,Provider 是一个“更简单的 InheritedWidget”; Provider 只是一个 InheritedWidget 包装器,因此它受到它的限制。

下面是已知的提供者程序问题列表。

提供者程序不能保留两个(或多个)相同“类型”的提供者程序​

声明两个 Provider<Item> 将导致不可靠的行为: InheritedWidget 的 API 只能获取两者中的一个:即最接近 Provider<Item> 的祖先。
虽然 Provider 的文档中解释了解决方法,但 Riverpod 根本没有这个问题。

通过消除这个限制,我们可以自由地将逻辑拆分为小块,如下所示:

final itemsProvider = Provider.autoDispose(
(ref) => <Item>[], // ...
);

final evenItemsProvider = Provider.autoDispose((ref) {
final items = ref.watch(itemsProvider);
return [...items.whereIndexed((index, element) => index.isEven)];
});

提供者程序一次合理地只发出一个值​

读取外部 RESTful API 时,通常会显示上次读取值,而新调用会加载下一个读取值。
Riverpod 通过其 AsyncValue 的 API 一次发出两个值(即一个前一个数据值和一个传入的新加载值)来允许这种行为:

final itemsApiProvider = FutureProvider.autoDispose((ref) async {
final client = Dio();
final result = await client.get<List<dynamic>>('your-favorite-api');
final parsed = [...result.data!.map((e) => Item.fromJson(e as Json))];
return parsed;
});

final evenItemsProvider = Provider.autoDispose((ref) {
final asyncValue = ref.watch(itemsApiProvider);
if (asyncValue.isLoading) return <Item>[];
if (asyncValue.hasError) return const [Item(id: -1)];

final items = asyncValue.requireValue;

return [...items.whereIndexed((index, element) => index.isEven)];
});

在前面的代码片段中,观察 evenItemsProvider 将产生以下效果:

  1. 最初,正在发出请求。我们得到一个空列表;
  2. 然后,假设发生错误。我们获得 [Item(id: -1)];
  3. 然后,我们使用拉取刷新逻辑重试请求(例如,通过 ref.invalidate);
  4. 当我们重新加载第一个提供者程序时,第二个提供者程序仍然公开 [Item(id: -1)];
  5. 这一次,一些解析后的数据被正确接收:我们的偶数项被正确返回。

使用 Provider,上述功能无法远程实现,甚至更难解决。

合并提供者程序很困难且容易出错​

对于 Provider,我们可能很想在 provider 的 create 中使用 context.watch。
这将是不可靠的,因为即使没有依赖项发生更改(例如,当小部件树中涉及 GlobalKey 时), didChangeDependencies 也可能被触发。

尽管如此,Provider 有一个名为 ProxyProvider 的临时解决方案,但它被认为是乏味且容易出错的。

合并状态是 Riverpod 的核心机制,因为我们可以使用简单而强大的方法(如 ref.watch 和 ref.listen)以零开销组合和缓存值:

final numberProvider = Provider.autoDispose((ref) {
return Random().nextInt(10);
});

final doubledProvider = Provider.autoDispose((ref) {
final number = ref.watch(numberProvider);

return number * 2;
});

使用 Riverpod 组合值感觉很自然:依赖项是可读的,并且 API 保持不变。

缺乏安全性​

使用 Provider,在重构和/或大型更改期间以 ProviderNotFoundException 结束是很常见的。
事实上,这个运行时异常是最初创建 Riverpod 的主要原因之一。

尽管它带来了比这更多的实用性,但 Riverpod 根本无法抛出此异常。

处置状态很困难​

InheritedWidget 无法对消费者程序停止监听他们的情况做出反应。
这可以防止提供者程序在不再使用时自动处置其提供者程序的状态。
在使用 Provider 的情况下,我们必须依靠作用域提供者程序在停止使用状态时对其进行处置。
但这并不容易,因为当在页面之间共享状态时,它会变得棘手。

Riverpod 通过易于理解的 API(如 autodispose 和 keepAlive)解决了这个问题。
这两个 API 支持灵活且创造性的缓存策略(例如基于时间的缓存):

final diceRollProvider = Provider.autoDispose((ref) {
// Since this provider is .autoDispose, un-listening to it will dispose
// its current exposed state.
// Then, whenever this provider is listened to again,
// a new dice will be rolled and exposed again.
final dice = Random().nextInt(10);
return dice.isEven;
});

final cachedDiceRollProvider = Provider.autoDispose((ref) {
final coin = Random().nextInt(10);
if (coin > 5) throw Exception('Way too large.');
// The above condition might fail;
// If it doesn't, the following instruction tells the Provider
// to keep its cached state, even when no one listens to it anymore.
ref.keepAlive();
return coin.isEven;
});

不幸的是,没有办法用原始 InheritedWidget 的来实现这一点,因此没有办法用 Provider 来实现。

缺乏可靠的参数化机制​

Riverpod 允许其用户使用 .family 修饰符声明“参数化”提供者程序。
事实上,这是 Riverpod 最强大的功能之一,也是其创新的核心, 例如,.family 它能够极大地简化逻辑。

如果我们想使用 Provider 实现类似的东西, 我们将不得不放弃这些参数的易用性和类型安全性。

此外,无法使用 Provider 实现类似的 .autoDispose 机制 本身就阻止了 .family 的任何等效实现,因为这两个功能是齐头并进的。

最后,如前所述,事实证明,小部件永远不会停止收听 InheritedWidget。
这意味着如果某些提供者程序状态是“动态挂载”的, 即当使用参数构建提供者程序时,则会出现严重的内存泄漏,而这正是 .family 这样做的。
因此,目前从根本上不可能获得 Provider 的 .family 等价物。

测试很乏味​

为了能够编写测试,您必须在每个测试中重新定义提供者程序。

默认情况下,借助 Riverpod,提供者程序已准备好在内部测试中使用。 此外,Riverpod 还公开了一组方便的“覆盖”的工具,这些实用程序在模拟提供者程序时至关重要。

测试上面的组合状态代码段非常简单,如下所示:

void main() {
test('it doubles the value correctly', () {
final container = ProviderContainer(
overrides: [numberProvider.overrideWith((ref) => 9)],
);
final doubled = container.read(doubledProvider);
expect(doubled, 9 * 2);
});
}

有关测试的详细信息,请参阅测试。

引发副作用并不简单​

由于 InheritedWidget 没有 onChange 回调,因此 Provider 也没有回调。
这对于导航来说是有问题的,例如小吃栏、模态等。

相反,Riverpod 只是提供 ref.listen,它与 Flutter 很好地集成在一起。

class DiceRollWidget extends ConsumerWidget {
const DiceRollWidget({super.key});


Widget build(BuildContext context, WidgetRef ref) {
ref.listen(diceRollProvider, (previous, next) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Dice roll! We got: $next')),
);
});
return TextButton.icon(
onPressed: () => ref.invalidate(diceRollProvider),
icon: const Icon(Icons.casino),
label: const Text('Roll a dice'),
);
}
}

转向 Riverpod​

从概念上讲,Riverpod 和 Provider 非常相似。 这两个包都扮演着类似的角色。两者都尝试:

  • 缓存和处置一些有状态对象;
  • 提供一种在测试期间模拟这些对象的方法;
  • 为 Widget 提供了一种以简单的方式监听这些对象的方法。

你可以把 Riverpod 想象成 Provider 在几年内继续成熟时的样子。

为什么要单独建包?​

最初,计划发布 Provider 的主要版本,以解决上述问题。
但随后决定反对它,因为由于新的 ConsumerWidget API,这将“太麻烦”甚至有争议。
由于 Provider 仍然是最常用的 Flutter 包之一,因此决定创建一个单独的包,因此创建了 Riverpod。

启用创建单独的包:

  • 通过同时临时使用这两种方法,为任何想要迁移的人提供便利;
  • 如果人们原则上不喜欢 Riverpod,或者他们觉得它还不可靠,请允许他们坚持使用 Provider;
  • 实验,允许 Riverpod 搜索生产就绪的解决方案,以应对各种提供者程序的技术限制。

事实上,Riverpod 旨在成为 Provider 的精神继承者。因此得名“Riverpod”(它是“Provider”的字谜,异位词)。

破坏性变化​

Riverpod 唯一真正的缺点是它需要更改小部件类型才能工作:

  • 使用 Riverpod,您应该扩展 ConsumerWidget,而不是扩展 StatelessWidget。
  • 使用 Riverpod,您应该扩展 ConsumerStatefulWidget,而不是扩展 StatefulWidget。

但这种不便在宏伟的计划中是相当小的。有朝一日,这种要求可能会消失。

选择正确的库​

您可能会问自己:“那么,作为 Provider 用户,我应该使用 Provider 还是 Riverpod?”

我们想非常清楚地回答这个问题:

您可能应该使用 Riverpod

Riverpod 总体上设计得更好,可以大大简化您的逻辑。