状态管理与架构篇-ViewModel 如何写得可测试、可复用

5 阅读4分钟

ViewModel 如何写得可测试、可复用

系列:状态管理与架构篇(2/6)
建议标签:FlutterRiverpod单元测试架构

上一篇把 Riverpod 拆成 data / application / presentation。这一篇盯着 application 里那一层:习惯叫 ViewModel、Presenter 或 Notifier 都行,关键是:它能不能脱离界面单测、能不能在另一个页面原样接上


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

真实业务里,ViewModel 往往从「几个 ref.watch + 调接口」长成一坨:

  • 改一个校验规则,不敢动,因为只有真机点一遍才放心。
  • 列表页和弹窗里都要「选标签」,拷贝两份 Notifier,改一处漏一处。
  • 单测要么不写,要么起完整 App、mock 一整条网络链,维护成本比业务还大
  • PR 里经常出现:BuildContextGoRouterSnackBar 混在 Notifier 里。

目标很简单:ViewModel = 可替换依赖的协调层 + 可观察的状态;测试时只换依赖,不重写逻辑。


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

2.1 为什么难测

  1. 隐藏依赖:在方法里直接 Dio()GetIt()Firebase.*,测试无法注入替身。
  2. 副作用绑死在状态变更里:改 state 的同时写磁盘、埋点、弹 toast,断言 state 时副作用已经炸一片。
  3. BuildContext:单测没有 Widget 树,一引用就歇菜。
  4. 时间、随机数、设备信息:未抽象就写进分支,快照不稳定。

2.2 为什么难复用

  1. 把「页面专用字段」和「领域共用规则」塞在同一个 State,别处在意的不一样却共享一颗大对象。
  2. Notifier 里写死路由名、Dialog 业务文案,复用就等于复制粘贴再改字符串。
  3. 没有「用例层」或薄 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 ProviderProviderContaineroverride 成 fake,单测只关心契约
  • 构造函数注入纯类:Notifier 变薄,重逻辑进 XxxController(纯 Dart),测 Controller 甚至不用 Riverpod。

最终选择:业务复杂就用「接口 + override」;核心算法抽到纯函数/纯类,测试优先写那儿

3.3 可复用:两种常用形态

  1. 参数化 Providerfamily / 带参数的 Provider,同一套 Notifier,不同 id、不同 mode
  2. 内核 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 里出现 BuildContextNavigatorScaffoldMessenger,直接打回。

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 里怎样确认重建是否收敛。