Riverpod 实战指南
本文不堆概念,用 9 个递进的完整场景 把 Riverpod 讲透。
每个场景给出 完整可运行代码 + 运行效果 + 踩坑点 。
建议边读边敲,不要只看。
基于
flutter_riverpod: ^2.6.x(Riverpod 2),文末附 Riverpod 3 迁移要点。
准备工作
yaml
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^2.6.1
riverpod_annotation: ^2.6.1
http: ^1.2.0
freezed_annotation: ^2.4.1
dev_dependencies:
riverpod_generator: ^2.6.2
build_runner: ^2.4.0
freezed: ^2.5.2
json_serializable: ^6.7.1
flutter_test:
sdk: flutter
bash
# 生成代码(一直跑着)
dart run build_runner watch -d
入口固定写法(后面每个场景都假设已有这段):
dart
// main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() {
runApp(const ProviderScope(child: MyApp()));
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Riverpod Demo',
theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.blue),
home: const HomePage(), // 替换成各场景页面
);
}
}
场景 1:主题切换 ------ 最小的「跨组件共享状态」
需求:页面顶部一个开关切换深色/浅色模式,底部文字实时响应。
完整代码
dart
// theme_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
// StateProvider 适合"一个布尔值、一个枚举"级别的极简状态
final isDarkModeProvider = StateProvider<bool>((ref) => false);
dart
// theme_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'theme_provider.dart';
class ThemePage extends ConsumerWidget {
const ThemePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// ① watch:build 里订阅,值变 → 自动重建
// 🔄 刷新范围:isDark 变化 → 整个 ThemePage.build() 重跑
// → Scaffold、Switch、Text 全部重建(因为 watch 写在最顶层)
final isDark = ref.watch(isDarkModeProvider);
return Scaffold(
appBar: AppBar(title: const Text('场景1:主题切换')),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Switch(
value: isDark,
onChanged: (v) {
// ② read:事件回调里只"读一次"并修改,不建立订阅
ref.read(isDarkModeProvider.notifier).state = v;
},
),
const SizedBox(height: 20),
Text(
isDark ? '当前是深色模式 🌙' : '当前是浅色模式 ☀️',
style: const TextStyle(fontSize: 24),
),
],
),
),
);
}
}
学到什么
| 要点 | 说明 |
|---|---|
ref.watch |
写在 build 里,状态变了界面自动刷新 |
ref.read |
写在 onChanged / onPressed 里,只读一次去改值 |
StateProvider |
一个值、没有业务方法时够用 |
反面教材
dart
// ❌ 在 build 里用 read → 界面永远不会因为 isDarkMode 变化而刷新
final isDark = ref.read(isDarkModeProvider);
场景 2:待办清单 ------ 有业务方法的状态用 Notifier
需求:添加待办、标记完成、删除。比一个布尔值复杂,需要方法。
完整代码
dart
// todo_model.dart
class Todo {
final String id;
final String title;
final bool completed;
Todo({required this.id, required this.title, this.completed = false});
Todo copyWith({String? title, bool? completed}) {
return Todo(
id: id,
title: title ?? this.title,
completed: completed ?? this.completed,
);
}
}
dart
// todo_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'todo_model.dart';
class TodoListNotifier extends Notifier<List<Todo>> {
@override
List<Todo> build() => []; // 初始状态:空列表
void add(String title) {
// 不可变更新:创建新 list
state = [
...state,
Todo(id: DateTime.now().millisecondsSinceEpoch.toString(), title: title),
];
}
void toggle(String id) {
state = [
for (final todo in state)
if (todo.id == id) todo.copyWith(completed: !todo.completed) else todo,
];
}
void remove(String id) {
state = state.where((t) => t.id != id).toList();
}
}
final todoListProvider =
NotifierProvider<TodoListNotifier, List<Todo>>(TodoListNotifier.new);
// 派生 Provider:已完成数量(只读、自动跟随 todoList 变化)
final completedCountProvider = Provider<int>((ref) {
final todos = ref.watch(todoListProvider);
return todos.where((t) => t.completed).length;
});
dart
// todo_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'todo_notifier.dart';
class TodoPage extends ConsumerWidget {
const TodoPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// 🔄 刷新范围:todoListProvider 或 completedCountProvider 任一变化
// → 整个 TodoPage.build() 重跑
// → AppBar(计数文字) + ListView(所有 ListTile) 全部重建
// → FloatingActionButton 也重建(但 const Icon 会被 Flutter 复用)
final todos = ref.watch(todoListProvider);
final doneCount = ref.watch(completedCountProvider);
return Scaffold(
appBar: AppBar(title: Text('待办 (已完成 $doneCount/${todos.length})')),
body: ListView.builder(
itemCount: todos.length,
itemBuilder: (_, i) {
final todo = todos[i];
return ListTile(
leading: Checkbox(
value: todo.completed,
onChanged: (_) =>
ref.read(todoListProvider.notifier).toggle(todo.id),
),
title: Text(
todo.title,
style: TextStyle(
decoration:
todo.completed ? TextDecoration.lineThrough : null,
),
),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () =>
ref.read(todoListProvider.notifier).remove(todo.id),
),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddDialog(context, ref),
child: const Icon(Icons.add),
),
);
}
void _showAddDialog(BuildContext context, WidgetRef ref) {
final controller = TextEditingController();
showDialog(
context: context,
builder: (_) => AlertDialog(
title: const Text('添加待办'),
content: TextField(controller: controller, autofocus: true),
actions: [
TextButton(
onPressed: () {
if (controller.text.isNotEmpty) {
ref.read(todoListProvider.notifier).add(controller.text);
}
Navigator.pop(context);
},
child: const Text('确定'),
),
],
),
);
}
}
学到什么
| 要点 | 说明 |
|---|---|
Notifier |
状态有"动作"(add/toggle/remove)时用它,比 StateProvider 清晰 |
state = ... |
必须整体赋新值(不可变更新),Riverpod 才能检测到变化 |
| 派生 Provider | completedCountProvider 自动跟随 todoListProvider,不需要手动同步 |
.notifier |
在回调里 ref.read(xxx.notifier).method() 调用业务方法 |
踩坑点
dart
// ❌ 直接 mutate list → Riverpod 检测不到变化,界面不刷新
void add(String title) {
state.add(Todo(...)); // 错!引用没变
}
// ✅ 赋新 list
void add(String title) {
state = [...state, Todo(...)];
}
场景 3:网络请求 ------ FutureProvider + Loading/Error/Data 三态
需求:从网络拉取用户列表,显示加载中 → 数据 → 出错三种状态。
完整代码
dart
// user_model.dart
class User {
final int id;
final String name;
final String email;
User({required this.id, required this.name, required this.email});
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id'] as int,
name: json['name'] as String,
email: json['email'] as String,
);
}
dart
// user_repository.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'user_model.dart';
class UserRepository {
Future<List<User>> fetchUsers() async {
final response = await http.get(
Uri.parse('https://jsonplaceholder.typicode.com/users'),
);
if (response.statusCode != 200) {
throw Exception('请求失败: ${response.statusCode}');
}
final list = jsonDecode(response.body) as List;
return list.map((e) => User.fromJson(e as Map<String, dynamic>)).toList();
}
}
dart
// user_providers.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'user_repository.dart';
import 'user_model.dart';
// 依赖注入:Repository 本身也是 Provider,方便测试时替换
final userRepositoryProvider = Provider<UserRepository>((ref) {
return UserRepository();
});
// FutureProvider:声明"这个数据怎么来",框架管 loading/error/data
final userListProvider = FutureProvider<List<User>>((ref) async {
final repo = ref.watch(userRepositoryProvider);
return repo.fetchUsers();
});
dart
// user_list_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'user_providers.dart';
class UserListPage extends ConsumerWidget {
const UserListPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// 🔄 刷新范围:userListProvider 状态切换(loading → data → error)
// → 整个 UserListPage.build() 重跑
// → body 在 CircularProgressIndicator / ListView / 错误提示 之间切换
// → AppBar 不受数据影响(标题是 const),但仍在 build 树内会被重建
final usersAsync = ref.watch(userListProvider);
return Scaffold(
appBar: AppBar(
title: const Text('场景3:网络请求'),
actions: [
// 下拉刷新:invalidate 让 Provider 重新执行
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => ref.invalidate(userListProvider),
),
],
),
// .when 一次性处理三种状态,编译器保证你不遗漏
body: usersAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('出错了: $err'),
const SizedBox(height: 8),
ElevatedButton(
onPressed: () => ref.invalidate(userListProvider),
child: const Text('重试'),
),
],
),
),
data: (users) => ListView.builder(
itemCount: users.length,
itemBuilder: (_, i) => ListTile(
leading: CircleAvatar(child: Text('${users[i].id}')),
title: Text(users[i].name),
subtitle: Text(users[i].email),
),
),
),
);
}
}
学到什么
| 要点 | 说明 |
|---|---|
FutureProvider |
声明"数据怎么来",自动管理 loading/error/data 生命周期 |
AsyncValue.when |
三态分支,编译器强制你每个都处理,不会漏 |
ref.invalidate |
让缓存失效,Provider 重新执行(用于刷新/重试) |
| Repository 注入 | userRepositoryProvider 让测试时可以 override 成假实现 |
踩坑点
dart
// ❌ 只判断 data,忘了 loading 和 error → 白屏
body: Text(usersAsync.value?.first.name ?? ''),
// ✅ 用 .when 或 .maybeWhen 显式处理每种状态
场景 4:详情页 ------ family 按参数缓存 + autoDispose 自动释放
需求:列表点击进入用户详情页,每个用户 ID 对应独立缓存;离开页面自动释放。
完整代码
dart
// user_detail_provider.dart
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'user_model.dart';
// .family:按 userId 参数化,每个 id 一份独立缓存
// .autoDispose:没人看这个详情页时,自动释放缓存
final userDetailProvider =
FutureProvider.autoDispose.family<User, int>((ref, userId) async {
final response = await http.get(
Uri.parse('https://jsonplaceholder.typicode.com/users/$userId'),
);
if (response.statusCode != 200) throw Exception('请求失败');
return User.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
});
dart
// user_detail_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'user_detail_provider.dart';
class UserDetailPage extends ConsumerWidget {
final int userId;
const UserDetailPage({super.key, required this.userId});
@override
Widget build(BuildContext context, WidgetRef ref) {
// 🔄 刷新范围:该 userId 对应的数据状态变化(loading → data)
// → 整个 UserDetailPage.build() 重跑
// → body 从 CircularProgressIndicator 切换到用户详情
// → 其他 userId 的 Provider 变化不影响这个页面
final detailAsync = ref.watch(userDetailProvider(userId));
return Scaffold(
appBar: AppBar(title: Text('用户 #$userId')),
body: detailAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('$e')),
data: (user) => Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(user.name, style: const TextStyle(fontSize: 28)),
const SizedBox(height: 8),
Text(user.email, style: const TextStyle(fontSize: 18)),
const SizedBox(height: 24),
const Text(
'💡 返回列表后,这个 Provider 会自动释放\n'
' 再次进入会重新请求',
),
],
),
),
),
);
}
}
从场景 3 的列表页跳转:
dart
// 在 user_list_page.dart 的 ListTile 加 onTap
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => UserDetailPage(userId: users[i].id),
),
),
学到什么
| 要点 | 说明 |
|---|---|
.family |
一个 Provider 定义 → 按参数生成 N 个独立实例 |
.autoDispose |
页面 pop 后无人 watch → 自动释放,不留内存 |
| 组合链 | FutureProvider.autoDispose.family ------ 这三个能力可以自由组合 |
踩坑点
dart
// ❌ family 参数用了复杂对象,但没实现 == 和 hashCode
// → 每次 build 都认为是"新参数",无限重建
final p = FutureProvider.family<Data, MyFilter>((ref, filter) { ... });
// ✅ family 参数优先用基本类型(int, String, enum)
// 复杂参数务必正确实现 == / hashCode(推荐用 freezed)
场景 5:登录认证 ------ AsyncNotifier + listen 做副作用 + 依赖链
需求 :登录表单 → 提交 → 加载中 → 成功后自动跳转首页 / 失败显示错误。
这是把前面学到的东西串起来的「综合场景」。
完整代码
dart
// auth_state.dart
class AuthState {
final String? token;
final String? username;
const AuthState({this.token, this.username});
bool get isLoggedIn => token != null;
}
dart
// auth_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth_state.dart';
class AuthNotifier extends AsyncNotifier<AuthState> {
@override
Future<AuthState> build() async {
// 初始状态:未登录(实际项目这里可以读本地 token)
return const AuthState();
}
Future<void> login(String username, String password) async {
// 先切到 loading 状态
state = const AsyncLoading();
// AsyncValue.guard 自动把异常转为 AsyncError
state = await AsyncValue.guard(() async {
// 模拟网络请求
await Future.delayed(const Duration(seconds: 2));
if (password != '123456') {
throw Exception('密码错误');
}
return AuthState(token: 'fake_token_abc', username: username);
});
}
void logout() {
state = const AsyncData(AuthState());
}
}
final authProvider =
AsyncNotifierProvider<AuthNotifier, AuthState>(AuthNotifier.new);
// 派生:当前是否已登录(其他地方只关心这个布尔值)
final isLoggedInProvider = Provider<bool>((ref) {
return ref.watch(authProvider).valueOrNull?.isLoggedIn ?? false;
});
dart
// login_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth_notifier.dart';
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@override
ConsumerState<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage> {
final _userCtrl = TextEditingController(text: 'admin');
final _passCtrl = TextEditingController();
@override
void dispose() {
_userCtrl.dispose();
_passCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// 🔄 刷新范围:authProvider 变化(未登录 → loading → 已登录/错误)
// → 整个 _LoginPageState.build() 重跑
// → ElevatedButton:loading 时变 disabled + 显示转圈
// → TextField 不受影响(由 TextEditingController 自己管理内容)
final authState = ref.watch(authProvider);
// ③ listen:监听状态变化做副作用(跳转)
// ⚠️ listen 不触发 build 重建!它只在值变化时执行回调
// 跳转和 SnackBar 是"副作用",不是 UI 重建
ref.listen(authProvider, (prev, next) {
// 登录成功 → 跳转
if (next.valueOrNull?.isLoggedIn == true) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const HomePage()),
);
}
// 登录失败 → 弹错误提示
if (next.hasError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${next.error}')),
);
}
});
final isLoading = authState is AsyncLoading;
return Scaffold(
appBar: AppBar(title: const Text('场景5:登录')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextField(
controller: _userCtrl,
decoration: const InputDecoration(labelText: '用户名'),
),
const SizedBox(height: 12),
TextField(
controller: _passCtrl,
obscureText: true,
decoration: const InputDecoration(
labelText: '密码',
hintText: '输入 123456 登录成功',
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: isLoading
? null
: () => ref.read(authProvider.notifier).login(
_userCtrl.text,
_passCtrl.text,
),
child: isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('登录'),
),
),
],
),
),
);
}
}
class HomePage extends ConsumerWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// 🔄 刷新范围:authProvider 变化 → 整个 HomePage.build() 重跑
// → AppBar title 更新用户名
// → body 是 const,Flutter 会复用,但仍在 build 产出树内
final auth = ref.watch(authProvider).valueOrNull;
return Scaffold(
appBar: AppBar(
title: Text('欢迎, ${auth?.username ?? ""}'),
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () {
ref.read(authProvider.notifier).logout();
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const LoginPage()),
);
},
),
],
),
body: const Center(child: Text('登录成功!', style: TextStyle(fontSize: 24))),
);
}
}
学到什么
| 要点 | 说明 |
|---|---|
AsyncNotifier |
有异步方法(login)的状态用它,自带 loading/error/data 生命周期 |
AsyncValue.guard |
一行代码把 try/catch 变成 AsyncData 或 AsyncError |
ref.listen |
副作用 (跳转、SnackBar)放这里,不是 放在 build 返回的 Widget 树里 |
| 派生 Provider | isLoggedInProvider 只暴露布尔值,其他页面不需要知道 token 细节 |
ConsumerStatefulWidget |
需要 TextEditingController 等有生命周期的东西时用它 |
watch / read / listen 完整对照
scss
┌──────────────────────────────────────────────────────────────┐
│ build() 方法体 │
│ │
│ ref.watch(authProvider) ← 订阅,值变了 build 重跑 │
│ ref.listen(authProvider, callback) ← 订阅,值变了跑回调 │
│ │
├──────────────────────────────────────────────────────────────┤
│ onPressed / 事件回调 │
│ │
│ ref.read(authProvider.notifier).login(...) ← 读一次,调方法│
│ │
└──────────────────────────────────────────────────────────────┘
场景 6:搜索 + 防抖 ------ 多 Provider 协作的真实模式
需求 :搜索框输入关键词 → 防抖 500ms → 发请求 → 显示结果。
展示多个 Provider 组合成链的典型做法。
完整代码
dart
// search_providers.dart
import 'dart:async';
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
// ① 搜索关键词(UI 写入,下游 watch)
final searchQueryProvider = StateProvider<String>((ref) => '');
// ② 防抖后的关键词
final debouncedQueryProvider = FutureProvider.autoDispose<String>((ref) async {
final query = ref.watch(searchQueryProvider);
// 关键:等 500ms;如果 500ms 内 searchQueryProvider 又变了,
// 这个 Provider 会被 dispose 重建 → 旧的 Future 被丢弃 → 天然防抖
await Future.delayed(const Duration(milliseconds: 500));
// 如果这里 Provider 已被 dispose(用户又输入了),不会继续往下
return query;
});
// ③ 搜索结果(依赖防抖后的关键词)
final searchResultsProvider =
FutureProvider.autoDispose<List<String>>((ref) async {
// watch 防抖后的值;它是 AsyncValue,用 .value 取实际值
final query = await ref.watch(debouncedQueryProvider.future);
if (query.isEmpty) return [];
// 用 JSONPlaceholder 模拟搜索
final response = await http.get(
Uri.parse('https://jsonplaceholder.typicode.com/users?username=$query'),
);
final list = jsonDecode(response.body) as List;
return list.map((e) => '${e["name"]} (${e["email"]})').toList();
});
dart
// search_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'search_providers.dart';
class SearchPage extends ConsumerWidget {
const SearchPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// 🔄 刷新范围:searchResultsProvider 变化(防抖结束 → 请求中 → 拿到结果)
// → 整个 SearchPage.build() 重跑
// → Expanded 区域在 loading 转圈 / 结果列表 / "无结果" 之间切换
// ⚠️ TextField 不受影响:它通过 ref.read 写入 searchQueryProvider,
// 自己不 watch 任何 Provider,所以搜索结果变化不会导致输入框重建或丢失焦点
final resultsAsync = ref.watch(searchResultsProvider);
return Scaffold(
appBar: AppBar(title: const Text('场景6:搜索防抖')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
decoration: const InputDecoration(
hintText: '试试输入 Bret 或 Delphine',
prefixIcon: Icon(Icons.search),
),
onChanged: (value) {
// 写入搜索关键词 → 触发整条依赖链
ref.read(searchQueryProvider.notifier).state = value;
},
),
),
Expanded(
child: resultsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('搜索出错: $e')),
data: (results) => results.isEmpty
? const Center(child: Text('无结果'))
: ListView.builder(
itemCount: results.length,
itemBuilder: (_, i) => ListTile(
leading: const Icon(Icons.person),
title: Text(results[i]),
),
),
),
),
],
),
);
}
}
依赖链图
scss
用户输入
↓
searchQueryProvider (StateProvider<String>)
↓ watch
debouncedQueryProvider (FutureProvider, 500ms 延迟)
↓ watch
searchResultsProvider (FutureProvider, 发请求)
↓ watch
SearchPage UI (显示结果)
学到什么
| 要点 | 说明 |
|---|---|
| Provider 链 | 复杂逻辑拆成多个 Provider,每个只做一件事 |
autoDispose 实现防抖 |
上游变化 → 旧 Provider dispose → 新 Provider 重新等 500ms |
| 数据流可追溯 | 出 bug 时沿着链条一个一个检查,而不是在一坨代码里找 |
场景 7:实时数据 ------ StreamProvider 监听持续变化
需求:页面实时接收服务器推送事件(类似 WebSocket / Firebase / SSE),展示连接状态 + 累积的消息历史。离开页面后自动断开连接。
这是 FutureProvider(一次性拉取)覆盖不了的场景------数据是连续推过来的。
完整代码
dart
// live_event.dart
class LiveEvent {
final int id;
final String content;
final DateTime time;
LiveEvent({required this.id, required this.content, required this.time});
}
dart
// live_event_provider.dart
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'live_event.dart';
// 模拟服务器推送(实际项目替换为 WebSocket / Firebase / SSE 连接)
Stream<LiveEvent> _connectToServer() async* {
const contents = [
'新订单 #1024',
'用户A发来消息',
'库存预警:商品B不足10件',
'支付成功:¥299.00',
'系统维护提醒',
];
for (var i = 0;; i++) {
await Future.delayed(const Duration(seconds: 2));
yield LiveEvent(
id: i,
content: contents[i % contents.length],
time: DateTime.now(),
);
}
}
// StreamProvider:声明"数据流怎么来",框架自动管理订阅和取消
// autoDispose:离开页面 → 无人 watch → 自动取消 stream 订阅
final liveEventProvider = StreamProvider.autoDispose<LiveEvent>((ref) {
ref.onDispose(() {
// 实际项目:关闭 WebSocket 连接、释放资源
print('实时连接已关闭');
});
return _connectToServer();
});
dart
// live_event_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'live_event_provider.dart';
import 'live_event.dart';
class LiveEventPage extends ConsumerStatefulWidget {
const LiveEventPage({super.key});
@override
ConsumerState<LiveEventPage> createState() => _LiveEventPageState();
}
class _LiveEventPageState extends ConsumerState<LiveEventPage> {
// StreamProvider 只持有"最新一条",历史记录用局部 state 累积
final List<LiveEvent> _history = [];
@override
Widget build(BuildContext context) {
// 🔄 刷新范围:每次 stream 推送新事件 → 整个 _LiveEventPageState.build() 重跑
// → 顶部 Container(连接状态颜色+文字)更新
// → AppBar title(计数)更新
// → ListView(历史记录)更新
final latestAsync = ref.watch(liveEventProvider);
// listen:每来一条新事件,追加到历史列表
// ⚠️ listen 本身不触发 build;但它内部的 setState(() => _history.insert(...))
// 会触发 StatefulWidget 自己的重建。两次重建(watch + setState)
// 在同一帧内被 Flutter 合并,实际只执行一次 build
ref.listen(liveEventProvider, (prev, next) {
final event = next.valueOrNull;
if (event != null && !_history.any((e) => e.id == event.id)) {
setState(() => _history.insert(0, event));
}
});
return Scaffold(
appBar: AppBar(title: Text('实时事件(${_history.length} 条)')),
body: Column(
children: [
// 顶部:连接状态指示
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
color: latestAsync.when(
loading: () => Colors.orange.shade100,
error: (_, __) => Colors.red.shade100,
data: (_) => Colors.green.shade100,
),
child: latestAsync.when(
loading: () => const Row(
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 8),
Text('正在连接服务器...'),
],
),
error: (e, _) => Text('连接断开: $e'),
data: (event) => Text(
'最新: ${event.content}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
),
// 下方:历史记录
Expanded(
child: _history.isEmpty
? const Center(child: Text('等待事件推送...'))
: ListView.builder(
itemCount: _history.length,
itemBuilder: (_, i) {
final e = _history[i];
final t = e.time;
return ListTile(
leading: CircleAvatar(child: Text('${e.id}')),
title: Text(e.content),
subtitle: Text(
'${t.hour.toString().padLeft(2, '0')}:'
'${t.minute.toString().padLeft(2, '0')}:'
'${t.second.toString().padLeft(2, '0')}',
),
);
},
),
),
],
),
);
}
}
FutureProvider vs StreamProvider 对比
markdown
FutureProvider(场景 3) StreamProvider(场景 7)
┌─────────────────────┐ ┌─────────────────────┐
│ 请求 ──→ 等待 ──→ 结果 │ │ 订阅 ──→ 事件1 │
│ (一次性) │ │ ──→ 事件2 │
│ │ │ ──→ 事件3 │
│ │ │ ──→ ...持续 │
└─────────────────────┘ └─────────────────────┘
用于:GET 接口、配置加载 用于:WebSocket、Firebase、
一次拉取的数据 实时推送、传感器数据
学到什么
| 要点 | 说明 |
|---|---|
StreamProvider |
声明"流怎么来",框架自动订阅/取消,暴露 AsyncValue |
ref.onDispose |
Provider 销毁时的清理回调,用于关闭连接、释放资源 |
autoDispose + Stream |
离开页面 → 无人 watch → Provider 销毁 → Stream 取消 → 不泄漏 |
watch + listen 配合 |
watch 驱动 UI 刷新,listen 处理"累积历史"这种副作用 |
踩坑点
dart
// ❌ 忘了 autoDispose → 离开页面后 stream 还在跑,浪费资源
final provider = StreamProvider<Event>((ref) => myStream);
// ✅ 加 autoDispose,离开页面自动取消
final provider = StreamProvider.autoDispose<Event>((ref) => myStream);
dart
// ❌ 想在 StreamProvider 里累积历史 → 做不到,它只持有最新值
final historyProvider = StreamProvider<List<Event>>((ref) => ...);
// 每次 stream 发一个 event,你拿到的是单个 event 不是列表
// ✅ StreamProvider 拿最新值 + 另一个 Notifier/StatefulWidget 累积
// 就像上面例子中 ref.listen + setState 的做法
场景 8(番外):测试 ------ Riverpod 的「高级价值」
前面 7 个场景都能用别的方案做,但测试体验是 Riverpod 真正拉开差距的地方。
dart
// 纯逻辑测试,不需要 Flutter、不需要 Widget、不需要模拟器
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'todo_notifier.dart';
void main() {
test('添加待办', () {
// 创建一个独立容器,和 app 完全隔离
final container = ProviderContainer();
addTearDown(container.dispose);
expect(container.read(todoListProvider), isEmpty);
container.read(todoListProvider.notifier).add('买牛奶');
expect(container.read(todoListProvider), hasLength(1));
expect(container.read(todoListProvider).first.title, '买牛奶');
});
test('完成数量自动更新', () {
final container = ProviderContainer();
addTearDown(container.dispose);
container.read(todoListProvider.notifier).add('任务A');
container.read(todoListProvider.notifier).add('任务B');
expect(container.read(completedCountProvider), 0);
final id = container.read(todoListProvider).first.id;
container.read(todoListProvider.notifier).toggle(id);
expect(container.read(completedCountProvider), 1);
});
test('替换 Repository 做假数据测试', () async {
final container = ProviderContainer(
overrides: [
// 把真 Repository 换成假的,不发网络请求
userRepositoryProvider.overrideWithValue(FakeUserRepository()),
],
);
addTearDown(container.dispose);
final users = await container.read(userListProvider.future);
expect(users.first.name, 'Fake User');
});
}
// 假实现
class FakeUserRepository implements UserRepository {
@override
Future<List<User>> fetchUsers() async {
return [User(id: 1, name: 'Fake User', email: 'fake@test.com')];
}
}
核心优势 :ProviderContainer 在纯 Dart 环境下工作,不需要 pumpWidget,测试跑得快;overrides 让你能替换任何一层依赖,不需要全局 mock 框架。
场景 9:模块化开发 ------ 主项目与子插件之间的状态同步
需求 :团队按业务拆包,主项目(app)和多个子插件(package)独立开发。
子插件需要读主项目的状态(比如登录用户信息),主项目也要响应子插件的状态变化(比如购物车数量)。
关键约束:子插件不能 import 主项目代码,否则就不叫"独立"了。
目录结构
vbnet
my_flutter_app/ ← 主项目
├── lib/
│ ├── main.dart
│ ├── auth/
│ │ └── auth_providers.dart ← 主项目持有登录状态
│ └── app_providers.dart ← 组装所有模块的 overrides
│
├── packages/
│ ├── core_shared/ ← 共享层:只放接口和数据模型,不放实现
│ │ └── lib/
│ │ ├── models/
│ │ │ └── user_info.dart
│ │ └── providers/
│ │ ├── shared_auth_provider.dart ← 抽象 Provider(占位)
│ │ └── shared_cart_provider.dart ← 抽象 Provider(占位)
│ │
│ ├── feature_profile/ ← 子插件A:个人中心
│ │ └── lib/
│ │ └── profile_page.dart ← watch 共享层的 Provider
│ │
│ └── feature_cart/ ← 子插件B:购物车
│ └── lib/
│ ├── cart_notifier.dart ← 购物车业务逻辑
│ └── cart_page.dart
核心思路:三层
scss
┌──────────────────────────────────────────────────────┐
│ 主项目 (app) │
│ · 持有真实实现(auth_providers 等) │
│ · 在 ProviderScope(overrides: [...]) 里把真实实现 │
│ 注入到共享层的"占位 Provider" │
├──────────────────────────────────────────────────────┤
│ 共享层 (core_shared package) │
│ · 只定义接口 + 数据模型 + "占位 Provider" │
│ · 不依赖任何具体实现 │
├──────────────────────────────────────────────────────┤
│ 子插件 (feature_xxx packages) │
│ · 只 import core_shared │
│ · watch/read 共享层的 Provider │
│ · 完全不知道主项目的存在 │
└──────────────────────────────────────────────────────┘
第一步:共享层 ------ 定义接口和占位 Provider
dart
// packages/core_shared/lib/models/user_info.dart
class UserInfo {
final String uid;
final String name;
final String avatar;
const UserInfo({
required this.uid,
required this.name,
required this.avatar,
});
static const empty = UserInfo(uid: '', name: '', avatar: '');
bool get isLoggedIn => uid.isNotEmpty;
}
dart
// packages/core_shared/lib/providers/shared_auth_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/user_info.dart';
/// 占位 Provider:运行时必须被主项目 override,否则直接报错。
/// 子插件只 watch 这个,不需要知道登录逻辑的实现细节。
final sharedUserProvider = Provider<UserInfo>((ref) {
throw UnimplementedError(
'sharedUserProvider 必须在主项目的 ProviderScope 中 override!'
'请检查 main.dart 的 ProviderScope(overrides: [...])',
);
});
dart
// packages/core_shared/lib/providers/shared_cart_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 购物车条目数(子插件写入,主项目可以 watch)
/// 默认实现返回 0;子插件会 override 注入真实 Notifier
final sharedCartCountProvider = Provider<int>((ref) => 0);
第二步:子插件 A(个人中心) ------ 只 import 共享层
yaml
# packages/feature_profile/pubspec.yaml
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^2.6.1
core_shared:
path: ../core_shared # 只依赖共享层
# ↑ 注意:不依赖主项目,不依赖 feature_cart
dart
// packages/feature_profile/lib/profile_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:core_shared/providers/shared_auth_provider.dart';
import 'package:core_shared/providers/shared_cart_provider.dart';
class ProfilePage extends ConsumerWidget {
const ProfilePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// 🔄 刷新范围:sharedUserProvider 或 sharedCartCountProvider 任一变化
// → 整个 ProfilePage.build() 重跑
// → CircleAvatar、用户名 Text、购物车数 Text 全部更新
// ⚠️ CartPage 和 MainShell 不受 ProfilePage 重建影响(各自独立 watch)
final user = ref.watch(sharedUserProvider);
final cartCount = ref.watch(sharedCartCountProvider);
return Scaffold(
appBar: AppBar(title: const Text('个人中心(子插件A)')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 40,
child: Text(user.name.isNotEmpty ? user.name[0] : '?',
style: const TextStyle(fontSize: 32)),
),
const SizedBox(height: 16),
Text('用户名:${user.name}', style: const TextStyle(fontSize: 20)),
Text('UID:${user.uid}'),
const Divider(height: 32),
Text('购物车商品数:$cartCount',
style: const TextStyle(fontSize: 18)),
const SizedBox(height: 12),
const Text(
'👆 这个数字来自 feature_cart 子插件,\n'
' 但 profile 完全不知道 cart 的存在,\n'
' 两个插件通过共享层的 Provider 间接通信。',
style: TextStyle(color: Colors.grey),
),
],
),
),
);
}
}
第三步:子插件 B(购物车) ------ 暴露 Notifier 供主项目注入
yaml
# packages/feature_cart/pubspec.yaml
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^2.6.1
core_shared:
path: ../core_shared # 只依赖共享层
dart
// packages/feature_cart/lib/cart_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:core_shared/providers/shared_auth_provider.dart';
import 'package:core_shared/models/user_info.dart';
class CartItem {
final String id;
final String name;
final int quantity;
const CartItem({required this.id, required this.name, this.quantity = 1});
CartItem copyWith({int? quantity}) =>
CartItem(id: id, name: name, quantity: quantity ?? this.quantity);
}
class CartNotifier extends Notifier<List<CartItem>> {
@override
List<CartItem> build() {
// 购物车依赖当前用户 → watch 共享层的 user
// 用户切换时,购物车自动清空重建
final user = ref.watch(sharedUserProvider);
if (!user.isLoggedIn) return [];
// 实际项目这里可以从本地缓存加载该用户的购物车
return [];
}
void addItem(String name) {
state = [...state, CartItem(id: '${state.length + 1}', name: name)];
}
void removeItem(String id) {
state = state.where((item) => item.id != id).toList();
}
void clear() {
state = [];
}
}
// 子插件内部的完整 Provider(主项目会用它来 override 共享层的计数)
final cartProvider =
NotifierProvider<CartNotifier, List<CartItem>>(CartNotifier.new);
dart
// packages/feature_cart/lib/cart_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'cart_notifier.dart';
class CartPage extends ConsumerWidget {
const CartPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// 🔄 刷新范围:cartProvider 变化(添加/删除/清空商品)
// → 整个 CartPage.build() 重跑
// → AppBar(件数) + ListView(商品列表) 全部更新
// ⚠️ 同时 MainShell 的 cartCount 也会变 → MainShell 也重建(独立的 watch 链路)
final items = ref.watch(cartProvider);
return Scaffold(
appBar: AppBar(
title: Text('购物车(${items.length} 件)'),
actions: [
if (items.isNotEmpty)
IconButton(
icon: const Icon(Icons.delete_sweep),
onPressed: () => ref.read(cartProvider.notifier).clear(),
),
],
),
body: items.isEmpty
? const Center(child: Text('购物车是空的'))
: ListView.builder(
itemCount: items.length,
itemBuilder: (_, i) => ListTile(
title: Text(items[i].name),
trailing: IconButton(
icon: const Icon(Icons.remove_circle_outline),
onPressed: () =>
ref.read(cartProvider.notifier).removeItem(items[i].id),
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => ref.read(cartProvider.notifier).addItem('商品 ${items.length + 1}'),
child: const Icon(Icons.add_shopping_cart),
),
);
}
}
第四步:主项目 ------ 用 overrides 把一切串起来
dart
// lib/auth/auth_providers.dart (主项目内部的真实登录实现)
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:core_shared/models/user_info.dart';
class AuthNotifier extends Notifier<UserInfo> {
@override
UserInfo build() => UserInfo.empty;
void login() {
state = const UserInfo(
uid: 'u_10086',
name: '张三',
avatar: 'https://example.com/avatar.png',
);
}
void logout() {
state = UserInfo.empty;
}
}
final authProvider =
NotifierProvider<AuthNotifier, UserInfo>(AuthNotifier.new);
dart
// lib/main.dart (核心:ProviderScope overrides 把所有模块连起来)
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// 共享层
import 'package:core_shared/providers/shared_auth_provider.dart';
import 'package:core_shared/providers/shared_cart_provider.dart';
// 主项目
import 'auth/auth_providers.dart';
// 子插件
import 'package:feature_profile/profile_page.dart';
import 'package:feature_cart/cart_notifier.dart';
import 'package:feature_cart/cart_page.dart';
void main() {
runApp(
ProviderScope(
overrides: [
// ★ 关键:把共享层的"占位 Provider"指向真实实现
// 1. 共享用户信息 ← 主项目的 authProvider
sharedUserProvider.overrideWith((ref) {
return ref.watch(authProvider); // auth 变 → 共享 user 变 → 子插件自动刷新
}),
// 2. 共享购物车数量 ← 子插件 cart 的 cartProvider
sharedCartCountProvider.overrideWith((ref) {
return ref.watch(cartProvider).length; // cart 变 → 数量变 → profile 自动刷新
}),
],
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '模块化 Riverpod Demo',
theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.blue),
home: const MainShell(),
);
}
}
class MainShell extends ConsumerWidget {
const MainShell({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// 🔄 刷新范围:authProvider / cartProvider / _tabIndexProvider 任一变化
// → 整个 MainShell.build() 重跑
// → AppBar(用户名/登录按钮) + BottomNavigationBar(购物车 Badge + 选中态) 更新
// ⚠️ body 是 const _TabBody(),它是独立的 ConsumerWidget
// MainShell 重建时 Flutter 发现 _TabBody 是同一个 const 实例,跳过重建
// _TabBody 只在自己 watch 的 _tabIndexProvider 变化时才重建
final user = ref.watch(authProvider);
final cartCount = ref.watch(cartProvider).length;
return Scaffold(
appBar: AppBar(
title: Text(user.isLoggedIn ? '你好, ${user.name}' : '未登录'),
actions: [
if (user.isLoggedIn)
IconButton(
icon: const Icon(Icons.logout),
onPressed: () => ref.read(authProvider.notifier).logout(),
)
else
IconButton(
icon: const Icon(Icons.login),
onPressed: () => ref.read(authProvider.notifier).login(),
),
],
),
body: const _TabBody(),
bottomNavigationBar: BottomNavigationBar(
items: [
const BottomNavigationBarItem(icon: Icon(Icons.person), label: '我的'),
BottomNavigationBarItem(
icon: Badge(
label: Text('$cartCount'),
isLabelVisible: cartCount > 0,
child: const Icon(Icons.shopping_cart),
),
label: '购物车',
),
],
onTap: (i) => ref.read(_tabIndexProvider.notifier).state = i,
currentIndex: ref.watch(_tabIndexProvider),
),
);
}
}
final _tabIndexProvider = StateProvider<int>((ref) => 0);
class _TabBody extends ConsumerWidget {
const _TabBody();
@override
Widget build(BuildContext context, WidgetRef ref) {
// 🔄 刷新范围:_tabIndexProvider 变化(切换 tab)
// → _TabBody.build() 重跑 → 切换显示 ProfilePage 或 CartPage
// ⚠️ authProvider / cartProvider 变化不影响 _TabBody(它不 watch 它们)
// 但 ProfilePage / CartPage 各自有自己的 watch,会独立刷新
return switch (ref.watch(_tabIndexProvider)) {
0 => const ProfilePage(), // 子插件 A
1 => const CartPage(), // 子插件 B
_ => const SizedBox(),
};
}
}
数据流全景图
scss
┌─ 主项目 ──────────────────────────────────────────────────────────────┐
│ │
│ authProvider (AuthNotifier) │
│ │ │
│ │ override │
│ ▼ │
│ ┌─ 共享层 ──────────────────────────────────────────────────────┐ │
│ │ sharedUserProvider ◄──── watch ──── CartNotifier.build() │ │
│ │ (用户切换→购物车清空) │ │
│ │ sharedCartCountProvider ◄── override ── cartProvider.length │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │ │ │
│ │ watch │ watch │
│ ▼ ▼ │
│ ProfilePage CartPage │
│ (子插件A) (子插件B) │
│ 显示用户名+购物车数 添加/删除商品 │
└───────────────────────────────────────────────────────────────────────┘
学到什么
| 要点 | 说明 |
|---|---|
| 共享层只放接口 | core_shared 只有数据模型 + 占位 Provider,不含任何业务逻辑实现 |
| 占位 Provider 抛异常 | 忘了 override 会立即报错,不会悄悄返回错误数据 |
overrideWith 建立桥梁 |
主项目在 ProviderScope 把真实实现注入占位 Provider |
| 子插件互不 import | profile 不 import cart,cart 不 import profile,但通过共享层间接通信 |
| 依赖方向清晰 | 子插件 → 共享层 ← 主项目;箭头永远指向共享层,不会交叉 |
| 用户切换自动连锁 | auth 变 → sharedUser 变 → CartNotifier.build 重跑 → 购物车清空 → 数量归零 → profile 页刷新 |
踩坑点
dart
// ❌ 子插件直接 import 主项目代码 → 循环依赖,无法独立编译
import 'package:my_app/auth/auth_providers.dart'; // 千万别这样
// ✅ 子插件只 import core_shared
import 'package:core_shared/providers/shared_auth_provider.dart';
dart
// ❌ 忘了在 ProviderScope 写 override → 运行时 UnimplementedError 崩溃
ProviderScope(
overrides: [], // 漏了!
child: MyApp(),
)
// ✅ 占位 Provider 的报错信息会明确告诉你漏了什么
dart
// ❌ override 里用 read 代替 watch → 后续变化不同步
sharedUserProvider.overrideWith((ref) {
return ref.read(authProvider); // 只读一次,用户登出后 profile 不会更新!
}),
// ✅ override 里用 watch → 源头变化自动传播到所有下游
sharedUserProvider.overrideWith((ref) {
return ref.watch(authProvider); // auth 变 → sharedUser 变 → 全链路刷新
}),
Riverpod 3 迁移速查(影响线上行为的 5 条)
如果你的项目要升级到 Riverpod 3.0,以下是会改变运行时行为的变更:
| 变更 | 影响 | 应对 |
|---|---|---|
| 自动重试 | 失败的 Provider 默认自动重试 | 非幂等操作需要显式 retry: (...) => null |
| 不可见时暂停 | 页面不可见时 watch 暂停 | 需要后台持续监听的场景用 TickerMode(enabled: true) 包裹 |
统一用 == 过滤 |
相等的新值不再触发更新 | Stream 场景需注意;可 override updateShouldNotify |
| 异常包装 | catch 拿到的是 ProviderException |
需要 e.exception is XxxError 二次拆包 |
| Legacy import | StateProvider 等移到 legacy.dart |
新代码用 Notifier,旧代码改 import 路径 |
总结:一张表选 Provider 类型
| 你的需求 | 用什么 | 场景参照 |
|---|---|---|
| 一个开关/枚举 | StateProvider |
场景 1 |
| 有方法的同步状态 | Notifier + NotifierProvider |
场景 2 |
| 异步只读数据 | FutureProvider |
场景 3 |
| 按参数缓存 | .family |
场景 4 |
| 页面级临时状态 | .autoDispose |
场景 4 |
| 有方法的异步状态 | AsyncNotifier |
场景 5 |
| 多步数据管道 | 多个 Provider 链式 watch | 场景 6 |
| 只读派生计算 | Provider watch 上游 |
场景 2 (completedCount) |
| 实时数据流 | StreamProvider.autoDispose + ref.onDispose |
场景 7 |
| 主项目与子插件状态同步 | 共享层占位 Provider + overrideWith |
场景 9 |
附录:9 个场景的刷新范围总览
一句话原则
ref.watch写在哪个 Widget 的build里,那个 Widget 就是刷新边界。Provider 变化只会重建 watch 了它的 ConsumerWidget / ConsumerStatefulWidget,不会波及其他。
全景对照表
scss
场景1 isDarkModeProvider 变化
└─🔄 ThemePage.build() ← 整个页面重建(Switch + Text)
场景2 todoListProvider 变化
└─🔄 TodoPage.build() ← AppBar(计数) + ListView(所有 ListTile) 重建
└─ completedCountProvider ← 派生 Provider 跟着自动重算
场景3 userListProvider 状态切换 (loading ↔ data ↔ error)
└─🔄 UserListPage.build() ← body 区域三态切换
场景4 userDetailProvider(userId) 状态切换
└─🔄 UserDetailPage.build() ← 仅该 userId 的详情页重建
⚠️ 其他 userId 的页面不受影响 ← family 的隔离作用
场景5 authProvider 变化 (未登录 → loading → 已登录)
├─🔄 _LoginPageState.build() ← ElevatedButton 切换 loading/可点击
│ └─ ref.listen → 回调 ← ⚠️ 不触发 build,只执行跳转/SnackBar
└─🔄 HomePage.build() ← AppBar 用户名更新
场景6 searchResultsProvider 变化 (防抖 → 请求 → 结果)
└─🔄 SearchPage.build() ← Expanded 区域切换
⚠️ TextField 不受影响 ← 它通过 ref.read 写入,不 watch
场景7 liveEventProvider (stream 每 2s 推送)
└─🔄 _LiveEventPageState.build()← Container(状态) + ListView(历史) 更新
└─ ref.listen + setState ← 与 watch 合并为同一帧,不双重渲染
场景9 authProvider 变化 (登录/登出)
├─🔄 MainShell.build() ← AppBar(用户名) + BottomNav 更新
│ └─ const _TabBody() ← ⚠️ 不被 MainShell 重建波及(const 复用)
├─ override 链: auth → sharedUser → CartNotifier.build()
│ └─🔄 CartPage.build() ← 购物车清空,列表重建
│ └─ override 链: cart.length → sharedCartCount
│ └─🔄 ProfilePage.build() ← 购物车数字归零
└─🔄 _TabBody.build() ← 仅在 _tabIndexProvider 变化时重建
cartProvider 变化 (添加商品)
├─🔄 CartPage.build() ← 列表更新
├─🔄 MainShell.build() ← Badge 数字更新
└─🔄 ProfilePage.build() ← 购物车数字更新
⚠️ _TabBody 不受影响 ← 它不 watch cartProvider
如何缩小刷新范围(进阶技巧)
上面每个场景中,ref.watch 都写在 Widget 的 build 最顶层 ,所以整个 build 树都会重建。
实际项目中可以用以下手段 缩小刷新范围。
先理解一个前提 :
build()重跑 ≠ 整个页面像素级重绘。Flutter 渲染分三层:
Widget 树 (build 产出)→ Element 树 (框架做新旧 diff)→ RenderObject 树 (真正布局和绘制像素)。
constWidget 在 diff 时直接跳过;只有真正内容变了的 RenderObject 才会重新 layout/paint。所以 build 本身是轻量的,真正昂贵的是 layout 和 paint 。
下面的技巧目标是:连 build 的调用范围也收窄到最小。
技巧 1:Consumer 局部包裹 ------ 最常用,改造成本最低
场景:一个商品详情页,只有底部的「购物车数量」需要实时更新,其他部分(图片、标题、描述)都是静态的。
dart
// product_detail_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// 假设已有
final cartProvider = StateProvider<int>((ref) => 0);
// ⚠️ 注意:外层是普通 StatelessWidget,不是 ConsumerWidget
// → 它自己永远不会因为 Provider 变化而重建
class ProductDetailPage extends StatelessWidget {
const ProductDetailPage({super.key});
@override
Widget build(BuildContext context) {
print('ProductDetailPage.build()'); // 只在首次进入时打印一次
return Scaffold(
appBar: AppBar(title: const Text('AirPods Pro')),
body: Column(
children: [
// ────── 这些全是静态内容,永远不重建 ──────
const SizedBox(
height: 200,
child: Placeholder(), // 模拟商品图片
),
const Padding(
padding: EdgeInsets.all(16),
child: Text(
'Apple AirPods Pro 第二代',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Text('主动降噪 · 自适应通透模式 · 个性化空间音频'),
),
const Divider(height: 32),
// ────── 只有这一小块会因为 cartProvider 变化而重建 ──────
Consumer(
builder: (context, ref, child) {
print(' Consumer.build()'); // 每次 cart 变化都打印
// 🔄 刷新范围:仅此 Consumer 内部的 Row
final count = ref.watch(cartProvider);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('购物车: $count 件',
style: const TextStyle(fontSize: 18)),
FilledButton.icon(
onPressed: () =>
ref.read(cartProvider.notifier).state++,
icon: const Icon(Icons.add_shopping_cart),
label: const Text('加入购物车'),
),
],
),
);
},
),
// ────── 这下面也是静态内容,永远不重建 ──────
const SizedBox(height: 20),
const Padding(
padding: EdgeInsets.all(16),
child: Text('商品详情介绍...(很长的文字)'),
),
],
),
);
}
}
刷新范围图:
arduino
ProductDetailPage (StatelessWidget) ← 永远不重建
├── AppBar ← 永远不重建
├── 商品图片 (const) ← 永远不重建
├── 商品标题 (const) ← 永远不重建
├── 商品描述 (const) ← 永远不重建
├── Consumer ← 🔄 仅此块重建
│ └── Row: "购物车: N 件" + 按钮 ← 🔄 count 变了才更新
├── 商品详情 (const) ← 永远不重建
学到什么:
- 外层用
StatelessWidget而不是ConsumerWidget→ 整个页面骨架永远不参与刷新 Consumer是一个普通 Widget,可以塞在树的任意位置,不需要重构整个页面- 点击「加入购物车」→
cartProvider变化 → 只有 Consumer 内部的 Row 重建,图片/标题/描述纹丝不动
技巧 2:select 精确订阅某个字段 ------ 大对象只关心一部分时
场景:用户资料有很多字段(头像、昵称、签名、积分、等级...),但某个 Widget 只显示昵称。
dart
// user_profile_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
class UserProfile {
final String name;
final String avatar;
final String bio;
final int points;
final int level;
const UserProfile({
required this.name,
required this.avatar,
required this.bio,
required this.points,
required this.level,
});
}
class UserProfileNotifier extends Notifier<UserProfile> {
@override
UserProfile build() => const UserProfile(
name: '张三',
avatar: 'https://example.com/avatar.png',
bio: 'Flutter 开发者',
points: 1000,
level: 5,
);
void addPoints(int p) {
state = UserProfile(
name: state.name,
avatar: state.avatar,
bio: state.bio,
points: state.points + p,
level: state.level,
);
}
void updateName(String name) {
state = UserProfile(
name: name,
avatar: state.avatar,
bio: state.bio,
points: state.points,
level: state.level,
);
}
}
final userProfileProvider =
NotifierProvider<UserProfileNotifier, UserProfile>(UserProfileNotifier.new);
dart
// select_demo_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'user_profile_provider.dart';
class SelectDemoPage extends ConsumerWidget {
const SelectDemoPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// ❌ 不用 select:user 的任何字段变化(积分、等级...)都会让这里重建
// final user = ref.watch(userProfileProvider);
// ✅ 用 select:只订阅 name 字段
// 积分变了?不重建。等级变了?不重建。只有 name 变了才重建
// 🔄 刷新范围:仅当 name 字段的 == 比较结果变化时,才重建 SelectDemoPage
final name = ref.watch(
userProfileProvider.select((profile) => profile.name),
);
print('SelectDemoPage.build() - name=$name');
return Scaffold(
appBar: AppBar(title: Text('你好, $name')),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('当前用户: $name', style: const TextStyle(fontSize: 24)),
const SizedBox(height: 24),
// 点这个按钮 → 积分变了 → 但 name 没变 → SelectDemoPage 不重建
ElevatedButton(
onPressed: () =>
ref.read(userProfileProvider.notifier).addPoints(100),
child: const Text('加 100 积分(不触发本页重建)'),
),
const SizedBox(height: 12),
// 点这个按钮 → name 变了 → SelectDemoPage 重建
ElevatedButton(
onPressed: () =>
ref.read(userProfileProvider.notifier).updateName('李四'),
child: const Text('改名为李四(触发本页重建)'),
),
const SizedBox(height: 32),
// 用另一个 Consumer + select 单独显示积分
Consumer(
builder: (context, ref, _) {
// 🔄 刷新范围:仅当 points 变化时,这个 Consumer 重建
final points = ref.watch(
userProfileProvider.select((p) => p.points),
);
print(' Points Consumer.build() - points=$points');
return Text('积分: $points', style: const TextStyle(fontSize: 20));
},
),
],
),
),
);
}
}
刷新范围图:
scss
用户点击「加 100 积分」→ userProfileProvider 变化(points 字段改了)
SelectDemoPage (select: name) ← ⚠️ 不重建!name 没变
├── AppBar(title: '你好, 张三') ← ⚠️ 不重建
├── Text('当前用户: 张三') ← ⚠️ 不重建
├── ElevatedButton('加积分') ← ⚠️ 不重建
├── ElevatedButton('改名') ← ⚠️ 不重建
└── Consumer (select: points) ← 🔄 重建!points 变了
└── Text('积分: 1100') ← 🔄 更新数字
用户点击「改名为李四」→ userProfileProvider 变化(name 字段改了)
SelectDemoPage (select: name) ← 🔄 重建!name 变了
├── AppBar(title: '你好, 李四') ← 🔄 更新
├── Text('当前用户: 李四') ← 🔄 更新
└── Consumer (select: points) ← ⚠️ 不重建!points 没变
学到什么:
select用==对比投影结果,相等就跳过 → 精确到字段级别的刷新控制- 同一个 Provider 可以在不同位置用不同
select,各自只关心自己要的字段 - 特别适合大 Model 对象(User、Order、Settings...),避免无关字段变化导致的连锁重建
技巧 3:拆分成多个小 ConsumerWidget ------ 中大型页面的标准做法
场景:把场景 2 的待办清单拆分,让「AppBar 计数」和「列表内容」各自独立刷新。
dart
// todo_page_optimized.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'todo_notifier.dart';
// ⚠️ 主页面是普通 StatelessWidget,不参与任何 Provider 刷新
class TodoPageOptimized extends StatelessWidget {
const TodoPageOptimized({super.key});
@override
Widget build(BuildContext context) {
print('TodoPageOptimized.build()'); // 只在首次时打印一次
return Scaffold(
appBar: AppBar(
title: const _TodoAppBarTitle(), // 独立 ConsumerWidget
),
body: const _TodoListBody(), // 独立 ConsumerWidget
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddDialog(context),
child: const Icon(Icons.add),
),
);
}
void _showAddDialog(BuildContext context) {
final controller = TextEditingController();
showDialog(
context: context,
builder: (_) => Consumer(
builder: (context, ref, _) => AlertDialog(
title: const Text('添加待办'),
content: TextField(controller: controller, autofocus: true),
actions: [
TextButton(
onPressed: () {
if (controller.text.isNotEmpty) {
ref.read(todoListProvider.notifier).add(controller.text);
}
Navigator.pop(context);
},
child: const Text('确定'),
),
],
),
),
);
}
}
// ────── 拆出来的小组件 1:AppBar 标题 ──────
class _TodoAppBarTitle extends ConsumerWidget {
const _TodoAppBarTitle();
@override
Widget build(BuildContext context, WidgetRef ref) {
print(' _TodoAppBarTitle.build()');
// 🔄 刷新范围:仅此 Widget
// 用 select 只订阅 length 和完成数,列表内容变化但数量不变时不重建
final total = ref.watch(todoListProvider.select((list) => list.length));
final done = ref.watch(completedCountProvider);
return Text('待办 (已完成 $done/$total)');
}
}
// ────── 拆出来的小组件 2:列表 ──────
class _TodoListBody extends ConsumerWidget {
const _TodoListBody();
@override
Widget build(BuildContext context, WidgetRef ref) {
print(' _TodoListBody.build()');
// 🔄 刷新范围:仅此 Widget
// 列表数据变化时只重建列表,不影响 AppBar
final todos = ref.watch(todoListProvider);
return ListView.builder(
itemCount: todos.length,
itemBuilder: (_, i) {
final todo = todos[i];
return ListTile(
leading: Checkbox(
value: todo.completed,
onChanged: (_) =>
ref.read(todoListProvider.notifier).toggle(todo.id),
),
title: Text(
todo.title,
style: TextStyle(
decoration: todo.completed ? TextDecoration.lineThrough : null,
),
),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () =>
ref.read(todoListProvider.notifier).remove(todo.id),
),
);
},
);
}
}
刷新范围对比(优化前 vs 优化后):
scss
优化前(场景 2 原始写法):
todoListProvider 变化
└─🔄 TodoPage.build() ← 整页重建(AppBar + ListView + FAB 全跑一遍)
优化后(拆分写法):
勾选一个待办(toggle)→ todoListProvider 变化
├─ TodoPageOptimized.build() ← ⚠️ 不重建(StatelessWidget,没 watch)
├─🔄 _TodoAppBarTitle.build() ← 🔄 重建(completedCount 变了)
├─🔄 _TodoListBody.build() ← 🔄 重建(列表内容变了)
└─ FloatingActionButton ← ⚠️ 不重建(const Icon)
添加一个待办 → todoListProvider 变化,但没有新完成的
├─ TodoPageOptimized.build() ← ⚠️ 不重建
├─🔄 _TodoAppBarTitle.build() ← 🔄 重建(total 从 2 变 3)
├─🔄 _TodoListBody.build() ← 🔄 重建(列表多了一条)
└─ FloatingActionButton ← ⚠️ 不重建
学到什么:
- 把一个大
ConsumerWidget拆成 外壳StatelessWidget+ 多个小ConsumerWidget - 每个小组件只
watch自己关心的数据 → 不相关的变化不会波及 - 配合
select还能进一步收窄(比如_TodoAppBarTitle只 select length) - 这是中大型项目的标准做法,不是过度优化
三种技巧选择指南
| 情况 | 推荐技巧 | 改造成本 |
|---|---|---|
| 页面大部分是静态的,只有一小块需要动态数据 | Consumer 局部包裹 |
最低,加 3 行代码 |
| 一个大对象(User/Order),但只用其中 1-2 个字段 | select 精确订阅 |
低,改一行 watch |
| 页面复杂、多个区域依赖不同 Provider | 拆分小 ConsumerWidget | 中等,需要提取组件 |
| 以上组合 | Consumer + select + 拆 Widget 混用 | 视情况而定 |
总结 :场景 1-9 的示例为了教学清晰,用整个 ConsumerWidget 做页面。
生产项目中应该按上面的技巧 把刷新范围收窄到真正需要更新的部分 。
build() 重跑不等于像素重绘(Flutter 框架会 diff),但收窄 build 范围仍然是好习惯------
减少不必要的 Widget 实例创建、diff 对比和 GC 压力,尤其在列表页和复杂表单页效果明显。