卷 IV · 状态CH 17深度 17/24

状态该放在哪:提升、Notifier 与 Repository

直觉过境 「ViewModel + Repository 那套分层可以照搬。」 直接放行

选好状态管理库只是第一步,真正决定项目好不好维护的是另一个问题:每一个状态该住在哪一层。放错了,要么到处传参、要么状态一变全乱、要么根本没法测。这一章给你一套可判定的规则,外加一份能直接抄进学校 App 的骨架——因为你的 ViewModel + Repository 直觉在这里完全成立。

状态提升Notifier=ViewModelRepository项目骨架

先问两个问题,答案就定了住址

面对任何一个状态(一个布尔、一个列表、一个用户对象),只问两件事:

◆ 状态归属的两个判据

问题一:谁需要读/改它?——只有一个 Widget?一个页面里的几个 Widget?还是跨了好几个页面?

问题二:它活多久?——离开这个页面就该消失?还是整个 App 生命周期都要在?

「需要它的范围」越大、「它活的时间」越长,它就该放得越高、越靠近数据层。

把这两个问题的答案连起来,就是一张住址表:

状态谁需要住哪例子
UI 局部状态一个 Widget 自己它自己的 StatesetState某个卡片展开没、密码框可见否
页面内共享一个页面里几个 Widget提升到共同的父,或页面级 Notifier表单各字段、当前选中的 Tab
功能级状态一个功能的多个页面一个 Notifier(≈ ViewModel)购物车、某次报名流程
全局/会话状态全 App顶层 Notifier + Repository登录用户、主题、语言
服务端数据视情况Repository(带缓存),Notifier 读它课程列表、成绩、通知

状态提升:最基本、最该先想到的那一步

「提升」你在 Compose 里天天做,Flutter 一字不差:当两个兄弟 Widget 要共享一个状态时,把它移到它们最近的共同父节点,父节点把值往下传、把「改值的回调」也往下传。

// 父持有状态,子只负责显示和上报
class _FilterPageState extends State<FilterPage> {
  String _keyword = '';

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        SearchBar(
          value: _keyword,
          onChanged: (v) => setState(() => _keyword = v),   // 子上报,父改
        ),
        ResultList(keyword: _keyword),                        // 子只读
      ],
    );
  }
}

这样 SearchBarResultList 都是无状态的、纯展示的——好测、好复用、没有隐藏状态。提升永远是第一选择,不够用了再往上升级到 Notifier。别一有共享需求就直接上全局状态管理,那是过度设计。

✎ 什么时候从「提升」升级到「Notifier」

两个信号:一,提升的层数太多了(要传三四层,中间层全在转发它不关心的参数);二,这个状态还需要在别的页面用。出现任意一个,就把它从 Widget 里拎出来,做成一个 Notifier。

Notifier 就是你的 ViewModel

这是这一章最让你安心的一句话。第 16 章的 Riverpod Notifier,职责和你熟的 Android ViewModel 完全一致:

  • 持有状态stateStateFlow 里的值)。
  • 暴露改状态的方法add()submit() ≈ ViewModel 的 onEvent)。
  • 不认识 UI——没有 BuildContext、不 import 任何 Widget。
  • 能脱离 UI 单独测——这是它和「塞在 State 里的逻辑」最大的区别。
@riverpod
class CartNotifier extends _$CartNotifier {   // ← 这就是 CartViewModel
  @override
  List<Item> build() => [];

  void add(Item i) => state = [...state, i];
  void remove(String id) => state = state.where((x) => x.id != id).toList();
  double get total => state.fold(0, (s, i) => s + i.price);
}

UI 侧就三种动作,和你在 Compose 里 collectAsStateWithLifecycle + 调 ViewModel 方法一模一样:

final cart = ref.watch(cartNotifierProvider);          // 订阅状态(= collectAsState)
final n = cart.length;
ref.read(cartNotifierProvider.notifier).add(item);     // 调方法(= viewModel.add)

Repository:数据从哪来,只此一处知道

Notifier 管「UI 需要的状态」,但数据(课程、成绩、通知)来自网络和本地缓存——这部分该独立成 Repository,和你在 Android 里的用法完全相同:

◆ Repository 的职责

它是「数据从哪来」的唯一权威。Notifier 不关心课程是从网络拉的还是从缓存读的——它只问 Repository 要,Repository 内部决定:先看缓存、过期了打网络、拿回来更新缓存、失败了怎么退化。

好处:换后端、加缓存、做离线,只动 Repository 一个地方,Notifier 和 UI 完全不受影响。这就是分层的全部意义。

class CourseRepository {
  CourseRepository(this._api, this._db);
  final CourseApi _api;
  final CourseDb _db;

  Future<List<Course>> getCourses({bool refresh = false}) async {
    if (!refresh) {
      final cached = await _db.readCourses();
      if (cached.isNotEmpty) return cached;      // 有缓存先给缓存
    }
    final fresh = await _api.fetchCourses();     // 打网络
    await _db.saveCourses(fresh);                // 回写缓存
    return fresh;
  }
}

Riverpod 让这层依赖变得极干净——因为它本身就是依赖注入(第 16 章):

@riverpod
CourseRepository courseRepository(Ref ref) =>
    CourseRepository(ref.watch(courseApiProvider), ref.watch(courseDbProvider));

@riverpod
Future<List<Course>> courseList(Ref ref) =>
    ref.watch(courseRepositoryProvider).getCourses();   // Notifier 层读 Repository

三层串起来,画成一张图

UI 层
Widget。只做两件事:读状态渲染、把用户操作转发给 Notifier。不含业务逻辑。
状态层
Notifier(= ViewModel)。持有 UI 状态、暴露方法、协调 Repository。不 import Widget。
数据层
Repository + 数据源(API、数据库)。数据从哪来的唯一真相。

数据往上流(Repository → Notifier 的 state → Widget 渲染),事件往下流(Widget 的 onTap → Notifier 的方法 → Repository)。这就是单向数据流,和你在 Android 的 MVVM/MVI 是同一个形状。

一份能直接抄的目录骨架

按功能分包(feature-first),比按类型分包(把所有 widget 堆一起、所有 model 堆一起)更好维护——功能内聚,改一个功能不用满项目跳:

lib/
├── main.dart
├── app/
│   ├── app.dart              # MaterialApp、主题、路由
│   └── router.dart           # go_router 配置(第 21 章)
├── core/                     # 跨功能共享
│   ├── theme/
│   ├── network/              # dio 客户端、拦截器
│   └── widgets/              # 通用小组件
└── features/
    ├── auth/
    │   ├── data/             # AuthRepository、UserApi
    │   ├── domain/           # User 模型
    │   └── presentation/     # 登录页 + AuthNotifier
    ├── courses/
    │   ├── data/
    │   ├── domain/
    │   └── presentation/
    └── notifications/
        └── ...
✎ 小 App 别过度分层

上面那套 data / domain / presentation 三分是「标准答案」,但你的学校 App 如果就五六个页面,可以先合并 domaindata,每个 feature 就 data + presentation 两层。分层是为了让项目长大时不乱,不是为了一开始就摆满文件夹。先按 feature 分包,层数按实际复杂度加。

⚠ 三条踩过就懂的分层纪律

1. Widget 里不写业务逻辑。看到 buildonTap 里出现「if 判断业务规则」「拼接请求参数」,就该把它挪进 Notifier。判据:这段逻辑能不能不开模拟器就测?不能,就说明它粘在 UI 上了。

2. Notifier 里不 import Widget。一旦 Notifier 里出现 import 'package:flutter/material.dart'(除了极少数如 Color),多半是逻辑和 UI 又搅上了。保持它对 UI 无知,它才可测。

3. 数据模型和 UI 状态分开。从 API 来的 Course(数据模型)和「课程页当前的筛选/排序/加载态」(UI 状态)是两种东西,别混进一个类。前者归 Repository,后者归 Notifier。

一个走完整条链路的例子:登录态

把这一章拼成一个真实场景——学校 App 的登录态,它是最典型的「全局、长命、跨页、要持久化」状态。看它怎么穿过四层:

// 数据层:谁存 token、谁调登录接口
class AuthRepository {
  AuthRepository(this._api, this._storage);
  final AuthApi _api;
  final SecureStorage _storage;              // flutter_secure_storage(第 22 章)

  Future<User> login(String id, String pwd) async {
    final resp = await _api.login(id, pwd);
    await _storage.write('token', resp.token);   // 持久化,重启还在
    return resp.user;
  }
  Future<void> logout() => _storage.delete('token');
  Future<String?> currentToken() => _storage.read('token');
}

// 状态层:全局的登录态 Notifier(= AuthViewModel)
@riverpod
class Auth extends _$Auth {
  @override
  Future<User?> build() async {                 // App 启动时读一次
    final token = await ref.watch(authRepoProvider).currentToken();
    return token == null ? null : await ref.watch(authRepoProvider).me();
  }
  Future<void> login(String id, String pwd) async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(() => ref.read(authRepoProvider).login(id, pwd));
  }
}

然后:UI 层的登录页调 ref.read(authProvider.notifier).login(...);go_router 的 redirect(第 21 章)watch 这个 authProvider 决定要不要跳登录页;任何页面要显示当前用户名,ref.watch(authProvider) 就行。一个状态,四层各司其职,没有一层越界——这就是分层的样子。token 在最底层持久化,UI 层完全不知道它存在。

什么状态不该进 Notifier

分层容易走向另一个极端:把什么都塞进全局 Notifier。有些状态就该留在 Widget 本地,提上去反而是过度设计:

  • 纯 UI 的瞬时状态:一个卡片展开没、密码框可见否、Tab 选中了哪个、动画进度——这些没人跨页要,setState 就地解决最清爽。
  • 表单输入的中间态:用户正在打字的内容,用 TextEditingController 存在 State 里,提交时才交给 Notifier。没必要每敲一个字都往全局灌。
  • 滚动位置、页面局部的临时选择:随页面生灭即可。

判据还是那两个问题:没人跨页要、离开就该消失的,留在本地。把这些也塞进全局,你会得到一个臃肿的、和无数页面耦合的「上帝 Notifier」——比 prop drilling 更难维护。分层的目标是「各归其位」,不是「越往上越好」。

⇄ Compose 对照 · 几乎零迁移成本

这一整章你可能有种「这不就是我天天写的吗」的感觉——对,因为它就是。你在 Android 里的 UI(Composable)→ ViewModel(StateFlow)→ Repository → DataSource 分层,逐层映射到 Flutter 的 Widget → Notifier → Repository → API/DB,连单向数据流的方向都一样。

唯一的实质差别是依赖注入:Android 你用 Hilt,Flutter 你用 Riverpod 自带的依赖图(provider 依赖 provider)。省掉了单独学一个 DI 框架。所以「状态该放哪」这个问题,你其实早就有答案了,这一章只是帮你把 Android 的答案翻译成 Flutter 的词。

这一章的一句话

状态的住址由两个问题决定——谁需要它、它活多久:范围越大活得越久就放得越高;具体就是「局部用 setState、页面内提升、功能级用 Notifier、数据层用 Repository」,而这套 UI→Notifier→Repository 分层就是你熟的 ViewModel + Repository,几乎零迁移成本。

卷 IV 到此结束——你已经能把一个界面从渲染到状态完整地推理和组织了。接下来卷 V 换一个维度:时间。你的 App 要发网络请求、要等数据库、要跑动画,而 Dart只有一根线程。它凭什么在干这些「等待」的事时不卡住界面?下一章从那个让所有人困惑的输出顺序题开始。