状态管理与架构篇-Riverpod 在业务项目中的分层实践

1 阅读6分钟

Riverpod 在业务项目中的分层实践:从「能跑」到「能养」

系列:状态管理与架构篇(2/6)

当你把 Riverpod 从 Demo 搬进真实业务,最常见的情况不是「不会写 Provider」,而是:Provider 放哪儿都对、放哪儿都乱——页面里既要管 UI 又要拼接口,改一个字段全局重建,侧滑返回偶发状态错乱,新人接手不敢动 ref.watch

这篇文章用一套可落地的分层:把「数据从哪来、怎么变、谁消费」拆清楚,让 Riverpod 真正成为工程能力,而不是语法糖。


1. 问题背景:业务场景 + 现象

  • 场景:中大型 Flutter 业务(多 Tab、多路由栈、列表+详情+表单、登录态与实验开关穿插)。
  • 典型现象
    • ConsumerWidget / Consumer 里堆满 ref.watch,业务和 UI 耦合,setState 的升级版地狱。
    • StateNotifier / AsyncNotifier 直接调 Dio单元测试要等网络或抽一大坨 Fake
    • 同一用户资料在首页、个人中心、支付页各自 fetch缓存与失效各写一套。
    • ref.watch(provider) 引用大块状态,列表滑一下 CPU 占用明显,排查后发现是整页重绘
    • 需求变更:要在多处复用「订单列表 + 筛选」,最后 copy 一整份 Notifier。

本质:缺少一套「分层边界」,Riverpod 的便利反而放大了随意性。


2. 原因分析:核心原理 + 排查过程

2.1 Riverpod 在干什么(和业务分层的联系)

Riverpod 解决的是 依赖注入 + 响应式数据流 + 作用域(override)。它不替你规定「业务代码放哪」,所以若团队没有约定,Provider 会变成全局单例垃圾场

2.2 为什么「不分层」后患无穷

问题根因
难测UI/Notifier 与 IO(HTTP、DB、SDK)同层,隔离成本高
难复用「用例」散落各处,没有稳定输入输出边界
性能抖动watch 的粒度 = 你的「状态模型」粒度;模型太大则监听太粗
协作摩擦Code Review 看不出「这是领域规则还是网络脏活」

2.3 排查思路(自检清单)

  1. 找 IODioSharedPreferences、原生 Channel 是否出现在 Notifier/Widget 里?
  2. 找重复:同一实体是否在多个 Provider 里各查一次?
  3. 找监听ref.watch 的对象是否比「当前 Widget 真正需要的字段」大一个数量级?
  4. 找生命周期:登出/切换租户后,哪些 Provider 仍缓存旧数据?

命中任意 2 条以上,就有分层整改价值。


3. 解决方案:方案对比 + 最终选择

3.1 常见几种组织方式

A. 按「页面」分包(feature-first,但不分层)

  • 优点:找文件快。
  • 缺点:跨页复用和测试仍然困难,容易在 page_x/providers.dart 里膨胀。

B. 按「技术层」分包(data/domain/presentation 经典三层)

  • 优点:边界清晰、可测性最好。
  • 缺点:小团队摩擦大,目录跳转多。

C. Feature + 层(推荐默认:折中且可演进)

在每个业务模块内再拆:

  • data:DTO、Repository 实现、远端/本地数据源(只谈 IO)
  • domain:实体、用例(可选)、纯规则(无 Flutter/Riverpod 依赖)
  • applicationNotifier / AsyncNotifier、组合多个 repo、暴露给 UI 的 State
  • presentation:页面、Widget、ref.watch 仅出现在这里
  • diProvider 定义、ProviderScope override(测试/环境)

这不是为了「高大上」,而是让 Riverpod 的 Provider 主要落在 application + di 两层,Widget 只消费「已拍平」的读模型。

3.2 最终选择(落地口径)

  • 小模块:feature 内四层(或 domain 薄到没有单独目录)。
  • 跨模块共享:把「用户会话」「路由参数解析」等提到 core / shared禁止业务 A 直接 import 业务 B 的 Notifier。
  • 异步统一:列表/详情优先 AsyncValue + 统一错误映射(下一篇会展开「加载/空态/错误态」)。
  • 依赖方向presentation -> application -> domain -> data,反向只通过接口(abstract repo)。

4. 关键代码:最小必要代码片段

下面用「伪代码 + 最小骨架」说明分层长什么样(命名可按项目统一)。

4.1 data:Repository 实现(只负责 IO)

// data/order_repository_impl.dart
class OrderRepositoryImpl implements OrderRepository {
  OrderRepositoryImpl(this._client);
  final ApiClient _client;

  @override
  Future<List<OrderDto>> fetchOrders({required String userId}) {
    return _client.get('/orders', query: {'userId': userId});
  }
}

4.2 domain:接口 + 实体(无 Riverpod)

// domain/order.dart
class Order {
  const Order({required this.id, required this.title});
  final String id;
  final String title;
}

// domain/order_repository.dart
abstract class OrderRepository {
  Future<List<Order>> listByUser(String userId);
}

4.3 application:Notifier 组合业务、暴露 UI State

// application/order_list_notifier.dart
@riverpod
class OrderList extends _$OrderList {
  @override
  Future<List<Order>> build() async {
    final userId = ref.watch(sessionProvider).userId;
    final repo = ref.watch(orderRepositoryProvider);
    return repo.listByUser(userId);
  }

  Future<void> refresh() async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(() async {
      final userId = ref.read(sessionProvider).userId;
      final repo = ref.read(orderRepositoryProvider);
      return repo.listByUser(userId);
    });
  }
}

4.4 di:把实现绑到接口

// di/providers.dart
final orderRepositoryProvider = Provider<OrderRepository>((ref) {
  final client = ref.watch(apiClientProvider);
  return OrderRepositoryImpl(client);
});

4.5 presentation:Widget 只 watch「读模型」

// presentation/order_list_page.dart
class OrderListPage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final asyncOrders = ref.watch(orderListProvider);
    return asyncOrders.when(
      data: (orders) => OrderListView(orders: orders),
      loading: () => const Center(child: CircularProgressIndicator()),
      error: (e, st) => ErrorPlaceholder(error: e, onRetry: () {
        ref.read(orderListProvider.notifier).refresh();
      }),
    );
  }
}

要点:页面不碰 Dio;Notifier 不写 BuildContext;Repository 不知道 Widget


5. 效果验证:数据/截图/日志

维度验证方式预期
可测性Notifier 单测用 ProviderContainer + override 注入 FakeRepo核心列表/下单流程有单测覆盖,无需启动 App
构建成本DevTools / Timeline:同页面滑动帧率、重建次数配合 select(下一篇)减少无谓 rebuild
重复请求埋点或日志统计同一 userId 的订单接口 QPS合并为单一数据源后可下降
代码审阅PR 中 IO 是否只在 dataReview checklist 可机器辅助 grep

实战里最常见的第一手感受:改需求时知道该打开哪个目录,而不是全项目 ref.watch 搜索。


6. 可复用结论:通用经验 + 避坑清单

6.1 通用经验(背这几条就够推广)

  1. Provider 是胶水,不是垃圾桶:依赖注入与生命周期可以放 Provider,业务规则优先放在可被纯测或窄依赖的类型里
  2. build() 里慎做副作用AsyncNotifier.build 适合拉数据,但复杂场景要分清「冷启动拉一次」vs「监听参数变化重拉」。
  3. 跨模块通信用「事件/契约」:下一篇会写「避免页面地狱」;原则是先定义接口或消息,再选 Stream / Provider 组合。
  4. 性能是模型设计问题:先收紧 State 粒度,再谈 selectchild 拆分。

6.2 避坑清单

  • 在 Widget 里 new 出带有 IO 的 Repository ——应通过 Provider 注入,否则无法 override。
  • 多个 Provider 各自缓存同一实体且无失效策略 ——宁可「单一数据源 + 派生 Provider」。
  • ** giant State + 全局 watch ** ——拆读模型或 select(系列第 3 篇)。
  • 业务 B import 业务 A 的 Notifier ——改为依赖 core 接口或应用层 Facade。
  • 把导航、弹窗、SnackBar 写进 Notifier ——保留 SideEffect 在 UI 层或通过回调/事件通道向上抛。

下期预告

  • 第 2 篇:ViewModel 如何写得可测试、可复用ProviderContainerfake/async、边界案例)
  • 第 3 篇:Provider select 与局部刷新(何时无效、列表场景、DevTools 验证)

结语:Riverpod 分层的目标不是多几个文件夹,而是让「改需求」的路径变短:业务规则可测、数据流向可读、Widget 只做展示与交互。当你团队在 Code Review 里能一致地说出「这段该放 application 还是 data」,这篇的实践就落地了。