记录日期:2026-02-05
Bug Flutter / BLoC Employee App
确认过:
{"success": true, ...}(之前的 SAVEPOINT 修复已生效)AuthAuthenticated 状态要理解这个 Bug,需要先了解三样东西。
GoRouter 是 Flutter 的路由库,负责"根据路径显示对应页面"。你可以把它理解成一个调度员:
路径 '/splash' → 显示 SplashPage (启动页)
路径 '/login' → 显示 LoginPage (登录页)
路径 '/home' → 显示 HomePage (主页)
路径 '/pin-setup' → 显示 PinSetupPage(PIN 设置页)
关键点:页面不会自己跳转。必须有代码主动调用 context.go('/home') 才会跳到主页。
GoRouter 不会"自动"根据某个条件切换页面。
BLoC(Business Logic Component)是 Flutter 常用的状态管理模式。
AuthBloc 专门管理"用户认证状态",你可以把它想象成一个广播站:
AuthBloc 自己不做导航,它只负责广播"当前是什么认证状态"。导航的事情交给页面自己处理。
页面通过 BlocListener 来"订阅"广播站的状态变化。但关键是:
先看 APP 冷启动时的正常流程,这一段是没问题的:
代码位置: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'); // ✅ 出错也去登录页
}
},
)
✅ 四种状态全部处理了,非常完整。
用户到了 LoginPage 后,输入账号密码点击 Sign In:
代码位置: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'); }
},
)
❌ 只写了失败情况,没写成功情况。
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 又没有补上成功状态的导航。
在 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)),
);
}
},
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; // 不重定向
},
...
)
refreshListenable 参数),实现起来稍微复杂。
当前阶段用方案 A(补上 LoginPage 的导航)最合适—— 改动小、风险低、立即解决问题。将来如果认证流程变复杂了(更多需要登录才能访问的页面),再考虑迁移到方案 B。
BLoC = Business Logic Component。它是 Flutter 中用来分离 UI 和业务逻辑的设计模式。
核心思想:UI 不直接处理业务逻辑,而是通过"发事件"和"监听状态"来间接交互。
| Event(事件) | State(状态) | |
|---|---|---|
| 方向 | UI → BLoC(输入) | BLoC → UI(输出) |
| 含义 | "发生了什么事" | "现在的状况是什么" |
| 谁创建 | UI 层(用户操作触发) | BLoC 内部(逻辑处理后 emit) |
| 本项目示例 | AuthLoginRequested(用户请求登录) |
AuthAuthenticated(已认证) |
Flutter BLoC 提供两种监听 Widget:
| BlocListener | BlocBuilder | |
|---|---|---|
| 用途 | 执行"一次性副作用" | 根据状态"重新构建 UI" |
| 典型场景 | 导航跳转、显示 SnackBar、弹对话框 | 更新文本、切换按钮样式、显示/隐藏组件 |
| 调用频率 | 每次状态变化调用一次 listener 函数 | 每次状态变化重新 build 整个子树 |
| 返回值 | 无返回值(void),只做事情 | 返回 Widget,用于渲染 |
本项目中导航跳转用的是 BlocListener,因为跳转是"一次性动作",不是"UI 重建"。
GoRouter 有两种导航方式:
context.go('/path') | context.push('/path') | |
|---|---|---|
| 行为 | 替换整个导航栈 | 在栈顶添加一个新页面 |
| 能返回吗 | 不能返回到之前的页面 | 可以用返回键回到上一页 |
| 适用场景 | 登录→主页(不允许返回登录页) | 列表→详情(允许返回列表) |
登录成功后用 context.go('/home') 是正确的——跳到主页后不能返回到登录页。
GoRouter 支持全局 redirect 回调。每次导航时都会调用,相当于"导航守卫":
GoRouter(
redirect: (context, state) {
// state.matchedLocation 是用户要去的路径
// 返回 null = 允许导航
// 返回 '/login' = 强制跳转到登录页
return null;
},
)
我们的项目目前没有使用 redirect,导航逻辑分散在各个页面的 BlocListener 中。
我们项目中 /home、/messages、/schedule 等主页面包在 ShellRoute 里,
这意味着它们共享同一个底部导航栏(_MainShell)。而 /login、/splash
在 ShellRoute 外面,所以没有底部导航栏。
这个 Bug 的本质是一个生命周期问题。
Flutter 中,Widget 只在"还在 widget tree 上"时才活着:
当两个页面都需要监听同一个 BLoC 的状态时,如果页面之间是替换关系(go), 就必须确保每个页面都处理了它可能遇到的所有状态。因为一旦切走,前一个页面的监听就没了。
| 模式 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 集中式 redirect | 在 GoRouter 的 redirect 回调里统一处理认证导航 |
逻辑只写一次,不会遗漏 | 需要让 GoRouter 感知 BLoC 状态变化(refreshListenable) |
| App 级 BlocListener | 在 MaterialApp 外层放一个全局 BlocListener,处理认证跳转 |
始终在 tree 上,不会失效 | 需要能拿到 GoRouter 的 navigator |
| 每页都写 | 每个页面的 BlocListener 都完整处理所有可能的状态 | 简单直接 | 导航逻辑分散,容易遗漏(就像这个 Bug) |
context.go() 替换掉后,
它的 BlocListener 就失效了。不要指望"别的页面的 listener 帮你跳转"。AuthError 了,所以看起来"好像写了",
但其实漏掉了成功和 PIN 两个状态。这种遗漏在代码审查中很容易被忽略。涉及文件:
employee_mobile_app/lib/features/auth/presentation/pages/login_page.dart — 第 56-69 行(缺失导航)employee_mobile_app/lib/features/auth/presentation/pages/splash_page.dart — 第 29-39 行(完整导航,可参考)employee_mobile_app/lib/features/auth/presentation/bloc/auth_bloc.dart — 第 71-93 行(登录逻辑,emit 状态)employee_mobile_app/lib/app/router.dart — 路由配置(无 redirect)