动机
这篇深入的文章旨在说明为什么需要 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(Ref ref) {
return []; // ...
}
List<Item> evenItems(Ref ref) {
final items = ref.watch(itemsProvider);
return [...items.whereIndexed((index, element) => index.isEven)];
}
提供者程序一次合理地只发出一个值
读取外部 RESTful API 时,通常会显示上次读取值,而新调用会加载下一个读取值。
Riverpod 通过其 AsyncValue
的 API 一次发出两个值(即一个前一个数据值和一个传入的新加载值)来允许这种行为:
Future<List<Item>> itemsApi(Ref 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(Ref 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
将产生以下效果:
- 最初,正在发出请求。我们得到一个空列表;
- 然后,假设发生错误。我们获得
[Item(id: -1)]
; - 然后,我们使用拉取刷新逻辑重试请求(例如,通过
ref.invalidate
); - 当我们重新加载第一个提供者程序时,第二个提供者程序仍然公开
[Item(id: -1)]
; - 这一次,一些解析后的数据被正确接收:我们的偶数项被正确返回。
使用 Provider,上述功能无法远程实现,甚至更难解决。
合并提供者程序很困难且容易出错
对于 Provider,我们可能很想在 provider 的 create
中使用 context.watch
。
这将是不可靠的,因为即使没有依赖项发生更改(例如,当小部件树中涉及 GlobalKey 时),
didChangeDependencies
也可能被触发。
尽管如此,Provider 有一个名为 ProxyProvider
的临时解决方案,但它被认为是乏味且容易出错的。
合并状态是 Riverpod 的核心机制,因为我们可以使用简单而强大的方法(如 ref.watch 和 ref.listen)以零开销组合和缓存值:
int number(Ref ref) {
return Random().nextInt(10);
}
int doubled(Ref ref) {
final number = ref.watch(numberProvider);
return number * 2;
}
使用 Riverpod 组合值感觉很自然:依赖项是可读的,并且 API 保持不变。
缺乏安全性
使用 Provider,在重构和/或大型更改期间以 ProviderNotFoundException
结束是很常见的。
事实上,这个运行时异常是最初创建 Riverpod 的主要原因之一。
尽管它带来了比这更多的实用性,但 Riverpod 根本无法抛出此异常。
处置状态很困难
InheritedWidget
无法对消费者程序停止监听他们的情况做出反应。
这可以防止提供者程序在不再使用时自动处置其提供者程序的状态。
在使用 Provider 的情况下,我们必须依靠作用域提供者程序在停止使用状态时对其进行处置。
但这并不容易,因为当在页面之间共享状态时,它会变得棘手。
Riverpod 通过易于理解的 API(如 autodispose 和 keepAlive)解决了这个问题。
这两个 API 支持灵活且创造性的缓存策略(例如基于时间的缓存):
// With code gen, .autoDispose is the default
int diceRoll(Ref 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(Ref 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 总体上设计得更好,可以大大简化您的逻辑。