当前位置: 首页 > news >正文

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)

设计要点:

  1. 底部 Tab 页面:使用StatefulShellRoute保持状态
  2. 功能页面:使用普通GoRoute,支持返回
  3. 初始路由:根据登录状态和引导页状态动态决定

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 的积分系统比较复杂:

  1. 用户点击"开始去水印"
  2. 检查积分是否足够
  3. 调用 API 处理图片
  4. 扣除积分(乐观更新)
  5. 刷新用户信息

实现代码:

// 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 失败,再回滚积分

本篇小结

这篇文章我们完成了:

  1. ✅ go_router 路由配置(包括 StatefulShellRoute)
  2. ✅ Riverpod 状态管理(AsyncNotifier)
  3. ✅ 用户状态管理实战
  4. ✅ 积分系统的乐观更新

下一篇我们会讲解 UI 设计规范和主题系统。


思考题

  1. 为什么 Tab 页面要用 StatefulShellRoute 而不是普通 GoRoute?
  2. 什么时候用 ref.watch,什么时候用 ref.read?
  3. 乐观更新有什么风险?如何处理失败情况?

下一篇预告:第03篇 - UI 设计规范与主题系统

http://www.jsqmd.com/news/908807/

相关文章:

  • 供应链管理:理解链主企业 / 谁可以成为链主企业
  • RTX51信号量机制与任务调度优化策略
  • 2026年资阳市正规上门黄金白银回收品牌门店名录:K金+铂金+金条+银条回收门店联系方式推荐+指南 - 前途无量YY
  • 2026年最新仪征市黄金回收白银回收铂金回收靠谱店铺权威排行榜:纯金+金条+银条+钯金 门店地址及联系方式推荐 - 亦辰小黄鸭
  • 【Gemini企业版深度解析】:20年AI架构师亲测的5大核心功能与落地避坑指南
  • 2025_NIPS_On the Overlooked Structure of Stochastic Gradients
  • 中兴光猫工厂模式破解终极指南:zteOnu工具3步解锁高级权限
  • 告别‘电波打架’:手把手教你设置Win10电脑优先连接5G WiFi,彻底解决蓝牙断连
  • 3步搞定魔兽争霸3卡顿问题!这款终极优化工具让你重回巅峰体验
  • 【Elasticsearch从入门到精通】第52篇:Elastic Stack全景解读——ES、Logstash、Beats与Kibana的协作
  • 大语言模型在糖尿病管理中的应用:技术架构与挑战
  • 如何高效使用Mermaid Live Editor:专业流程图编辑的终极指南
  • 【独家内参】Gemini企业级客户LTV提升方法论:基于237家客户数据的客单价增长公式
  • 2026年最新宜宾市黄金回收白银回收铂金回收靠谱店铺权威排行榜:纯金+金条+银条+钯金 门店地址及联系方式推荐 - 亦辰小黄鸭
  • 从收音机到单片机:聊聊锁相环(PLL)的前世今生与STM32里的那些事儿
  • AMD Ryzen调试终极指南:5分钟解锁SMU调试工具隐藏性能
  • Elsevier Tracker:3个步骤让学术投稿不再焦虑等待
  • 基于Arduino与GRBL的迷你CNC绘图仪:从零搭建自动绘图机器人
  • 【Mysql】B+树索引
  • 从有线到无线:为什么Wi-Fi不用CSMA/CD?聊聊CSMA/CA里的RTS/CTS和退避算法
  • 帝国CMS阿里云OSS插件
  • TVA凭什么成为具身机器人的“类人智眼“(3)
  • 2026年最新宜昌市黄金回收白银回收铂金回收靠谱店铺权威排行榜:纯金+金条+银条+钯金 门店地址及联系方式推荐 - 亦辰小黄鸭
  • 有限域多智能体系统同步:NP难拓扑设计的高效算法与工程实践
  • ncmdump终极指南:快速解密网易云音乐NCM格式的完整解决方案
  • 基于SpringBoot2+vue2电商平台
  • 别再手动拖控件了!用Qt的QHBoxLayout搞定复杂界面布局(附完整代码)
  • ACM下学期第六次周赛
  • 终极指南:如何用ncmdumpGUI轻松转换网易云音乐NCM格式,实现跨设备音乐自由
  • 2026年最新宜城市黄金回收白银回收铂金回收靠谱店铺权威排行榜:纯金+金条+银条+钯金 门店地址及联系方式推荐 - 亦辰小黄鸭