教训:Flutter BLoC 状态广播 + GoRouter 导航——登录成功后不跳转

记录日期:2026-02-05

Bug Flutter / BLoC Employee App

目录

1. 问题现象

症状:在员工端 APP 上使用 Test Super Admin 账户登录,后端返回 200 成功, 但 APP 的 loading 转圈消失后,页面依然停留在登录页,没有跳转到主页。

确认过:

2. 背景知识:三个核心概念

要理解这个 Bug,需要先了解三样东西。

2.1 GoRouter(页面调度员)

GoRouter 是 Flutter 的路由库,负责"根据路径显示对应页面"。你可以把它理解成一个调度员:

路径 '/splash'     → 显示 SplashPage (启动页)
路径 '/login'      → 显示 LoginPage  (登录页)
路径 '/home'       → 显示 HomePage   (主页)
路径 '/pin-setup'  → 显示 PinSetupPage(PIN 设置页)

关键点:页面不会自己跳转。必须有代码主动调用 context.go('/home') 才会跳到主页。 GoRouter 不会"自动"根据某个条件切换页面。

2.2 AuthBloc(认证状态广播站)

BLoC(Business Logic Component)是 Flutter 常用的状态管理模式。 AuthBloc 专门管理"用户认证状态",你可以把它想象成一个广播站

AuthBloc(广播站) 接收事件(输入): 广播状态(输出): ┌─────────────────┐ ┌─────────────────────┐ │ AuthCheckRequested│ ──────→ │ AuthUnauthenticated │ 未登录 │ AuthLoginRequested│ ──────→ │ AuthLoading │ 登录中... │ AuthLogoutRequested│ │ AuthAuthenticated │ 登录成功 └─────────────────┘ │ AuthNeedsPinSetup │ 需要设 PIN │ AuthError │ 登录失败 └─────────────────────┘

AuthBloc 自己不做导航,它只负责广播"当前是什么认证状态"。导航的事情交给页面自己处理。

2.3 BlocListener(广播接收器)

页面通过 BlocListener 来"订阅"广播站的状态变化。但关键是:

BlocListener 只在它所在的 Widget 还在屏幕上时才能工作。 如果页面被导航走了(不在 widget tree 里了),它的 BlocListener 就失效了,收不到任何广播。

3. APP 启动流程(正常路径)

先看 APP 冷启动时的正常流程,这一段是没问题的:

时间线 ──────────────────────────────────────────────→ [APP启动] │ ▼ ┌─────────────────────────────────────────────────┐ │ SplashPage 显示在屏幕上 │ │ │ │ 1. initState() 触发 │ │ 2. 告诉 AuthBloc: "检查用户是否已登录" │ │ → AuthBloc.add(AuthCheckRequested) │ │ │ │ 3. AuthBloc 检查本地存储: 没有 token │ │ 4. AuthBloc 广播: AuthUnauthenticated │ │ │ │ 5. SplashPage 的 BlocListener 收到广播 │ │ 6. 执行 context.go('/login') │ │ → 跳转到 LoginPage ✅ │ └─────────────────────────────────────────────────┘

代码位置:splash_page.dart 第 21-39 行

SplashPage 的 BlocListener 写了所有状态的处理:

// splash_page.dart(第 29-39 行)
BlocListener<AuthBloc, AuthState>(
  listener: (context, state) {
    if (state is AuthUnauthenticated) {
      context.go('/login');         // ✅ 去登录页
    } else if (state is AuthNeedsPinSetup) {
      context.go('/pin-setup');     // ✅ 去 PIN 设置
    } else if (state is AuthAuthenticated) {
      context.go('/home');          // ✅ 去主页
    } else if (state is AuthError) {
      context.go('/login');         // ✅ 出错也去登录页
    }
  },
)

✅ 四种状态全部处理了,非常完整。

4. 用户登录流程(出 Bug 的路径)

用户到了 LoginPage 后,输入账号密码点击 Sign In:

时间线 ──────────────────────────────────────────────→ [用户在 LoginPage 点击 Sign In] │ ▼ ┌─────────────────────────────────────────────────┐ │ LoginPage 显示在屏幕上 │ │ (SplashPage 已经不在屏幕上了 ❌) │ │ │ │ 1. 用户点击 Sign In │ │ 2. 告诉 AuthBloc: "用户要登录" │ │ → AuthBloc.add(AuthLoginRequested) │ │ │ │ 3. AuthBloc 广播: AuthLoading │ │ 4. LoginPage 收到 → 显示 loading 转圈 ✅ │ │ │ │ 5. AuthBloc 调用后端 API,登录成功 │ │ 6. AuthBloc 广播: AuthAuthenticated │ │ │ │ 7. 谁在收听这个广播? │ │ • SplashPage? ──→ ❌ 不在屏幕上,收不到 │ │ • LoginPage? ──→ ✅ 在屏幕上,收到了 │ │ │ │ 8. LoginPage 的 BlocListener 收到广播后做了什么? │ │ → _isLoading = false (转圈消失) │ │ → 检查 state is AuthError? → 不是 │ │ → 结束。没有任何导航代码。 │ │ │ │ 9. 页面停在 LoginPage,什么都不发生 💥 │ └─────────────────────────────────────────────────┘

代码位置:login_page.dart 第 56-69 行

LoginPage 的 BlocListener 只处理了错误:

// login_page.dart(第 56-69 行)
BlocListener<AuthBloc, AuthState>(
  listener: (context, state) {
    // 更新 loading 状态
    setState(() {
      _isLoading = state is AuthLoading;
    });

    // 只处理了错误
    if (state is AuthError) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(state.message)),
      );
    }

    // 🚨 缺少:
    // if (state is AuthAuthenticated) { context.go('/home'); }
    // if (state is AuthNeedsPinSetup) { context.go('/pin-setup'); }
  },
)

❌ 只写了失败情况,没写成功情况。

5. 根本原因

LoginPage 的 BlocListener 只处理了 AuthError(登录失败), 没有处理 AuthAuthenticated(登录成功)和 AuthNeedsPinSetup(需要 PIN), 导致登录成功后没有代码触发页面跳转。

对比两个页面的 BlocListener 覆盖情况:

认证状态 SplashPage 处理 LoginPage 处理
AuthUnauthenticated ✅ context.go('/login') 不需要(已经在登录页了)
AuthAuthenticated ✅ context.go('/home') ❌ 缺失——不跳转
AuthNeedsPinSetup ✅ context.go('/pin-setup') ❌ 缺失——不跳转
AuthError ✅ context.go('/login') ✅ 显示错误 SnackBar
AuthLoading (不处理) ✅ 显示 loading 转圈

两个页面各自都有 BlocListener,但它们的监听范围不重叠—— SplashPage 离开屏幕后就失效了,LoginPage 又没有补上成功状态的导航。

6. 修复方案

方案 A:在 LoginPage 补上导航(最直接)

在 LoginPage 的 BlocListener 里加上成功和 PIN 两种状态的跳转:

// login_page.dart - BlocListener
listener: (context, state) {
  setState(() {
    _isLoading = state is AuthLoading;
  });

  if (state is AuthAuthenticated) {
    context.go('/home');           // 补上:登录成功 → 去主页
  } else if (state is AuthNeedsPinSetup) {
    context.go('/pin-setup');      // 补上:需要 PIN → 去设置 PIN
  } else if (state is AuthError) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text(state.message)),
    );
  }
},
优点:改动最小,只加几行;直觉清晰——"登录页负责登录后的跳转"。
缺点:导航逻辑分散在多个页面(SplashPage 一份,LoginPage 一份), 以后加新的认证状态时容易忘记同步更新。

方案 B:在 GoRouter 加 redirect(更系统化)

GoRouter 支持 redirect 回调——每次路由变化时自动检查,根据 auth 状态决定是否需要重定向。 这样导航逻辑集中在一个地方,不用每个页面都写。

GoRouter(
  redirect: (context, state) {
    final authState = context.read<AuthBloc>().state;
    final isOnLogin = state.matchedLocation == '/login';
    final isOnSplash = state.matchedLocation == '/splash';

    // 已登录 → 不要停留在登录页
    if (authState is AuthAuthenticated && (isOnLogin || isOnSplash)) {
      return '/home';
    }

    // 需要 PIN → 跳转 PIN 设置
    if (authState is AuthNeedsPinSetup && isOnLogin) {
      return '/pin-setup';
    }

    // 未登录 → 不能访问主页
    if (authState is AuthUnauthenticated && !isOnLogin && !isOnSplash) {
      return '/login';
    }

    return null; // 不重定向
  },
  ...
)
优点:导航逻辑集中管理,所有认证相关的跳转都在一个地方。
缺点:改动较大;需要让 GoRouter 能够感知 AuthBloc 的状态变化(通常需要 refreshListenable 参数),实现起来稍微复杂。

建议

当前阶段用方案 A(补上 LoginPage 的导航)最合适—— 改动小、风险低、立即解决问题。将来如果认证流程变复杂了(更多需要登录才能访问的页面),再考虑迁移到方案 B。

7. 知识补充:BLoC 模式详解

7.1 BLoC 是什么

BLoC = Business Logic Component。它是 Flutter 中用来分离 UI 和业务逻辑的设计模式。

核心思想:UI 不直接处理业务逻辑,而是通过"发事件"和"监听状态"来间接交互。

┌──────────┐ Event(事件) ┌──────────┐ State(状态) ┌──────────┐ │ │ ───────────────→ │ │ ───────────────→ │ │ │ UI │ │ BLoC │ │ UI │ │ (页面) │ │ (业务逻辑)│ │ (页面) │ │ │ ←── 用户操作 │ │ ←── 状态变化 │ │ └──────────┘ └──────────┘ └──────────┘ 用户点按钮 → UI 发送 Event → BLoC 处理逻辑 → BLoC 广播新 State → UI 更新显示

7.2 Event 和 State 的区别

Event(事件)State(状态)
方向 UI → BLoC(输入) BLoC → UI(输出)
含义 "发生了什么事" "现在的状况是什么"
谁创建 UI 层(用户操作触发) BLoC 内部(逻辑处理后 emit)
本项目示例 AuthLoginRequested(用户请求登录) AuthAuthenticated(已认证)

7.3 BlocListener vs BlocBuilder

Flutter BLoC 提供两种监听 Widget:

BlocListenerBlocBuilder
用途 执行"一次性副作用" 根据状态"重新构建 UI"
典型场景 导航跳转、显示 SnackBar、弹对话框 更新文本、切换按钮样式、显示/隐藏组件
调用频率 每次状态变化调用一次 listener 函数 每次状态变化重新 build 整个子树
返回值 无返回值(void),只做事情 返回 Widget,用于渲染

本项目中导航跳转用的是 BlocListener,因为跳转是"一次性动作",不是"UI 重建"。

8. 知识补充:GoRouter 导航详解

8.1 go vs push

GoRouter 有两种导航方式:

context.go('/path')context.push('/path')
行为 替换整个导航栈 在栈顶添加一个新页面
能返回吗 不能返回到之前的页面 可以用返回键回到上一页
适用场景 登录→主页(不允许返回登录页) 列表→详情(允许返回列表)

登录成功后用 context.go('/home') 是正确的——跳到主页后不能返回到登录页。

8.2 GoRouter 的 redirect 机制

GoRouter 支持全局 redirect 回调。每次导航时都会调用,相当于"导航守卫":

GoRouter(
  redirect: (context, state) {
    // state.matchedLocation 是用户要去的路径
    // 返回 null = 允许导航
    // 返回 '/login' = 强制跳转到登录页
    return null;
  },
)

我们的项目目前没有使用 redirect,导航逻辑分散在各个页面的 BlocListener 中。

8.3 ShellRoute(底部导航栏)

我们项目中 /home/messages/schedule 等主页面包在 ShellRoute 里, 这意味着它们共享同一个底部导航栏(_MainShell)。而 /login/splash 在 ShellRoute 外面,所以没有底部导航栏。

GoRouter 路由结构: /splash → SplashPage (无底部导航栏) /login → LoginPage (无底部导航栏) ShellRoute(_MainShell + 底部导航栏) /home → HomePage /messages → MessagesPage /schedule → MySchedulePage /reports → ReportsPage /profile → SettingsPage

9. 知识补充:BlocListener 的生命周期陷阱

这个 Bug 的本质是一个生命周期问题

9.1 Widget 的生命周期

Flutter 中,Widget 只在"还在 widget tree 上"时才活着:

context.go('/login') 执行后: Widget Tree(屏幕上的组件树) 之前: 之后: ┌─────────────┐ ┌─────────────┐ │ MaterialApp │ │ MaterialApp │ │ ┌────────┐ │ │ ┌────────┐ │ │ │Splash │ │ ────→ │ │Login │ │ │ │Page │ │ │ │Page │ │ │ │(Bloc │ │ │ │(Bloc │ │ │ │Listener│ │ │ │Listener│ │ │ │ 活跃✅) │ │ │ │ 活跃✅) │ │ │ └────────┘ │ │ └────────┘ │ └─────────────┘ └─────────────┘ SplashPage 被移出 tree SplashPage 的 → 它的 BlocListener 失效 ❌ BlocListener 不再 收到任何广播

9.2 陷阱模式

当两个页面都需要监听同一个 BLoC 的状态时,如果页面之间是替换关系(go), 就必须确保每个页面都处理了它可能遇到的所有状态。因为一旦切走,前一个页面的监听就没了。

常见错误模式:

9.3 避免这个陷阱的模式

模式做法优点缺点
集中式 redirect 在 GoRouter 的 redirect 回调里统一处理认证导航 逻辑只写一次,不会遗漏 需要让 GoRouter 感知 BLoC 状态变化(refreshListenable
App 级 BlocListener MaterialApp 外层放一个全局 BlocListener,处理认证跳转 始终在 tree 上,不会失效 需要能拿到 GoRouter 的 navigator
每页都写 每个页面的 BlocListener 都完整处理所有可能的状态 简单直接 导航逻辑分散,容易遗漏(就像这个 Bug)

10. 经验总结

核心教训:
  1. BlocListener 只在页面活着时工作——页面被 context.go() 替换掉后, 它的 BlocListener 就失效了。不要指望"别的页面的 listener 帮你跳转"。
  2. 每个页面都要对"自己会遇到的状态"负责—— LoginPage 会触发登录操作,就必须处理登录的所有结果(成功、失败、需要 PIN)。
  3. "写了一半"比"完全没写"更难发现—— LoginPage 的 BlocListener 已经在处理 AuthError 了,所以看起来"好像写了", 但其实漏掉了成功和 PIN 两个状态。这种遗漏在代码审查中很容易被忽略。
  4. 状态变化 ≠ 页面跳转—— BLoC 只负责广播状态,GoRouter 只负责根据路径显示页面, 没有人自动把状态变化翻译成页面跳转。这个"翻译"必须由开发者显式编写。

涉及文件: