状态管理与架构篇(2/6):ViewModel 如何写得可测试、可复用

24 阅读6分钟

ViewModel 如何写得可测试、可复用(以首页为例)

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

我们项目里的首页 ViewModel 是典型的「业务中枢」:StateNotifier<HomeState> 里既拼 Tab、又拉房间列表、又管 TRTC/进房。这类类一胖,单测跑不起来、复用只能复制粘贴,问题往往不在 Riverpod,而在 ViewModel 边界没划清


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

lib/modules/home/view-model/view_model_home.dart 里,HomeViewModel 负责:

  • 初始化首页 Tab(本地拼 HomeTabModel,并串 caseHomeProvidergetHomeGameResourceList 等)
  • 推荐/分类房间分页(RoomServer.getRecommendRoomList / getRoomListByPage
  • Banner(BannerServer
  • 进房链路:createRoomHandle / joinRoomHandle / matchRoomHandle,内部再读 userProvidercaseRoomUseProvider,并吃 BuildContext

一线常遇到的现象:

  1. 想给 initHomeTabDatamatchRoomHandle 写单测:一 import 就带上 BuildContextNavigatorCommonRoom、静态 RoomServer,必须起大半套 Flutter 或全 Mock,成本高到放弃。
  2. 想复用「匹配房间 + 进房」:逻辑和 context.mountedNavigator.pop 绑死,只能在页面里调 ViewModel,挪到别的入口要复制改一轮。
  3. getHomeRecommendRoomList / getHomeOtherRoomList:入参 oldHomeTabRoomList 在方法里被就地 addAll、改 pageNum,副作用藏得深,同一段代码在测试里「断言状态」很别扭

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

2.1 可测试性在卡什么

单测要的是:确定输入 → 确定输出/副作用。你们当前 ViewModel 里混了三类「不可控」依赖:

类型HomeViewModel 里的体现
框架/导航BuildContextNavigator.of(context).pop()context.mounted
静态网关RoomServer.*BannerServer.*HomeServer.*(全局硬编码,难以替换)
过程式副作用分页方法直接改传入的 oldHomeTabRoomList,而不是返回新结构或统一 copyWith

只要 BuildContext 和静态 Server 进了核心业务分支(例如匹配结果决定要不要 joinRoom),测试就必须伪造 UI 环境,这是 Flutter 单测里最贵的那条路。

2.2 可复用在卡什么

「可复用」本质是 API 稳定:调用方只关心「房间创建参数」「匹配结果」「错误」,不关心「谁负责关弹窗」。现在像 createRoomHandlecontext 传给下层 RoomUseCase,复用场景一多,每个入口都要传 BuildContext,ViewModel 就变成页面附属物,而不是应用层协调者。

2.3 自查可以怎么用(对照你们文件)

打开 view_model_home.dart 扫一遍:

  • 方法签名里有没有 BuildContext
  • 有没有直接 XXXServer.xxx
  • 有没有对入参引用类型做原地修改?

命中越多,越适合按下一节的思路「收口」——不是推倒重写,而是把最值钱的几段逻辑先剥出来


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

3.1 三条路(简略)

  • A. 维持现状 + Widget 集成测
    能覆盖主流程,但慢、脆,CI 上跑多了团队会关。

  • B. 整包上 mockito 把所有 Server 都 Mock
    对静态方法要配 generate_mocks / 包装类,维护成本不低,且 BuildContext 仍难搞

  • C. 拆「纯协调」与「UI 副作用」——推荐

    • Domain/Application 小对象:匹配房间、拼 Tab 所需数据、分页请求参数 → 只依赖接口RoomRepository 等),返回 Future<Result> 或不可变数据结构。
    • ViewModel:组合这些对象 + Ref只负责把结果写进 HomeState
    • 导航/弹窗:留在 Widget 或单独的 HomeRouter/DialogCoordinator(由页面注入接口),不进单测核心路径

3.2 最终选择(贴合项目的表述)

  • Provider 定义方式不变:继续 StateNotifierProvider<HomeViewModel, HomeState> 或由代码片段生成等价骨架,HomeViewModel(Ref ref) 作为入口。
  • 变的是依赖形状HomeViewModel 构造或 build 时通过 ref.read(homeRepositoryProvider) 拿「首页数据门面」,而不是在方法体里写死 RoomServer
  • matchRoomHandle 一类方法:拆成「纯异步:MatchRoomResult match(gameId, roomType)」+ 页面里根据结果 if (context.mounted) Navigator.pop(),ViewModel 只 state = ... 或返回 bool/密封类。

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

4.1 现状(你们代码里的真实模式)

final homeProvider = StateNotifierProvider<HomeViewModel, HomeState>((ref) {
  return HomeViewModel(ref);
});

class HomeViewModel extends StateNotifier<HomeState> {
  HomeViewModel(this._ref) : super(HomeState(homeTabList: []));
  final Ref _ref;
  // ...
}

这是很好的注入点:所有后续依赖都该能从 Ref 或构造函数参数追溯,而不是在方法里突然 RoomServer.getFindMatchingRoom

4.2 反例:为什么难测(摘自你们方法形态)

matchRoomHandle 同时包含:业务请求、Navigator、异步进房 —— 单测要同时模拟三层:

Future<bool> matchRoomHandle({
  required BuildContext context,
  required int gameId,
  required HomeTabType roomType,
}) async {
  // ...
  final response = await RoomServer.getFindMatchingRoom(...);
  if (context.mounted) {
    Navigator.of(context).pop();
    await _roomUseCase?.joinRoom(context: context, rid: response.id, roomId: response.roomId);
  }
  return true;
}

调整方向RoomServer.getFindMatchingRoomref.watch(homeRoomApi)(接口实现);Navigator.pop + joinRoom( context: … ) → 页面 listen 结果后执行,或注入 void Function() onCloseDialog

4.3 可测核心:同一套 StateNotifierProvider,用 ProviderContainer 换掉底层

单测不关心 Widget,只关心 override 掉「会打真网」的 Provider

test('matchRoom 在服务端返回 null 时为 false', () async {
  final container = ProviderContainer(
    overrides: [
      homeRoomRepositoryProvider.overrideWithValue(_FakeRepo(alwaysNull: true)),
      // caseRoomUseProvider / userProvider 同理
    ],
  );
  final vm = container.read(homeProvider.notifier);
  final ok = await vm.runMatchOnly(gameId: 1, roomType: HomeTabType.game);
  expect(ok, false);
});

runMatchOnly 即你们从 matchRoomHandle 里拆出的、BuildContext、只调 Repository 的那一层;页面代码变成:

onPressed: () async {
  final ok = await ref.read(homeProvider.notifier).runMatchOnly(...);
  if (!context.mounted) return;
  if (ok) Navigator.of(context).pop();
}

这样 ViewModel 的单测是「纯 Dart + Container」,和你们用不用代码片段生成 homeProvider 无冲突。

4.4 分页:减少「改入参」带来的心智负担

getHomeOtherRoomList 里对 oldHomeTabRoomList 的原地修改,测试要对比前后同一个对象引用,读起来累。可选两种之一:

  • 返回 HomeTabRoomList 的不可变副本copyWith + 新 roomList);或
  • 明确约定「仅此 ViewModel 拥有该实例」,单测里只断言 state 里那份。

无论哪种,都比隐式改传参更容易在 PR 里说清楚


5. 效果验证

上线前我们一般就认两件事:HomeViewModel 里最关键的分支(例如匹配失败、空列表分页边界)有没有 flutter test 定点覆盖;以及重构后 matchRoomHandle 这类方法是否还持有 BuildContext——第二条一过,CI 上单测基本就能常驻,而不是「只写在注释里」。


6. 可复用结论

  1. Ref 已有了,把 RoomServer / BannerServer 收敛成可 override 的 Provider,手写 Provider 和代码片段生成 PROVIDER 都是同一套注入点。
  2. BuildContext 不进「决策逻辑」:导航、弹窗、SnackBar 留在 Widget 或注入窄接口,ViewModel 只暴露数据与命令结果。
  3. 分页与列表:优先返回新状态或明确所有权,避免「入参被悄悄改满」的写法,复用到别的 Tab 时差异最小。
  4. 已有 caseHomeProvider / caseRoomUseProvider:新需求优先扩这些 UseCase,而不是在 HomeViewModel 里再开一条 XXXServer 直链——这是团队层面最好守的一条线。

避坑:为了「快」继续在 ViewModel 里 debugPrint + 吞掉异常默然返回 false / 空列表,短期省事,长期 既没有断言抓手,也没有错误上报,单测和业务监控两边都吃亏——这块和你们 getHomeGameResourceListcatch return [] 是同一类技术债,可以排期用 Result 或统一异常类型收口。


下期预告

第 3 篇:Provider.select + 首页多 Tab / 房间列表场景下的局部刷新(对照 HomeState 里列表与当前 Tab 的 watch 粒度)。


以上内容可直接贴掘金;若你希望文中类名、方法名全部与仓库一字不差(例如保留 caseRoomUseProvider 不叫 Repository),可把「抽象接口」段落标题改成你们内部的 server/case 命名,我也可以按你们术语再压一版用语。