目标
- 理解
setState在大型应用中的天花板,以及 Riverpod 如何突破。 - 深入理解 Riverpod 的核心概念:
ProviderScope、ConsumerWidget、ref.watch、ref.read、ref.listen。 - 掌握
NotifierProvider与AsyncNotifierProvider的使用方法。 - 掌握 go_router 的声明式路由配置:
GoRouter、GoRoute、context.go、context.push。 - 掌握
redirect守卫、refreshListenable、StatefulShellRoute。 - 掌握路径参数、查询参数、
extra传参三种参数传递方式。 - 能用 Riverpod + go_router 重构第10课的完整项目。
- 通过"题目要求 → 答案 → 答案解读"的多元化习题完成练中学。
一、为什么需要 Riverpod 和 go_router?
1.1 一个真实的故事
小张用第10课的知识做了一个待办应用,一开始很好用。后来产品经理加了几个需求:
- 待办需要按项目分组,项目列表在另一个页面。
- 用户可以在设置页切换"默认项目",影响首页显示。
- 待办数量要在底部导航上显示小红点。
- 登录后才能查看私有待办。
小张发现代码越来越乱:
dart
// 设置页修改默认项目,需要通知首页刷新
// 怎么办?回调要穿过好几层
SettingsPage(
onDefaultProjectChanged: (project) {
// 传回给 HomePage
},
)
// 首页需要通过回调层层传递
HomePage(
onDefaultProjectChanged: (project) {
// 又要传给 TodoListPage
TodoListPage(
onDefaultProjectChanged: (project) {
// 再传给底部导航
// 再传给小红点
},
);
},
)
这就是 setState 的天花板:
| 痛点 | 表现 | 具体场景 |
|---|---|---|
| 跨页面共享困难 | 状态只能通过构造函数层层传递 | 用户信息从登录页传到首页、详情页、设置页 |
| 状态提升过度 | 回调函数从上往下传五六层 | 底部导航的子页面需要修改父页面状态 |
| 重建范围过大 | setState 重建整个 Widget 树 |
只改了收藏状态,但整个列表页都重建了 |
| 逻辑与 UI 耦合 | 业务逻辑写在 State 里 |
收藏、登录、购物车逻辑散落在各页面 |
| 测试困难 | 状态和 Widget 绑定,无法独立测试 | 想单独测试收藏逻辑,必须启动整个 Widget |
1.2 Riverpod 的解决思路
Riverpod 把状态从 Widget 树中抽离出来 ,放到一个独立的容器(ProviderScope)里。任何 Widget 都能按需读取,状态变化时自动通知依赖者。
心智模型:
┌─────────────────────────────────────────────┐
│ ProviderScope │
│ ┌─────────────────────────────────────┐ │
│ │ Provider 容器 │ │
│ │ ┌──────────┐ ┌──────────┐ │ │
│ │ │ counter │ │ favorites│ │ │
│ │ │ = 5 │ │ = {1,3} │ │ │
│ │ └────┬─────┘ └────┬─────┘ │ │
│ └───────┼──────────────┼──────────────┘ │
│ │ │ │
│ ┌───────┼──────────────┼──────────────┐ │
│ │ ▼ ▼ │ │
│ │ CounterPage FavoritePage │ │
│ │ ref.watch ref.watch │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
和 setState 的对比:
setState:状态在 Widget 里 → 只能当前 Widget 用 → 跨页面要手动传
Riverpod:状态在 Provider 里 → 谁需要谁来读 → 自动通知更新
1.3 Navigator 的天花板
Navigator.push 是命令式路由,适合简单场景,但遇到以下需求就力不从心:
| 痛点 | 表现 |
|---|---|
| 路由分散 | 每个页面都写 Navigator.push,难以统一管理 |
| 深度链接 | 无法通过 URL 直接打开某个页面 |
| 认证守卫 | 需要在每个页面手动检查登录状态 |
| 嵌套导航 | 底部导航 + 子路由嵌套困难 |
| Web 后退 | URL 不变化,浏览器后退按钮行为异常 |
go_router 的解决思路 :把整个路由树定义在一处,支持 URL 映射、深度链接、认证守卫和嵌套导航。
1.4 本课知识地图
状态管理进阶与声明式路由
│
├── Riverpod 基础
│ ├── ProviderScope 存储所有 Provider 的状态
│ ├── ConsumerWidget 能读取 Provider 的 Widget
│ ├── ref.watch 监听变化,变化时重建
│ ├── ref.read 一次性读取,不监听
│ └── ref.listen 监听变化,执行副作用
│
├── Riverpod Provider 类型
│ ├── Provider 只读计算值 / 依赖注入
│ ├── StateProvider 简单可变状态
│ ├── NotifierProvider 复杂同步状态 + 业务方法
│ ├── AsyncNotifierProvider 异步状态 + 业务方法
│ ├── FutureProvider 一次性异步数据
│ ├── StreamProvider 实时数据流
│ ├── .autoDispose 不再使用时自动销毁
│ └── .family 带参数的状态
│
├── go_router 基础
│ ├── GoRouter 路由配置实例
│ ├── GoRoute 一条路由规则
│ ├── context.go 声明式导航(替换栈)
│ ├── context.push 命令式导航(压入栈)
│ └── context.pushReplacement 替换当前页
│
├── go_router 进阶
│ ├── redirect 全局/路由级重定向(认证守卫)
│ ├── refreshListenable 登录状态变化时自动重定向
│ ├── ShellRoute 嵌套导航外壳
│ ├── StatefulShellRoute 保持分支状态的外壳
│ ├── 路径参数 :id state.pathParameters
│ └── 查询参数 ?key=val state.uri.queryParameters
│
└── 综合实战
└── 用 Riverpod + go_router 重构第10课
二、Riverpod 核心概念详解
2.1 ProviderScope:状态的容器
dart
void main() {
runApp(const ProviderScope(child: MyApp()));
}
为什么必须包裹 ProviderScope?
Riverpod 的状态不放在 Widget 树中,而是放在 ProviderScope 维护的一个独立容器里。没有 ProviderScope,所有 Provider 都无法工作------就像没有 MaterialApp 就无法使用 Navigator 一样。
调试技巧 :如果看到 No ProviderScope found 错误,就是忘了包裹 ProviderScope。
2.2 ConsumerWidget:能读取 Provider 的 Widget
普通的 StatelessWidget 没有 ref 参数,无法读取 Provider。Riverpod 提供了 ConsumerWidget:
dart
class CounterPage extends ConsumerWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// ↑ 多了这个参数
final count = ref.watch(counterProvider);
return Scaffold(
body: Center(child: Text('$count')),
);
}
}
对应关系:
| 普通 Widget | Riverpod 版本 | 区别 |
|---|---|---|
StatelessWidget |
ConsumerWidget |
build 多一个 ref 参数 |
StatefulWidget |
ConsumerStatefulWidget |
State 中多一个 ref 属性 |
2.3 ref.watch / ref.read / ref.listen
这是 Riverpod 最容易混淆的地方。三个方法用途完全不同:
| 方法 | 用途 | 在哪里用 | 是否监听 | 典型场景 |
|---|---|---|---|---|
ref.watch |
监听 Provider 的值 | build 方法中 |
是 | 显示状态数据 |
ref.read |
一次性读取 | 回调 / 事件处理中 | 否 | 按钮点击时调用方法 |
ref.listen |
监听变化执行副作用 | build 方法中 |
是(但不重建) | 弹 SnackBar、导航 |
用一句话记住:
watch在 build 里看数据,read在回调里做操作,listen在 build 里做副作用。
常见错误:
dart
// ❌ 在 build 中用 read 读数据,数据变化时不会重建
final count = ref.read(counterProvider); // 永远显示初始值
// ✅ 在 build 中用 watch 读数据
final count = ref.watch(counterProvider);
// ❌ 在回调中用 watch,虽然能运行但不推荐
onPressed: () => ref.watch(counterProvider.notifier).increment();
// ✅ 在回调中用 read
onPressed: () => ref.read(counterProvider.notifier).increment();
ref.listen 的典型用法:
dart
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.listen(authProvider, (previous, next) {
if (next == AuthState.loggedIn) {
context.go('/home'); // 登录成功后跳转
}
});
return Scaffold(/* ... */);
}
2.4 Provider 类型全览
| Provider 类型 | 状态类型 | 适用场景 | 示例 |
|---|---|---|---|
Provider |
只读值 | 依赖注入、计算值 | API 客户端、配置 |
StateProvider |
简单可变值 | 开关、计数器 | 主题模式、筛选条件 |
NotifierProvider |
复杂同步状态 | 购物车、待办列表 | 带业务方法的列表 |
AsyncNotifierProvider |
异步状态 | 网络请求 + 修改 | 文章列表 + 收藏 |
FutureProvider |
一次性异步值 | 只读的网络请求 | 加载配置 |
StreamProvider |
数据流 | WebSocket、实时数据 | 聊天消息 |
2.5 Notifier:复杂同步状态
Notifier 是 Riverpod 2.0 引入的新 API,替代旧的 StateNotifier。
旧写法(StateNotifier) :
dart
class CounterNotifier extends StateNotifier<int> {
CounterNotifier() : super(0);
void increment() => state++;
}
final counterProvider = StateNotifierProvider<CounterNotifier, int>((ref) {
return CounterNotifier();
});
新写法(Notifier) :
dart
class CounterNotifier extends Notifier<int> {
@override
int build() => 0; // 初始值在 build 中返回
void increment() => state++;
}
final counterProvider = NotifierProvider<CounterNotifier, int>(
CounterNotifier.new,
);
新 API 的优势:
- 初始值在
build()中返回,不再通过构造函数。 - 类内部直接可以访问
ref,不需要从构造函数传入。 - 同步和异步版本语法统一。
- 支持代码生成(
@riverpod)。
2.6 AsyncNotifier:异步状态 + 修改
AsyncNotifier 是处理异步状态的标准方式,可以理解为"带方法的 FutureProvider"。
为什么用 AsyncNotifier 而不是 FutureProvider?
FutureProvider 只能读取异步数据,无法从 UI 修改状态。AsyncNotifier 既有 FutureProvider 的异步能力,又有 Notifier 的修改方法。
dart
class ArticlesNotifier extends AsyncNotifier<List<Article>> {
@override
Future<List<Article>> build() async {
// build 中加载数据
return ApiService.fetchArticles();
}
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() => ApiService.fetchArticles());
}
void toggleFavorite(int id) {
// 修改状态,不需要重新请求
final current = state.valueOrNull ?? [];
state = AsyncData(
current.map((a) => a.id == id
? a.copyWith(favorite: !a.favorite)
: a,
).toList(),
);
}
}
final articlesProvider =
AsyncNotifierProvider<ArticlesNotifier, List<Article>>(
ArticlesNotifier.new,
);
AsyncValue 的三种状态:
| 状态 | 含义 | 访问方式 |
|---|---|---|
AsyncLoading |
加载中 | state.isLoading |
AsyncData |
有数据 | state.valueOrNull |
AsyncError |
有错误 | state.error |
在 UI 中使用:
dart
class ArticleListPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final articlesAsync = ref.watch(articlesProvider);
return articlesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('错误:$e')),
data: (articles) => ListView.builder(
itemCount: articles.length,
itemBuilder: (context, index) => ArticleCard(
article: articles[index],
),
),
);
}
}
AsyncNotifier 的核心优势:
- 自动处理 loading / error / data 三种状态。
- 可以在异步加载后直接修改状态,不需要重新请求。
- 支持
refresh、update等实用方法。 - 比
FutureProvider更适合需要"加载 + 修改"的场景。
2.7 autoDispose 和 family
.autoDispose :Provider 不再被使用时自动销毁,释放内存。
dart
final searchProvider = FutureProvider.autoDispose<List<Article>>((ref) {
final keyword = ref.watch(searchKeywordProvider);
return ApiService.search(keyword);
});
适用场景:搜索、详情页等用完即弃的数据。
.family :带参数的 Provider,同一逻辑生成多个独立状态。
dart
final userProvider = FutureProvider.family<User, int>((ref, userId) async {
return ApiService.fetchUser(userId);
});
// 使用
final user1 = ref.watch(userProvider(1));
final user2 = ref.watch(userProvider(2));
2.8 Provider 测试与依赖注入
Riverpod 的 ProviderScope 支持 overrides,方便测试时替换依赖:
dart
testWidgets('显示文章列表', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
articlesProvider.overrideWith(() {
return FakeArticlesNotifier();
}),
],
child: const MyApp(),
),
);
// 测试...
});
这是 Riverpod 相对于 setState 的一大优势:状态和 Widget 解耦,测试时可以轻松替换依赖。
三、go_router 核心概念详解
3.1 路由配置
dart
final router = GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomePage(),
routes: [
GoRoute(
path: 'detail/:id',
builder: (context, state) {
final id = state.pathParameters['id']!;
return DetailPage(id: id);
},
),
],
),
],
);
关键点:
initialLocation设置初始路由。routes是路由列表,支持嵌套。- 子路由的
path不带/,最终路径是父路径 + 子路径。
3.2 context.go vs context.push
| 方法 | 行为 | 能否返回 | 典型场景 |
|---|---|---|---|
context.go('/detail') |
替换整个栈 | 不能 | 切换 Tab |
context.push('/detail') |
压入新页面 | 能 | 进入详情 |
context.pushReplacement('/home') |
替换当前页 | 不能回被替换页 | 登录后跳首页 |
context.pop() |
弹出当前页 | --- | 返回上一页 |
go_router 的导航方式对比 Navigator:
| Navigator | go_router | 区别 |
|---|---|---|
Navigator.push |
context.push |
go_router 支持 URL |
Navigator.pop |
context.pop |
类似 |
pushReplacement |
context.pushReplacement |
类似 |
pushAndRemoveUntil |
context.go |
go 会替换整个栈 |
3.3 三种参数传递方式
路径参数:参数嵌入 URL 路径中,适合必传参数。
dart
// 定义
GoRoute(
path: 'detail/:id',
builder: (context, state) {
final id = state.pathParameters['id']!;
return DetailPage(id: id);
},
)
// 跳转
context.go('/detail/42');
// URL: /detail/42
查询参数 :参数在 ? 后面,适合可选参数。
dart
// 定义(不需要在 path 中声明)
GoRoute(
path: 'search',
builder: (context, state) {
final keyword = state.uri.queryParameters['q'] ?? '';
final sort = state.uri.queryParameters['sort'] ?? 'desc';
return SearchPage(keyword: keyword, sort: sort);
},
)
// 跳转
context.go('/search?q=flutter&sort=asc');
// URL: /search?q=flutter&sort=asc
extra 传参:传递任意对象,不显示在 URL 中。
dart
// 跳转
context.go('/detail', extra: article);
// 接收
GoRoute(
path: 'detail',
builder: (context, state) {
final article = state.extra as Article;
return DetailPage(article: article);
},
)
三种方式对比:
| 方式 | 显示在 URL | 类型安全 | 适用场景 |
|---|---|---|---|
| 路径参数 | 是 | 需手动转型 | 必传 ID、slug |
| 查询参数 | 是 | 需手动转型 | 可选筛选、排序 |
| extra | 否 | 需手动转型 | 传对象、不需要分享链接 |
3.4 redirect 守卫:认证拦截
redirect 是 go_router 实现认证守卫的核心机制。它在每次导航时 被调用,返回 null 表示放行,返回路径字符串表示重定向。
dart
final router = GoRouter(
initialLocation: '/',
redirect: (context, state) {
final isLoggedIn = ...;
final isLoginRoute = state.matchedLocation == '/login';
// 未登录且不在登录页 → 重定向到登录页
if (!isLoggedIn && !isLoginRoute) return '/login';
// 已登录且在登录页 → 重定向到首页
if (isLoggedIn && isLoginRoute) return '/';
// 其他情况放行
return null;
},
routes: [
GoRoute(path: '/', builder: (_, __) => const HomePage()),
GoRoute(path: '/login', builder: (_, __) => const LoginPage()),
],
);
redirect 的优先级规则:如果多个路由都有 redirect,父路由的 redirect 优先于子路由。
3.5 refreshListenable:登录状态变化自动重定向
redirect 只在导航时被调用。如果用户已经停留在某个页面,登录状态变了(比如 token 过期),redirect 不会自动执行。
refreshListenable 解决了这个问题:它监听一个 Listenable,当状态变化时,自动重新评估 redirect。
dart
class AuthNotifier extends ChangeNotifier {
bool _isLoggedIn = false;
bool get isLoggedIn => _isLoggedIn;
void login() {
_isLoggedIn = true;
notifyListeners(); // 触发 router 重新评估 redirect
}
void logout() {
_isLoggedIn = false;
notifyListeners();
}
}
final authNotifier = AuthNotifier();
final router = GoRouter(
refreshListenable: authNotifier,
redirect: (context, state) {
if (!authNotifier.isLoggedIn && state.matchedLocation != '/login') {
return '/login';
}
return null;
},
routes: [/* ... */],
);
工作流程:
用户点击登录 → authNotifier.login() → notifyListeners()
↓
GoRouter 收到通知 → 重新执行 redirect
↓
redirect 检查状态 → 已登录 → 放行
↓
页面自动跳转到目标页
3.6 ShellRoute 和 StatefulShellRoute:嵌套导航
ShellRoute 用于创建带有固定 UI(如底部导航栏)的嵌套导航。
dart
ShellRoute(
builder: (context, state, child) {
return Scaffold(
body: child, // 子路由的页面显示在这里
bottomNavigationBar: BottomNavigationBar(/* ... */),
);
},
routes: [
GoRoute(path: '/home', builder: (_, __) => const HomePage()),
GoRoute(path: '/profile', builder: (_, __) => const ProfilePage()),
],
)
ShellRoute 的问题:切换 Tab 时,子页面会被销毁,状态丢失。
StatefulShellRoute 解决了这个问题 :它为每个分支创建独立的 Navigator,切换时保持状态。
dart
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) {
return Scaffold(
body: navigationShell, // 自动用 IndexedStack 保持状态
bottomNavigationBar: BottomNavigationBar(
currentIndex: navigationShell.currentIndex,
onTap: (i) => navigationShell.goBranch(i),
items: const [/* ... */],
),
);
},
branches: [
StatefulShellBranch(routes: [
GoRoute(path: '/home', builder: (_, __) => const HomePage()),
]),
StatefulShellBranch(routes: [
GoRoute(path: '/profile', builder: (_, __) => const ProfilePage()),
]),
],
)
ShellRoute vs StatefulShellRoute 对比:
| 维度 | ShellRoute | StatefulShellRoute |
|---|---|---|
| 状态保持 | 不保持 | 保持 |
| 独立 Navigator | 共享一个 | 每个分支独立 |
| 适用场景 | 不需要保持状态的嵌套 | 底部导航 + 子路由 |
| 实现方式 | 手动管理 | indexedStack 自动管理 |
四、多元化习题
难度:⭐ 入门 ⭐⭐ 进阶 ⭐⭐⭐ 综合
每题标注【练什么】,帮你明确训练目标。
练习0:热重载体验 Riverpod 与 go_router ⭐
【练什么】watch / read 的区别,go / push 的区别
题目要求
添加依赖:
yaml
dependencies:
flutter_riverpod: ^2.4.0
go_router: ^14.0.0
运行 flutter pub get,把 lib/main.dart 改成:
dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
void main() {
runApp(const ProviderScope(child: MyApp()));
}
final counterProvider = NotifierProvider<CounterNotifier, int>(
CounterNotifier.new,
);
class CounterNotifier extends Notifier<int> {
@override
int build() => 0;
void increment() => state++;
void reset() => state = 0;
}
final router = GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomePage(),
routes: [
GoRoute(
path: 'counter',
builder: (context, state) => const CounterPage(),
),
],
),
],
);
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(routerConfig: router);
}
}
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('首页')),
body: Center(
child: ElevatedButton(
onPressed: () => context.go('/counter'),
child: const Text('打开计数器'),
),
),
);
}
}
class CounterPage extends ConsumerWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider);
return Scaffold(
appBar: AppBar(title: const Text('计数器')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('计数:$count', style: const TextStyle(fontSize: 32)),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () =>
ref.read(counterProvider.notifier).increment(),
child: const Text('+1'),
),
const SizedBox(width: 12),
TextButton(
onPressed: () =>
ref.read(counterProvider.notifier).reset(),
child: const Text('重置'),
),
],
),
],
),
),
);
}
}
完成以下操作并记录变化:
- 把
ref.watch改成ref.read放在build中,观察计数是否更新。 - 把
context.go('/counter')改成context.push('/counter'),观察返回行为差异。 - 把
GoRouter的counter路径改成count,同时把按钮跳转也改成count。
答案
dart
// 1. ref.read 在 build 中不会监听变化,计数不会刷新
final count = ref.read(counterProvider); // ❌ 永远显示 0
// 2. push 会压栈,可以返回;go 会替换栈
onPressed: () => context.push('/counter'),
// 3. 路径同步修改
GoRoute(path: 'count', builder: (context, state) => const CounterPage()),
onPressed: () => context.go('/count'),
答案解读
ref.watch在build中监听状态变化,状态更新时自动重建。ref.read只读取一次,不监听。记住:在 build 中用 watch,在回调中用 read。context.go是声明式导航,会重建整个栈以匹配目标路径;context.push是命令式,在当前栈上压入新页面。Go 适合切换主页面,push 适合进入详情页。- 路由路径必须与跳转路径一致,否则会找不到页面,报
No route found。
练习1:选择题 ⭐
【练什么】ref 三种读取方式
题目要求
以下哪个方法用于在 build 中监听 Provider 变化?
A. ref.read
B. ref.watch
C. ref.listen
D. ref.refresh
答案
B。
答案解读
ref.watch在build中监听,状态变化时重建 Widget。ref.read用于回调中一次性读取,不监听。ref.listen用于监听变化执行副作用(如弹 SnackBar),不用于构建 UI。ref.refresh用于手动刷新。
记忆口诀:watch 看,read 做,listen 副作用。
练习2:判断题 ⭐
【练什么】Riverpod 与 go_router 概念边界
题目要求
判断对错,并说明理由:
ProviderScope必须包裹在MaterialApp外层。NotifierProvider适合管理复杂同步状态。GoRouter可以配置重定向实现登录守卫。context.go会在当前栈上压入新页面。ShellRoute切换 Tab 时会保持子页面状态。
答案
- 对。
- 对。
- 对。
- 错。
- 错。
答案解读
ProviderScope需要包裹整个应用,让所有 Widget 都能访问 Provider。NotifierProvider配合Notifier管理复杂同步状态和业务方法。GoRouter的redirect回调可以在每次导航时检查登录状态并重定向。context.go是替换整个栈,不是压栈;压栈用context.push。ShellRoute切换时子页面会销毁,状态丢失。StatefulShellRoute才保持状态。
练习3:连线题 ⭐
【练什么】Provider 类型与路由概念对应
题目要求
将左侧概念与右侧作用连线:
| 左侧 | 右侧 |
|---|---|
| ProviderScope | 复杂同步状态 + 业务方法 |
| NotifierProvider | 异步数据 + 修改方法 |
| AsyncNotifierProvider | 包裹应用,存储 Provider 状态 |
| GoRoute | 路由配置实例 |
| GoRouter | 一条路由规则 |
| refreshListenable | 状态变化时自动重定向 |
答案
- ProviderScope --- 包裹应用,存储 Provider 状态
- NotifierProvider --- 复杂同步状态 + 业务方法
- AsyncNotifierProvider --- 异步数据 + 修改方法
- GoRoute --- 一条路由规则
- GoRouter --- 路由配置实例
- refreshListenable --- 状态变化时自动重定向
答案解读
Riverpod 的 Provider 类型各有用途。NotifierProvider 适合同步状态,AsyncNotifierProvider 适合异步状态。go_router 的 GoRouter 是配置实例,GoRoute 是树中的一个节点。refreshListenable 是解决登录状态变化自动重定向的关键。
练习4:填空题 ⭐⭐
【练什么】Notifier 语法
题目要求
补全 Riverpod 计数器 Provider:
dart
final counterProvider = ______Provider<CounterNotifier, int>(
CounterNotifier.new,
);
class CounterNotifier extends ______<int> {
@override
int ______() => 0;
void increment() => ______++;
}
答案
dart
final counterProvider = NotifierProvider<CounterNotifier, int>(
CounterNotifier.new,
);
class CounterNotifier extends Notifier<int> {
@override
int build() => 0;
void increment() => state++;
}
答案解读
NotifierProvider的泛型参数是<NotifierType, StateType>。Notifier的初始值通过build()返回。- 修改状态直接对
state赋值或运算,Riverpod 会自动通知监听者。
练习5:动手编码题:跨页面共享购物车 ⭐⭐
【练什么】NotifierProvider 跨页面共享状态
题目要求
用 Riverpod 实现购物车状态,在商品列表页添加商品,在购物车页面查看商品列表和总价。要求使用 NotifierProvider。
答案
dart
class CartItem {
final String name;
final double price;
const CartItem(this.name, this.price);
}
class CartNotifier extends Notifier<List<CartItem>> {
@override
List<CartItem> build() => [];
void add(CartItem item) => state = [...state, item];
void removeAt(int index) => state = [...state]..removeAt(index);
double get total => state.fold(0, (sum, item) => sum + item.price);
}
final cartProvider = NotifierProvider<CartNotifier, List<CartItem>>(
CartNotifier.new,
);
// 商品列表页
class ProductListPage extends ConsumerWidget {
const ProductListPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final products = [
const CartItem('Flutter 课程', 99),
const CartItem('Dart 课程', 49),
const CartItem('布局课程', 79),
];
return Scaffold(
appBar: AppBar(
title: const Text('商品列表'),
actions: [
IconButton(
icon: const Icon(Icons.shopping_cart),
onPressed: () => context.go('/cart'),
),
],
),
body: ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return ListTile(
title: Text(product.name),
subtitle: Text('¥${product.price}'),
trailing: IconButton(
icon: const Icon(Icons.add),
onPressed: () => ref.read(cartProvider.notifier).add(product),
),
);
},
),
);
}
}
// 购物车页面
class CartPage extends ConsumerWidget {
const CartPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final items = ref.watch(cartProvider);
final total = ref.read(cartProvider.notifier).total;
return Scaffold(
appBar: AppBar(title: const Text('购物车')),
body: items.isEmpty
? const Center(child: Text('购物车是空的'))
: Column(
children: [
Expanded(
child: ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => ListTile(
title: Text(items[index].name),
subtitle: Text('¥${items[index].price}'),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () =>
ref.read(cartProvider.notifier).removeAt(index),
),
),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Text('总价:¥$total',
style: const TextStyle(fontSize: 20)),
),
],
),
);
}
}
答案解读
CartNotifier管理购物车列表,add和removeAt通过state = [...]创建新列表,保证不可变性。- 商品页通过
ref.read调用方法,购物车页通过ref.watch监听列表变化。 - 跨页面共享状态无需传递回调,这正是 Riverpod 的优势。
为什么用 state = [...state, item] 而不是 state.add(item)?
Riverpod 通过比较 state 的引用来判断是否变化。如果直接 state.add(item),引用没变,Riverpod 认为状态没变,不会通知监听者。创建新列表才能触发更新。
练习6:动手编码题:登录守卫 + 路由 ⭐⭐⭐
【练什么】redirect + refreshListenable
题目要求
用 go_router 实现登录守卫:
- 路径
/为首页,/login为登录页,/profile为个人页。 - 未登录时访问
/profile重定向到/login。 - 登录页点击"登录"后设置登录状态,跳转到
/profile。
答案
dart
final authProvider = StateProvider<bool>((ref) => false);
final routerProvider = Provider<GoRouter>((ref) {
final auth = ref.watch(authProvider);
return GoRouter(
initialLocation: '/',
refreshListenable: _AuthRefreshNotifier(ref),
redirect: (context, state) {
final isLoginRoute = state.matchedLocation == '/login';
if (!auth && !isLoginRoute && state.matchedLocation == '/profile') {
return '/login';
}
if (auth && isLoginRoute) return '/profile';
return null;
},
routes: [
GoRoute(path: '/', builder: (_, __) => const HomePage()),
GoRoute(path: '/login', builder: (_, __) => const LoginPage()),
GoRoute(path: '/profile', builder: (_, __) => const ProfilePage()),
],
);
});
class _AuthRefreshNotifier extends ChangeNotifier {
_AuthRefreshNotifier(Ref ref) {
ref.listen(authProvider, (_, __) => notifyListeners());
}
}
// 在 ConsumerWidget 中使用
final router = ref.watch(routerProvider);
答案解读
redirect是守卫的核心。它接收当前导航状态,返回null放行,返回路径字符串则重定向。- 关键点:
redirect中需要读取登录状态,而GoRouter的配置是在Provider中创建的,通过ref.watch(authProvider)把状态注入路由配置。 refreshListenable让登录状态变化时自动重新评估redirect。没有它,用户停留当前页面时登录状态变化不会触发重定向。
为什么需要 _AuthRefreshNotifier?
refreshListenable 需要一个 Listenable。authProvider 是 Riverpod 的 Provider,不是 Listenable。_AuthRefreshNotifier 把 Riverpod 的监听转成 ChangeNotifier,桥接两者。
练习7:综合挑战:用 Riverpod + go_router 重构第10课 ⭐⭐⭐
【练什么】综合运用
题目要求
把第10课的文章收藏应用中的收藏状态和文章加载迁移到 Riverpod,路由迁移到 go_router:
- 用
AsyncNotifierProvider替代FutureBuilder加载文章列表。 - 用
NotifierProvider管理收藏 ID 集合。 - 用 go_router 配置路由,支持详情页路径参数。
答案示例
dart
// Provider 定义
final apiProvider = Provider<ApiService>((ref) => ApiService());
final articlesProvider =
AsyncNotifierProvider<ArticlesNotifier, List<Article>>(
ArticlesNotifier.new,
);
class ArticlesNotifier extends AsyncNotifier<List<Article>> {
@override
Future<List<Article>> build() async {
return ref.read(apiProvider).fetchArticles();
}
}
class FavoritesNotifier extends Notifier<Set<int>> {
@override
Set<int> build() => {};
void toggle(int id) {
state = {...state};
if (state.contains(id)) {
state.remove(id);
} else {
state.add(id);
}
}
bool contains(int id) => state.contains(id);
}
final favoritesProvider = NotifierProvider<FavoritesNotifier, Set<int>>(
FavoritesNotifier.new,
);
// 路由配置
final router = GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/',
builder: (context, state) => const ArticleListPage(),
routes: [
GoRoute(
path: 'detail/:id',
builder: (context, state) {
final id = int.parse(state.pathParameters['id']!);
return ArticleDetailPage(articleId: id);
},
),
],
),
],
);
// 列表页
class ArticleListPage extends ConsumerWidget {
const ArticleListPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final articlesAsync = ref.watch(articlesProvider);
final favorites = ref.watch(favoritesProvider);
return Scaffold(
appBar: AppBar(title: const Text('文章列表')),
body: articlesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('出错了:$e')),
data: (articles) => ListView.builder(
itemCount: articles.length,
itemBuilder: (context, index) {
final article = articles[index];
return ArticleCard(
article: article.copyWith(
favorite: favorites.contains(article.id),
),
onTap: () => context.push('/detail/${article.id}'),
);
},
),
),
);
}
}
答案解读
AsyncNotifierProvider把异步加载封装成 Provider,ref.watch返回AsyncValue,用.when处理三种状态。NotifierProvider管理收藏集合,详情页通过ref.watch(favoritesProvider)读取。- go_router 的路径参数
:id通过state.pathParameters['id']读取。 - 第10课的
FutureBuilder+setState+shared_preferences被 Riverpod 统一管理,代码更集中、更易测试。
重构带来的好处:
| 维度 | 第10课 | 第11课 |
|---|---|---|
| 状态位置 | 分散在各页面 | 集中在 Provider |
| 跨页面共享 | 回调层层传递 | ref.watch 直接读 |
| 重建范围 | 整个页面 | 只重建依赖者 |
| 测试 | 需启动 Widget | 可独立测试 |
| 路由 | 分散 | 集中配置 |
五、本课小结
你练了什么
- Riverpod 核心概念:
ProviderScope、ConsumerWidget、ref.watch、ref.read、ref.listen; NotifierProvider管理复杂同步状态,AsyncNotifierProvider处理异步数据;autoDispose和family的使用场景;- go_router 声明式路由配置:
GoRouter、GoRoute、context.go、context.push; - 路径参数
:id、查询参数?key=val、extra传参三种方式; redirect守卫、refreshListenable、ShellRoute/StatefulShellRoute;- 用 Riverpod + go_router 重构第10课的收藏应用。
核心心法
在 build 中用 watch,在回调中用 read,在副作用中用 listen;路由配置集中管理,认证守卫用 redirect。
Riverpod vs setState 对比
| 维度 | setState | Riverpod |
|---|---|---|
| 状态位置 | Widget 内部 | Provider 容器 |
| 跨页面共享 | 手动传递 | 自动共享 |
| 重建范围 | 整个 Widget 树 | 只重建依赖者 |
| 测试 | 需启动 Widget | 可独立测试 |
| 业务逻辑 | 写在 State 里 | 写在 Notifier 里 |
go_router vs Navigator 对比
| 维度 | Navigator | go_router |
|---|---|---|
| 路由定义 | 分散在各页面 | 集中配置 |
| URL 映射 | 无 | 有 |
| 深度链接 | 不支持 | 支持 |
| 认证守卫 | 手动检查 | redirect 自动 |
| 嵌套导航 | 需手动管理 | ShellRoute |
常见错误速查
| 错误信息 | 原因 | 解决 |
|---|---|---|
No ProviderScope found |
忘记包裹 ProviderScope | runApp(ProviderScope(child: MyApp())) |
No GoRouter found in context |
用了 MaterialApp 而非 MaterialApp.router |
改用 MaterialApp.router |
ref.watch 在回调中 |
用错方法 | 回调中用 ref.read |
ref.read 在 build 中 |
数据不更新 | build 中改用 ref.watch |
redirect 无限循环 |
重定向到自身 | 检查目标路径 |
StatefulShellRoute 状态丢失 |
用了 ShellRoute |
改用 StatefulShellRoute |
No route found |
路径不匹配 | 检查 GoRoute 路径与跳转路径 |
Provider 类型选择指南
| 需求 | 推荐 Provider |
|---|---|
| 依赖注入 | Provider |
| 简单开关 | StateProvider |
| 带方法的同步列表 | NotifierProvider |
| 带方法的异步列表 | AsyncNotifierProvider |
| 只读的一次性异步 | FutureProvider |
| 实时数据流 | StreamProvider |
面试常见问题
-
ref.watch和ref.read有什么区别?ref.watch在build中监听,状态变化时重建 Widget;ref.read在回调中一次性读取,不监听。 -
Notifier和StateNotifier有什么区别?Notifier是 Riverpod 2.0 的新 API,初始值在build()中返回,类内部直接访问ref。StateNotifier是旧 API,初始值通过构造函数传入。 -
AsyncNotifier和FutureProvider怎么选?FutureProvider只读,适合只加载不修改的场景;AsyncNotifier可读写,适合加载后还需要修改的场景。 -
go_router 的
redirect和refreshListenable分别解决什么问题?redirect在每次导航时检查,决定是否放行;refreshListenable在状态变化时自动重新评估redirect,解决用户停留当前页面时登录状态变化的问题。 -
ShellRoute和StatefulShellRoute有什么区别?ShellRoute切换时子页面销毁,状态丢失;StatefulShellRoute为每个分支创建独立 Navigator,保持状态。 -
context.go和context.push有什么区别?go替换整个栈,push压入新页面。Go 适合切换主页面,push 适合进入详情页。
下一课预告
性能优化与测试------const 构造函数、RepaintBoundary、ListView.builder 优化、WidgetTester、单元测试、集成测试,继续用"练中学"的方式为应用提速和加保障。