5 个连 Remi 都不会告诉你的实用 Flutter Riverpod 技巧

欢迎关注公众号OpenFlutter,谢谢

我使用 Riverpod 已经一年多了。在这段时间里,我独立开发或将两个生产环境应用迁移到了 Riverpod,并在过程中总结了一些实用的技巧。

这些技巧源于以下一些顾虑:

  • 全局可用的 Provider 很方便,但让它们随处可访问,难道不会带来很多风险吗?
  • 当一个界面接收到路由参数时,我如何才能高效地将这些参数传递和管理到深层嵌套的 Widget 结构中?
  • 如果我需要在一个无法直接获取 WidgetRef 的地方访问一个 Provider,该如何处理?
  • 我经常使用 requiredValuevalue 这样的扩展方法来引用 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 中。在 ProviderScopeoverrides 里,用 .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 依赖于参数的初始化。然后,你就可以在 Notifierbuild 方法中直接访问该参数了。

⚠️ 注意 如果你像这样设置了依赖,那么在参数未初始化的作用域之外访问该 Provider 将不起作用。因此,只有在你确定该 Provider 是那个特定屏幕独占时,才使用此模式。

3. 在无法访问 WidgetRef 时,使用 UncontrolledProviderScopeProviderContainer

有时候,你需要在一个无法获取 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();
  }
}

页面通常需要在初始化或销毁时触发某些事件。在这里,你只需重写 onInitonDispose 即可。

参数 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,你需要定义 appBarbodybackgroundColor 等等。有时你还会用 PopScope 来包裹页面,以处理手势。在每一个页面都做这些事情会让你的 UI 代码变得非常混乱。

在这种基础页面模式中,每个页面只需在 buildPage 方法中实现主体 ,而其他布局属性(例如 AppBarbackgroundColor 等)则根据需要重写即可。

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 使用提升到生产级别的方法。这些只是我个人的经验和技巧,你可以根据项目的需要和团队的风格随意调整。

感谢你阅读本文!🙇

相关推荐
用户22152044278002 小时前
JavaScript事件循环
前端
万少2 小时前
可可图片编辑 HarmonyOS(4)图片裁剪-canvas
前端·harmonyos
gnip2 小时前
接入高德地图
前端·javascript
WindStormrage2 小时前
崩溃埋点的实现 —— 基于 Reporting API 的前端崩溃上报
前端·性能优化
十一.3662 小时前
171-178CSS3新增
前端·javascript·css3
用户21411832636023 小时前
dify案例分享-零代码用 Dify 使用梦 AI 3.0 多模态模型,免费生成影视级视频
前端
Jerry3 小时前
Compose 布局和修饰符的基础知识
前端
袁煦丞3 小时前
Reader断点续传高手:cpolar内网穿透实验室第609个成功挑战
前端·程序员·远程工作
快乐是Happy3 小时前
使用 vue+electron+vite 搭建一个属于自己的桌面端应用
前端