【Flutter入门练中学】第11课:状态管理进阶与声明式路由

目标

  • 理解 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 里 → 谁需要谁来读 → 自动通知更新

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('重置'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

完成以下操作并记录变化:

  1. 把 ref.watch 改成 ref.read 放在 build 中,观察计数是否更新。
  2. 把 context.go('/counter') 改成 context.push('/counter'),观察返回行为差异。
  3. 把 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 概念边界

题目要求

判断对错,并说明理由:

  1. ProviderScope 必须包裹在 MaterialApp 外层。
  2. NotifierProvider 适合管理复杂同步状态。
  3. GoRouter 可以配置重定向实现登录守卫。
  4. context.go 会在当前栈上压入新页面。
  5. ShellRoute 切换 Tab 时会保持子页面状态。

答案

  1. 对。
  2. 对。
  3. 对。
  4. 错。
  5. 错。

答案解读

  • 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 可独立测试
路由 分散 集中配置

五、本课小结

你练了什么

  1. Riverpod 核心概念:ProviderScope、ConsumerWidget、ref.watch、ref.read、ref.listen;
  2. NotifierProvider 管理复杂同步状态,AsyncNotifierProvider 处理异步数据;
  3. autoDispose 和 family 的使用场景;
  4. go_router 声明式路由配置:GoRouter、GoRoute、context.go、context.push;
  5. 路径参数 :id、查询参数 ?key=val、extra 传参三种方式;
  6. redirect 守卫、refreshListenable、ShellRoute / StatefulShellRoute;
  7. 用 Riverpod + go_router 重构第10课的收藏应用。

核心心法

在 build 中用 watch,在回调中用 read,在副作用中用 listen;路由配置集中管理,认证守卫用 redirect。

Riverpod vs setState 对比

维度 setState Riverpod
状态位置 Widget 内部 Provider 容器
跨页面共享 手动传递 自动共享
重建范围 整个 Widget 树 只重建依赖者
测试 需启动 Widget 可独立测试
业务逻辑 写在 State 里 写在 Notifier 里
维度 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

面试常见问题

  1. ref.watch 和 ref.read 有什么区别?

    ref.watch 在 build 中监听,状态变化时重建 Widget;ref.read 在回调中一次性读取,不监听。

  2. Notifier 和 StateNotifier 有什么区别?

    Notifier 是 Riverpod 2.0 的新 API,初始值在 build() 中返回,类内部直接访问 ref。StateNotifier 是旧 API,初始值通过构造函数传入。

  3. AsyncNotifier 和 FutureProvider 怎么选?

    FutureProvider 只读,适合只加载不修改的场景;AsyncNotifier 可读写,适合加载后还需要修改的场景。

  4. go_router 的 redirect 和 refreshListenable 分别解决什么问题?

    redirect 在每次导航时检查,决定是否放行;refreshListenable 在状态变化时自动重新评估 redirect,解决用户停留当前页面时登录状态变化的问题。

  5. ShellRoute 和 StatefulShellRoute 有什么区别?

    ShellRoute 切换时子页面销毁,状态丢失;StatefulShellRoute 为每个分支创建独立 Navigator,保持状态。

  6. context.go 和 context.push 有什么区别?

    go 替换整个栈,push 压入新页面。Go 适合切换主页面,push 适合进入详情页。

下一课预告

性能优化与测试------const 构造函数、RepaintBoundary、ListView.builder 优化、WidgetTester、单元测试、集成测试,继续用"练中学"的方式为应用提速和加保障。

相关推荐
承渊政道1 小时前
Linux系统学习【进程信号详细解析——认识、产生、保存以及捕捉信号】
linux·学习·ubuntu·ssh·vs code·进程信号
JasonSJX1 小时前
四端自建播放器怎么落地:Android、iOS、tvOS、Tizen 的 DRM 播放 SDK 技术盘点
android·ios·音视频·视频防录屏·加密保护课程·直播安全
陆断枫1 小时前
Flutter 列表性能优化
flutter
驰骋工作流2 小时前
工作流引擎四大流程模块功能点统计:769 项能力清单梳理低代码工作流引擎表单
android·低代码·rxjava
恋猫de小郭2 小时前
Flutter 多窗口重要优化合并,多窗口性能和实用性大幅提升
android·前端·flutter
AirDroid_cn2 小时前
实时定位孩子手机超便捷!iPhone家长同步收提醒,省心又踏实
android
个 人 练 习 生2 小时前
C++中的内存管理
开发语言·c++·经验分享·学习
浩瀚地学2 小时前
deepagents学习打卡day08
经验分享·笔记·python·学习·agent
hai_android3 小时前
LruCache 图片浏览器内存缓存
android·java·kotlin