flutter-navigation-go-router
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseFlutter Navigation — go_router
Flutter Navigation — go_router
원칙
原则
- 경로 상수는 한 곳에 모은다. 의
lib/core/routing/route_paths.dart에 문자열 상수로 선언한다. 코드 어디에서도 raw 문자열을 직접 쓰지 않는다.RoutePaths - 라우터 정의는 하나.
lib/core/routing/router.dart인스턴스는 전역GoRouter변수로 노출되고router의main.dart가 소비한다.MaterialApp.router - 라우트 빌더는 Root 위젯만 만든다. DI/이벤트/상태 구독은 Root 가 담당하므로 라우터는 Screen 에 대한 지식이 없다.
- 크로스 feature 네비게이션은 /
context.go콜백으로 처리한다. Screen/Root 가 다른 feature 의 라우트 문자열을 알아도 된다는 것이 이 프로젝트의 관례다 —context.push상수를 통해서 참조하기 때문에 안전하다.RoutePaths
- 路径常量集中管理:在的
lib/core/routing/route_paths.dart中以字符串常量形式声明。代码中禁止直接使用原始字符串。RoutePaths - 路由器定义单一化:实例通过全局
GoRouter变量暴露,由router中的main.dart使用。MaterialApp.router - 路由构建器仅创建根组件:DI/事件/状态订阅由根组件负责,因此路由器无需了解具体页面细节。
- 跨功能模块导航通过/
context.go回调处理:本项目约定,页面/根组件可以知晓其他功能模块的路由字符串——通过context.push常量引用,确保安全性。RoutePaths
경로 상수
路径常量
dart
// lib/core/routing/route_paths.dart
abstract class RoutePaths {
static const String splash = '/Splash';
static const String signIn = '/SingIn';
static const String signUp = '/SingUp';
static const String home = '/Home';
static const String savedRecipes = '/SavedRecipes';
static const String notifications = '/Notifications';
static const String profile = '/Profile';
static const String search = '/Home/Search';
static const String ingredient = '/Home/Ingredient/:recipeId';
}규칙:
- 최상위 화면은 형태.
/<Name> - 중첩(서브) 경로는 상위 경로를 접두어로 포함 ().
/Home/Ingredient/:recipeId - 경로 파라미터는 토큰으로.
:name
dart
// lib/core/routing/route_paths.dart
abstract class RoutePaths {
static const String splash = '/Splash';
static const String signIn = '/SingIn';
static const String signUp = '/SingUp';
static const String home = '/Home';
static const String savedRecipes = '/SavedRecipes';
static const String notifications = '/Notifications';
static const String profile = '/Profile';
static const String search = '/Home/Search';
static const String ingredient = '/Home/Ingredient/:recipeId';
}规则:
- 顶级页面采用格式。
/<Name> - 嵌套(子)路径需包含父路径作为前缀(如)。
/Home/Ingredient/:recipeId - 路径参数使用标记。
:name
GoRouter 정의
GoRouter 定义
dart
// lib/core/routing/router.dart
final router = GoRouter(
initialLocation: RoutePaths.splash,
routes: [
GoRoute(
path: RoutePaths.ingredient,
builder: (context, state) {
final recipeId = int.parse(state.pathParameters['recipeId']!);
return IngredientRoot(recipeId: recipeId);
},
),
GoRoute(
path: RoutePaths.splash,
builder: (context, state) => SplashScreen(
onTapStartCooking: () => context.go(RoutePaths.signIn),
),
),
// ... 기타 라우트
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) => MainScreen(
body: navigationShell,
currentPageIndex: navigationShell.currentIndex,
onChangeIndex: (index) => navigationShell.goBranch(
index,
initialLocation: index == navigationShell.currentIndex,
),
),
branches: [
StatefulShellBranch(routes: [
GoRoute(path: RoutePaths.home, builder: (c, s) => const HomeRoot()),
]),
StatefulShellBranch(routes: [
GoRoute(path: RoutePaths.savedRecipes, builder: (c, s) => const SavedRecipesRoot()),
]),
// notifications, profile ...
],
),
],
);dart
// lib/main.dart
return MaterialApp.router(
routerConfig: router,
// ...
);dart
// lib/core/routing/router.dart
final router = GoRouter(
initialLocation: RoutePaths.splash,
routes: [
GoRoute(
path: RoutePaths.ingredient,
builder: (context, state) {
final recipeId = int.parse(state.pathParameters['recipeId']!);
return IngredientRoot(recipeId: recipeId);
},
),
GoRoute(
path: RoutePaths.splash,
builder: (context, state) => SplashScreen(
onTapStartCooking: () => context.go(RoutePaths.signIn),
),
),
// ... 其他路由
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) => MainScreen(
body: navigationShell,
currentPageIndex: navigationShell.currentIndex,
onChangeIndex: (index) => navigationShell.goBranch(
index,
initialLocation: index == navigationShell.currentIndex,
),
),
branches: [
StatefulShellBranch(routes: [
GoRoute(path: RoutePaths.home, builder: (c, s) => const HomeRoot()),
]),
StatefulShellBranch(routes: [
GoRoute(path: RoutePaths.savedRecipes, builder: (c, s) => const SavedRecipesRoot()),
]),
// notifications, profile ...
],
),
],
);dart
// lib/main.dart
return MaterialApp.router(
routerConfig: router,
// ...
);경로 파라미터 읽기
读取路径参数
파라미터는 에서 문자열로 나온다. 타입 변환은 builder 안에서 수행해 Root 위젯에는 타입이 맞춰진 값만 전달한다.
state.pathParametersdart
GoRoute(
path: RoutePaths.ingredient, // '/Home/Ingredient/:recipeId'
builder: (context, state) {
final recipeId = int.parse(state.pathParameters['recipeId']!);
return IngredientRoot(recipeId: recipeId);
},
),Root 위젯 시그니처:
dart
class IngredientRoot extends StatelessWidget {
final int recipeId;
const IngredientRoot({super.key, required this.recipeId});
}IngredientRootgetIt<IngredientViewModel>()loadRecipe(recipeId)参数从中以字符串形式获取。类型转换需在构建器内完成,确保根组件仅接收类型匹配的值。
state.pathParametersdart
GoRoute(
path: RoutePaths.ingredient, // '/Home/Ingredient/:recipeId'
builder: (context, state) {
final recipeId = int.parse(state.pathParameters['recipeId']!);
return IngredientRoot(recipeId: recipeId);
},
),根组件签名:
dart
class IngredientRoot extends StatelessWidget {
final int recipeId;
const IngredientRoot({super.key, required this.recipeId});
}IngredientRootgetIt<IngredientViewModel>()loadRecipe(recipeId)화면 이동
页面跳转
context.go
vs context.push
context.gocontext.pushcontext.go
vs context.push
context.gocontext.push- — 스택을 교체한다. 로그인 → 홈처럼 돌아갈 필요가 없을 때.
context.go(path) - — 스택에 쌓는다. 디테일 화면처럼 돌아갈 수 있어야 할 때.
context.push(path)
dart
// 교체: splash → sign in
context.go(RoutePaths.signIn);
// 푸시: 레시피 상세로 진입
context.push('/Home/Ingredient/${recipe.id}');경로에 파라미터가 있으면 보간으로 만든다. 상수 외의 부분을 문자열로 조립해도 상위 경로 상수는 유지하는 것이 좋다.
- — 替换路由栈。适用于无需返回的场景,如登录→首页。
context.go(path) - — 添加到路由栈。适用于需要返回的场景,如详情页。
context.push(path)
dart
// 替换:启动页 → 登录页
context.go(RoutePaths.signIn);
// 添加:进入食谱详情页
context.push('/Home/Ingredient/${recipe.id}');若路径包含参数,需通过字符串插值生成。建议保留父路径常量,仅动态拼接参数部分。
Root 위젯에서 쓰는 전형적 패턴
根组件常用模式
Root 위젯이 Action 을 받아 적절히 분기한다 ( 참고):
saved_recipes_root.dartdart
onAction: (action) {
if (action is OnTapRecipe) {
context.push('/Home/Ingredient/${action.recipe.id}');
return;
}
viewModel.onAction(action);
},즉 상태를 바꾸는 Action 은 ViewModel 로, 이동이라는 효과는 로 분리한다.
context.push根组件接收Action并进行相应分支处理(参考):
saved_recipes_root.dartdart
onAction: (action) {
if (action is OnTapRecipe) {
context.push('/Home/Ingredient/${action.recipe.id}');
return;
}
viewModel.onAction(action);
},即状态变更类Action由ViewModel处理,页面跳转类副作用由处理,实现职责分离。
context.push바텀 내비게이션 — StatefulShellRoute
底部导航 — StatefulShellRoute
각 탭이 자기 스택을 유지해야 할 때 을 쓴다. 로 탭을 전환한다. 로 두면 같은 탭을 다시 눌렀을 때 루트로 돌아가는 동작이 구현된다.
StatefulShellRoute.indexedStacknavigationShell.goBranch(index, initialLocation: ...)initialLocation: index == currentIndexdart
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) => MainScreen(
body: navigationShell,
currentPageIndex: navigationShell.currentIndex,
onChangeIndex: (index) => navigationShell.goBranch(
index,
initialLocation: index == navigationShell.currentIndex,
),
),
branches: [ /* 탭마다 StatefulShellBranch */ ],
)当每个标签页需要独立维护路由栈时,使用。通过切换标签页。设置后,再次点击当前标签页会返回该标签页的根页面。
StatefulShellRoute.indexedStacknavigationShell.goBranch(index, initialLocation: ...)initialLocation: index == currentIndexdart
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) => MainScreen(
body: navigationShell,
currentPageIndex: navigationShell.currentIndex,
onChangeIndex: (index) => navigationShell.goBranch(
index,
initialLocation: index == navigationShell.currentIndex,
),
),
branches: [ /* 每个标签页对应一个StatefulShellBranch */ ],
)콜백을 통해 느슨하게 결합하기
通过回调实现松耦合
SplashScreenSignInScreenSignUpScreendart
GoRoute(
path: RoutePaths.signUp,
builder: (context, state) => SignUpScreen(
onTapSignIn: () => context.go(RoutePaths.signIn),
),
),이 방식은 테스트 가능한 Screen 을 만드는 핵심이다. Screen 이 를 직접 import 하지 않는다.
go_routerSplashScreenSignInScreenSignUpScreendart
GoRoute(
path: RoutePaths.signUp,
builder: (context, state) => SignUpScreen(
onTapSignIn: () => context.go(RoutePaths.signIn),
),
),这种方式是创建可测试页面的核心。页面无需直接导入。
go_router체크리스트 — 새 화면 라우트 추가
清单 — 添加新页面路由
- 에 경로 상수 추가
RoutePaths - 의 적절한 위치(탭 내부라면
router.dart안)에StatefulShellBranch추가GoRoute - builder 가 파라미터를 파싱해 Root 위젯에 전달
- Root 위젯 생성자는 파라미터를 타입 안전하게 받음
- 이동이 필요한 호출자는 /
context.go+context.push상수 사용RoutePaths - 단순 화면은 Screen 파라미터로 콜백()을 받아 느슨하게 결합
onTap...
- 在中添加路径常量
RoutePaths - 在的对应位置(若属于标签页则在
router.dart内)添加StatefulShellBranchGoRoute - 构建器解析参数并传递给根组件
- 根组件构造函数安全接收参数
- 需要跳转的调用方使用/
context.go+context.push常量RoutePaths - 简单页面通过接收回调参数(如)实现松耦合
onTap...
안티 패턴
反模式
- ❌ 경로 문자열을 화면 곳곳에 하드코딩 → 로 모아라.
RoutePaths - ❌ ViewModel 안에서 호출 → ViewModel 은
context.go(...)를 모른다.BuildContext - ❌ Screen 에서 를 직접 import → 콜백 파라미터로 받게 바꿔라.
go_router - ❌ 서로 다른 feature 간 이동을 위해 를 섞어 쓰기 → 일관된
Navigator.of(context).push(MaterialPageRoute(...))API 하나만 쓴다.go_router - ❌ 경로 파라미터 에서
state.pathParameters['id']없이 기본값을 걸어 에러를 가림 → 파라미터가 필수라면!처럼 강제 언래핑해 라우트 정의 오류가 즉시 드러나게 하라.int.parse(state.pathParameters['recipeId']!)
- ❌ 在页面各处硬编码路径字符串 → 统一使用管理
RoutePaths - ❌ 在ViewModel内调用→ ViewModel不应依赖
context.go(...)BuildContext - ❌ 在页面中直接导入→ 改为通过回调参数实现
go_router - ❌ 混合使用实现跨功能模块跳转 → 统一使用
Navigator.of(context).push(MaterialPageRoute(...))APIgo_router - ❌ 为路径参数设置默认值而省略
state.pathParameters['id']→ 若参数为必填项,需像!一样强制解包,以便及时发现路由定义错误int.parse(state.pathParameters['recipeId']!)