跳到主要内容

动机

这篇深入的文章旨在说明为什么需要 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 根本没有这个问题。

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



List<Item> items(ItemsRef ref) {
return []; // ...
}


List<Item> evenItems(EvenItemsRef ref) {
final items = ref.watch(itemsProvider);
return [...items.whereIndexed((index, element) => index.isEven)];
}

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

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



Future<List<Item>> itemsApi(ItemsApiRef 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;
}


List<Item> evenItems(EvenItemsRef ref) {
final asyncValue = ref.watch(itemsApiProvider);
if (asyncValue.isReloading) return [];
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.watchref.listen)以零开销组合和缓存值:



int number(NumberRef ref) {
return Random().nextInt(10);
}


int doubled(DoubledRef ref) {
final number = ref.watch(numberProvider);

return number * 2;
}

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

缺乏安全性

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

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

处置状态很困难

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

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


// With code gen, .autoDispose is the default

int diceRoll(DiceRollRef 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;
}


int cachedDiceRoll(CachedDiceRollRef 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;
}

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

缺乏可靠的参数化机制

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

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

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

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

测试很乏味

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

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

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


void main() {
test('it doubles the value correctly', () async {
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 总体上设计得更好,可以大大简化您的逻辑。