Flutter路由与导航

What — 是什么

Flutter 路由与导航是指在应用中管理页面跳转、返回、传参和页面栈的机制。Flutter 提供了从简单的 Navigator 1.0(命令式 push/pop)到声明式的 Navigator 2.0/Router API,以及社区主流的 GoRouter 方案。

核心概念:

  • Navigator:管理 Route 对象栈的组件,push 入栈、pop 出栈
  • Route:页面的抽象,MaterialPageRoute 是最常用的实现
  • 页面栈:后进先出的路由栈,栈顶为当前可见页面
  • 命名路由:通过字符串名称映射到页面构建函数
  • 声明式路由:通过路由配置表描述路由结构,如 GoRouter

路由体系演进:

Navigator 1.0          Navigator 2.0 / Router API       GoRouter
(命令式)               (声明式,官方)                    (声明式,社区)
─────────────          ────────────────────────         ────────────
简单直接                复杂但灵活                        简单且灵活
push/pop               Router + Delegate + Parser        路由配置表
难以处理深链接           支持深链接                        原生支持深链接
不支持浏览器地址栏       支持                              支持
无重定向/守卫           手动实现                          内置 redirect
适合小型应用            适合大型/多平台                    适合所有规模

Why — 为什么

适用场景:

  • 多页面应用需要页面跳转和返回
  • 需要路由守卫(未登录重定向到登录页)
  • 需要深度链接(从 URL 直接打开特定页面)
  • 需要底部导航 Tab 切换
  • Web 端需要浏览器前进/后退支持

对比同类方案:

维度Navigator 1.0Router APIGoRouter
编程范式命令式声明式声明式
学习曲线
深度链接不支持支持支持
路由守卫手动手动内置
Web 地址栏不支持支持支持
嵌套路由困难支持支持
代码量
官方/社区官方官方社区(官方推荐)

How — 怎么用

1. Navigator 1.0 基础

// ===== push / pop =====
// 压入新页面
Navigator.push(
  context,
  MaterialPageRoute(builder: (context) => const DetailPage()),
);

// 返回上一页
Navigator.pop(context);

// ===== pushReplacement — 替换当前页 =====
Navigator.pushReplacement(
  context,
  MaterialPageRoute(builder: (context) => const HomePage()),
);

// ===== pushAndRemoveUntil — 清空栈到指定页面 =====
// 登录成功后清空所有页面回到首页
Navigator.pushAndRemoveUntil(
  context,
  MaterialPageRoute(builder: (context) => const HomePage()),
  (route) => false,    // 清空所有路由
);

// ===== popUntil — 返回到指定页面 =====
Navigator.popUntil(context, (route) => route.isFirst);

// ===== 路由传参 =====
// 传参
Navigator.push(
  context,
  MaterialPageRoute(
    builder: (context) => DetailPage(id: '123', name: 'Product'),
  ),
);

// 回传结果
final result = await Navigator.push(
  context,
  MaterialPageRoute(builder: (context) => const SelectCityPage()),
);
if (result != null) {
  print('Selected: $result');
}

// 被调用方返回结果
Navigator.pop(context, 'Beijing');

2. 命名路由

// ===== 定义命名路由 =====
MaterialApp(
  initialRoute: '/',
  routes: {
    '/': (context) => const HomePage(),
    '/detail': (context) => const DetailPage(),
    '/profile': (context) => const ProfilePage(),
  },
  onGenerateRoute: (settings) {
    // 处理带参数的路由
    if (settings.name == '/product') {
      final args = settings.arguments as Map<String, dynamic>;
      return MaterialPageRoute(
        builder: (context) => ProductPage(id: args['id']),
      );
    }
    return null;
  },
);

// ===== 使用命名路由 =====
Navigator.pushNamed(context, '/detail');
Navigator.pushReplacementNamed(context, '/home');
Navigator.popAndPushNamed(context, '/login');

// 带参数
Navigator.pushNamed(
  context,
  '/product',
  arguments: {'id': '123'},
);

// 获取参数
class ProductPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final args = ModalRoute.of(context)!.settings.arguments as Map<String, dynamic>;
    return Text('Product: ${args['id']}');
  }
}

3. 自定义路由过渡动画

// ===== PageRouteBuilder 自定义动画 =====
Navigator.push(
  context,
  PageRouteBuilder(
    pageBuilder: (context, animation, secondaryAnimation) => const DetailPage(),
    transitionDuration: const Duration(milliseconds: 300),
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      // 滑动动画
      var tween = Tween(begin: const Offset(1.0, 0.0), end: Offset.zero)
          .chain(CurveTween(curve: Curves.easeInOut));
      return SlideTransition(position: animation.drive(tween), child: child);
    },
  ),
);

// 淡入淡出
transitionsBuilder: (context, animation, secondaryAnimation, child) {
  return FadeTransition(opacity: animation, child: child);
}

// 缩放
transitionsBuilder: (context, animation, secondaryAnimation, child) {
  var tween = Tween(begin: 0.0, end: 1.0).chain(CurveTween(curve: Curves.easeOutBack));
  return ScaleTransition(scale: animation.drive(tween), child: child);
}

// 组合动画(滑动 + 淡入)
transitionsBuilder: (context, animation, secondaryAnimation, child) {
  return SlideTransition(
    position: Tween<Offset>(begin: const Offset(1, 0), end: Offset.zero)
        .chain(CurveTween(curve: Curves.easeOut)).animate(animation),
    child: FadeTransition(opacity: animation, child: child),
  );
}

4. GoRouter(推荐方案)

// ===== 安装 =====
// flutter pub add go_router

// ===== 路由配置 =====
final router = GoRouter(
  initialLocation: '/',
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const HomePage(),
    ),
    GoRoute(
      path: '/detail/:id',           // 路径参数
      builder: (context, state) {
        final id = state.pathParameters['id']!;
        return DetailPage(id: id);
      },
    ),
    GoRoute(
      path: '/search',
      builder: (context, state) {
        final query = state.uri.queryParameters['q'] ?? '';  // 查询参数
        return SearchPage(query: query);
      },
    ),
    // 嵌套路由
    GoRoute(
      path: '/settings',
      builder: (context, state) => const SettingsPage(),
      routes: [
        GoRoute(
          path: 'account',            // 完整路径:/settings/account
          builder: (context, state) => const AccountPage(),
        ),
        GoRoute(
          path: 'privacy',
          builder: (context, state) => const PrivacyPage(),
        ),
      ],
    ),
  ],
  // 全局重定向(路由守卫)
  redirect: (context, state) {
    final isLoggedIn = AuthService.isLoggedIn;
    final isLoginRoute = state.matchedLocation == '/login';

    if (!isLoggedIn && !isLoginRoute) return '/login';
    if (isLoggedIn && isLoginRoute) return '/';
    return null;  // 无需重定向
  },
);

// ===== MaterialApp.router =====
MaterialApp.router(
  routerConfig: router,
  title: 'My App',
);

// ===== 导航使用 =====
context.go('/');                          // 替换当前路由
context.go('/detail/123');                // 路径参数
context.go('/search?q=flutter');          // 查询参数
context.push('/detail/123');              // 压入新路由(可返回)
context.pop();                            // 返回

// 命名路由(GoRouter)
GoRoute(
  name: 'detail',                        // 命名
  path: 'detail/:id',
  builder: (context, state) => DetailPage(id: state.pathParameters['id']!),
),
context.namedLocation('detail', pathParameters: {'id': '123'});
context.goNamed('detail', pathParameters: {'id': '123'});

// ===== ShellRoute — 布局嵌套 =====
ShellRoute(
  builder: (context, state, child) {
    return Scaffold(
      body: child,
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _currentIndex,
        onTap: (index) {
          switch (index) {
            case 0: context.go('/');
            case 1: context.go('/explore');
            case 2: context.go('/profile');
          }
        },
        items: const [
          BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
          BottomNavigationBarItem(icon: Icon(Icons.explore), label: 'Explore'),
          BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'),
        ],
      ),
    );
  },
  routes: [
    GoRoute(path: '/', builder: (context, state) => const HomePage()),
    GoRoute(path: '/explore', builder: (context, state) => const ExplorePage()),
    GoRoute(path: '/profile', builder: (context, state) => const ProfilePage()),
  ],
)

5. 底部导航(BottomNavigationBar + PageView)

class MainNavigation extends StatefulWidget {
  @override
  State<MainNavigation> createState() => _MainNavigationState();
}

class _MainNavigationState extends State<MainNavigation> {
  int _currentIndex = 0;
  final _pages = const [HomePage(), ExplorePage(), ProfilePage()];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: IndexedStack(              // 保持页面状态
        index: _currentIndex,
        children: _pages,
      ),
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _currentIndex,
        onTap: (index) => setState(() => _currentIndex = index),
        items: const [
          BottomNavigationBarItem(icon: Icon(Icons.home), label: '首页'),
          BottomNavigationBarItem(icon: Icon(Icons.explore), label: '发现'),
          BottomNavigationBarItem(icon: Icon(Icons.person), label: '我的'),
        ],
      ),
    );
  }
}

6. Tab 导航

class TabPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 3,
      child: Scaffold(
        appBar: AppBar(
          title: const Text('Tabs'),
          bottom: const TabBar(
            tabs: [
              Tab(icon: Icon(Icons.chat), text: '聊天'),
              Tab(icon: Icon(Icons.contacts), text: '通讯录'),
              Tab(icon: Icon(Icons.settings), text: '设置'),
            ],
          ),
        ),
        body: const TabBarView(
          children: [
            ChatList(),
            ContactList(),
            SettingsList(),
          ],
        ),
      ),
    );
  }
}

// 自定义 TabController(需要动态控制 Tab)
class CustomTabPage extends StatefulWidget {
  @override
  State<CustomTabPage> createState() => _CustomTabPageState();
}

class _CustomTabPageState extends State<CustomTabPage>
    with SingleTickerProviderStateMixin {
  late TabController _tabController;

  @override
  void initState() {
    super.initState();
    _tabController = TabController(length: 3, vsync: this);
  }

  @override
  void dispose() {
    _tabController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: TabBarView(
        controller: _tabController,
        children: const [ChatList(), ContactList(), SettingsList()],
      ),
      bottomNavigationBar: TabBar(
        controller: _tabController,
        tabs: const [
          Tab(icon: Icon(Icons.chat), text: '聊天'),
          Tab(icon: Icon(Icons.contacts), text: '通讯录'),
          Tab(icon: Icon(Icons.settings), text: '设置'),
        ],
      ),
    );
  }
}

7. 深度链接

# 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" android:host="detail" />
</intent-filter>
<!-- iOS: ios/Runner/Info.plist -->
<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>myapp</string>
    </array>
  </dict>
</array>
// GoRouter 自动处理深度链接
// myapp://detail/123 → 自动导航到 /detail/123
// https://myapp.com/detail/123 → 需要配置 App Links / Universal Links

常见问题与踩坑

问题原因解决方案
pop 后黑屏页面栈为空用 pushAndRemoveUntil 保证有根页面
路由传参丢失重建时参数未保存用 state.extra 或路径参数
BottomNavigationBar 状态丢失切换 Tab 时 Widget 重建用 IndexedStack 保持状态
GoRouter 上下文错误context 不在 Router 内使用 GoRouter.of(context) 或全局 key
深度链接不生效平台配置缺失检查 AndroidManifest/Info.plist
页面切换卡顿过渡动画太重缩短 duration 或用简单动画
Web 刷新 404服务端未配置 SPA 路由nginx 配置 try_files 回退 index.html

最佳实践

  • 新项目优先使用 GoRouter,简洁且功能完整
  • 路由守卫统一在 redirect 中处理
  • 底部导航用 ShellRoute 或 IndexedStack
  • 路径参数用于资源标识(/detail/:id),查询参数用于筛选(/search?q=xxx)
  • 自定义动画用 PageRouteBuilder
  • Web 端注意配置服务端 SPA 路由回退
  • 路由传参用 state.extra 传复杂对象
  • 避免在路由中传递大对象,用状态管理共享

面试题

Q1: Flutter 的 Navigator 1.0 和 Navigator 2.0 有什么区别?为什么推荐 GoRouter?

Navigator 1.0 是命令式 API,通过 push/pop 管理路由栈,简单直接但不支持深度链接和浏览器地址栏同步。Navigator 2.0 是声明式 API,通过 Router/RouteInformationParser/RouterDelegate 三件套实现,灵活但极其复杂。GoRouter 是社区方案,用声明式路由配置表实现了 Navigator 2.0 的所有能力(深度链接、嵌套路由、重定向),代码量大幅减少,Flutter 官方也推荐使用。

Q2: GoRouter 的 redirect 如何实现路由守卫?

GoRouter 的 redirect 是一个全局回调函数,在每次路由跳转前执行,接收当前路由状态并返回目标路径。如果返回非 null 值则执行重定向,返回 null 则正常导航。典型实现:检查 AuthService.isLoggedIn,如果未登录且目标不是 /login,则重定向到 /login;如果已登录且目标是 /login,则重定向到 /。这比在 Widget 层判断更优雅,且在 Web 端不会闪现未授权页面。

Q3: IndexedStack 和 PageView 在底部导航中各有什么优缺点?

IndexedStack 同时构建所有子页面,切换时不重建,状态完全保留,但所有页面都常驻内存。PageView 按需构建页面,内存友好,但默认会销毁离屏页面(需设置 AutomaticKeepAliveClientMixin 保留状态)。选择:页面少(3-5个)、内容重要用 IndexedStack;页面多或内容占内存大用 PageView + KeepAlive。

Q4: 如何实现页面间的数据传递?有哪些方式?

五种方式:①构造函数参数 — 最简单,push 时传入;②路由参数 — pushNamed 的 arguments 或 GoRouter 的 state.extra;③回传结果 — pop(context, result) 配合 await push 获取;④状态管理 — Provider/Riverpod/Bloc 共享状态,最灵活;⑤路由路径参数 — /detail/:id,适合 RESTful 风格。推荐:简单传参用路径参数/extra,复杂数据用状态管理,一次性结果用 pop 回传。

Q5: ShellRoute 是什么?解决什么问题?

ShellRoute 是 GoRouter 提供的布局嵌套机制,为子路由共享一个共同的 Shell(外壳)Widget。典型场景:底部导航栏在多个 Tab 页面间共享,切换 Tab 时导航栏不重建。原理:ShellRoute 的 builder 接收 child 参数(当前匹配的子路由页面),在 Shell 中包裹 child 并添加共享 UI(如 Scaffold + BottomNavigationBar)。嵌套 ShellRoute 可以实现多层布局(如侧边栏 + 底部导航)。

Q6: Flutter Web 端的路由和移动端有什么不同?需要注意什么?

核心区别:Web 端路由需要与浏览器地址栏同步,支持前进/后退按钮和 URL 直接访问。注意事项:①URL 直接访问需要服务端配置 SPA 回退(所有路径返回 index.html),否则刷新会 404;②深度链接需要配置 App Links/Universal Links;③使用 GoRouter 自动处理浏览器历史记录;④路由参数出现在 URL 中,注意不要暴露敏感信息;⑤页面标题需要随路由变化更新(GoRouter 的 builder 中设置)。

Q7: 自定义路由过渡动画有哪些方式?

三种方式:①PageRouteBuilder — 最灵活,可自定义 transitionDuration 和 transitionsBuilder,支持滑动/淡入/缩放/旋转等任意组合;②Theme 全局配置 — PageTransitionsTheme 在 ThemeData 中设置全局过渡动画;③Hero 动画 — 共享元素过渡,两个页面的相同 Widget 间平滑过渡。推荐:单页面自定义用 PageRouteBuilder,全局统一用 Theme,页面间元素共享用 Hero。

Q8: GoRouter 的 push 和 go 有什么区别?

go 是替换式导航,替换当前路由栈顶为新的路由,类似 pushReplacement,不会在栈中积累页面。push 是压栈式导航,将新页面压入路由栈,可以通过 pop 返回上一页。使用场景:底部导航 Tab 切换用 go(不需要返回上一个 Tab),从列表进入详情用 push(需要返回列表)。关键区别:go 修改当前路由匹配结果,push 在栈上叠加新路由。


相关链接: