← 返回目录
🧭

路由与导航

2. 命名路由

命名路由通过字符串标识路由,适合简单应用。在 MaterialApproutes 参数中注册路由映射表。

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_routerauto_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),
        ),
      ),
    );
  }
}
提示:Hero 动画的关键是两个页面中的 tag 必须完全一致。可以用 flightShuttleBuilder 自定义飞行中的 Widget 外观,用 placeholderBuilder 指定离开后原位置的占位 Widget。

4. 嵌套导航

复杂应用通常需要多个独立的导航栈,例如底部标签栏每个 Tab 各有自己的导航栈。

4.1 多个 Navigator

class MainApp extends StatefulWidget {
  @override
  State<MainApp> createState() => _MainAppState();
}

class _MainAppState extends State<MainApp> {
  int _currentIndex = 0;

  // 每个 Tab 独立的 GlobalKey
  final _navigatorKeys = [
    GlobalKey<NavigatorState>(),
    GlobalKey<NavigatorState>(),
    GlobalKey<NavigatorState>(),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: IndexedStack(
        index: _currentIndex,
        children: [
          Navigator(
            key: _navigatorKeys[0],
            onGenerateRoute: (_) => MaterialPageRoute(
              builder: (_) => HomeTab(),
            ),
          ),
          Navigator(
            key: _navigatorKeys[1],
            onGenerateRoute: (_) => MaterialPageRoute(
              builder: (_) => SearchTab(),
            ),
          ),
          Navigator(
            key: _navigatorKeys[2],
            onGenerateRoute: (_) => MaterialPageRoute(
              builder: (_) => ProfileTab(),
            ),
          ),
        ],
      ),
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _currentIndex,
        onTap: (index) => setState(() => _currentIndex = index),
        items: [
          BottomNavigationBarItem(icon: Icon(Icons.home), label: '首页'),
          BottomNavigationBarItem(icon: Icon(Icons.search), label: '搜索'),
          BottomNavigationBarItem(icon: Icon(Icons.person), label: '我的'),
        ],
      ),
    );
  }
}

4.2 PopScope 拦截返回

PopScope(替代了已废弃的 WillPopScope)用于拦截系统返回按钮事件,常用于嵌套导航中将返回事件分派给子 Navigator。

PopScope(
  // canPop 为 false 时拦截返回
  canPop: false,
  onPopInvokedWithResult: (didPop, result) {
    if (didPop) return;

    // 尝试让当前 Tab 的 Navigator 弹出
    final navigator = _navigatorKeys[_currentIndex].currentState!;
    if (navigator.canPop()) {
      navigator.pop();
    } else {
      // 已经在 Tab 首页,显示退出确认
      showDialog(
        context: context,
        builder: (context) => AlertDialog(
          title: Text('确认退出?'),
          actions: [
            TextButton(
              onPressed: () => Navigator.pop(context),
              child: Text('取消'),
            ),
            TextButton(
              onPressed: () => SystemNavigator.pop(),
              child: Text('退出'),
            ),
          ],
        ),
      );
    }
  },
  child: /* ... */,
)
注意:WillPopScope 在 Flutter 3.12 中被废弃,请使用 PopScope 替代。新 API 通过 canPoponPopInvokedWithResult 提供更清晰的语义。

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),
    );
  }
}
go vs push:context.go('/path') 会替换整个导航栈(声明式),适合 Tab 切换;context.push('/path') 会在栈顶压入新页面(命令式),适合详情页跳转。根据场景选择合适的方法。

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 vs go_router: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',
    );
  });
}
建议:1) 使用路径常量而非硬编码字符串;2) 通过 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