状态管理与架构篇-我踩过的 5 个反模式

4 阅读5分钟

业务架构复盘:我踩过的 5 个反模式

Flutter + Riverpod 做久了,容易有一种错觉:能跑、能发版,就等于架构没问题。真正拖累团队的往往是「当时省事」的写法:改一个入口牵一片、侧滑返回状态诡异、新人不敢动 ref.watch 那一层。

这篇不写正确废话,只复盘我(以及身边项目)反复撞过的 5 个反模式:它们各自长什么样、为什么伤人、后面怎么一点点扭过来。


1. 问题背景

业务项目常见的痛不是「不会写 Provider」,而是:

  • 需求来了改一处,连着三四个模块都要动,边界不清
  • 某个页面一复杂,ViewModelNotifier 单文件上千行,没人敢重构
  • 登出、切账号、杀进程恢复后,界面偶尔「脏数据」或 事件触发两次(Toast、跳转、埋点)。
  • 列表滑动手感差,Profile 显示 CPU 尖刺,最后发现是 无关字段变动导致整页重绘

这些往往不是因为 Riverpod「不行」,而是架构里默认了一些坏习惯。


2. 原因分析(5 个反模式拆开说)

反模式 1:「上帝 ViewModel」——一个类包办 IO、导航、拼装 UI 数据

现象:单个 StateNotifier 里既有 Dio/xxxServer 调用,又有 NavigatorshowDialogBuildContext,还直接拼列表用的 ViewModel
原因:短期最快;长期变成不可测试的黑盒,改任何一行都怕波及导航和弹窗。
本质副作用与业务编排没有从「状态更新」里剥出去。

反模式 2:跨模块「硬连线」——直接 import 别人的 Provider / Notifier

现象:订单页里 ref.read(userProvider) 还不够,再 ref.read(couponNotifierProvider),甚至反过来依赖首页的私有 Provider。
原因:图省事,「反正都在一个 repo」。
本质:模块图变成全连接图,编译层面能通过,演进层面是灾难——任何模块发布都要担心被谁偷袭 import。

反模式 3:用「长期状态」承载「一次性事件」

现象:用 state 里的 shouldNavigateToXshowToastMessage 这类字段,在 widget 里 ref.listensetState 再清掉,或者忘了清导致二次进入同一页又弹一次
原因:事件和数据共用一个模型,图省事。
本质事实(state)与信号(event) 混在同一通道里,Riverpod 的响应式会忠实把你的错误放大。

反模式 4:没有单一数据源——同一实体多处各自请求、各自缓存

现象:用户信息、房间摘要、实验开关在三四个 feature 里各拉一次;刷新其中一个,别的 Tab 仍显示旧头像。
原因:复制粘贴接口调用最快;没有「谁拥有这份数据」的约定
本质:缓存一致性交给运气和问题单。

反模式 5:异步与错误处理「各写各的」

现象:有的页面 try/catch 返回空列表,有的抛给上层,有的 debugPrint 了事;加载态有的用局部变量,有的塞进 state.isLoading
原因:项目初期没统一 AsyncValue / Result 约定。
本质用户可见行为(空态、错误、重试)无法一致,排查线上只能靠猜。


3. 解决方案(对治思路,不堆名词)

反模式方向一句话
上帝 VM拆层 / 拆类IO 下沉;导航、弹窗留在更靠近 UI 的一层或用事件出站
硬连线依赖倒置跨模块只依赖接口、Facade、application 层暴露的窄 API
事件混在 state分通道事实用 State/Notifier,信号用 Stream 或专用 Event Channel + 消费后丢弃
多数据源所有权每个核心实体一个「owner」Provider,其他只读派生或监听
异步乱约定列表/详情统一 AsyncValue 或 sealed 的 LoadState,错误映射集中在一处

没有银弹:先定规则,再在 Code Review 里挡,比一次大重构现实。


4. 关键代码(最小示意)

4.1 别把 BuildContext 塞进 Notifier(反模式 1)

不推荐(示意):

Future<void> submit(Ref ref, BuildContext context) async {
  final data = await ApiClient.load();
  if (!context.mounted) return;
  Navigator.of(context).pushNamed('/ok'); // 业务核心与导航绑死
}

更稳的折中:Notifier 只产出结果;页面 ref.listenref.listenManual 里做导航。

// Notifier:只改状态或抛出可观察结果
state = SubmitState.success(orderId);

// Widget:
ref.listen<SubmitState>(submitProvider, (prev, next) {
  next.maybeWhen(
    success: (id) => context.push('/order/$id'),
    orElse: () {},
  );
});

4.2 一次性事件用 Stream(反模式 3)

// 简单事件总线:只负责「发生过什么」,不负责长期真相
final authEventsProvider = StreamProvider<AuthEvent>((ref) async* {
  // 实际项目里由登陆模块往 controller add
  yield* authEventController.stream;
});

sealed class AuthEvent {}
class LoggedOut extends AuthEvent {}

UI 侧 ref.listen 或独立 StreamSubscription:收到 LoggedOut 做路由栈清理,不必UserState 里再塞一个 justLoggedOut 布尔。

4.3 单一数据源 + 派生(反模式 4)

final profileProvider = FutureProvider<UserProfile>((ref) async {
  return ref.watch(userRepository).fetchProfile();
});

// 别处只读,不要再次 hit 网络,除非 invalidate
final displayNameProvider = Provider<String?>((ref) {
  return ref.watch(profileProvider).valueOrNull?.nickname;
});

5. 效果验证

这类改造很难用一行「性能提升 30%」糊弄过去,更靠谱的验收是:

  • 改动半径:新需求涉及文件数是否明显下降(自己心里要有数)。
  • 可测性:核心业务能否在 ProviderContainer + override 下跑单测,不启模拟器。
  • 稳定性:登出/切账号后,是否还会出现「重复的 Toast / 二次跳转」(打日志或在 debug 包加断言)。
  • 一致性:新同学按文档能否在 10 分钟内说清楚「用户信息从哪个 Provider 来」。

有任一维度的改善,就说明反模式在退。


6. 可复用结论

  1. 上帝 ViewModel 是技术债的利息:一开始省 20 分钟,后面每次需求都多付一点。
  2. 模块之间要靠「窄接口」对话,不是靠「我知道你文件路径」。
  3. 状态是事实,事件是信号,混在一个 model 里迟早被 listen 误伤。
  4. 谁写入谁负责失效,读侧尽量派生,别各拉各的。
  5. 异步形态要统一,否则「空列表是失败还是没数据」永远扯不清。

避坑清单(Review 时可随口问)

  • 这个 Notifier 里有没有 Navigator / context
  • 跨 feature 的 import 是否是「单向、自上而下」?
  • 导航/Toast 是不是靠「状态位」模拟的?能否改成事件或 UI 层监听?
  • 同一份用户资料,全 repo 里有几个写入点?
  • 新页面接异步,是否沿用项目的 AsyncValue(或统一 Result)?

状态管理与架构篇到这里可以系列收尾:分层、可测 VM、select、异步三态、事件流、再加这篇反模式复盘——中间任何一篇单独落地都有价值,凑齐只是方便团队对齐口径。

#Flutter #Riverpod #架构 #重构 #工程实践