2. 命名路由
命名路由通过字符串标识路由,适合简单应用。在 MaterialApp 的 routes 参数中注册路由映射表。
2.1 路由映射表
MaterialApp( title: '导航示例', initialRoute: '/', routes: { '/': (context) => HomePage(), '/detail': (context) => DetailPage(), '/settings': (context) => SettingsPage(), '/profile': (context) => ProfilePage(), }, )
2.2 pushNamed 导航
// 使用命名路由跳转 Navigator.pushNamed(context, '/detail'); // 传递参数 Navigator.pushNamed( context, '/detail', arguments: {'id': 42, 'title': 'Flutter 导航'}, ); // 在目标页面接收参数 class DetailPage extends StatelessWidget { @override Widget build(BuildContext context) { final args = ModalRoute.of(context)!.settings.arguments as Map<String, dynamic>; return Scaffold( appBar: AppBar(title: Text(args['title'])), body: Center(child: Text('ID: ${args['id']}')), ); } }
2.3 onGenerateRoute
当 routes 表中没有匹配到路由时,会调用 onGenerateRoute。这适用于需要动态路由或路径参数的场景。
MaterialApp( onGenerateRoute: (RouteSettings settings) { final uri = Uri.parse(settings.name!); // 匹配 /product/:id 格式 if (uri.pathSegments.length == 2 && uri.pathSegments.first == 'product') { final id = uri.pathSegments[1]; return MaterialPageRoute( builder: (context) => ProductPage(id: id), settings: settings, ); } // 匹配 /user/:userId/orders if (uri.pathSegments.length == 3 && uri.pathSegments[0] == 'user' && uri.pathSegments[2] == 'orders') { final userId = uri.pathSegments[1]; return MaterialPageRoute( builder: (context) => UserOrdersPage(userId: userId), ); } return null; // 返回 null 会交给 onUnknownRoute }, )
2.4 onUnknownRoute
当所有路由都无法匹配时的兜底处理,类似 Web 中的 404 页面。
MaterialApp( onUnknownRoute: (RouteSettings settings) { return MaterialPageRoute( builder: (context) => Scaffold( appBar: AppBar(title: Text('404')), body: Center( child: Text('页面未找到: ${settings.name}'), ), ), ); }, )
arguments 传递时是 Object? 类型,需要手动转换。对于较大的项目,推荐使用 go_router 或 auto_route 等类型安全的路由方案。
3. 页面过渡动画
默认的 MaterialPageRoute 提供了平台默认的过渡动画。如果你想自定义过渡效果,可以使用 PageRouteBuilder。
3.1 PageRouteBuilder
Navigator.push( context, PageRouteBuilder( pageBuilder: (context, animation, secondaryAnimation) { return DetailPage(); }, transitionsBuilder: (context, animation, secondaryAnimation, child) { return FadeTransition( opacity: animation, child: child, ); }, transitionDuration: Duration(milliseconds: 300), reverseTransitionDuration: Duration(milliseconds: 200), ), );
3.2 SlideTransition 滑动动画
Route createSlideRoute(Widget page) { return PageRouteBuilder( pageBuilder: (context, animation, secondaryAnimation) => page, transitionsBuilder: (context, animation, secondaryAnimation, child) { // 从底部滑入 const begin = Offset(0.0, 1.0); const end = Offset.zero; final tween = Tween(begin: begin, end: end) .chain(CurveTween(curve: Curves.easeInOut)); return SlideTransition( position: animation.drive(tween), child: child, ); }, ); } // 从右侧滑入 const begin = Offset(1.0, 0.0); // 从左侧滑入 const begin = Offset(-1.0, 0.0); // 从顶部滑入 const begin = Offset(0.0, -1.0);
3.3 组合动画
transitionsBuilder: (context, animation, secondaryAnimation, child) {
// 同时使用缩放 + 淡入效果
return ScaleTransition(
scale: Tween<double>(begin: 0.8, end: 1.0)
.animate(CurvedAnimation(
parent: animation,
curve: Curves.easeOut,
)),
child: FadeTransition(
opacity: animation,
child: child,
),
);
}
3.4 Hero 动画
Hero 动画实现两个页面之间的共享元素过渡。只需在两个页面中使用相同的 tag 包裹元素即可。
// 列表页 - 图片带 Hero GestureDetector( onTap: () => Navigator.push( context, MaterialPageRoute( builder: (_) => PhotoDetailPage(photoUrl: url), ), ), child: Hero( tag: 'photo-$url', child: Image.network(url, width: 100, height: 100), ), ) // 详情页 - 相同 tag 的 Hero class PhotoDetailPage extends StatelessWidget { final String photoUrl; const PhotoDetailPage({required this.photoUrl}); @override Widget build(BuildContext context) { return Scaffold( body: Center( child: Hero( tag: 'photo-$photoUrl', child: Image.network(photoUrl), ), ), ); } }
tag 必须完全一致。可以用 flightShuttleBuilder 自定义飞行中的 Widget 外观,用 placeholderBuilder 指定离开后原位置的占位 Widget。
5. go_router
go_router 是 Flutter 团队推荐的声明式路由方案,支持深度链接、嵌套路由、重定向、路径参数等。它是目前 Flutter 生态中最流行的路由库。
5.1 基本设置
// pubspec.yaml // dependencies: // go_router: ^14.0.0 import 'package:go_router/go_router.dart'; final GoRouter router = GoRouter( initialLocation: '/', routes: [ GoRoute( path: '/', builder: (context, state) => HomePage(), ), GoRoute( path: '/detail', builder: (context, state) => DetailPage(), ), ], ); class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp.router( routerConfig: router, title: 'GoRouter 示例', ); } }
5.2 路径参数与查询参数
GoRoute( path: '/product/:id', builder: (context, state) { // 路径参数 final productId = state.pathParameters['id']!; // 查询参数: /product/42?tab=reviews final tab = state.uri.queryParameters['tab'] ?? 'info'; return ProductPage(id: productId, tab: tab); }, ), // 导航方式 context.go('/product/42'); // 替换整个栈 context.push('/product/42?tab=reviews'); // 压入栈顶 context.pop(); // 弹出
5.3 嵌套路由
GoRoute( path: '/user/:userId', builder: (context, state) { return UserPage(id: state.pathParameters['userId']!); }, routes: [ // 匹配 /user/:userId/orders GoRoute( path: 'orders', builder: (context, state) { return UserOrdersPage( userId: state.pathParameters['userId']!, ); }, ), // 匹配 /user/:userId/settings GoRoute( path: 'settings', builder: (context, state) { return UserSettingsPage( userId: state.pathParameters['userId']!, ); }, ), ], ),
5.4 ShellRoute 保持底部导航栏
ShellRoute 提供了一个外壳 Widget(如 Scaffold + BottomNavigationBar),子路由在外壳内部切换,而导航栏保持不变。
final router = GoRouter( routes: [ ShellRoute( builder: (context, state, child) { return ScaffoldWithNav(child: child); }, routes: [ GoRoute( path: '/', builder: (context, state) => HomeScreen(), ), GoRoute( path: '/search', builder: (context, state) => SearchScreen(), ), GoRoute( path: '/profile', builder: (context, state) => ProfileScreen(), ), ], ), // 登录页不在 ShellRoute 中,没有底部导航栏 GoRoute( path: '/login', builder: (context, state) => LoginScreen(), ), ], ); class ScaffoldWithNav extends StatelessWidget { final Widget child; const ScaffoldWithNav({required this.child}); @override Widget build(BuildContext context) { return Scaffold( body: child, bottomNavigationBar: BottomNavigationBar( currentIndex: _calculateIndex(context), onTap: (index) { switch (index) { case 0: context.go('/'); case 1: context.go('/search'); case 2: context.go('/profile'); } }, items: [ BottomNavigationBarItem(icon: Icon(Icons.home), label: '首页'), BottomNavigationBarItem(icon: Icon(Icons.search), label: '搜索'), BottomNavigationBarItem(icon: Icon(Icons.person), label: '我的'), ], ), ); } int _calculateIndex(BuildContext context) { final location = GoRouterState.of(context).uri.path; if (location.startsWith('/search')) return 1; if (location.startsWith('/profile')) return 2; return 0; } }
5.5 重定向与路由守卫
final router = GoRouter( // 全局重定向 redirect: (context, state) { final isLoggedIn = AuthService.of(context).isLoggedIn; final isLoginRoute = state.uri.path == '/login'; // 未登录且不在登录页 → 重定向到登录 if (!isLoggedIn && !isLoginRoute) { return '/login?redirect=${state.uri.path}'; } // 已登录但在登录页 → 重定向到首页 if (isLoggedIn && isLoginRoute) { return '/'; } return null; // 不重定向 }, routes: [ GoRoute( path: '/admin', // 路由级别的重定向 redirect: (context, state) { final isAdmin = AuthService.of(context).isAdmin; if (!isAdmin) return '/'; return null; }, builder: (context, state) => AdminPage(), ), ], // 监听认证状态变化,自动刷新路由 refreshListenable: authStateNotifier, );
5.6 完整示例
import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; void main() => runApp(MyApp()); final _router = GoRouter( initialLocation: '/', routes: [ ShellRoute( builder: (context, state, child) => AppShell(child: child), routes: [ GoRoute( path: '/', name: 'home', builder: (context, state) => HomePage(), routes: [ GoRoute( path: 'product/:id', name: 'product', builder: (context, state) => ProductPage( id: state.pathParameters['id']!, ), ), ], ), GoRoute( path: '/cart', name: 'cart', builder: (context, state) => CartPage(), ), GoRoute( path: '/profile', name: 'profile', builder: (context, state) => ProfilePage(), ), ], ), GoRoute( path: '/login', name: 'login', builder: (context, state) => LoginPage(), ), ], errorBuilder: (context, state) => ErrorPage( error: state.error.toString(), ), redirect: (context, state) { // 路由守卫逻辑 return null; }, ); class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp.router( routerConfig: _router, title: '电商 App', theme: ThemeData(useMaterial3: true), ); } }
context.go('/path') 会替换整个导航栈(声明式),适合 Tab 切换;context.push('/path') 会在栈顶压入新页面(命令式),适合详情页跳转。根据场景选择合适的方法。
6. Deep Link
Deep Link(深度链接)允许用户通过 URL 直接打开应用的特定页面。Flutter 天然支持 Deep Link,配合 go_router 可以无缝处理。
6.1 Android App Links
在 android/app/src/main/AndroidManifest.xml 中配置:
<!-- AndroidManifest.xml --> <activity android:name=".MainActivity" android:launchMode="singleTop"> <!-- Deep Link 配置 --> <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" android:pathPrefix="/product" /> </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" /> </intent-filter> </activity>
6.2 iOS Universal Links
在 Xcode 中配置 Associated Domains,并在服务器上放置 apple-app-site-association 文件。
// ios/Runner/Runner.entitlements // 添加 Associated Domains: // applinks:example.com // 服务器上放置 /.well-known/apple-app-site-association { "applinks": { "apps": [], "details": [ { "appID": "TEAMID.com.example.myapp", "paths": [ "/product/*", "/user/*" ] } ] } } // iOS 也可使用自定义 scheme // 在 Info.plist 中添加 URL Types: // URL Schemes: myapp
6.3 在 Flutter 中处理 Deep Link
// go_router 自动处理 Deep Link // 只需确保路由路径与 Deep Link 路径匹配 final router = GoRouter( routes: [ GoRoute( path: '/product/:id', builder: (context, state) => ProductPage( id: state.pathParameters['id']!, ), ), ], );
6.4 测试 Deep Link
# Android 测试 adb shell am start -a android.intent.action.VIEW \ -d "https://example.com/product/42" \ com.example.myapp # iOS 测试 xcrun simctl openurl booted "https://example.com/product/42" # 自定义 scheme 测试 adb shell am start -a android.intent.action.VIEW \ -d "myapp://product/42" xcrun simctl openurl booted "myapp://product/42"
myapp://),生产环境建议使用 HTTPS 域名链接。
7. auto_route
auto_route 是基于代码生成的路由方案,通过注解自动生成路由配置代码,提供完整的类型安全。
7.1 基本设置
// pubspec.yaml // dependencies: // auto_route: ^9.0.0 // dev_dependencies: // auto_route_generator: ^9.0.0 // build_runner: ^2.4.0 import 'package:auto_route/auto_route.dart'; part 'app_router.gr.dart'; @AutoRouterConfig() class AppRouter extends RootStackRouter { @override List<AutoRoute> get routes => [ AutoRoute(page: HomeRoute.page, initial: true), AutoRoute(page: ProductRoute.page, path: '/product/:id'), AutoRoute(page: LoginRoute.page), ]; }
7.2 页面定义
@RoutePage() class ProductPage extends StatelessWidget { final String id; const ProductPage({ @PathParam('id') required this.id, }); @override Widget build(BuildContext context) { return Scaffold( body: Text('商品: $id'), ); } } // 运行代码生成 // dart run build_runner build // 导航(类型安全) context.router.push(ProductRoute(id: '42')); context.router.navigate(HomeRoute());
auto_route 通过代码生成实现类型安全,适合大型项目;go_router 是 Flutter 官方推荐,配置更直观,社区支持更好。中小型项目推荐 go_router,大型项目可以考虑 auto_route。
8. 最佳实践
8.1 集中管理路由
将所有路由配置放在单独的文件中,便于维护和查找。
// lib/router/app_router.dart import 'package:go_router/go_router.dart'; // 路由路径常量,避免硬编码字符串 abstract class AppRoutes { static const home = '/'; static const login = '/login'; static const product = '/product/:id'; static const cart = '/cart'; static const profile = '/profile'; static const settings = '/settings'; // 便捷方法生成带参数的路径 static String productPath(String id) => '/product/$id'; } GoRouter createRouter(AuthService authService) { return GoRouter( initialLocation: AppRoutes.home, redirect: (context, state) => _guardRoute(authService, state), routes: _routes, errorBuilder: (context, state) => NotFoundPage(path: state.uri.path), ); } String? _guardRoute(AuthService auth, GoRouterState state) { if (!auth.isLoggedIn && state.uri.path != AppRoutes.login) { return AppRoutes.login; } return null; } final _routes = <RouteBase>[ // ... 路由配置 ];
8.2 类型安全的参数传递
// 使用 extra 传递复杂对象(go_router) class Product { final String id; final String name; final double price; const Product({required this.id, required this.name, required this.price}); } // 导航时传递对象 context.push( AppRoutes.productPath('42'), extra: Product(id: '42', name: 'Flutter 实战', price: 59.9), ); // 接收时类型转换 GoRoute( path: '/product/:id', builder: (context, state) { final product = state.extra as Product?; final id = state.pathParameters['id']!; // product 可能为 null(通过 Deep Link 进入时) return ProductPage(id: id, product: product); }, )
8.3 测试导航
import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; void main() { testWidgets('导航到详情页', (tester) async { final router = GoRouter( initialLocation: '/', routes: [ GoRoute( path: '/', builder: (_, __) => HomePage(), routes: [ GoRoute( path: 'detail', builder: (_, __) => DetailPage(), ), ], ), ], ); await tester.pumpWidget( MaterialApp.router(routerConfig: router), ); // 验证首页显示 expect(find.byType(HomePage), findsOneWidget); // 模拟导航 router.go('/detail'); await tester.pumpAndSettle(); // 验证详情页显示 expect(find.byType(DetailPage), findsOneWidget); }); test('重定向未登录用户到登录页', () { final router = createRouter( MockAuthService(isLoggedIn: false), ); // 验证重定向配置 expect( router.configuration.redirect( MockBuildContext(), GoRouterState( router.configuration, uri: Uri.parse('/profile'), ), ), '/login', ); }); }
extra 传递的对象在 Deep Link 场景下会丢失,务必用 pathParameters 作为后备;3) 将路由守卫逻辑抽取为独立函数方便测试;4) 避免在 Widget 中直接写路由路径字符串。
9. 实践练习
练习 1:多页面导航应用
创建一个包含 3 个页面的应用,实现以下功能:
- 首页:显示一个商品列表(至少 5 个商品),每个商品有名称和价格
- 详情页:通过构造函数接收商品信息,显示商品详情,包含"加入购物车"按钮
- 购物车页:显示已添加的商品,点击"去结算"返回结果给首页
- 使用 Hero 动画在列表图片和详情页图片之间过渡
- 自定义 SlideTransition 从底部滑入详情页
练习 2:go_router 标签导航应用
使用 go_router 构建一个带底部导航栏的应用:
- 使用
ShellRoute实现底部导航栏(首页、发现、消息、我的) - 首页包含一个列表,点击进入详情页(使用路径参数
/home/post/:id) - "我的" 页面需要登录才能访问,未登录时通过
redirect跳转到登录页 - 登录页不显示底部导航栏(放在 ShellRoute 外部)
- 登录成功后自动跳转回之前想访问的页面
练习 3:Deep Link 测试
在练习 2 的基础上添加 Deep Link 支持:
- 配置 Android 和 iOS 的 Deep Link(可使用自定义 scheme)
- 确保通过
myapp://home/post/123能直接打开指定帖子的详情页 - 处理无效的 Deep Link 路径,显示友好的 404 页面
- 编写至少 2 个导航相关的 Widget Test