Flutter+HarmonyOS跨端实战—第02篇:路由与状态管理实战
用 go_router 和 Riverpod 构建可维护的应用架构
前言
上一篇我们完成了技术选型和架构设计,这篇文章我们开始写代码。路由和状态管理是 Flutter 应用的骨架,搞不好后面会很痛苦。
我会结合 CleanMark AI 项目的实际代码,讲解如何用 go_router 管理复杂的页面跳转,以及如何用 Riverpod 管理全局状态。
一、go_router 路由配置
1.1 路由设计思路
CleanMark AI 有 12 个页面,路由关系比较复杂:
启动页 (/)
↓
引导页 (/onboarding) [首次启动]
↓
登录页 (/login)
↓
主界面 (底部 Tab)
├─ 首页 (/home)
├─ 历史 (/history-list)
└─ 我的 (/profile)
↓
功能页面(无底部导航)
├─ 图片上传 (/image-upload)
├─ 图片对比 (/image-comparison)
├─ 视频上传 (/video-upload)
├─ 视频结果 (/video-result)
├─ 积分历史 (/points)
├─ 赚取积分 (/earn-points)
└─ 历史详情 (/history-detail)
设计要点:
- 底部 Tab 页面:使用
StatefulShellRoute保持状态 - 功能页面:使用普通
GoRoute,支持返回 - 初始路由:根据登录状态和引导页状态动态决定

1.2 路由配置代码
// lib/app/router.dart
import 'package:go_router/go_router.dart';
/// 创建应用路由
GoRouter createRouter({
required bool skipOnboarding, // 是否跳过引导页
required bool isLoggedIn, // 是否已登录
}) {
// 根据状态决定初始路由
String initialLocation = '/';
if (isLoggedIn) {
initialLocation = '/home'; // 已登录 → 首页
} else if (skipOnboarding) {
initialLocation = '/login'; // 看过引导 → 登录页
}
return GoRouter(
initialLocation: initialLocation,
routes: [
// ---- 引导 & 认证(无底部导航) ----
GoRoute(
path: '/',
builder: (ctx, state) => const SplashScreen(),
),
GoRoute(
path: '/login',
builder: (ctx, state) => const LoginScreen(),
),
// ---- 功能页(无底部导航) ----
GoRoute(
path: '/image-upload',
builder: (ctx, state) => const ImageUploadScreen(),
),
GoRoute(
path: '/image-comparison',
builder: (ctx, state) {
// 通过 extra 传递参数
final extra = state.extra as Map<String, dynamic>?;
return ImageComparisonScreen(
originalPath: extra?['original'] as String?,
resultUrl: extra?['resultUrl'] as String?,
);
},
),
// ---- Tab Shell 路由(三个主 Tab,共享底部导航) ----
StatefulShellRoute.indexedStack(
builder: (ctx, state, shell) => MainShell(navigationShell: shell),
branches: [
// Tab 0: 首页
StatefulShellBranch(
routes: [
GoRoute(
path: '/home',
builder: (ctx, state) => const HomeScreen(),
),
],
),
// Tab 1: 历史
StatefulShellBranch(
routes: [
GoRoute(
path: '/history-list',
builder: (ctx, state) => const HistoryListScreen(),
),
],
),
// Tab 2: 我的
StatefulShellBranch(
routes: [
GoRoute(
path: '/profile',
builder: (ctx, state) => const ProfileScreen(),
),
],
),
],
),
],
);
}
1.3 StatefulShellRoute 详解
为什么用 StatefulShellRoute?
普通的 Tab 切换会导致页面重建,状态丢失。比如:
- 首页滚动到一半,切换到历史页,再切回来,滚动位置丢失
- 历史页的筛选条件,切换后重置
StatefulShellRoute.indexedStack 会保持每个 Tab 的状态:
StatefulShellRoute.indexedStack(
// builder 返回包含底部导航的 Shell
builder: (ctx, state, shell) => MainShell(navigationShell: shell),
branches: [
// 每个 branch 对应一个 Tab
StatefulShellBranch(routes: [...]),
StatefulShellBranch(routes: [...]),
StatefulShellBranch(routes: [...]),
],
)
MainShell 实现:
// lib/features/shell/main_shell.dart
class MainShell extends StatelessWidget {
final StatefulNavigationShell navigationShell;
const MainShell({required this.navigationShell});
Widget build(BuildContext context) {
return Scaffold(
body: navigationShell, // 显示当前 Tab 的内容
bottomNavigationBar: BottomNavigationBar(
currentIndex: navigationShell.currentIndex,
onTap: (index) {
// 切换 Tab
navigationShell.goBranch(
index,
initialLocation: index == navigationShell.currentIndex,
);
},
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: '首页'),
BottomNavigationBarItem(icon: Icon(Icons.history), label: '历史'),
BottomNavigationBarItem(icon: Icon(Icons.person), label: '我的'),
],
),
);
}
}
1.4 路由跳转与参数传递
基本跳转:
// 跳转到新页面(可返回)
context.push('/image-upload');
// 替换当前页面(不可返回)
context.go('/login');
// 返回上一页
context.pop();
// 返回并传递结果
context.pop({'success': true});
传递参数的三种方式:
方式1:路径参数(适合简单参数)
// 路由定义
GoRoute(
path: '/user/:id',
builder: (ctx, state) {
final userId = state.pathParameters['id'];
return UserDetailScreen(userId: userId);
},
)
// 跳转
context.push('/user/123');
方式2:查询参数(适合可选参数)
// 路由定义
GoRoute(
path: '/search',
builder: (ctx, state) {
final keyword = state.uri.queryParameters['q'];
return SearchScreen(keyword: keyword);
},
)
// 跳转
context.push('/search?q=flutter');
方式3:extra 参数(适合复杂对象)
// 路由定义
GoRoute(
path: '/image-comparison',
builder: (ctx, state) {
final extra = state.extra as Map<String, dynamic>?;
return ImageComparisonScreen(
originalPath: extra?['original'] as String?,
resultUrl: extra?['resultUrl'] as String?,
);
},
)
// 跳转
context.push('/image-comparison', extra: {
'original': '/path/to/image.jpg',
'resultUrl': 'https://api.com/result.jpg',
});
我的建议:
- 简单参数用路径参数
- 可选参数用查询参数
- 复杂对象用 extra(但不要传太大的对象)
二、Riverpod 状态管理
2.1 为什么选择 Riverpod
上一篇提到了 Riverpod 的优势,这里再强调几点:
1. 编译时类型检查
// Provider 不存在会编译报错
final user = ref.watch(userProviderTypo); // ❌ 编译错误
// Provider 类型不匹配会编译报错
final user = ref.watch(userProvider); // user 类型是 AsyncValue<UserModel?>
final name = user.name; // ❌ 编译错误,AsyncValue 没有 name 属性
2. 不依赖 BuildContext
// Provider 需要 context
final user = Provider.of<User>(context); // ❌ 必须在 Widget 中
// Riverpod 不需要 context
class UserService {
void updateUser(WidgetRef ref) {
final user = ref.read(userProvider); // ✅ 任何地方都能用
}
}
3. 自动依赖管理
// userProvider 依赖 authProvider
final userProvider = FutureProvider((ref) async {
final token = ref.watch(authProvider); // 自动监听 authProvider
return fetchUser(token);
});
// authProvider 变化时,userProvider 自动重新计算
2.2 用户状态管理实战
CleanMark AI 的用户状态包括:
- 用户信息(ID、邮箱、昵称)
- 积分余额
- 登录状态
定义 UserModel:
// lib/features/auth/user_model.dart
class UserModel {
final String id;
final String email;
final String? nickname;
final int credits; // 积分
UserModel({
required this.id,
required this.email,
this.nickname,
required this.credits,
});
// JSON 序列化
factory UserModel.fromJson(Map<String, dynamic> json) {
return UserModel(
id: json['id'] as String,
email: json['email'] as String,
nickname: json['nickname'] as String?,
credits: json['credits'] as int,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'email': email,
'nickname': nickname,
'credits': credits,
};
}
// copyWith 方法(用于更新部分字段)
UserModel copyWith({
String? id,
String? email,
String? nickname,
int? credits,
}) {
return UserModel(
id: id ?? this.id,
email: email ?? this.email,
nickname: nickname ?? this.nickname,
credits: credits ?? this.credits,
);
}
}
定义 UserProvider:
// lib/features/auth/user_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 全局用户状态 Provider
final userProvider = AsyncNotifierProvider<UserNotifier, UserModel?>(
UserNotifier.new,
);
/// 用户信息状态管理
class UserNotifier extends AsyncNotifier<UserModel?> {
/// 启动时从 SharedPreferences 还原缓存用户信息
Future<UserModel?> build() async {
return AppPrefs.loadUser();
}
/// 使用 token 调用 API 拉取最新用户信息并持久化
Future<void> loadFromApi(String token) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final resp = await ApiClient.instance.get(
ApiConstants.userMe,
options: ApiClient.authOptions(token),
);
final user = UserModel.fromJson(resp.data);
await AppPrefs.saveUser(user);
return user;
});
}
/// 登出时清除用户信息
Future<void> clear() async {
await AppPrefs.clearUser();
state = const AsyncData(null);
}
/// 更新积分(本地立即更新,用于乐观更新)
void updateCredits(int newCredits) {
final currentUser = state.value;
if (currentUser != null) {
final updatedUser = currentUser.copyWith(credits: newCredits);
state = AsyncData(updatedUser);
AppPrefs.saveUser(updatedUser);
}
}
}
为什么用 AsyncNotifier?
用户信息需要从网络或本地存储加载,是异步的。AsyncNotifier 提供了三种状态:
AsyncLoading:加载中AsyncData:加载成功AsyncError:加载失败
2.3 在 Widget 中使用 Provider
ConsumerWidget 方式(推荐):
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
Widget build(BuildContext context, WidgetRef ref) {
// 监听 userProvider,状态变化时自动重建
final userAsync = ref.watch(userProvider);
return userAsync.when(
loading: () => const CircularProgressIndicator(),
error: (err, stack) => Text('加载失败: $err'),
data: (user) {
if (user == null) {
return const Text('未登录');
}
return Column(
children: [
Text('欢迎, ${user.nickname ?? user.email}'),
Text('积分: ${user.credits}'),
],
);
},
);
}
}
Consumer 方式(局部监听):
class HomeScreen extends StatelessWidget {
Widget build(BuildContext context) {
return Column(
children: [
const Text('首页'),
// 只有这部分会在 userProvider 变化时重建
Consumer(
builder: (context, ref, child) {
final userAsync = ref.watch(userProvider);
return userAsync.when(
loading: () => const CircularProgressIndicator(),
error: (err, stack) => Text('加载失败'),
data: (user) => Text('积分: ${user?.credits ?? 0}'),
);
},
),
],
);
}
}
ref.read vs ref.watch:
// ref.watch:监听状态变化,自动重建
final user = ref.watch(userProvider);
// ref.read:只读取一次,不监听变化(用于事件处理)
onPressed: () {
final notifier = ref.read(userProvider.notifier);
notifier.updateCredits(100);
}
三、积分系统状态管理
3.1 积分扣除流程
CleanMark AI 的积分系统比较复杂:
- 用户点击"开始去水印"
- 检查积分是否足够
- 调用 API 处理图片
- 扣除积分(乐观更新)
- 刷新用户信息
实现代码:
// lib/features/image/image_upload_screen.dart
class ImageUploadScreen extends ConsumerWidget {
Future<void> _startRemove(BuildContext context, WidgetRef ref) async {
// 1. 检查积分
final userAsync = ref.read(userProvider);
final user = userAsync.value;
if (user == null || user.credits < 1) {
_showInsufficientCreditsDialog(context);
return;
}
// 2. 乐观更新积分(立即扣除,提升体验)
ref.read(userProvider.notifier).updateCredits(user.credits - 1);
// 3. 调用 API
try {
final result = await InpaintService.removeWatermark(imagePath);
// 4. 跳转到结果页
if (context.mounted) {
context.push('/image-comparison', extra: {
'original': imagePath,
'resultUrl': result.url,
});
}
} catch (e) {
// 5. 失败时回滚积分
ref.read(userProvider.notifier).updateCredits(user.credits);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('处理失败: $e')),
);
}
}
}
}
乐观更新的好处:
- 用户点击后立即看到积分减少,体验更流畅
- 如果 API 失败,再回滚积分
本篇小结
这篇文章我们完成了:
- ✅ go_router 路由配置(包括 StatefulShellRoute)
- ✅ Riverpod 状态管理(AsyncNotifier)
- ✅ 用户状态管理实战
- ✅ 积分系统的乐观更新
下一篇我们会讲解 UI 设计规范和主题系统。
思考题
- 为什么 Tab 页面要用 StatefulShellRoute 而不是普通 GoRoute?
- 什么时候用 ref.watch,什么时候用 ref.read?
- 乐观更新有什么风险?如何处理失败情况?
下一篇预告:第03篇 - UI 设计规范与主题系统
更多推荐

所有评论(0)