Flutter路由跳转失败:go_router深度踩坑与解决方案
Flutter路由跳转失败:go_router深度踩坑与解决方案
Flutter 的路由管理经历过几个阶段:最早的 Navigator 1.0,后来的 Navigator 2.0 声明式路由,再到如今最流行的 go_router。团队从手动管理路由切换到 go_router 时,遇到的问题远比预期多。
这篇整理一下实际踩过的坑,以及对应的解决方案。
为什么选 go_router
Flutter 官方在推 Navigator 2.0,但直接用 API 太啰嗦。go_router 是 Flutter 团队推荐的最优解,核心能力:
- 声明式路由配置
- 深层链接(Deep Link)支持
- 路由守卫
- 路径参数和查询参数解析
- 嵌套路由
实际项目里最痛的两个点:一个是页面跳转时状态丢失(比如扫码后跳转,页面刷新栈乱了),另一个是 iOS 物理返回键和业务逻辑冲突。go_router 对这两个问题都有成熟的解决方案。
基础配置与常见坑
坑 1:首次加载路径不对
最常遇到的问题是首次打开 App,路径解析不正确:
GoRouter(
routerNeglectel: true,
routes: [
GoRoute(
path: '/',
builder: (context, state) => HomePage(),
),
GoRoute(
path: '/product/:id',
builder: (context, state) => ProductDetail(id: state.pathParameters['id']!),
),
],
)
配置没问题,但实际运行时,首次打开 /product/123 可能会先经过 / 再跳到详情页,导致一些初始化逻辑被执行两遍。
原因是 GoRouter.materialAppRouterBuilder 内部会自动处理重定向,如果你的根路径 / 有逻辑,会先触发。
解决方案:把需要首次判断的逻辑放到 redirect 里,不要在 builder 里做副作用:
GoRoute(
path: '/product/:id',
redirect: (context, state) {
// 这里判断是否需要拦截
if (!AuthStore.isLoggedIn) {
return '/login?redirect=${state.uri}';
}
return null;
},
builder: (context, state) => ProductDetail(id: state.pathParameters['id']!),
)
坑 2:路由栈管理混乱
页面 A 跳到 B,B 再跳到 C,然后从 C 返回希望直接回 A,而不是 B。这种场景下默认的栈管理会出问题。
go_router 提供了几种跳转模式:
// 替换当前栈(类似 pushReplacement)
context.pushReplacement('/new-page');
// 跳到新页面并清空中间所有页面
context.go('/home');
// 保持当前页面在栈底,跳转后按返回会回到当前页
context.push('/detail');
典型错误用法是混合使用 Navigator.push 和 go_router,导致栈状态不一致:
// 错误:混用两种路由
Navigator.push(context, MaterialPageRoute(builder: (_) => PageB()));
context.go('/page-c'); // 栈状态混乱
// 正确:统一用 go_router
context.push('/page-b');
context.go('/page-c'); // 明确知道栈行为
坑 3:iOS 物理返回键拦截
有时候业务需要拦截物理返回键,比如在表单页提示用户"还有未保存的内容"。go_router 用 PopScope 处理:
GoRoute(
path: '/form',
pageBuilder: (context, state) {
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
final shouldPop = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: Text('确认退出?'),
content: Text('还有未保存的内容'),
actions: [
TextButton(onPressed: () => Navigator.pop(false), child: Text('取消')),
TextButton(onPressed: () => Navigator.pop(true), child: Text('确定')),
],
),
);
if (shouldPop == true && context.mounted) {
Navigator.of(context).pop();
}
},
child: FormPage(),
);
},
)
这里有个关键点:canPop: false 阻止了默认的 pop 行为,但 onPopInvokedWithResult 仍然会被调用。通过这个回调可以实现自定义逻辑。
深层链接实战
App 内跳转到外部 App,再通过 URL Scheme 唤回,这是移动端常见场景。
配置 iOS URL Scheme
在 ios/Runner/Info.plist 添加:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
</dict>
</array>
Android 配置
在 android/app/src/main/AndroidManifest.xml 添加:
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="myapp"/>
</intent-filter>
go_router 接收唤起
配置好之后,被唤起时的路径会被 go_router 解析:
GoRouter(
routes: [
GoRoute(
path: '/product/:id',
builder: (context, state) {
// 从外部唤起时,state.uri 会包含完整路径
// myapp://product/123 -> /product/123
return ProductDetail(id: state.pathParameters['id']!);
},
),
],
)
坑 4:唤起后页面空白
常见问题是 App 在后台被唤起时,状态没正确恢复。尤其是之前用 Navigator.push 做的跳转,唤起后页面栈已经丢失。
解决方案是统一用 go_router 的 path 策略,并在 AppState 里保存路由状态:
// 全局保存当前路由路径
final _currentPathProvider = StateProvider<String>((ref) => '/');
GoRouter(
refreshListenable: GoRouterRefreshStream(ref.read(_currentPathProvider.notifier).stream),
redirect: (context, state) {
// 每次路由变化时同步更新
ref.read(_currentPathProvider.notifier).state = state.uri.path;
return null;
},
routes: [...],
)
嵌套路由与 ShellRoute
管理底部导航 + 多 Tab 页面是另一个高频场景。go_router 的 ShellRoute 可以优雅处理:
GoRouter(
routes: [
ShellRoute(
builder: (context, state, child) {
return MainScaffold(child: child);
},
routes: [
GoRoute(
path: '/home',
builder: (context, state) => HomeTab(),
routes: [
GoRoute(
path: 'detail',
builder: (context, state) => HomeDetail(),
),
],
),
GoRoute(
path: '/cart',
builder: (context, state) => CartTab(),
),
GoRoute(
path: '/mine',
builder: (context, state) => MineTab(),
),
],
),
],
)
这样 /home 和 /cart 共用 MainScaffold,切换 Tab 时只有内容区变化,底部导航状态保持。/home/detail 会在 HomeTab 内部展示,不会影响底部导航。
路由守卫与权限控制
简单的登录拦截
GoRouter(
redirect: (context, state) {
final isLoggedIn = AuthStore.instance.isLoggedIn;
final isLoginPage = state.uri.path == '/login';
if (!isLoggedIn && !isLoginPage) {
return '/login?redirect=${state.uri.path}';
}
if (isLoggedIn && isLoginPage) {
return '/home';
}
return null;
},
routes: [...],
)
复杂的权限判断
有时候需要更细粒度的权限控制,比如普通用户不能访问管理后台:
GoRoute(
path: '/admin',
redirect: (context, state) {
final user = AuthStore.currentUser;
if (user == null) return '/login';
if (!user.hasPermission('admin')) return '/unauthorized';
return null;
},
builder: (context, state) => AdminPage(),
)
路由变化监听与埋点
有时候需要在路由变化时做统一处理,比如页面埋点、权限校验等。go_router 提供了 NavigatorBuilder:
GoRouter(
navigatorBuilder: (context, state, child) {
// 每次路由变化都会执行
AnalyticsService.logPage(state.uri.path);
return child;
},
routes: [...],
)
或者用 routeInformationParser 自定义解析逻辑:
final router = GoRouter(
routeInformationParser: AppRouteInformationParser(),
routes: [...],
);
class AppRouteInformationParser extends RouteInformationParser<AppRoutePath> {
Future<AppRoutePath> parseRouteInformation(RouteInformation routeInformation) async {
final uri = routeInformation.uri;
if (uri.pathSegments.isEmpty) {
return AppRoutePath.home();
}
if (uri.pathSegments[0] == 'product') {
return AppRoutePath.product(uri.pathParameters['id']!);
}
return AppRoutePath.unknown();
}
}
总结
go_router 解决的是 Flutter 路由历史遗留问题,但引入新工具也意味着新的学习成本和新的坑。
几点实操建议:
- 统一路由入口:整个 App 只通过 go_router 跳转,不要混用
Navigator.push - 路由状态和 App 状态分开:不要在路由里存业务状态,用 Provider 或其他状态管理方案
- redirect 里不做副作用:redirect 可能会被多次调用,副作用逻辑放 builder 或初始化逻辑里
- 测试路由:用
GoRouter. location检查当前路径,用Tester.tap(find.byType(GoRouter))测试路由行为 - 移动端 URL Scheme 要提前配:等上线才发现唤回失效,修复成本很高
路由问题排查通常比想象中花时间,建议在项目初期就把路由架构定清楚。
