为什么把 DI 和路由放在一起讲

PipeMonitor 的 Flutter 端是一个典型的”单 Shell + 四 Tab”监控应用:实时数据、历史曲线、告警列表、用户中心。看起来结构简单,但只要把登录态、WebSocket 生命周期、Tab 间状态保留这几件事一起做对,依赖注入和路由就不可避免地耦合在一起——AuthRepository 既是 Provider 树里的一环,又是 GoRouter 的 refreshListenableRealtimeService 既由 Provider 注入,又要在 WidgetsBindingObserver 回调里被直接取出重连。

这篇把 main.dartapp.dart 两个文件里的关键设计一次性讲透。依赖版本取自 pubspec.yamlprovider: ^6.1.2go_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.useMockString.fromEnvironment 编译期常量,Mock 与真实实现的切换发生在构造期,运行时不会有分支开销,也不会把 Dio/web_socket_channel 拖进 Mock 包体积。
  • tryRestoreSession()runApp 之前调用。这样第一帧渲染时 AuthRepository.isLoggedIn 已经是真实值,GoRouter 的初始评估不会先显示未登录态再跳转,避免了一次”闪登录表单”。
  • ApiServiceabstract 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 它们,否则热重载或局部重建会误杀单例。SecureStorageServiceSharedPreferencesApiServiceAuthRepository 都属于这一类。
  • 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,
),

goBranchinitialLocation 参数为 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() 里的顺序:先 setAuthTokenwriteTokenconnect。这是有意安排的——如果 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。回到前台后需要主动检查并重连。_PipeMonitorAppStateWidgetsBindingObserver 实现:

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 内部在 logintryRestoreSession 时也会调用 _realtime.connect(),所以 WS 的连接时机有三处:启动恢复、登录、回到前台重连。三处都走 RealtimeService 自己的连接状态机,互不冲突。

小结

  • MultiProvider 按”存储 → 服务 → 仓库”的依赖方向装配,tryRestoreSessionrunApp 前完成,避免首帧闪烁
  • .value 用于跨 App 生命周期的单例,create + dispose 用于持有需释放资源的对象,unawaited 包裹 dispose 避免阻塞卸载
  • StatefulShellRoute.indexedStack 让四个 Tab 各自保留导航栈,goBranch(initialLocation: true) 实现”再点回首页”
  • refreshListenable: AuthRepository 把登录态变化直接驱动到路由层,无需手动 push/pop
  • 不用 redirect 强制跳转,改用”空壳 Tab + refreshListenable”保留监控应用的首屏识别度
  • WidgetsBindingObserverresumed 时幂等重连 WS,配合启动恢复、登录连接形成完整的三段式连接时机

后续阅读