用 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.dartimportpackage: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 传递参数finalextrastate.extraasMapString,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:indexnavigationShell.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){finaluserIdstate.pathParameters[id];returnUserDetailScreen(userId:userId);},)// 跳转context.push(/user/123);方式2查询参数适合可选参数// 路由定义GoRoute(path:/search,builder:(ctx,state){finalkeywordstate.uri.queryParameters[q];returnSearchScreen(keyword:keyword);},)// 跳转context.push(/search?qflutter);方式3extra 参数适合复杂对象// 路由定义GoRoute(path:/image-comparison,builder:(ctx,state){finalextrastate.extraasMapString,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 不存在会编译报错finaluserref.watch(userProviderTypo);// ❌ 编译错误// Provider 类型不匹配会编译报错finaluserref.watch(userProvider);// user 类型是 AsyncValueUserModel?finalnameuser.name;// ❌ 编译错误AsyncValue 没有 name 属性2. 不依赖 BuildContext// Provider 需要 contextfinaluserProvider.ofUser(context);// ❌ 必须在 Widget 中// Riverpod 不需要 contextclassUserService{voidupdateUser(WidgetRefref){finaluserref.read(userProvider);// ✅ 任何地方都能用}}3. 自动依赖管理// userProvider 依赖 authProviderfinaluserProviderFutureProvider((ref)async{finaltokenref.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(MapString,dynamicjson){returnUserModel(id:json[id]asString,email:json[email]asString,nickname:json[nickname]asString?,credits:json[credits]asint,);}MapString,dynamictoJson(){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.dartimportpackage:flutter_riverpod/flutter_riverpod.dart;/// 全局用户状态 ProviderfinaluserProviderAsyncNotifierProviderUserNotifier,UserModel?(UserNotifier.new,);/// 用户信息状态管理classUserNotifierextendsAsyncNotifierUserModel?{/// 启动时从 SharedPreferences 还原缓存用户信息overrideFutureUserModel?build()async{returnAppPrefs.loadUser();}/// 使用 token 调用 API 拉取最新用户信息并持久化FuturevoidloadFromApi(Stringtoken)async{stateconstAsyncLoading();stateawaitAsyncValue.guard(()async{finalrespawaitApiClient.instance.get(ApiConstants.userMe,options:ApiClient.authOptions(token),);finaluserUserModel.fromJson(resp.data);awaitAppPrefs.saveUser(user);returnuser;});}/// 登出时清除用户信息Futurevoidclear()async{awaitAppPrefs.clearUser();stateconstAsyncData(null);}/// 更新积分本地立即更新用于乐观更新voidupdateCredits(int newCredits){finalcurrentUserstate.value;if(currentUser!null){finalupdatedUsercurrentUser.copyWith(credits:newCredits);stateAsyncData(updatedUser);AppPrefs.saveUser(updatedUser);}}}为什么用 AsyncNotifier用户信息需要从网络或本地存储加载是异步的。AsyncNotifier提供了三种状态AsyncLoading加载中AsyncData加载成功AsyncError加载失败2.3 在 Widget 中使用 ProviderConsumerWidget 方式推荐classHomeScreenextendsConsumerWidget{constHomeScreen({super.key});overrideWidgetbuild(BuildContextcontext,WidgetRefref){// 监听 userProvider状态变化时自动重建finaluserAsyncref.watch(userProvider);returnuserAsync.when(loading:()constCircularProgressIndicator(),error:(err,stack)Text(加载失败:$err),data:(user){if(usernull){returnconstText(未登录);}returnColumn(children:[Text(欢迎,${user.nickname??user.email}),Text(积分:${user.credits}),],);},);}}Consumer 方式局部监听classHomeScreenextendsStatelessWidget{overrideWidgetbuild(BuildContextcontext){returnColumn(children:[constText(首页),// 只有这部分会在 userProvider 变化时重建Consumer(builder:(context,ref,child){finaluserAsyncref.watch(userProvider);returnuserAsync.when(loading:()constCircularProgressIndicator(),error:(err,stack)Text(加载失败),data:(user)Text(积分:${user?.credits??0}),);},),],);}}ref.read vs ref.watch// ref.watch监听状态变化自动重建finaluserref.watch(userProvider);// ref.read只读取一次不监听变化用于事件处理onPressed:(){finalnotifierref.read(userProvider.notifier);notifier.updateCredits(100);}三、积分系统状态管理3.1 积分扣除流程CleanMark AI 的积分系统比较复杂用户点击开始去水印检查积分是否足够调用 API 处理图片扣除积分乐观更新刷新用户信息实现代码// lib/features/image/image_upload_screen.dartclassImageUploadScreenextendsConsumerWidget{Futurevoid_startRemove(BuildContextcontext,WidgetRefref)async{// 1. 检查积分finaluserAsyncref.read(userProvider);finaluseruserAsync.value;if(usernull||user.credits1){_showInsufficientCreditsDialog(context);return;}// 2. 乐观更新积分立即扣除提升体验ref.read(userProvider.notifier).updateCredits(user.credits-1);// 3. 调用 APItry{finalresultawaitInpaintService.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 设计规范与主题系统