flutter-tab-auth-debug
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseFlutter Tab Auth Debug
Flutter标签页授权调试
Core problem
核心问题
Flutter + Riverpod + GoRouter bottom tab app có thể bị stuck loading hoặc redirect sai sau reload. Nguyên nhân thường là auth status transition race + redirect logic hardcode gây mất tab context.
Flutter + Riverpod + GoRouter 底部标签页应用可能会在重载后出现加载卡住或重定向错误的问题。常见原因是授权状态转换竞争 + 硬编码重定向逻辑导致标签页上下文丢失。
1. Khi nào áp dụng
1. 适用场景
- Bottom tab stuck at loading spinner, especially Account tab
- SplashScreen redirect về wrong tab sau reload
- Reload page ở thì quay về
/account/attendance - hiển thị loading vô hạn dù đã login
session == null - App treo ở SplashScreen vì không resolve
checkAuth()
- 底部标签页卡在加载 spinner,尤其是“账户”标签页
- 启动页重载后重定向到错误的标签页
- 在页面重载后跳转到
/account/attendance - 已登录但仍显示无限加载
session == null - 因未执行完成,应用卡在启动页
checkAuth()
2. Quy trình debug
2. 调试流程
Bước 1: Reproduce → mở DevTools console/log, xem log có in ra không, authStatus qua các state nào:
Splash auth status changedunknown → authenticated → ?Bước 2: Nếu authStatus mãi là → treo. Thêm timeout cho (10s).
unknowncheckAuth()verifySession()Bước 3: Nếu authStatus sang nhưng tab vẫn sai → SplashScreen hardcode redirect sang . Phải giữ route hiện tại.
authenticated/attendanceBước 4: Nếu reload ở tab Profile mà stuck loading → check redirect logic: có trả không. Nếu trả , GoRouter load route trực tiếp trước khi auth resolve → AccountScreen thấy → stuck.
app.dartauthStatus == unknownnullnullsession == null步骤1:复现问题 → 打开DevTools控制台/日志,查看是否输出日志,以及authStatus的状态变化:
Splash auth status changedunknown → authenticated → ?步骤2:如果authStatus一直是 → 卡住。为添加超时(10秒)。
unknowncheckAuth()verifySession()步骤3:如果authStatus变为但标签页仍错误 → 启动页硬编码重定向到。需要保留当前路由。
authenticated/attendance步骤4:如果在“个人资料”标签页重载后卡住加载 → 检查的重定向逻辑:当时是否返回。如果返回,GoRouter会在授权验证完成前直接加载路由 → AccountScreen检测到 → 卡住。
app.dartauthStatus == unknownnullnullsession == null3. Pattern đúng
3. 正确实现模式
Pattern A: checkAuth() có timeout
模式A:为checkAuth()添加超时
dart
Future<void> checkAuth() async {
// ... read token, baseUrl ...
try {
final result = await client
.verifySession(token)
.timeout(const Duration(seconds: 10));
if (result != null) {
final sessionData = result['session'] as Map<String, dynamic>?;
if (sessionData != null) {
final session = Session.fromJson(sessionData);
ref.read(sessionProvider.notifier).set(session);
state = AuthStatus.authenticated;
return;
}
}
} on TimeoutException {
// server treo → coi unauthenticated
} catch (_) {}
state = AuthStatus.unauthenticated;
}dart
Future<void> checkAuth() async {
// ... 读取token、baseUrl ...
try {
final result = await client
.verifySession(token)
.timeout(const Duration(seconds: 10));
if (result != null) {
final sessionData = result['session'] as Map<String, dynamic>?;
if (sessionData != null) {
final session = Session.fromJson(sessionData);
ref.read(sessionProvider.notifier).set(session);
state = AuthStatus.authenticated;
return;
}
}
} on TimeoutException {
// 服务器无响应 → 视为未授权
} catch (_) {}
state = AuthStatus.unauthenticated;
}Pattern B: SplashScreen giữ route hiện tại
模式B:启动页保留当前路由
dart
ref.listen<AuthStatus>(authStatusProvider, (previous, next) {
if (!mounted) return;
if (next == AuthStatus.authenticated) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
final location = GoRouter.of(context).state.matchedLocation;
if (location == '/' || location == '/login') {
context.go('/attendance');
}
// Otherwise stay on current route
}
});
} else if (next == AuthStatus.unauthenticated) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) context.go('/login');
});
}
});dart
ref.listen<AuthStatus>(authStatusProvider, (previous, next) {
if (!mounted) return;
if (next == AuthStatus.authenticated) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
final location = GoRouter.of(context).state.matchedLocation;
if (location == '/' || location == '/login') {
context.go('/attendance');
}
// 否则保留当前路由
}
});
} else if (next == AuthStatus.unauthenticated) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) context.go('/login');
});
}
});Pattern C: app.dart redirect về / khi unknown
模式C:app.dart在状态为unknown时重定向到/
dart
redirect: (context, state) {
final authStatus = ref.read(authStatusProvider);
if (authStatus == AuthStatus.unknown) {
final path = state.matchedLocation;
if (path == '/' || path == '/login') return null;
return '/';
}
// ... authenticated / unauthenticated branches
}dart
redirect: (context, state) {
final authStatus = ref.read(authStatusProvider);
if (authStatus == AuthStatus.unknown) {
final path = state.matchedLocation;
if (path == '/' || path == '/login') return null;
return '/';
}
// ... 已授权/未授权分支逻辑
}Pattern D: AccountScreen handle session == null
模式D:AccountScreen处理session == null的情况
dart
if (session == null) {
final authStatus = ref.watch(authStatusProvider);
if (authStatus == AuthStatus.unauthenticated) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) context.go('/login');
});
return Scaffold(body: Center(child: Text('Phiên hết hạn')));
}
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}dart
if (session == null) {
final authStatus = ref.watch(authStatusProvider);
if (authStatus == AuthStatus.unauthenticated) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) context.go('/login');
});
return Scaffold(body: Center(child: Text('会话已过期')));
}
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}4. Luồng xử lý sau fix
4. 修复后的处理流程
Reload page at /account
├─ app.dart: authStatus == unknown → redirect → /
├─ SplashScreen renders
├─ checkAuth() gọi verifySession (có timeout 10s)
├─ AuthStatus → authenticated
├─ SplashScreen: location == '/' → go('/attendance')
└─ User manually navigate sang /account → OK
Reload page at /account (fix state A/B/C applied)
├─ app.dart: authStatus == unknown → redirect → /
├─ SplashScreen renders
├─ checkAuth() gọi verifySession (có timeout 10s)
├─ AuthStatus → authenticated
├─ SplashScreen: location == '/' → go('/attendance')
└─ User manually navigate sang /account → OK在/account页面重载
├─ app.dart: authStatus == unknown → 重定向 → /
├─ 启动页渲染
├─ checkAuth()调用verifySession(带10秒超时)
├─ AuthStatus → authenticated
├─ 启动页: location == '/' → 跳转到/attendance
└─ 用户手动导航到/account → 正常
在/account页面重载(已应用A/B/C修复)
├─ app.dart: authStatus == unknown → 重定向 → /
├─ 启动页渲染
├─ checkAuth()调用verifySession(带10秒超时)
├─ AuthStatus → authenticated
├─ 启动页: location == '/' → 跳转到/attendance
└─ 用户手动导航到/account → 正常5. Kiểm tra
5. 验证检查
- Login → switch tab nhanh 5-10 lần → không stuck
- Stop server sau login → switch tab → không loading vô hạn
- Reload ở từng tab (Chấm công / Yêu cầu / Tài khoản) → giữ nguyên tab
- Token hết hạn → redirect về /login
- 登录后快速切换标签页5-10次 → 无卡顿
- 登录后停止服务器 → 切换标签页 → 无无限加载
- 在每个标签页(考勤/申请/账户)重载 → 保留当前标签页
- 令牌过期 → 重定向到/login
6. Lỗi thường gặp
6. 常见错误
| Lỗi | Nguyên nhân | Fix |
|---|---|---|
| go_router ^17 không có getter | Dùng |
SplashScreen chuyển về | Hardcode | Kiểm tra |
| AccountScreen loading vô hạn sau reload | | Redirect về |
| Không có timeout trên verifySession | |
| 错误 | 原因 | 修复方案 |
|---|---|---|
| go_router ^17版本没有 | 使用 |
| 启动页即使在/account仍跳转到/attendance | 硬编码 | 先检查 |
| 重载后AccountScreen无限加载 | | 改为重定向到/而非 |
| verifySession未设置超时 | 添加 |
7. Tài liệu tham khảo
7. 参考文档
- — fix gốc cho bottom tab stuck loading
.edits/fix1.md - — GoRouter config
lib/app.dart - — auth listener + redirect
lib/screens/splash_screen.dart - — checkAuth + routerRefreshProvider
lib/providers/providers.dart
- — 底部标签页加载卡住的原始修复方案
.edits/fix1.md - — GoRouter配置
lib/app.dart - — 授权监听器 + 重定向逻辑
lib/screens/splash_screen.dart - — checkAuth + routerRefreshProvider
lib/providers/providers.dart