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 排查思路(自检清单)
- 找 IO:
Dio、SharedPreferences、原生 Channel 是否出现在 Notifier/Widget 里? - 找重复:同一实体是否在多个 Provider 里各查一次?
- 找监听:
ref.watch的对象是否比「当前 Widget 真正需要的字段」大一个数量级? - 找生命周期:登出/切换租户后,哪些 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 依赖)
- application:
Notifier/AsyncNotifier、组合多个 repo、暴露给 UI 的State - presentation:页面、Widget、
ref.watch仅出现在这里 - di:
Provider定义、ProviderScopeoverride(测试/环境)
这不是为了「高大上」,而是让 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 是否只在 data | Review checklist 可机器辅助 grep |
实战里最常见的第一手感受:改需求时知道该打开哪个目录,而不是全项目 ref.watch 搜索。
6. 可复用结论:通用经验 + 避坑清单
6.1 通用经验(背这几条就够推广)
- Provider 是胶水,不是垃圾桶:依赖注入与生命周期可以放 Provider,业务规则优先放在可被纯测或窄依赖的类型里。
build()里慎做副作用:AsyncNotifier.build适合拉数据,但复杂场景要分清「冷启动拉一次」vs「监听参数变化重拉」。- 跨模块通信用「事件/契约」:下一篇会写「避免页面地狱」;原则是先定义接口或消息,再选 Stream / Provider 组合。
- 性能是模型设计问题:先收紧 State 粒度,再谈
select与child拆分。
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 如何写得可测试、可复用(
ProviderContainer、fake/async、边界案例) - 第 3 篇:Provider
select与局部刷新(何时无效、列表场景、DevTools 验证)
结语:Riverpod 分层的目标不是多几个文件夹,而是让「改需求」的路径变短:业务规则可测、数据流向可读、Widget 只做展示与交互。当你团队在 Code Review 里能一致地说出「这段该放 application 还是 data」,这篇的实践就落地了。