You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

使用GoRouter StatefulShellRoute结合redirect时出现Duplicate GlobalKey错误

在StatefulShellRoute中使用GoRouter的redirect导致Duplicate GlobalKey错误

问题描述

在Flutter应用中使用GoRouter的StatefulShellRoute实现底部导航栏,结合redirect做登录态校验时,触发了Duplicate GlobalKey错误。以下是可在DartPad中复现的最小代码:

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';

class LoggedinNotifier extends StateNotifier<bool> {
  LoggedinNotifier() : super(false);

  Future<void> set(bool val) async {
    await Future.delayed(const Duration(seconds: 1));
    state = val;
  }
}

final loggedInProvider =
    StateNotifierProvider<LoggedinNotifier, bool>((ref) => LoggedinNotifier());

class NavigationKeys {
  static final rootNavigatorKey = GlobalKey<NavigatorState>();
  static final homeNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'Home');
  static final profileNavigatorKey =
      GlobalKey<NavigatorState>(debugLabel: 'Profile');
}

enum AppRoutes {
  home,
  profile,
  profileLoggedIn,
  profileLogin,
}

final appRouterProvider = Provider<GoRouter>((ref) {
  final loggedIn = ref.watch(loggedInProvider);

  return GoRouter(
    debugLogDiagnostics: true,
    initialLocation: '/home',
    routes: [
      StatefulShellRoute.indexedStack(
        branches: [
          StatefulShellBranch(routes: [
            GoRoute(
              path: '/home',
              name: AppRoutes.home.name,
              builder: (context, state) => const Scaffold(
                body: Center(child: Text('Home')),
              ),
            )
          ], navigatorKey: NavigationKeys.homeNavigatorKey),
          StatefulShellBranch(routes: [
            GoRoute(
                path: '/profile',
                name: AppRoutes.profile.name,
                builder: (context, state) {
                  return Consumer(
                    builder: (context, ref, child) => TextButton(
                        onPressed: () async {
                          await ref.read(loggedInProvider.notifier).set(false);
                        },
                        child: const Text('Logout')),
                  );
                },
                routes: [
                  GoRoute(
                    path: 'authentication/login',
                    name: AppRoutes.profileLogin.name,
                    builder: (context, state) {
                      return Consumer(
                        builder: (context, ref, child) => TextButton(
                            onPressed: () async {
                              await ref
                                  .read(loggedInProvider.notifier)
                                  .set(true);
                            },
                            child: const Text('Login')),
                      );
                    },
                  ),
                ])
          ], navigatorKey: NavigationKeys.profileNavigatorKey),
        ],
        builder: (context, state, navigationShell) => Scaffold(
          body: navigationShell,
          bottomNavigationBar: BottomNavigationBar(
              enableFeedback: true,
              type: BottomNavigationBarType.fixed,
              currentIndex: navigationShell.currentIndex,
              onTap: (value) {
                navigationShell.goBranch(
                  value,
                );
              },
              items: const [
                BottomNavigationBarItem(
                    icon: Icon(Icons.home_outlined), label: 'Home'),
                BottomNavigationBarItem(
                    icon: Icon(Icons.person_outline), label: 'Profile')
              ]),
        ),
      )
    ],
    navigatorKey: NavigationKeys.rootNavigatorKey,
    redirect: (context, state) {
      if (!loggedIn && !state.uri.toString().contains('/authentication')) {
        return state.namedLocation(AppRoutes.profileLogin.name);
      }
      return null;
    },
  );
});

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

class MyApp extends ConsumerWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return MaterialApp.router(routerConfig: ref.watch(appRouterProvider));
  }
}

复现步骤

  • 将代码复制到DartPad运行
  • 点击Login按钮
  • 切换到Profile标签页
  • 点击Logout按钮

报错信息

检测到Widget树中存在重复的GlobalKey。
Widget树中多次指定了以下GlobalKey。这会导致Widget树的部分被意外截断,因为当第二次遇到同一个Key时,之前的实例会被移动到新位置。该Key为:
- [GlobalObjectKey int#b8c6a]
通过观察发现,带有上述全局Key的Widget被移出其父Widget后,该父Widget在当前帧中从未更新,这意味着它要么完全没有更新,要么在Widget移动前就已更新,无论哪种情况都表明它仍然认为自己应该拥有一个带有该全局Key的子Widget。
因GlobalKey重父化而被强制移除一个或多个子Widget后未更新的特定父Widget是:
- _BranchNavigatorProxy-[StatefulShellBranch#a507a](state: _BranchNavigatorProxyState#1f5f1)
一个GlobalKey在Widget树中同一时间只能指定给一个Widget。

抛出异常时的调用栈:
#0      BuildOwner.finalizeTree.<anonymous closure> (package:flutter/src/widgets/framework.dart:3317:15)
framework.dart:3317
#1      BuildOwner.finalizeTree (package:flutter/src/widgets/framework.dart:3342:8)
framework.dart:3342
#2      WidgetsBinding.drawFrame (package:flutter/src/widgets/binding.dart:1169:19)
binding.dart:1169
#3      RendererBinding._handlePersistentFrameCallback (package:flutter/src/rendering/binding.dart:468:5)
binding.dart:468
#4      SchedulerBinding._invokeFrameCallback (package:flutter/src/scheduler/binding.dart:1397:15)
binding.dart:1397
#5      SchedulerBinding.handleDrawFrame (package:flutter/src/scheduler/binding.dart:1318:9)
binding.dart:1318
#6      SchedulerBinding._handleDrawFrame (package:flutter/src/scheduler/binding.dart:1176:5)
binding.dart:1176
#7      _invoke (dart:ui/hooks.dart:312:13)
hooks.dart:312
#8      PlatformDispatcher._drawFrame (dart:ui/platform_dispatcher.dart:419:5)
platform_dispatcher.dart:419
#9      _drawFrame (dart:ui/hooks.dart:283:31)
hooks.dart:283
════════════════════════════════════════════════════════════════════════════════

问题分析

错误核心原因是:登录状态变化触发全局redirect跳转时,StatefulShellRoute的分支导航器GlobalKey被重复绑定。Logout后redirect跳转到登录页时,Profile分支的旧导航器实例未被正确销毁,仍持有该GlobalKey,而新的导航实例又尝试复用同一个Key,导致冲突。同时,登录页作为Profile分支的子路由,全局redirect会干扰StatefulShellRoute内部的导航栈管理,进一步引发Key重复问题。

解决方案

方案1:将登录校验逻辑移至分支路由内部

移除全局redirect,在Profile分支的根路由内处理登录态校验,避免全局跳转干扰分支导航器状态:

final appRouterProvider = Provider<GoRouter>((ref) {
  final loggedIn = ref.watch(loggedInProvider);

  return GoRouter(
    debugLogDiagnostics: true,
    initialLocation: '/home',
    routes: [
      // 将登录页改为根路由的子路由,使用根导航器
      GoRoute(
        path: '/login',
        name: AppRoutes.profileLogin.name,
        builder: (context, state) {
          return Consumer(
            builder: (context, ref, child) => TextButton(
                onPressed: () async {
                  await ref.read(loggedInProvider.notifier).set(true);
                  context.goNamed(AppRoutes.profile.name);
                },
                child: const Text('Login')),
          );
        },
      ),
      StatefulShellRoute.indexedStack(
        branches: [
          StatefulShellBranch(routes: [
            GoRoute(
              path: '/home',
              name: AppRoutes.home.name,
              builder: (context, state) => const Scaffold(
                body: Center(child: Text('Home')),
              ),
            )
          ], navigatorKey: NavigationKeys.homeNavigatorKey),
          StatefulShellBranch(routes: [
            GoRoute(
                path: '/profile',
                name: AppRoutes.profile.name,
                // 在Profile路由内部做登录校验
                redirect: (context, state) {
                  if (!loggedIn) {
                    return '/login';
                  }
                  return null;
                },
                builder: (context, state) {
                  return Consumer(
                    builder: (context, ref, child) => TextButton(
                        onPressed: () async {
                          await ref.read(loggedInProvider.notifier).set(false);
                          context.goNamed(AppRoutes.profileLogin.name);
                        },
                        child: const Text('Logout')),
                  );
                },)
          ], navigatorKey: NavigationKeys.profileNavigatorKey),
        ],
        builder: (context, state, navigationShell) => Scaffold(
          body: navigationShell,
          bottomNavigationBar: BottomNavigationBar(
              enableFeedback: true,
              type: BottomNavigationBarType.fixed,
              currentIndex: navigationShell.currentIndex,
              onTap: (value) {
                navigationShell.goBranch(
                  value,
                );
              },
              items: const [
                BottomNavigationBarItem(
                    icon: Icon(Icons.home_outlined), label: 'Home'),
                BottomNavigationBarItem(
                    icon: Icon(Icons.person_outline), label: 'Profile')
              ]),
        ),
      )
    ],
    navigatorKey: NavigationKeys.rootNavigatorKey,
  );
});

方案2:避免全局redirect跳转至分支内子路由

全局redirect时,优先跳转到分支的根路由,再由根路由内部跳转至登录页,或直接使用根导航器承载登录页,避免干扰分支导航器的Key管理。

总结

核心问题是全局redirect与StatefulShellRoute的分支导航器Key管理冲突,通过将登录校验逻辑移至分支路由内部,或调整路由结构让登录页脱离分支导航器,即可解决Duplicate GlobalKey错误。

内容的提问来源于stack exchange,提问作者SHUVAM JASWAL

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.16 17:15:54