为什么把 DI 和路由放在一起讲
PipeMonitor 的 Flutter 端是一个典型的”单 Shell + 四 Tab”监控应用:实时数据、历史曲线、告警列表、用户中心。看起来结构简单,但只要把登录态、WebSocket 生命周期、Tab 间状态保留这几件事一起做对,依赖注入和路由就不可避免地耦合在一起——AuthRepository 既是 Provider 树里的一环,又是 GoRouter 的 refreshListenable;RealtimeService 既由 Provider 注入,又要在 WidgetsBindingObserver 回调里被直接取出重连。
这篇把 main.dart 和 app.dart 两个文件里的关键设计一次性讲透。依赖版本取自 pubspec.yaml:provider: ^6.1.2、go_router: ^14.0.0。
MultiProvider 装配:一棵从存储到仓库的树
应用入口 main() 里手动构造好所有对象,再用 MultiProvider 一次性挂到 widget 树。装配顺序就是依赖方向:
// 安全存储:保存 token、用户偏好等需要持久化的小数据。
final storage = SecureStorageService();
final preferences = await SharedPreferences.getInstance();
final themeController = ThemeModeController(preferences: preferences)..load();
// 实时通道与 REST 服务。Mock 与真实实现切换由编译期常量 USE_MOCK 控制。
final RealtimeService realtime;
final ApiService api;
if (Env.useMock) {
final mockRt = MockRealtimeService();
realtime = mockRt;
api = MockApiService(realtime: mockRt);
} else {
realtime = WsRealtimeService();
api = HttpApiService();
}
// 业务仓库层:把 ApiService + RealtimeService 的原始数据
// 转换成上层 ViewModel 关心的领域模型。
final authRepo = AuthRepository(api: api, realtime: realtime, storage: storage);
final measurementRepo = MeasurementRepository(api: api, realtime: realtime);
final alarmRepo = AlarmRepository(api: api, realtime: realtime);
final commandRepo = CommandRepository(api: api, realtime: realtime);
// 启动时尝试用持久化的 token 直接恢复会话。
await authRepo.tryRestoreSession();
几个值得注意的点:
Env.useMock是String.fromEnvironment编译期常量,Mock 与真实实现的切换发生在构造期,运行时不会有分支开销,也不会把 Dio/web_socket_channel 拖进 Mock 包体积。tryRestoreSession()在runApp之前调用。这样第一帧渲染时AuthRepository.isLoggedIn已经是真实值,GoRouter 的初始评估不会先显示未登录态再跳转,避免了一次”闪登录表单”。ApiService是abstract interface class,仓库层只依赖抽象契约,所以 Mock 替换不需要改任何仓库代码。
.value vs create:谁来负责 dispose
MultiProvider 里混用了 Provider.value 和普通 create 构造,这不是随手写的,而是有明确的生命周期划分:
MultiProvider(
providers: [
Provider<SecureStorageService>.value(value: storage),
ChangeNotifierProvider<ThemeModeController>.value(value: themeController),
Provider<ApiService>.value(value: api),
Provider<SharedPreferences>.value(value: preferences),
Provider<RealtimeService>(
create: (_) => realtime,
// dispose 用 unawaited 避免阻塞 widget 销毁。
dispose: (_, service) => unawaited(service.disconnect()),
),
ChangeNotifierProvider<AuthRepository>.value(value: authRepo),
Provider<MeasurementRepository>(
create: (_) => measurementRepo,
dispose: (_, repo) => unawaited(repo.dispose()),
),
// AlarmRepository / CommandRepository 同理
],
child: const PipeMonitorApp(),
)
区分原则很直接:
.value:对象在main()里构造、跨整个 App 生命周期存活、退出时由进程回收。Provider 不应该在 widget 销毁时去 dispose 它们,否则热重载或局部重建会误杀单例。SecureStorageService、SharedPreferences、ApiService、AuthRepository都属于这一类。create+dispose:对象持有需要显式释放的资源(WebSocket 连接、Stream 订阅)。把它们交给 Provider 管理,保证 App 退出时连接被优雅关闭。RealtimeService、三个数据 Repository 走这条路径,dispose里调用disconnect()/dispose(),用unawaited包裹避免阻塞 widget 卸载。
AuthRepository 虽然也持有 RealtimeService 引用,但它本身是 ChangeNotifier,UI 和 GoRouter 都要 watch 它,所以用 ChangeNotifierProvider<AuthRepository>.value——既暴露监听能力,又保留 main() 里的单例身份。
StatefulShellRoute.indexedStack:四个 Tab 各自保留导航栈
底部 Tab 用 StatefulShellRoute.indexedStack 实现。这是 go_router 14 里做”多 Tab 状态保留”的标准做法:
StatefulShellRoute.indexedStack(
// HomeShell 负责绘制底部导航栏并切换当前 Branch。
builder: (context, state, navShell) => HomeShell(navigationShell: navShell),
branches: [
StatefulShellBranch(routes: [GoRoute(path: '/', builder: ...)]),
StatefulShellBranch(routes: [GoRoute(path: '/history', builder: ...)]),
StatefulShellBranch(routes: [GoRoute(path: '/alarm', builder: ...)]),
StatefulShellBranch(routes: [
GoRoute(
path: '/user',
builder: ...,
routes: [
GoRoute(path: 'settings', builder: ...),
GoRoute(path: 'export-history', builder: ...),
],
),
]),
],
)
indexedStack 的关键性质是:每个 StatefulShellBranch 对应一个独立的 Navigator,切换 Tab 时上一个 Branch 的 widget 树不会被销毁,只是被盖在栈底。这意味着用户在”历史”Tab 滚到某个时间点,切到”告警”再切回来,滚动位置和已加载的数据都还在。
HomeShell 里驱动切换的代码也藏着一个细节——“再点当前 Tab 回到首页”:
onDestinationSelected: (i) => navigationShell.goBranch(
i,
// 再次点击当前 Tab 时回到该 Branch 的 initial location,
// 实现"再点回到首页"的常见交互。
initialLocation: i == navigationShell.currentIndex,
),
goBranch 的 initialLocation 参数为 true 时会清掉当前 Branch 的导航栈回到根路由,这是用户已经熟悉的 iOS/Android 原生交互,go_router 一个参数就给到了。
每个页面的 ViewModel 通过路由 builder 里嵌套的 ChangeNotifierProvider 注入,生命周期跟随该 Branch 的页面:
GoRoute(
path: '/',
builder: (context, state) => ChangeNotifierProvider(
create: (c) => DashboardViewModel(
repository: c.read(),
auth: c.read<AuthRepository>(),
),
child: const DashboardPage(),
),
),
c.read() 不带泛型时取的是 MeasurementRepository(因为它是该上下文里第一个匹配的 Provider),c.read<AuthRepository>() 显式取登录态。ViewModel 在页面销毁时由 Provider 自动 dispose,不需要手动管理。
refreshListenable:登录态驱动路由重建
AuthRepository 继承 ChangeNotifier,登录、退出、会话恢复成功时都会 notifyListeners()。GoRouter 通过 refreshListenable 监听它:
GoRouter(
initialLocation: '/',
// 监听登录态变化:登录 / 退出后让 Router 重新评估,
// 让用户 Tab 在两态之间切换 UI 时立即生效。
refreshListenable: auth,
routes: [...],
)
AuthRepository 的三态切换全部集中在三个方法里,每个方法结束时都调用 notifyListeners():
// tryRestoreSession 成功
_isLoggedIn = true;
notifyListeners();
// login 成功
_api.setAuthToken(token);
await _storage.writeToken(token);
await _realtime.connect(token: token);
_username = username;
_isLoggedIn = true;
notifyListeners();
// logout
await _realtime.disconnect();
await _storage.clearToken();
_api.setAuthToken(null);
_isLoggedIn = false;
_username = null;
notifyListeners();
注意 login() 里的顺序:先 setAuthToken 再 writeToken 再 connect。这是有意安排的——如果 connect 抛异常,token 已经写进安全存储,下次启动 tryRestoreSession 还能恢复;而如果先 connect 再写 token,连接失败时本地没存任何凭据,用户得重新输入。
refreshListenable 触发后,GoRouter 会重新评估当前路由的 builder,各 Tab 内部根据 context.watch<AuthRepository>().isLoggedIn 切换登录表单与设置面板,整个流程不需要手动 push / pop。
登录守卫:为什么没用 redirect
go_router 文档里推荐的登录守卫写法是 redirect 回调:
GoRouter(
redirect: (context, state) {
final auth = context.read<AuthRepository>();
if (!auth.isLoggedIn && state.matchedLocation != '/user') {
return '/user';
}
return null;
},
...
)
PipeMonitor 没有采用这个方案。app.dart 的注释写明了取舍:未登录时三个数据 Tab 仍可见,仅展示空壳。用户 Tab 内部根据登录态切换登录表单 / 设置面板。
这样设计的原因是监控类应用的用户体验:运维人员打开 App 第一眼应该看到”这是一个监控系统”,而不是被一个登录页拦在门外。空壳 Tab 让用户先确认 App 正常运行、网络可达,再去”用户” Tab 完成登录。如果用 redirect 强制跳到 /user,用户会误以为 App 只有登录功能。
登录态变化的响应由 refreshListenable 兜底——登录成功后 Router 重新评估,三个数据 Tab 的 builder 重新执行,DashboardViewModel 这次能拿到有效 token 拉到真实数据,整个过程不需要 redirect 介入。
WidgetsBindingObserver:resumed 时幂等重连 WebSocket
App 被切到后台时,操作系统可能悄悄关掉 WebSocket。回到前台后需要主动检查并重连。_PipeMonitorAppState 用 WidgetsBindingObserver 实现:
class _PipeMonitorAppState extends State<PipeMonitorApp>
with WidgetsBindingObserver {
late final GoRouter _router;
@override
void initState() {
super.initState();
final auth = context.read<AuthRepository>();
_router = _buildRouter(auth);
// 监听 app 前后台切换,恢复时主动检查 WS 是否需要重连。
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
// 系统挂起期间 socket 可能已被悄悄关闭;触发一次幂等重连。
context.read<RealtimeService>().reconnectIfNeeded();
}
}
}
两个细节:
reconnectIfNeeded()是幂等的——内部会先检查当前连接状态,已连接就直接返回,所以多次resumed不会重复握手。context.read而不是context.watch:生命周期回调不在build流程里,不需要订阅重建,只需要一次性拿到实例调用方法。
AuthRepository 内部在 login 和 tryRestoreSession 时也会调用 _realtime.connect(),所以 WS 的连接时机有三处:启动恢复、登录、回到前台重连。三处都走 RealtimeService 自己的连接状态机,互不冲突。
小结
MultiProvider按”存储 → 服务 → 仓库”的依赖方向装配,tryRestoreSession在runApp前完成,避免首帧闪烁.value用于跨 App 生命周期的单例,create+dispose用于持有需释放资源的对象,unawaited包裹 dispose 避免阻塞卸载StatefulShellRoute.indexedStack让四个 Tab 各自保留导航栈,goBranch(initialLocation: true)实现”再点回首页”refreshListenable: AuthRepository把登录态变化直接驱动到路由层,无需手动 push/pop- 不用
redirect强制跳转,改用”空壳 Tab + refreshListenable”保留监控应用的首屏识别度 WidgetsBindingObserver在resumed时幂等重连 WS,配合启动恢复、登录连接形成完整的三段式连接时机