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.dartimport'package:go_router/go_router.dart';/// 创建应用路由GoRoutercreateRouter({required bool skipOnboarding,// 是否跳过引导页required bool isLoggedIn,// 是否已登录}){// 根据状态决定初始路由StringinitialLocation='/';if(isLoggedIn){initialLocation='/home';// 已登录 → 首页}elseif(skipOnboarding){initialLocation='/login';// 看过引导 → 登录页}returnGoRouter(initialLocation:initialLocation,routes:[// ---- 引导 & 认证(无底部导航) ----GoRoute(path:'/',builder:(ctx,state)=>constSplashScreen(),),GoRoute(path:'/login',builder:(ctx,state)=>constLoginScreen(),),// ---- 功能页(无底部导航) ----GoRoute(path:'/image-upload',builder:(ctx,state)=>constImageUploadScreen(),),GoRoute(path:'/image-comparison',builder:(ctx,state){// 通过 extra 传递参数finalextra=state.extraasMap<String,dynamic>?;returnImageComparisonScreen(originalPath:extra?['original']asString?,resultUrl:extra?['resultUrl']asString?,);},),// ---- Tab Shell 路由(三个主 Tab,共享底部导航) ----StatefulShellRoute.indexedStack(builder:(ctx,state,shell)=>MainShell(navigationShell:shell),branches:[// Tab 0: 首页StatefulShellBranch(routes:[GoRoute(path:'/home',builder:(ctx,state)=>constHomeScreen(),),],),// Tab 1: 历史StatefulShellBranch(routes:[GoRoute(path:'/history-list',builder:(ctx,state)=>constHistoryListScreen(),),],),// Tab 2: 我的StatefulShellBranch(routes:[GoRoute(path:'/profile',builder:(ctx,state)=>constProfileScreen(),),],),],),],);}1.3 StatefulShellRoute 详解
为什么用 StatefulShellRoute?
普通的 Tab 切换会导致页面重建,状态丢失。比如:
- 首页滚动到一半,切换到历史页,再切回来,滚动位置丢失
- 历史页的筛选条件,切换后重置
StatefulShellRoute.indexedStack会保持每个 Tab 的状态:
StatefulShellRoute.indexedStack(// builder 返回包含底部导航的 Shellbuilder:(ctx,state,shell)=>MainShell(navigationShell:shell),branches:[// 每个 branch 对应一个 TabStatefulShellBranch(routes:[...]),StatefulShellBranch(routes:[...]),StatefulShellBranch(routes:[...]),],)MainShell 实现:
// lib/features/shell/main_shell.dartclassMainShellextendsStatelessWidget{finalStatefulNavigationShellnavigationShell;constMainShell({requiredthis.navigationShell});@overrideWidgetbuild(BuildContextcontext){returnScaffold(body:navigationShell,// 显示当前 Tab 的内容bottomNavigationBar:BottomNavigationBar(currentIndex:navigationShell.currentIndex,onTap:(index){// 切换 TabnavigationShell.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){finaluserId=state.pathParameters['id'];returnUserDetailScreen(userId:userId);},)// 跳转context.push('/user/123');方式2:查询参数(适合可选参数)
// 路由定义GoRoute(path:'/search',builder:(ctx,state){finalkeyword=state.uri.queryParameters['q'];returnSearchScreen(keyword:keyword);},)// 跳转context.push('/search?q=flutter');方式3:extra 参数(适合复杂对象)
// 路由定义GoRoute(path:'/image-comparison',builder:(ctx,state){finalextra=state.extraasMap<String,dynamic>?;returnImageComparisonScreen(originalPath:extra?['original']asString?,resultUrl:extra?['resultUrl']asString?,);},)// 跳转context.push('/image-comparison',extra:{'original':'/path/to/image.jpg','resultUrl':'https://api.com/result.jpg',});我的建议:
- 简单参数用路径参数
- 可选参数用查询参数
- 复杂对象用 extra(但不要传太大的对象)
二、Riverpod 状态管理
2.1 为什么选择 Riverpod
上一篇提到了 Riverpod 的优势,这里再强调几点:
1. 编译时类型检查
// Provider 不存在会编译报错finaluser=ref.watch(userProviderTypo);// ❌ 编译错误// Provider 类型不匹配会编译报错finaluser=ref.watch(userProvider);// user 类型是 AsyncValue<UserModel?>finalname=user.name;// ❌ 编译错误,AsyncValue 没有 name 属性2. 不依赖 BuildContext
// Provider 需要 contextfinaluser=Provider.of<User>(context);// ❌ 必须在 Widget 中// Riverpod 不需要 contextclassUserService{voidupdateUser(WidgetRefref){finaluser=ref.read(userProvider);// ✅ 任何地方都能用}}3. 自动依赖管理
// userProvider 依赖 authProviderfinaluserProvider=FutureProvider((ref)async{finaltoken=ref.watch(authProvider);// 自动监听 authProviderreturnfetchUser(token);});// authProvider 变化时,userProvider 自动重新计算2.2 用户状态管理实战
CleanMark AI 的用户状态包括:
- 用户信息(ID、邮箱、昵称)
- 积分余额
- 登录状态
定义 UserModel:
// lib/features/auth/user_model.dartclassUserModel{finalStringid;finalStringemail;finalString?nickname;finalint credits;// 积分UserModel({requiredthis.id,requiredthis.email,this.nickname,requiredthis.credits,});// JSON 序列化factoryUserModel.fromJson(Map<String,dynamic>json){returnUserModel(id:json['id']asString,email:json['email']asString,nickname:json['nickname']asString?,credits:json['credits']asint,);}Map<String,dynamic>toJson(){return{'id':id,'email':email,'nickname':nickname,'credits':credits,};}// copyWith 方法(用于更新部分字段)UserModelcopyWith({String?id,String?email,String?nickname,int?credits,}){returnUserModel(id:id??this.id,email:email??this.email,nickname:nickname??this.nickname,credits:credits??this.credits,);}}定义 UserProvider:
// lib/features/auth/user_provider.dartimport'package:flutter_riverpod/flutter_riverpod.dart';/// 全局用户状态 ProviderfinaluserProvider=AsyncNotifierProvider<UserNotifier,UserModel?>(UserNotifier.new,);/// 用户信息状态管理classUserNotifierextendsAsyncNotifier<UserModel?>{/// 启动时从 SharedPreferences 还原缓存用户信息@overrideFuture<UserModel?>build()async{returnAppPrefs.loadUser();}/// 使用 token 调用 API 拉取最新用户信息并持久化Future<void>loadFromApi(Stringtoken)async{state=constAsyncLoading();state=awaitAsyncValue.guard(()async{finalresp=awaitApiClient.instance.get(ApiConstants.userMe,options:ApiClient.authOptions(token),);finaluser=UserModel.fromJson(resp.data);awaitAppPrefs.saveUser(user);returnuser;});}/// 登出时清除用户信息Future<void>clear()async{awaitAppPrefs.clearUser();state=constAsyncData(null);}/// 更新积分(本地立即更新,用于乐观更新)voidupdateCredits(int newCredits){finalcurrentUser=state.value;if(currentUser!=null){finalupdatedUser=currentUser.copyWith(credits:newCredits);state=AsyncData(updatedUser);AppPrefs.saveUser(updatedUser);}}}为什么用 AsyncNotifier?
用户信息需要从网络或本地存储加载,是异步的。AsyncNotifier提供了三种状态:
AsyncLoading:加载中AsyncData:加载成功AsyncError:加载失败
2.3 在 Widget 中使用 Provider
ConsumerWidget 方式(推荐):
classHomeScreenextendsConsumerWidget{constHomeScreen({super.key});@overrideWidgetbuild(BuildContextcontext,WidgetRefref){// 监听 userProvider,状态变化时自动重建finaluserAsync=ref.watch(userProvider);returnuserAsync.when(loading:()=>constCircularProgressIndicator(),error:(err,stack)=>Text('加载失败:$err'),data:(user){if(user==null){returnconstText('未登录');}returnColumn(children:[Text('欢迎,${user.nickname??user.email}'),Text('积分:${user.credits}'),],);},);}}Consumer 方式(局部监听):
classHomeScreenextendsStatelessWidget{@overrideWidgetbuild(BuildContextcontext){returnColumn(children:[constText('首页'),// 只有这部分会在 userProvider 变化时重建Consumer(builder:(context,ref,child){finaluserAsync=ref.watch(userProvider);returnuserAsync.when(loading:()=>constCircularProgressIndicator(),error:(err,stack)=>Text('加载失败'),data:(user)=>Text('积分:${user?.credits??0}'),);},),],);}}ref.read vs ref.watch:
// ref.watch:监听状态变化,自动重建finaluser=ref.watch(userProvider);// ref.read:只读取一次,不监听变化(用于事件处理)onPressed:(){finalnotifier=ref.read(userProvider.notifier);notifier.updateCredits(100);}三、积分系统状态管理
3.1 积分扣除流程
CleanMark AI 的积分系统比较复杂:
- 用户点击"开始去水印"
- 检查积分是否足够
- 调用 API 处理图片
- 扣除积分(乐观更新)
- 刷新用户信息
实现代码:
// lib/features/image/image_upload_screen.dartclassImageUploadScreenextendsConsumerWidget{Future<void>_startRemove(BuildContextcontext,WidgetRefref)async{// 1. 检查积分finaluserAsync=ref.read(userProvider);finaluser=userAsync.value;if(user==null||user.credits<1){_showInsufficientCreditsDialog(context);return;}// 2. 乐观更新积分(立即扣除,提升体验)ref.read(userProvider.notifier).updateCredits(user.credits-1);// 3. 调用 APItry{finalresult=awaitInpaintService.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 设计规范与主题系统
