状态管理与架构篇-事件流与跨模块通信

3 阅读4分钟

页面一多,最容易长成两种「地狱」:一种是 A 页面直接 ref.read 进 B 模块的 Provider,需求一改全链路跟着抖;另一种是 把导航、弹窗、NavigatorBuildContext 塞进 ViewModel,测试做不了,复用更是不敢想。

这一篇只盯一件事:跨模块时,数据怎么传、动作怎么交接,让调用链在代码里能一眼读完。


1. 问题背景

典型场景都不陌生:

  • 首页点个入口,房间模块要初始化引擎;登出要停掉房间;个人资料变更要让列表刷新。
  • 支付结果回来,要通知会员页;埋点也要在某个「业务节点」统一打。
  • 开发时的偷懒写法往往是:在页面里一把梭——ref.read(xxxProvider.notifier).doSomething(),再从另一个文件把 notifier 引进来。

短期能跑。问题是 谁依赖谁 很快消失在 import 里。后面来的人只能全文搜索 Provider 名字,才敢动一行。


2. 原因分析

跨模块本质上就两类需求:

  1. 读数据:别处已经算好的状态,我这要不要跟着变(缓存、失效、派生)。
  2. 发命令:这边发生的事,那边要执行一段流程(不一定关心返回值)。

Riverpod 对第一类很顺:单一数据源、watch、派生 Provider、select,都是「拉」模型。

麻烦常在第二类:你用 直接调对方 Notifier 的方法,等于在代码里手写了一条 硬编码调用链。链上没有名字、没有版本、没有「我只关心这件事发生了」的边界,所以一改就牵扯一串文件。

BuildContext、路由、showDialog 再掺进来,第二层地狱就来了:副作用和业务流程缠在一起,没法单测,也没法在别的入口复用同一条流程。


3. 解决方案

思路可以收成一句话:模块对外只暴露「契约」,对内爱怎么实现怎么实现。

几类做法,按团队熟悉程度选即可。

A. 窄接口 / Facade(同步、简单)
coreshared 里定义 RoomFacade 一类抽象,只放「创建房间」「退出房间」这种稳定方法。各模块依赖接口,不依赖具体 Provider 实现。适合调用少、参数固定的场景。

B. 事件流 Stream(异步、解耦)
定义 AppEvent 或按域拆 AuthEventRoomEvent。一处 publish,多处 listen,监听方自己决定要不要处理、怎么合并。适合 「广播」一对多不关心调用返回值 的情况。
注意:要有生命周期——和谁同生共死(Provider dispose 时取消订阅),避免泄漏和重复监听。

C. Riverpod 承载事件(和 DI 一致)
StreamProvider / Provider<Stream<T>> 暴露事件流,或用「只写的 Notifier」专门转发事件。好处是 override 方便,单测里换成 fake 流即可。坏处是新人要认清:这不是「全局 new 一个 StreamController」那种裸单例。

不推荐长期依赖的: 无约束的「EventBus 单例全局广播」。不是不能用,而是 没有类型、没有归属、bug 难追;真要全局,至少把事件建模成 sealed class / enum,并在文档或注释里写清谁发谁收。


4. 关键代码

4.1 事件模型(类型收口)

sealed class AppEvent {
  const AppEvent();
}

class UserLoggedOut extends AppEvent {
  const UserLoggedOut();
}

class RoomNeedDispose extends AppEvent {
  const RoomNeedDispose({required this.reason});
  final String reason;
}

新业务加子类型,编译器能帮你扫一遍 switch 漏网的地方。

4.2 事件源:Stream + Riverpod

class AppEventBus {
  AppEventBus() : _controller = StreamController<AppEvent>.broadcast();

  final StreamController<AppEvent> _controller;

  Stream<AppEvent> get stream => _controller.stream;

  void add(AppEvent event) {
    if (!_controller.isClosed) _controller.add(event);
  }

  void dispose() => _controller.close();
}

final appEventBusProvider = Provider<AppEventBus>((ref) {
  final bus = AppEventBus();
  ref.onDispose(bus.dispose);
  return bus;
});

登出时:

ref.read(appEventBusProvider).add(const UserLoggedOut());

4.3 订阅方:在模块内收口

final roomLifecycleProvider = Provider<void>((ref) {
  final sub = ref.read(appEventBusProvider).stream.listen((event) {
    if (event is UserLoggedOut || event is RoomNeedDispose) {
      // 停 TRTC、清缓存等,只写房间模块关心的逻辑
    }
  });
  ref.onDispose(sub.cancel);
});

页面如果必须「挂载时才订阅」,可以把监听放进对应 feature 的根 ConsumerProvider 里,原则一样:onDispose 里取消

4.4 和「直接调 Notifier」的边界

  • 要返回值、要强一致顺序:倾向于 Facade 或直接调用封装好的用例,不要用事件糊过去。
  • 要通知很多处、且互不相关:事件流更合适。
  • 同一种事又有同步又有广播:可以 Facade 里执行核心流程,末尾 bus.add(...) 一发,让统计、日志监听方自己玩。

5. 效果验证

上线前能检查的其实就几条:

  • 全局搜索:跨 feature 的 import 里,对方具体实现类是否变少,是否更多只剩接口或事件类型。
  • 登出 / 切账号 / 杀进程恢复:房间、音视频的 释放是否只跟一条事件链路,而不是散落在各页 dispose
  • 单测或集成测:appEventBusProvider 换成 fake,能否 不发网络就验证「收到登出后房间侧执行了 teardown」

6. 可复用结论

  • 跨模块优先想 「发布什么事实」(用户登出、订单支付成功),而不是 「帮我调一下对面类里的某个方法」
  • Stream + 类型化 AppEvent + ref.onDispose,比裸单例 EventBus 可控得多。
  • 需要顺序和返回值的地方别强行用事件,命令走 Facade,事实走事件,界限清楚就不容易写成页面地狱。