ViewModel 如何写得可测试、可复用(以首页为例)
系列:状态管理与架构篇 2/6
我们项目里的首页 ViewModel 是典型的「业务中枢」:StateNotifier<HomeState> 里既拼 Tab、又拉房间列表、又管 TRTC/进房。这类类一胖,单测跑不起来、复用只能复制粘贴,问题往往不在 Riverpod,而在 ViewModel 边界没划清。
1. 问题背景:业务场景 + 现象
lib/modules/home/view-model/view_model_home.dart 里,HomeViewModel 负责:
- 初始化首页 Tab(本地拼
HomeTabModel,并串caseHomeProvider、getHomeGameResourceList等) - 推荐/分类房间分页(
RoomServer.getRecommendRoomList/getRoomListByPage) - Banner(
BannerServer) - 进房链路:
createRoomHandle/joinRoomHandle/matchRoomHandle,内部再读userProvider、caseRoomUseProvider,并吃BuildContext
一线常遇到的现象:
- 想给
initHomeTabData或matchRoomHandle写单测:一 import 就带上BuildContext、Navigator、CommonRoom、静态RoomServer,必须起大半套 Flutter 或全 Mock,成本高到放弃。 - 想复用「匹配房间 + 进房」:逻辑和
context.mounted、Navigator.pop绑死,只能在页面里调 ViewModel,挪到别的入口要复制改一轮。 getHomeRecommendRoomList/getHomeOtherRoomList:入参oldHomeTabRoomList在方法里被就地addAll、改pageNum,副作用藏得深,同一段代码在测试里「断言状态」很别扭。
2. 原因分析:核心原理 + 排查过程
2.1 可测试性在卡什么
单测要的是:确定输入 → 确定输出/副作用。你们当前 ViewModel 里混了三类「不可控」依赖:
| 类型 | 在 HomeViewModel 里的体现 |
|---|---|
| 框架/导航 | BuildContext、Navigator.of(context).pop()、context.mounted |
| 静态网关 | RoomServer.*、BannerServer.*、HomeServer.*(全局硬编码,难以替换) |
| 过程式副作用 | 分页方法直接改传入的 oldHomeTabRoomList,而不是返回新结构或统一 copyWith |
只要 BuildContext 和静态 Server 进了核心业务分支(例如匹配结果决定要不要 joinRoom),测试就必须伪造 UI 环境,这是 Flutter 单测里最贵的那条路。
2.2 可复用在卡什么
「可复用」本质是 API 稳定:调用方只关心「房间创建参数」「匹配结果」「错误」,不关心「谁负责关弹窗」。现在像 createRoomHandle 把 context 传给下层 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(由页面注入接口),不进单测核心路径。
- Domain/Application 小对象:匹配房间、拼 Tab 所需数据、分页请求参数 → 只依赖接口(
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.getFindMatchingRoom → ref.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. 可复用结论
Ref已有了,把RoomServer/BannerServer收敛成可 override 的 Provider,手写 Provider 和代码片段生成 PROVIDER 都是同一套注入点。BuildContext不进「决策逻辑」:导航、弹窗、SnackBar 留在 Widget 或注入窄接口,ViewModel 只暴露数据与命令结果。- 分页与列表:优先返回新状态或明确所有权,避免「入参被悄悄改满」的写法,复用到别的 Tab 时差异最小。
- 已有
caseHomeProvider/caseRoomUseProvider:新需求优先扩这些 UseCase,而不是在HomeViewModel里再开一条XXXServer直链——这是团队层面最好守的一条线。
避坑:为了「快」继续在 ViewModel 里 debugPrint + 吞掉异常默然返回 false / 空列表,短期省事,长期 既没有断言抓手,也没有错误上报,单测和业务监控两边都吃亏——这块和你们 getHomeGameResourceList 的 catch return [] 是同一类技术债,可以排期用 Result 或统一异常类型收口。
下期预告
第 3 篇:Provider.select + 首页多 Tab / 房间列表场景下的局部刷新(对照 HomeState 里列表与当前 Tab 的 watch 粒度)。
以上内容可直接贴掘金;若你希望文中类名、方法名全部与仓库一字不差(例如保留 caseRoomUseProvider 不叫 Repository),可把「抽象接口」段落标题改成你们内部的 server/case 命名,我也可以按你们术语再压一版用语。