开篇:Flutter 导航的演进之路
Flutter 的导航系统经历了从 Navigator 1.0 到 Navigator 2.0 的深刻变革。在早期的 Navigator 1.0 中,路由管理通过命令式 API(push/pop)完成,简单直观但难以处理深层链接、浏览器 URL 同步等复杂场景。Navigator 2.0(又称 Router API)引入了声明式路由的概念,让 URL 成为应用状态的单向数据源,彻底解决了命令式导航的局限。
对于绝大多数项目,直接使用 Navigator 2.0 的底层 API 仍然过于繁琐。社区包 go_router 由 Flutter 团队官方维护,提供了声明式、类型安全的路由配置,是目前推荐的标准方案。
一、Navigator 1.0 基础
1.1 Push 与 Pop
// 基本页面跳转
Navigator.push(
context,
MaterialPageRoute(builder: (context) => DetailPage(itemId: '123')),
);
// 命名路由
Navigator.pushNamed(context, '/detail', arguments: {'id': '123'});
// 带返回值的跳转
final result = await Navigator.push(
context,
MaterialPageRoute(builder: (_) => EditPage(initialValue: 'Hello')),
);
// 在 EditPage 中返回:Navigator.pop(context, editedValue);
// 替换页面(不保留返回栈)
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => HomePage()),
);
// 清除路由栈并跳转到新页面
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(builder: (_) => HomePage()),
(route) => false, // 移除所有历史
);
1.2 命名路由与 onGenerateRoute
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
initialRoute: '/',
routes: {
'/': (context) => HomePage(),
'/profile': (context) => ProfilePage(),
},
onGenerateRoute: (settings) {
if (settings.name == '/detail') {
final args = settings.arguments as Map<String, dynamic>;
return MaterialPageRoute(
builder: (_) => DetailPage(id: args['id']),
);
}
return null; // 404
},
onUnknownRoute: (settings) => MaterialPageRoute(
builder: (_) => NotFoundPage(),
),
);
}
}
一句话总结:Navigator 1.0 的命令式 API 适合简单页面栈管理,但深层链接和 URL 同步需要大量手动代码。
二、Navigator 2.0 / Router API
2.1 核心组件
Navigator 2.0 引入四个核心类协同工作:
| 类 | 职责 |
|---|---|
RouteInformationParser | 解析 URL 字符串 → 应用路由状态 |
RouterDelegate | 监听路由状态变化 → 构建 Navigator |
RouteInformationProvider | 提供当前 URL 信息(通常由系统提供) |
BackButtonDispatcher | 处理返回按钮事件 |
// 路由状态类
@immutable
class AppRouteState {
final String? selectedItemId;
final bool isEditing;
const AppRouteState({this.selectedItemId, this.isEditing = false});
AppRouteState copyWith({String? selectedItemId, bool? isEditing}) {
return AppRouteState(
selectedItemId: selectedItemId ?? this.selectedItemId,
isEditing: isEditing ?? this.isEditing,
);
}
}
// RouteInformationParser
class AppRouteInformationParser extends RouteInformationParser<AppRouteState> {
@override
Future<AppRouteState> parseRouteInformation(RouteInformation routeInfo) async {
final uri = Uri.parse(routeInfo.uri.toString());
if (uri.pathSegments.isEmpty) return const AppRouteState();
if (uri.pathSegments.length == 2 && uri.pathSegments[0] == 'item') {
return AppRouteState(selectedItemId: uri.pathSegments[1]);
}
return const AppRouteState(); // 未知路由回首页
}
@override
RouteInformation restoreRouteInformation(AppRouteState configuration) {
if (configuration.selectedItemId != null) {
return RouteInformation(uri: Uri.parse('/item/${configuration.selectedItemId}'));
}
return RouteInformation(uri: Uri.parse('/'));
}
}
// RouterDelegate
class AppRouterDelegate extends RouterDelegate<AppRouteState>
with ChangeNotifier, PopNavigatorRouterDelegateMixin<AppRouteState> {
@override
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
AppRouteState _currentState = const AppRouteState();
@override
AppRouteState? get currentConfiguration => _currentState;
void goToItem(String id) {
_currentState = _currentState.copyWith(selectedItemId: id, isEditing: false);
notifyListeners();
}
void startEditing() {
_currentState = _currentState.copyWith(isEditing: true);
notifyListeners();
}
@override
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
pages: [
const MaterialPage(child: HomePage()),
if (_currentState.selectedItemId != null)
MaterialPage(
child: DetailPage(id: _currentState.selectedItemId!),
),
if (_currentState.isEditing)
const MaterialPage(child: EditPage(), fullscreenDialog: true),
],
onPopPage: (route, result) {
if (!route.didPop(result)) return false;
if (_currentState.isEditing) {
_currentState = _currentState.copyWith(isEditing: false);
} else if (_currentState.selectedItemId != null) {
_currentState = const AppRouteState();
}
notifyListeners();
return true;
},
);
}
@override
Future<void> setNewRoutePath(AppRouteState configuration) async {
_currentState = configuration;
}
}
// 使用
MaterialApp.router(
routerDelegate: AppRouterDelegate(),
routeInformationParser: AppRouteInformationParser(),
)
一句话总结:Navigator 2.0 的声明式设计让 URL 和应用状态完美同步,是处理深层链接和 Web 路由的正确方式。
三、GoRouter:推荐的声明式路由方案
3.1 基础配置
import 'package:go_router/go_router.dart';
// 定义路由
final _router = GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/',
builder: (context, state) => HomePage(),
),
GoRoute(
path: '/profile',
builder: (context, state) => ProfilePage(),
),
GoRoute(
path: '/item/:id',
builder: (context, state) {
final id = state.pathParameters['id']!;
return DetailPage(itemId: id);
},
),
GoRoute(
path: '/search',
builder: (context, state) {
final query = state.uri.queryParameters['q'] ?? '';
return SearchPage(query: query);
},
),
],
);
// 使用
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) => MaterialApp.router(routerConfig: _router);
}
3.2 路由跳转
// 导航(触发页面动画)
context.go('/item/123');
context.go('/search?q=flutter');
// 命名路由
goRouter.goNamed('item', pathParameters: {'id': '123'});
// 替换(不保留当前页)
context.replace('/home');
// Push(叠加在栈顶)
context.push('/item/456');
// 返回
context.pop();
context.pop('result'); // 带返回值
// 带查询参数
context.go('/search', extra: {'filter': 'new'});
3.3 嵌套路由(ShellRoute)
final _router = GoRouter(
routes: [
ShellRoute(
builder: (context, state, child) => ScaffoldWithNavBar(child: child),
routes: [
GoRoute(path: '/', builder: (_, __) => HomePage()),
GoRoute(path: '/explore', builder: (_, __) => ExplorePage()),
GoRoute(path: '/settings', builder: (_, __) => SettingsPage()),
],
),
// 独立于底部导航的页面
GoRoute(path: '/login', builder: (_, __) => LoginPage()),
],
);
class ScaffoldWithNavBar extends StatelessWidget {
final Widget child;
const ScaffoldWithNavBar({super.key, required this.child});
@override
Widget build(BuildContext context) {
return Scaffold(
body: child,
bottomNavigationBar: NavigationBar(
selectedIndex: _calculateIndex(context),
onDestinationSelected: (index) => _onItemTapped(index, context),
destinations: [
NavigationDestination(icon: Icon(Icons.home), label: '首页'),
NavigationDestination(icon: Icon(Icons.explore), label: '探索'),
NavigationDestination(icon: Icon(Icons.settings), label: '设置'),
],
),
);
}
int _calculateIndex(BuildContext context) {
final location = GoRouterState.of(context).uri.path;
if (location == '/explore') return 1;
if (location == '/settings') return 2;
return 0;
}
void _onItemTapped(int index, BuildContext context) {
switch (index) {
case 0: context.go('/');
case 1: context.go('/explore');
case 2: context.go('/settings');
}
}
}
3.4 路由守卫与重定向
final _router = GoRouter(
redirect: (context, state) {
final isLoggedIn = context.read<AuthProvider>().isLoggedIn;
final isGoingToLogin = state.matchedLocation == '/login';
// 未登录且不是去登录页 → 重定向到登录
if (!isLoggedIn && !isGoingToLogin) return '/login';
// 已登录且去登录页 → 重定向到首页
if (isLoggedIn && isGoingToLogin) return '/';
return null; // 不重定向
},
routes: [...],
);
3.5 类型安全路由(代码生成)
// 使用 go_router_builder
@TypedGoRoute<HomeRoute>(path: '/')
class HomeRoute extends GoRouteData {
const HomeRoute();
@override
Widget build(BuildContext context, GoRouterState state) => HomePage();
}
@TypedGoRoute<ItemRoute>(path: '/item/:id')
class ItemRoute extends GoRouteData {
final String id;
const ItemRoute({required this.id});
@override
Widget build(BuildContext context, GoRouterState state) =>
DetailPage(itemId: id);
}
// 自动生成的类型安全调用
const HomeRoute().go(context);
const ItemRoute(id: '123').push(context);
一句话总结:GoRouter 将 Navigator 2.0 的强大能力封装为简洁的声明式 API,ShellRoute、类型安全路由生成和流畅的重定向机制让它成为现代 Flutter 项目的标准选择。
四、深层链接(Deep Link)
4.1 Android 配置
<!-- AndroidManifest.xml -->
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop">
<!-- http/https 链接 -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="example.com" />
</intent-filter>
<!-- 自定义 scheme -->
<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" android:host="open" />
</intent-filter>
</activity>
4.2 iOS 配置
<!-- ios/Runner/Info.plist -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.example.myapp</string>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
</dict>
</array>
<!-- 通用链接(Universal Links) -->
<!-- ios/Runner/Runner.entitlements -->
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:example.com</string>
</array>
4.3 Flutter 处理
// go_router 自动处理传入的 URL
// 手动监听(如需要在应用内处理特定链接)
class _DeepLinkHandlerState extends State<DeepLinkHandler> {
StreamSubscription? _sub;
@override
void initState() {
super.initState();
// uni_links 包(或使用 app_links)
_sub = uriLinkStream.listen((Uri? uri) {
if (uri != null) _handleDeepLink(uri);
});
}
void _handleDeepLink(Uri uri) {
if (uri.path == '/promo') {
final code = uri.queryParameters['code'];
context.go('/promo?code=$code');
}
}
@override
void dispose() {
_sub?.cancel();
super.dispose();
}
}
一句话总结:深层链接的配置涉及原生 Android/iOS 和 Flutter 三层,go_router 大大简化了 Flutter 层的路由处理,但原生配置仍需仔细设置。
五、Hero 动画与页面过渡
5.1 Hero 共享元素转场
class ProductListPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: GridView.builder(
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return GestureDetector(
onTap: () => context.go('/item/${product.id}'),
child: Hero(
tag: 'product-image-${product.id}',
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(product.imageUrl),
),
),
);
},
),
);
}
}
class ProductDetailPage extends StatelessWidget {
final String productId;
const ProductDetailPage({super.key, required this.productId});
@override
Widget build(BuildContext context) {
final product = findProduct(productId);
return Scaffold(
body: Column(
children: [
Hero(
tag: 'product-image-$productId',
child: Image.network(product.imageUrl, height: 300, fit: BoxFit.cover),
),
Text(product.name, style: TextStyle(fontSize: 24)),
],
),
);
}
}
5.2 自定义页面过渡
// 自定义 PageRouteBuilder
class FadePageRoute<T> extends PageRouteBuilder<T> {
final Widget child;
FadePageRoute({required this.child})
: super(
pageBuilder: (context, animation, secondaryAnimation) => child,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(opacity: animation, child: child);
},
transitionDuration: Duration(milliseconds: 300),
);
}
// 在 GoRouter 中使用自定义过渡
goRouter = GoRouter(
routes: [
GoRoute(
path: '/item/:id',
pageBuilder: (context, state) => CustomTransitionPage(
key: state.pageKey,
child: DetailPage(id: state.pathParameters['id']!),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return SharedAxisTransition(
animation: animation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.horizontal,
child: child,
);
},
),
),
],
);
一句话总结:Hero 动画通过共享 tag 实现了流畅的元素转场,配合自定义页面过渡动画,可以打造媲美原生的导航体验。
六、底部导航栏状态保持
class MainPage extends StatefulWidget {
@override
_MainPageState createState() => _MainPageState();
}
class _MainPageState extends State<MainPage> {
int _currentIndex = 0;
final _pages = [
HomePage(),
ExplorePage(),
ProfilePage(),
];
@override
Widget build(BuildContext context) {
return Scaffold(
// IndexedStack 保持页面状态
body: IndexedStack(
index: _currentIndex,
children: _pages,
),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex,
onDestinationSelected: (index) => setState(() => _currentIndex = index),
destinations: [
NavigationDestination(icon: Icon(Icons.home), label: '首页'),
NavigationDestination(icon: Icon(Icons.explore), label: '探索'),
NavigationDestination(icon: Icon(Icons.person), label: '我的'),
],
),
);
}
}
// 如果页面本身需要滚动位置保持,使用 AutomaticKeepAliveClientMixin
class ExplorePage extends StatefulWidget {
@override
_ExplorePageState createState() => _ExplorePageState();
}
class _ExplorePageState extends State<ExplorePage>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context); // 必须调用
return ListView.builder(
itemCount: 100,
itemBuilder: (_, index) => ListTile(title: Text('Item $index')),
);
}
}
一句话总结:IndexedStack 切换 Tab 时不销毁页面,配合 AutomaticKeepAliveClientMixin 可实现滚动位置、滚动状态等全量保持。
七、状态恢复(Restoration)
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
restorationScopeId: 'app',
home: RestorationScope(
restorationId: 'home',
child: CounterPage(),
),
);
}
}
class CounterPage extends StatefulWidget {
@override
_CounterPageState createState() => _CounterPageState();
}
class _CounterPageState extends State<CounterPage> with RestorationMixin {
final RestorableInt _counter = RestorableInt(0);
@override
String? get restorationId => 'counter_page';
@override
void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
registerForRestoration(_counter, 'counter');
}
void _increment() => setState(() => _counter.value++);
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(child: Text('${_counter.value}')),
floatingActionButton: FloatingActionButton(
onPressed: _increment,
child: Icon(Icons.add),
),
);
}
@override
void dispose() {
_counter.dispose();
super.dispose();
}
}
一句话总结:Restoration API 让 Flutter 应用在系统回收内存后(如后台被杀)能够恢复到之前的状态,是生产级应用的必备能力。
FAQ
Q1: Navigator 1.0 和 2.0 能混用吗?
可以但不推荐。go_router 底层使用 Navigator 2.0,如果在同一个项目中混用命令式 push 和声明式路由,可能导致 URL 状态与页面栈不同步。
Q2: go_router 中 context.pop() 为什么有时不生效?
当使用的是 context.go()(替换路由)而非 context.push()(叠加路由)时,路由栈中只有当前页面,没有可 pop 的页面。确认跳转方式:
- 需要返回按钮 → 使用
context.push() - 不需要返回(如登录后进首页)→ 使用
context.go()
Q3: Web 端路由刷新后 404 怎么办?
Flutter Web 是单页应用,需要服务器配置 URL 重定向:
- Nginx:
try_files $uri $uri/ /index.html; - Apache:
FallbackResource /index.html - Firebase Hosting: 配置
rewrites规则
Q4: 如何传递复杂对象到下一个页面?
优先使用 extra 参数或序列化为查询参数:
context.push('/detail', extra: complexObject);
// 接收:final object = state.extra as MyObject;
注意:extra 在 Web 刷新后会丢失,Web 场景建议用查询参数或状态管理存储。
Q5: Hero 动画 tag 冲突会怎样?
会导致 Hero 动画失效,控制台会输出警告。确保 tag 在整个应用范围内唯一,通常使用数据 ID 前缀:'hero-image-${item.id}'。
Q6: 深层链接如何调试?
Android: adb shell am start -W -a android.intent.action.VIEW -d "https://example.com/item/123" com.example.app
iOS: xcrun simctl openurl booted "https://example.com/item/123"
Q7: GoRouter 与 Navigator 1.0 的 Dialog/BottomSheet 冲突吗?
不冲突。Dialog/BottomSheet 仍然通过 showDialog / showModalBottomSheet 使用 Navigator 1.0 API,它们运行在局部路由栈上,不影响 go_router 的 URL 状态。
相关阅读
- https://plumephp.com/flutter-state-management/ — 状态管理全解析
- https://plumephp.com/flutter-widgets-layout/ — Widget 体系与布局
- https://plumephp.com/graphql-fundamentals/ — REST/GraphQL API 设计(你的后端接口需要路由来导航)
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。