状态该放在哪:提升、Notifier 与 Repository
选好状态管理库只是第一步,真正决定项目好不好维护的是另一个问题:每一个状态该住在哪一层。放错了,要么到处传参、要么状态一变全乱、要么根本没法测。这一章给你一套可判定的规则,外加一份能直接抄进学校 App 的骨架——因为你的 ViewModel + Repository 直觉在这里完全成立。
先问两个问题,答案就定了住址
面对任何一个状态(一个布尔、一个列表、一个用户对象),只问两件事:
问题一:谁需要读/改它?——只有一个 Widget?一个页面里的几个 Widget?还是跨了好几个页面?
问题二:它活多久?——离开这个页面就该消失?还是整个 App 生命周期都要在?
「需要它的范围」越大、「它活的时间」越长,它就该放得越高、越靠近数据层。
把这两个问题的答案连起来,就是一张住址表:
| 状态 | 谁需要 | 住哪 | 例子 |
|---|---|---|---|
| UI 局部状态 | 一个 Widget 自己 | 它自己的 State(setState) | 某个卡片展开没、密码框可见否 |
| 页面内共享 | 一个页面里几个 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), // 子只读
],
);
}
}
这样 SearchBar 和 ResultList 都是无状态的、纯展示的——好测、好复用、没有隐藏状态。提升永远是第一选择,不够用了再往上升级到 Notifier。别一有共享需求就直接上全局状态管理,那是过度设计。
两个信号:一,提升的层数太多了(要传三四层,中间层全在转发它不关心的参数);二,这个状态还需要在别的页面用。出现任意一个,就把它从 Widget 里拎出来,做成一个 Notifier。
Notifier 就是你的 ViewModel
这是这一章最让你安心的一句话。第 16 章的 Riverpod Notifier,职责和你熟的 Android ViewModel 完全一致:
- 持有状态(
state≈StateFlow里的值)。 - 暴露改状态的方法(
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 里的用法完全相同:
它是「数据从哪来」的唯一权威。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
三层串起来,画成一张图
数据往上流(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/
└── ...
上面那套 data / domain / presentation 三分是「标准答案」,但你的学校 App 如果就五六个页面,可以先合并 domain 到 data,每个 feature 就 data + presentation 两层。分层是为了让项目长大时不乱,不是为了一开始就摆满文件夹。先按 feature 分包,层数按实际复杂度加。
1. Widget 里不写业务逻辑。看到 build 或 onTap 里出现「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 更难维护。分层的目标是「各归其位」,不是「越往上越好」。
这一整章你可能有种「这不就是我天天写的吗」的感觉——对,因为它就是。你在 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只有一根线程。它凭什么在干这些「等待」的事时不卡住界面?下一章从那个让所有人困惑的输出顺序题开始。