欢迎关注公众号OpenFlutter,谢谢
我使用 Riverpod 已经一年多了。在这段时间里,我独立开发或将两个生产环境应用迁移到了 Riverpod,并在过程中总结了一些实用的技巧。
这些技巧源于以下一些顾虑:
- 全局可用的 Provider 很方便,但让它们随处可访问,难道不会带来很多风险吗?
- 当一个界面接收到路由参数时,我如何才能高效地将这些参数传递和管理到深层嵌套的 Widget 结构中?
- 如果我需要在一个无法直接获取
WidgetRef
的地方访问一个 Provider,该如何处理? - 我经常使用
requiredValue
或value
这样的扩展方法来引用async
Provider 的值,但最终经常会遇到StateError
或空值错误...... - 能不能有一种方法,将每个页面中使用的
ConsumerWidget
模块化成一个更方便的工具类?
如果你在使用 Riverpod 时也有过类似的担忧,那么你可能会在这篇文章中找到一些有用的见解。
哦,顺便提一句,标题中提到的 Remi (Rémi Rousselet) 是一位非常著名的天才开发者,他为 Riverpod、Provider 和 Freezed 等被数万名开发者依赖的软件包做出了杰出贡献。
1. 使用 Mixin 类来组织全局 Provider

简而言之,我描述了如何将 Riverpod 的 Provider 声明在全局(顶层)作用域,这样你就可以在任何地方导入和访问它们。尽管这有明显的优势,但它也带来了一个矛盾:当很难确定一个特定的屏幕使用了哪些 Provider 时,就会产生副作用。
为了解决这个问题,我提出了一种方法,将每个屏幕的状态 和事件 都组织到Mixin 类中。让我们快速看一个例子。
HomeState
dart
mixin class HomeState {
List<Todo> filteredTodos(WidgetRef ref) => ref.watch(filteredTodosProvider);
}
HomeEvent
dart
mixin class HomeEvent {
void addTodo(
WidgetRef ref, {
required TextEditingController textEditingController,
required String value,
}) {
ref.read(todoListProvider.notifier).add(value);
textEditingController.clear();
}
}
HomePage

如上所示,引用 Provider 状态的逻辑被放在一个"状态 Mixin 类"中,而改变或引用这些状态来执行特定事件的逻辑,则被组织在"事件 Mixin 类"中。然后,我们将这些 Mixin 应用于页面。

这种结构的一个优势是,它能清晰地分离并让你轻松追踪哪些 Provider 在哪里被使用了。
只用两个类(State和Event Mixin)就能推断出哪些状态在页面中被传递,以及可能发生哪些事件,这能让你和你的队友都感到非常清晰。
这与我们选择好的变量名是同一个道理。
这也有助于提高可读性 和减少复杂性 。举个例子,假设有一个 ProductDetail
页面需要触发与 Home
页面上一个按钮点击时相同的事件。如果这个按钮点击的方法是在 UI 代码中定义的,那它看起来可能像这样。
dart
/// HomePage的一个子控件
Button(
onPress: () {
ref.read(aProvider).someIntent();
ref.read(bProvider).someIntent();
ref.read(aProvider).someIntent();
ref.read(bProvider).someIntent();
ref.read(fProvider).someIntent();
}
)
如果我们需要从 ProductDetailPage
触发同样的事件序列,我们可能需要将该事件方法提取到某个通用模块中,或者(更糟糕地)直接复制粘贴那段代码片段。这两种做法都不理想。
dart
mixin class HomeEvent {
void onBtnTapped() {
ref.read(aProvider).someIntent();
ref.read(bProvider).someIntent();
ref.read(aProvider).someIntent();
ref.read(bProvider).someIntent();
ref.read(fProvider).someIntent();
}
}
然而,如果我们已经将该逻辑放在一个 HomeEvent
的 Mixin 类中:
dart
mixin class ProductDetailEvent {
void onBtnTapped() {
final homeEvent = HomeEvent(); // <-- 初使化mixin class
homeEvent.onBtnTapped();
}
}
我们只需简单地实例化 HomeEvent
的 Mixin,然后调用 onBtnTapped()
。这正是我们使用 mixin class
而不是普通的 mixin
的原因。
2. 使用 ProviderScope 来高效传递和管理页面参数
从一个页面向另一个页面传递参数,这是很常见的。

举个例子,在一个电商应用中,如果你从一个产品列表页跳转到产品详情页,你可能会传递一个唯一的产品 id
。然后,详情页就会使用这个 id
来获取详细的产品数据。
dart
class ProductDetailPage extends ConsumerWidget {
const ProductDetailPage({super.key, required this.id});
final String id;
@override
Widget build(BuildContext context, WidgetRef ref) {
return _Scaffold(
header: _Header(id),
leadingInfoView: _LeadingInfoView(id),
imgListView: _ImgListView(id),
sizeInfoView: _SizeInfoView(id),
descriptionView: _DescriptionView(id),
reviewListView: _ReviewListView(id),
priceInfoView: _PriceInfoView(id),
bottomFixedButton: _BottomFixedButton(id),
);
}
}
举个例子,在电商应用中,ProductDetailPage
会接收一个 id
,然后 UI 会被拆分成多个独立的 Widget,比如评论区、商品描述等等。每个子 Widget 都会使用这个 id
来获取相应的信息。
将路由参数通过深层 UI 结构传递的问题
将 UI 拆分成子 Widget 确实有助于提高可读性并减少不必要的重建,但你现在不得不不断地将 id
从父 Widget 传递给子 Widget。这很快就会变得繁琐。

如果参数的类型发生了变化,你必须在所有相关的地方进行更新;如果更深层的子 Widget 也需要这个 id
,它必须被一层层地传递下去。这个问题被称为"Props 穿透"(Prop Drilling)。
"我不能把所有代码都写在一个文件里,那样就不用到处传参数了吧?" 随着代码变得越来越复杂,那样做会成为维护(和阅读)的噩梦。
我试过很多种变通方法,比如用静态方式(类似 GetX 的风格)管理参数,或者创建自定义的 InheritedWidgets
等等。每种方法都有其优点,但也有明显的局限性。我更倾向的解决方案是利用 ProviderScope
。
在新的 ProviderScope
中初始化参数 Provider
首先,声明一个用于存储/管理参数的 Provider(我们称之为参数 Provider)。
dart
/// 普通的 provider 声明
class ProductDetailArgumentNotifier extends StateNotifier<String> {
ProductDetailArgumentNotifier(super.state);
}
final productDetailArgumentProvider = Provider.autoDispose<String>(
(ref) => throw Exception('Argument must be initialized'),
);
/// 通过注解
part 'product_detail_page_argument_provider.g.dart';
@riverpod
String productDetailArgument(Ref ref) {
throw Exception('Argument must be initialized');
}
因为它的状态必须使用路由参数动态设置,所以我们不定义任何默认值,而是抛出一个 Exception
。此外,因为我们希望当 Widget 被移除时,它能够被销毁 ,所以它应该是一个 autoDispose
的 Provider。
scala
class ProductDetailPage extends ConsumerWidget {
const ProductDetailPage({super.key, required this.id});
final String id;
@override
Widget build(BuildContext context, WidgetRef ref) {
return ProviderScope(
overrides: [
/// 使用 'overrideWithValue' 初始化参数
productDetailArgumentProvider.overrideWithValue(id),
],
child: Consumer(
builder: (context, ref, _) {
return _Scaffold(...);
},
),
);
}
}
接下来,在页面的 build
方法中,将其包裹在一个 ProviderScope
和一个 Consumer
中。在 ProviderScope
的 overrides
里,用 .overrideWithValue(id)
来覆盖 这个 productDetailArgumentProvider
,搞定。
⚠️ 注意 在
ProviderScope
中覆盖之前,该 Provider 不会被初始化,因此请确保你在该作用域下的Consumer
(或HookConsumer
)中安全地访问它。
现在,所有的子 Widget 都可以直接使用 WidgetRef
来访问这个"参数 Provider"了。举个例子,下面是如何获取产品评论。
dart
/// Product review 视图
class _ReviewListView extends ConsumerWidget {
const _ReviewListView({super.key});
// ⬇️ 不需要从父控件获取 id!
// const _ReviewListView({super.key, required this.id});
// final String id;
Widget build(BuildContext context, WidgetRef ref) {
final id = ref.read(productDetailArgumentProvider);
final reviewListAsync = ref.watch(reivewListProvider(id));
return ...
}
}
不再需要将 id
一层层地传递下去了。你只需简单地调用 ref.read(productDetailArgumentProvider)
,就能获取到 id
,然后按照你的需要使用它。

你的 Widget 树越深,或者你需要在单个页面上管理的状态越多,这种方法就越方便。
将参数 Provider 与状态和事件 Mixin 结合
回想一下我们之前提到的 Mixin 方法。如果你正在使用的 Family Provider
依赖于路由参数,那么将两者结合会使代码变得更加简洁。
javascript
mixin class ProductDetailState {
/// ✅ GOOD
AsyncValue<List<Review>> reviewsAsync(WidgetRef ref) {
final id = ref.read(productDetailArgumentProvider);
return ref.watch(reivewListProvider(id));
}
/// ❌ BAD
AsyncValue<List<Review>> reviewsAsync(WidgetRef ref, {required String id}) {
return ref.watch(reivewListProvider(id));
}
}
在这里,Mixin 的方法直接读取"参数 Provider"来获取 id
,所以你只需要将 WidgetRef
作为参数,而不是每次都传递 id
。

让 Provider 本身来读取参数
你还可以进一步简化。如果你的 Family Provider
直接依赖于页面的参数,你可以让这个 Provider 本身来读取这个"参数 Provider",而不是将它作为参数来接收。
dart
/// 常规 provider
class Reviews extends AsyncNotifier<List<String>> {
@override
Future<List<Review>> build() async {
final id = ref.read(productDetailArgumentProvider); // <- Access the route argument here
return await productRepository.getReviews(id);
}
}
final reviewsProvider = AsyncNotifierProvider<Reviews, List<String>>(
dependencies: [productDetailArgumentProvider],
() => Reviews(),
);
/// 使用注解
part 'review_list_provider.g.dart';
@Riverpod(dependencies: [productDetailArgument])
class Reviews extends _$Reviews {
@override
FutureOr<List<Review>> build() async {
final id = ref.read(productDetailArgumentProvider); // <- Access the route argument here
return await productRepository.getReviews(id);
}
}
在这种情况下,请将 dependencies
指定为 [productDetailArgumentProvider]
。这是因为这两个 Provider 存在于不同的 ProviderScope
中,而我们希望这个 Provider 依赖于参数的初始化。然后,你就可以在 Notifier
的 build
方法中直接访问该参数了。
⚠️ 注意 如果你像这样设置了依赖,那么在参数未初始化的作用域之外访问该 Provider 将不起作用。因此,只有在你确定该 Provider 是那个特定屏幕独占时,才使用此模式。
3. 在无法访问 WidgetRef
时,使用 UncontrolledProviderScope
和 ProviderContainer
有时候,你需要在一个无法获取 WidgetRef
的地方访问一个 Provider。
dart
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
...
if (message.category == 'home') {
/// We don't have a WidgetRef here, so ref.read(...) is impossible
ref.read(bottomNavigationIndex.notifier).changeIndex(0);
}
});
举个例子,当你收到一条 FCM 消息,想根据它来改变底部导航栏的索引时,这部分逻辑通常位于一些初始化代码中,而不是在能够访问 WidgetRef
的 Widget 里。我们该如何处理这种情况?
ProviderContainer
登场。
dart
final globalContainer = ProviderContainer();
首先,全局声明一个ProviderContainer
。
dart
runApp(
UncontrolledProviderScope(
container: globalContainer, // <- 这里!
child: ProviderScope(
child: MyApp(),
),
),
);
然后,在你的应用入口点,用一个 UncontrolledProviderScope
包裹 ProviderScope
,并传入 globalContainer
。这将确保同样的全局状态在你的整个应用中都是可用的。
UncontrolledProviderScope
用于将一个已创建的 ProviderContainer
注入到 Widget 树中。与标准的 ProviderScope
(它会创建自己的容器并随 Widget 生命周期管理它)不同,UncontrolledProviderScope
不会管理容器的销毁。通过这种方式,ProviderContainer
就不再受任何 Widget 生命周期的约束了。
通过将容器与 Widget 树解耦,你就可以从非 Widget 层访问它了。
dart
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
...
if (message.category == 'home') {
globalContainer.read(bottomNavigationIndex.notifier).changeIndex(0);
}
});
不再需要WidgetRef
了。
dart
ProviderScope.containerOf(context).read(bottomNavigationIndex.notifier).changeIndex;
如果你有一个 BuildContext
但无法获取 WidgetRef
,你可以使用 ProviderScope.containerOf(context)
来获取 Widget 树中最接近的容器。
4. 访问 Async Provider 状态的最安全方式
在引用 async
Provider 时,你需要小心。
dart
class CartItems extends _$CartItems {
@override
Future<List<ProductEntity>> build() async {
...
}
}
想象一下,这个 Provider 存储着用户的"购物车商品列表",是从服务器获取的。因为它是一个以用户为中心的数据集,所以可能会被 UI 的多个部分引用。

举个例子,假设你有:
dart
Future<void> promptUserToPurchase(WidgetRef ref){
final cartItemsA = ref.read(cartItemsProvider).value;
final cartItemsB = ref.read(cartItemsProvider).valueOrNull;
final cartItemsC = ref.read(cartItemsProvider).requireValue;
}
为了获取购物车中的商品,你需要调用 cartItemsProvider
。但如果它仍然处于**loading
(加载中)或error
(错误)状态,引用 .value
或 .requireValue
可能会失败。如果使用了 .requireValue
而 Provider 尚未完全加载,你就会看到:
swift
'Tried to call `requireValue` on an `AsyncValue` that has no value: $this'
你可能会觉得,当代码执行到 promptUserToPurchase
时,cartItemsProvider
肯定已经加载完了,但这在实际项目中,这种"肯定"随时可能改变**。代码会不断演进,业务逻辑会四处移动,要始终确保加载时机是万无一失的,这很难。
一个更安全的方法是这样。
dart
Future<void> promptUserToPurchase() async {
// 如果 cartItemsProvider 仍在加载中,这会等待直到它就绪。
// 如果它已经加载完毕,则会立即返回现有数据。
final cartItems = await ref.read(cartItemsProvider.future);
}
使用 .future
可以确保当 cartItemsProvider
仍在加载时,该函数会等待直到它准备就绪。如果它已经加载完毕,则会立即返回。无论哪种情况,你都是安全的。这种方法通常更安全,能帮助你避免日后出现令人不快的意外。
5. 终极 Riverpod 页面工具类
最后,我喜欢为每个项目创建一个根据正在使用的状态管理包(在本例中是 Riverpod)量身定制的"页面工具类"。我把它命名为"终极 Riverpod 页面工具类",但它实际上只是一个基础页面,它封装了常用的逻辑片段,这样你就可以在每个页面中轻松地扩展它。
我不会解释代码的每一行。我只给你介绍主要思路,你可以在文章末尾查看完整的 BasePage
代码。
生命周期方法
less
class ProductDetailPage extends BasePage {
const ProductDetailPage({super.key});
/// Called when the page widget is inserted into the widget tree
@override
void onInit(WidgetRef ref) {
...
}
/// Called when the page widget is disposed of
@override
void onDispose(WidgetRef ref) {
...
}
@override
Widget buildPage(BuildContext context, WidgetRef ref) {
return Container();
}
}
页面通常需要在初始化或销毁时触发某些事件。在这里,你只需重写 onInit
和 onDispose
即可。
参数 Provider 初始化
正如之前所描述的,我们可以使用 ProviderScope
来管理路由参数。通常,你必须将所有内容都包裹在 ConsumerWidget
中,然后在 build
方法中添加一个 ProviderScope
来进行 overrideWithValue
。但通过基础页面这个方法。
dart
class ProductDetailPage extends BasePage {
const ProductDetailPage({super.key});
@override
Override? get argProviderOverrides => productDetailRouteArgProvider;
@override
Widget buildPage(BuildContext context, WidgetRef ref) {
return Placeholder();
}
}
我们只需定义 argProviderOverrides
,然后基础页面会处理创建作用域和初始化的事情。
布局属性
通常,页面的构建会用到 Scaffold
,你需要定义 appBar
、body
、backgroundColor
等等。有时你还会用 PopScope
来包裹页面,以处理手势。在每一个页面都做这些事情会让你的 UI 代码变得非常混乱。
在这种基础页面模式中,每个页面只需在 buildPage
方法中实现主体 ,而其他布局属性(例如 AppBar
、backgroundColor
等)则根据需要重写即可。
dart
class ProductDetailPage extends BasePage {
const ProductDetailPage({super.key});
@override
Widget buildPage(BuildContext context, WidgetRef ref) {
...
}
@override
PreferredSizeWidget? buildAppBar(BuildContext context, WidgetRef ref) {...}
@override
Widget? buildFloatingActionButton(WidgetRef ref) {...}
@override
Color? get screenBackgroundColor => Colors.white;
@override
bool get wrapWithSafeArea => false;
@override
bool get extendBodyBehindAppBar => false;
@override
void onWillPop(WidgetRef ref) {...}
}
从功能上讲,这与每次手动编写 Scaffold
是相同的。然而,它能让每个页面非常清晰地表明它正在使用哪些布局选项 ,并且它将常用的交互(如生命周期或 WillPop
钩子)集中管理了。
下面是 BasePage
的完整代码------它包含了更多你可以探索的便利属性。
less
abstract class BasePage extends HookConsumerWidget {
const BasePage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
/// 处理页面 init/dispose
useEffect(
() {
onInit(ref);
return () => onDispose(ref);
},
[],
);
/// 处理 app 生命周期状态
useOnAppLifecycleStateChange((previousState, state) {
switch (state) {
case AppLifecycleState.resumed:
onResumed(ref);
break;
case AppLifecycleState.paused:
onPaused(ref);
break;
case AppLifecycleState.inactive:
onInactive(ref);
break;
case AppLifecycleState.detached:
onDetached(ref);
break;
case AppLifecycleState.hidden:
onHidden(ref);
}
});
return PopScope(
canPop: canPop,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
onWillPop(ref);
},
child: ProviderScope(
overrides: argProviderOverrides != null ? [argProviderOverrides!] : [],
child: AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle(
systemNavigationBarColor: Colors.white,
systemNavigationBarIconBrightness: Brightness.dark,
statusBarColor: Colors.transparent,
statusBarBrightness: statusBarBrightness,
statusBarIconBrightness: statusBarBrightness,
),
child: HookConsumer(
builder: (context, ref, child) {
return GestureDetector(
onTap: !preventAutoUnfocus
? () => FocusManager.instance.primaryFocus?.unfocus()
: null,
child: Container(
color: unSafeAreaColor,
child: wrapWithSafeArea
? SafeArea(
top: setTopSafeArea,
bottom: setBottomSafeArea,
child: _buildScaffold(context, ref),
)
: _buildScaffold(context, ref),
),
);
},
),
),
),
);
}
Widget _buildScaffold(BuildContext context, WidgetRef ref) {
return Scaffold(
extendBody: extendBodyBehindAppBar,
resizeToAvoidBottomInset: resizeToAvoidBottomInset,
appBar: buildAppBar(context, ref),
body: buildPage(context, ref),
backgroundColor: screenBackgroundColor,
bottomNavigationBar: buildBottomNavigationBar(context),
bottomSheet: buildBottomSheet(ref),
floatingActionButtonLocation: floatingActionButtonLocation,
floatingActionButton: buildFloatingActionButton(ref),
);
}
/// Bottom navigation bar
@protected
Widget? buildBottomNavigationBar(BuildContext context) => null;
@protected
Widget? buildBottomSheet(WidgetRef ref) => null;
/// Set overlay style for top status bar
@protected
Brightness get statusBarBrightness =>
Platform.isIOS ? Brightness.light : Brightness.dark;
/// Page's main content
@protected
Widget buildPage(BuildContext context, WidgetRef ref);
/// Page's top AppBar
@protected
PreferredSizeWidget? buildAppBar(BuildContext context, WidgetRef ref) => null;
/// Floating Action Button
@protected
Widget? buildFloatingActionButton(WidgetRef ref) => null;
/// Color for unsafe area behind safe area
@protected
Color? get unSafeAreaColor => AppColor.of.white;
/// Whether to resize when keyboard appears
@protected
bool get resizeToAvoidBottomInset => true;
/// FloatingActionButton location
@protected
FloatingActionButtonLocation? get floatingActionButtonLocation => null;
/// Whether to extend body behind app bar
@protected
bool get extendBodyBehindAppBar => false;
/// Whether the page can be popped via back swipe, etc.
@protected
bool get canPop => true;
/// Scaffold background color
@protected
Color? get screenBackgroundColor => AppColor.of.white;
/// Wrap the page body in a SafeArea
@protected
bool get wrapWithSafeArea => true;
/// Apply safe area on the bottom
@protected
bool get setBottomSafeArea => true;
/// Apply safe area on the top
@protected
bool get setTopSafeArea => true;
/// Tapping outside text fields automatically unfocus them
@protected
bool get preventAutoUnfocus => false;
/// Called when the app returns to active state
@protected
void onResumed(WidgetRef ref) {}
/// Called when the app is paused
@protected
void onPaused(WidgetRef ref) {}
/// Called when the app becomes inactive
@protected
void onInactive(WidgetRef ref) {}
/// Called when the app is detached
@protected
void onDetached(WidgetRef ref) {}
/// Called when the app is hidden
@protected
void onHidden(WidgetRef ref) {}
/// 当页面时被调用
@protected
void onInit(WidgetRef ref) {}
/// 当页面dispose时被调用
@protected
void onDispose(WidgetRef ref) {}
/// Called on willPop
@protected
void onWillPop(WidgetRef ref) {}
/// Override route argument provider if needed
@protected
Override? get argProviderOverrides => null;
}
BasePage
的主要目标是收集一个页面中常用的功能 ,并让你能明确地重写布局属性。这增加了清晰度和一致性。
结论
在这篇文章中,我们探讨了一些将 Riverpod 使用提升到生产级别的方法。这些只是我个人的经验和技巧,你可以根据项目的需要和团队的风格随意调整。
感谢你阅读本文!🙇