Flutter 导航与路由管理

Flutter 导航全攻略:从 Navigator 1.0 到 2.0,从 GoRouter 到深层链接,从 Hero 动画到状态恢复,掌握现代 Flutter 路由架构。

开篇: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 项目的标准选择。


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 设计(你的后端接口需要路由来导航)

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「Flutter」更多文章

  1. Widget 体系与布局系统
  2. Flutter 状态管理全解析
  3. Flutter 测试策略与自动化