ViewModel 如何写得可测试、可复用
系列:状态管理与架构篇(2/6)
建议标签:Flutter、Riverpod、单元测试、架构
上一篇把 Riverpod 拆成 data / application / presentation。这一篇盯着 application 里那一层:习惯叫 ViewModel、Presenter 或 Notifier 都行,关键是:它能不能脱离界面单测、能不能在另一个页面原样接上。
1. 问题背景:业务场景 + 现象
真实业务里,ViewModel 往往从「几个 ref.watch + 调接口」长成一坨:
- 改一个校验规则,不敢动,因为只有真机点一遍才放心。
- 列表页和弹窗里都要「选标签」,拷贝两份 Notifier,改一处漏一处。
- 单测要么不写,要么起完整 App、mock 一整条网络链,维护成本比业务还大。
- PR 里经常出现:
BuildContext、GoRouter、SnackBar混在 Notifier 里。
目标很简单:ViewModel = 可替换依赖的协调层 + 可观察的状态;测试时只换依赖,不重写逻辑。
2. 原因分析:核心原理 + 排查过程
2.1 为什么难测
- 隐藏依赖:在方法里直接
Dio()、GetIt()、Firebase.*,测试无法注入替身。 - 副作用绑死在状态变更里:改 state 的同时写磁盘、埋点、弹 toast,断言
state时副作用已经炸一片。 BuildContext:单测没有 Widget 树,一引用就歇菜。- 时间、随机数、设备信息:未抽象就写进分支,快照不稳定。
2.2 为什么难复用
- 把「页面专用字段」和「领域共用规则」塞在同一个 State,别处在意的不一样却共享一颗大对象。
- Notifier 里写死路由名、Dialog 业务文案,复用就等于复制粘贴再改字符串。
- 没有「用例层」或薄 Repository,两个入口各自拼同一套 if-else。
2.3 排查时可以问的三句话
- 新建一个
ProviderContainer,只给 ViewModel 和它的直接依赖,能跑通主流程吗? - 把
WidgetRef换成「外部传入的Ref/ 纯构造函数注入」,逻辑还要改吗? - 第二个页面如果只是「展示维度不同」,State 能否拆成共用内核 + 页面扩展?
任一题答案是「不能 / 要改很多」,就有分层或拆分空间。
3. 解决方案:方案对比 + 最终选择
3.1 ViewModel 的职责边界(建议定死)
| 放在 ViewModel 里 | 不要放进来 |
|---|---|
| 组装多个 Repository、做校验与分支 | BuildContext、具体路由 API、showDialog |
暴露 State / AsyncValue、提供 refresh / submit | 直接 Dio、静态 SDK 调用 |
| 把 domain 结果映射成「这一屏需要的字段」 | UI 组件 import(Widget 只进 presentation) |
导航、SnackBar、权限弹窗:由 UI 监听 state 或一次性事件再触发;ViewModel 最多发「该导航了、参数是啥」的不可变数据。
3.2 可测试:依赖注入方式对比
- 全用具体类:最快,测起来要 mock 整个网络栈。
- 接口 + Riverpod Provider:
ProviderContainer里override成 fake,单测只关心契约。 - 构造函数注入纯类:Notifier 变薄,重逻辑进
XxxController(纯 Dart),测 Controller 甚至不用 Riverpod。
最终选择:业务复杂就用「接口 + override」;核心算法抽到纯函数/纯类,测试优先写那儿。
3.3 可复用:两种常用形态
- 参数化 Provider:
family/ 带参数的Provider,同一套Notifier,不同id、不同mode。 - 内核 State + 派生:共用
TagSelectionCore,列表页和弹窗各包一层「只多一个scrollOffset之类」的浅封装,避免两份 submit 逻辑。
4. 关键代码:最小必要代码片段
4.1 ViewModel 只依赖接口
abstract class ProfileRepository {
Future<Profile> load(String userId);
}
@riverpod
class ProfileVm extends _$ProfileVm {
@override
Future<ProfileUi> build(String userId) async {
final repo = ref.watch(profileRepositoryProvider);
final p = await repo.load(userId);
return ProfileUi(nickname: p.nickname, avatarUrl: p.avatarUrl);
}
}
测试里 profileRepositoryProvider override 成返回固定 Profile,不断网。
4.2 单测:ProviderContainer + override
test('loads profile into ui state', () async {
final container = ProviderContainer(overrides: [
profileRepositoryProvider.overrideWithValue(_FakeRepo()),
]);
addTearDown(container.dispose);
final sub = container.listen(profileVmProvider('u1'), (_, __) {});
await container.read(profileVmProvider('u1').future);
final ui = container.read(profileVmProvider('u1')).value!;
expect(ui.nickname, 'n1');
});
4.3 重逻辑下沉:可同时在多处复用
class OrderDraftValidator {
String? shippingError(Address? a) {
if (a == null) return '请填写地址';
if (a.phone.length < 11) return '手机号不合法';
return null;
}
}
@riverpod
class OrderSubmitVm extends _$OrderSubmitVm {
final _validator = OrderDraftValidator();
Future<void> submit() async {
final err = _validator.shippingError(state.shipTo);
if (err != null) {
state = state.copyWith(inlineError: err);
return;
}
// ...
}
}
OrderDraftValidator 的测试零 Riverpod,改规则只动一处。
4.4 导航副作用:用「意图」代替直接 context.go
sealed class ProfileEffect {}
class OpenEditProfile extends ProfileEffect {}
@riverpod
class ProfileScreenVm extends _$ProfileScreenVm {
final _effects = StreamController<ProfileEffect>.broadcast();
Stream<ProfileEffect> get effects => _effects.stream;
void onEditTapped() => _effects.add(OpenEditProfile());
}
页面 initState / ref.listenManual 里监听 effects,再调用路由。ViewModel 不 import go_router。
5. 效果验证:数据/截图/日志
跑 flutter test,针对 ViewModel 的用例应能在毫秒级结束,且不拉起 MaterialApp。
提交前看 CI:仅改动 OrderDraftValidator 时,只执行对应 test 文件,失败日志里应直接指出断言行,而不是 Widget 树超时。
你们在项目里可以固定两条习惯:
- 每个新 ViewModel 至少一条「happy path」单测(override repo 即可)。
- Code Review 看到 Notifier 里出现
BuildContext、Navigator、ScaffoldMessenger,直接打回。
6. 可复用结论:通用经验 + 避坑清单
经验
- 能 override 的依赖才是好依赖;具体实现出现在
data+di,ViewModel 只看到接口。 - 先测纯函数 / 校验器,再测 Notifier 的编排,投入产出最稳。
family+ 小 State 比「一个大全局 Notifier + 几十字段」更适合复用。- 副作用出口收束:要么 UI 监听,要么单独的
Effect流,不要散落在每个setter里。
避坑
- Notifier 里 static 拉配置、拉账号 ——单测全局污染。
- 在
build()里 订阅永远不反弹的 Stream 又不 dispose ——泄漏和诡异重跑。 - 为了省事 copy static 工具类进第二个 feature ——抽成
core或 package。 - 把 远端 DTO 原样塞进 State ——UI 和协议一变,两个页面一起爆。
下期预告
第 3 篇:Provider select 与局部刷新——列表和长表单里怎么收紧监听粒度、DevTools 里怎样确认重建是否收敛。