Flutter 依赖注入与路由架构:Provider DI 树 + GoRouter 状态保持

Mill 客户端是一套工业数据采集与控制的 Flutter 应用。本文拆解它的启动装配、依赖注入、远程配置引导、路由状态保持与生命周期管理,给同样在用 Provider + GoRouter 组合的团队一个可参考的工程范式。

一、整体装配:从 main 到 widget 树

main.dart 是整套架构的入口,它做了三件事:

  1. 初始化 Flutter 引擎与原生服务(SecureStorage、SharedPreferences、NotificationService)
  2. 拉远程配置(或进入引导页)
  3. 装配 DI 树并 runApp

核心装配流程示意:

SecureStorageService ──┐

SharedPreferences ──────┤

ThemeModeController ────┤

NotificationService ────┤

AppConfigService ───────┤

RuntimeConfig ──────────┤

RealtimeService ────────┼─→ ApiService ──→ ┌──────────────────────────┐
  (WS / Mock)           │                  │ MeasurementRepository    │
                        │                  │ AuthRepository (Notifier)│
                        ├─────────────────→│ AlarmRepository          │
                        │                  │ CommandRepository        │
                        └──────────────────┴──────────────────────────┘

                                          MultiProvider ↓

                                          MillApp (GoRouter)

                                  StatefulShellRoute.indexedStack

                            ┌──────────┬──────────┬──────────┬──────────┬──────────┐
                            /         /history   /control   /alarm     /user
                         Dashboard   History    Control    Alarm      User
                         (VM 注入)   (VM 注入)  (VM 注入)  (VM 注入)  (VM 注入)

整个装配过程是显式的——没有反射、没有代码生成,全部以构造函数注入。这种写法的好处是依赖关系一眼能看完,调试时能直接断点跟踪。

二、MultiProvider 装配完整 DI 树

main 函数的末尾把所有依赖通过 MultiProvider 注入到 widget 树:

runApp(
  MultiProvider(
    providers: [
      Provider<SecureStorageService>.value(value: storage),
      Provider<AppRestartController>.value(value: restartController),
      Provider<AppConfigService>.value(value: appConfigService),
      Provider<RuntimeConfig>.value(value: runtimeConfig),
      ChangeNotifierProvider<ThemeModeController>.value(value: themeController),
      Provider<ApiService>.value(value: api),
      Provider<SharedPreferences>.value(value: preferences),
      Provider<NotificationService>.value(value: notificationService),
      Provider<RealtimeService>(
        create: (_) => realtime,
        dispose: (_, service) => unawaited(service.disconnect()),
      ),
      ChangeNotifierProvider<AuthRepository>.value(value: authRepo),
      Provider<MeasurementRepository>(
        create: (_) => measurementRepo,
        dispose: (_, repo) => unawaited(repo.dispose()),
      ),
      Provider<AlarmRepository>(
        create: (_) => alarmRepo,
        dispose: (_, repo) => unawaited(repo.dispose()),
      ),
      Provider<CommandRepository>(
        create: (_) => commandRepo,
        dispose: (_, repo) => unawaited(repo.dispose()),
      ),
    ],
    child: const MillApp(),
  ),
);

这里有一个值得注意的工程细节:所有需要异步释放资源的依赖(Repository 的 dispose、RealtimeService 的 disconnect)都用 unawaited 包裹。这样 widget 销毁时不会被异步释放阻塞,避免出现卡帧。

三、.value vs create:生命周期的明确分界

Provider 提供两种装配方式, Mill 项目对它们的区分非常清晰:

方式适用场景生命周期归属
Provider.value(value: x)已经在 main 中构造好的单例调用方(main)负责
Provider(create: ...)与 widget 树同生共死的对象Provider 框架在销毁时调用 dispose

具体到 Mill 的实践:

  • .value 装配:SecureStorageServiceSharedPreferencesThemeModeControllerNotificationServiceAppConfigServiceRuntimeConfigApiServiceAuthRepository。这些对象要么是 App 全局单例,要么需要在 App 重启时被复用(比如 ThemeModeController),生命周期不能跟着 Provider 走。
  • create + dispose 装配:RealtimeServiceMeasurementRepositoryAlarmRepositoryCommandRepository。这些对象持有 WebSocket 连接或缓存流,必须随 widget 树销毁而释放。

这种区分的价值在于:当 App 通过 AppRestartController 重启时,.value 注入的对象可以原样保留(如 SharedPreferences),只重建需要切换后端的依赖即可。

四、Mock 模式:编译期常量切换

Env.useMockbool.fromEnvironment 编译期常量,对应 --dart-define=USE_MOCK=true

static const bool useMock = bool.fromEnvironment(
  'USE_MOCK',
  defaultValue: false,
);

Mock 与真实服务在 _runConfiguredApp 中分流:

final RealtimeService realtime;
final ApiService api;
if (Env.useMock) {
  final mockRt = MockRealtimeService();
  realtime = mockRt;
  api = MockApiService(realtime: mockRt);
} else {
  realtime = WsRealtimeService(wsUrl: runtimeConfig.wsUrl);
  api = HttpApiService(baseUrl: runtimeConfig.apiBaseUrl);
}

这种写法的优势:

  1. 编译期常量会被 tree-shaking 优化掉,Release 包里完全不含 Mock 代码
  2. MockApiService 直接接收 MockRealtimeService 引用,能模拟”假数据上报触发 UI 更新”的完整链路
  3. UI 层完全无感知,Repository 注入的依然是同一套接口

团队做演示或离线调试时只需 flutter run --dart-define=USE_MOCK=true,不需要改任何业务代码。

五、远程配置引导启动

RuntimeConfig 是从后端拉取的连接入口配置(API、WS、MQTT 地址等),与 Env 这种编译期常量形成两层配置:

  • Env:内置在二进制里的最小启动信息(CONFIG_URL、DEVICE_ID 等)
  • RuntimeConfig:启动后从 CONFIG_URL 拉取的实际服务地址

引导逻辑在 main() 中分流:

if (Env.useMock) {
  runtimeConfig = RuntimeConfig.defaults;
} else {
  final result = await appConfigService.load();
  final loadedConfig = result.config;
  if (loadedConfig == null) {
    runApp(
      ServerConfigBootstrapApp(
        configService: appConfigService,
        initialConfigUrl: result.configUrl,
        initialError: result.errorMessage,
        onConfigured: (config) {
          _runConfiguredApp(
            storage: storage,
            preferences: preferences,
            themeController: themeController,
            notificationService: notificationService,
            appConfigService: appConfigService,
            runtimeConfig: config,
          );
        },
      ),
    );
    return;
  }
  runtimeConfig = loadedConfig;
}

关键设计点:

  1. 没有缓存配置就直接渲染引导页,让用户手动填写服务器地址。ServerConfigBootstrapApp 拿到配置后回调 onConfigured,再走一遍 _runConfiguredApp
  2. RuntimeConfig.fromJson 内置完整的 URL scheme / 端口范围校验,避免脏配置直接进入运行时。
  3. RuntimeConfig.defaults 是 Mock 模式的兜底,避免 Mock 模式下还要发 HTTP。

这种”先引导后装配”的模式特别适合 SaaS 化部署的工业设备——同一份 APK 可以对接不同客户的后端。

六、AppRestartController:运行时切换配置

设置页允许用户改服务器地址后重启 App。但是 Flutter 没有原生”重启”概念,所以这里用一个轻量级控制器:

class AppRestartController {
  const AppRestartController(this._restart);

  final Future<void> Function(RuntimeConfig config) _restart;

  Future<void> restart(RuntimeConfig config) => _restart(config);
}

构造它时传入的回调做了三件事:

final restartController = AppRestartController((config) async {
  await realtime.disconnect();
  _runConfiguredApp(
    storage: storage,
    preferences: preferences,
    themeController: themeController,
    notificationService: notificationService,
    appConfigService: appConfigService,
    runtimeConfig: config,
  );
});

值得注意:

  1. 先断开 WebSocket 再重建,避免旧连接泄漏
  2. _runConfiguredApp 是可重入的——它会用新 config 重新构造 realtime / api / repository,再 runApp 一次替换掉根 widget
  3. SharedPreferencesThemeModeController 这些用户偏好对象原样保留,用户重启后不会丢主题与已保存的 token

这种”局部重建根 widget”的实现,比 RestartableApp 之类用 Key 触发整个树重建的方案更精准。

七、GoRouter + StatefulShellRoute.indexedStack

MillAppinitState 中只构造一次 GoRouter,避免热重载时丢失路由状态:

@override
void initState() {
  super.initState();
  WidgetsBinding.instance.addObserver(this);
  final auth = context.read<AuthRepository>();
  _router = _buildRouter(auth);
}

路由结构用 StatefulShellRoute.indexedStack 装配 5 个 Tab:

StatefulShellRoute.indexedStack(
  builder: (context, state, navShell) => HomeShell(navigationShell: navShell),
  branches: [
    StatefulShellBranch(routes: [GoRoute(path: '/', ...)]),
    StatefulShellBranch(routes: [GoRoute(path: '/history', ...)]),
    StatefulShellBranch(routes: [GoRoute(path: '/control', ...)]),
    StatefulShellBranch(routes: [GoRoute(path: '/alarm', ...)]),
    StatefulShellBranch(routes: [
      GoRoute(
        path: '/user',
        builder: ...,
        routes: [
          GoRoute(path: 'settings', ...),
          GoRoute(path: 'export-history', ...),
        ],
      ),
    ]),
  ],
)

StatefulShellRoute.indexedStack 的核心价值:

  • 每个 Branch 持有独立的 Navigator,切 Tab 时不会销毁前一个 Tab 的页面状态(滚动位置、表单输入、ViewModel 数据)
  • IndexedStack 在底层保证只 build 当前可见 Branch,但其他 Branch 的 State 仍然保留
  • 底部导航由 HomeShell 单独绘制,Tab 切换只动 navigationShell 的 index

这套结构对工业类 App 几乎是标配:操作员在控制 Tab 设好参数后切到历史 Tab 查曲线,再切回控制 Tab 时表单还在原样。

八、refreshListenable:登录态驱动的路由重评估

GoRouter(
  initialLocation: '/',
  refreshListenable: auth,
  routes: [...],
)

AuthRepositoryChangeNotifier,登录 / 退出时调用 notifyListenersGoRouter.refreshListenable 接到通知后会重新评估当前路由的 redirect / builder

这种写法的妙处:

  • 不需要 context.go 主动跳转,路由会根据 auth.isLoggedIn 自动决定渲染登录表单还是设置面板
  • 退出登录时所有数据 Tab 自动切到空壳状态,无需手动清空
  • StatefulShellBranch 配合,每个 Tab 的页面状态独立处理登录态变化

九、每个 Tab 注入独立 ViewModel

路由 builder 中嵌套 ChangeNotifierProvider 给每个页面注入对应 ViewModel:

GoRoute(
  path: '/',
  builder: (context, state) => ChangeNotifierProvider(
    create: (c) => DashboardViewModel(
      repository: c.read(),
      auth: c.read<AuthRepository>(),
    ),
    child: const DashboardPage(),
  ),
),

这里有两个细节:

  1. ViewModel 用 create 而非 .value,让 Provider 在页面销毁时自动释放 ViewModel,避免内存泄漏
  2. ViewModel 通过 c.read() 拿 Repository——这是 MultiProvider 注入的依赖,跨层访问非常自然

每个 Tab 都是独立 VM,切走 Tab 时旧 VM 仍在内存(因为 IndexedStack 不销毁),所以用户切回来时数据还在。

十、生命周期观察:WS 挂起与恢复

_MillAppStateWidgetsBindingObserver 监听 App 生命周期:

@override
void didChangeAppLifecycleState(AppLifecycleState state) {
  if (state == AppLifecycleState.resumed) {
    unawaited(context.read<AuthRepository>().refreshRealtimeSession());
  } else if (state == AppLifecycleState.inactive ||
      state == AppLifecycleState.paused) {
    context.read<AuthRepository>().suspendRealtimeSessionForBackground());
  }
}

这里把 WS 的挂起 / 恢复职责交给了 AuthRepository,而不是直接调 RealtimeService。原因是:

  1. WS 会话与认证 token 绑定,挂起后恢复需要重新走握手
  2. iOS 后台 WS 容易被系统杀掉,必须主动断开避免无效重连
  3. AuthRepository 同时持有 RealtimeService 引用,由它统一协调更内聚

resumed 时调用 refreshRealtimeSession 而非简单 connect,是因为 token 可能过期需要先刷新。

十一、启动会话恢复

main.dart 末尾有一段容易被忽视但很关键的逻辑:

WidgetsBinding.instance.addPostFrameCallback((_) {
  unawaited(notificationService.requestPermission());
});
unawaited(authRepo.tryRestoreSession());

两个关键点:

  1. 会话恢复放在 runApp 之后,避免阻塞首帧渲染导致白屏
  2. 通知权限请求也放到 PostFrameCallback,避免权限弹窗抢在主界面之前出现

tryRestoreSession 内部会读 SecureStorageService 里的 token,验证有效后调用 notifyListeners,于是 GoRouter 自动切到已登录态。整个流程对用户来说是无感的。

总结

Mill 客户端的这套架构有几个值得借鉴的工程决策:

  • DI 树显式装配:所有依赖在 main 中构造,用 .valuecreate 明确分界生命周期归属
  • 配置分层Env(编译期)+ RuntimeConfig(运行期)+ AppRestartController(运行时切换),让同一份 APK 能适配多套部署环境
  • 路由驱动 UI 状态:登录态通过 refreshListenable 让路由自动重评估,避免手动管理跳转
  • Tab 独立状态保持StatefulShellRoute.indexedStack 让每个 Tab 各自保活,VM 与 Repository 解耦
  • 生命周期与业务分层:WS 的挂起恢复由 AuthRepository 统一协调,不直接暴露给 UI 层

这套架构没有用任何”花哨”的 DI 框架(GetIt、Riverpod、Injectable),但通过 Provider + GoRouter 的组合 + 显式装配,把工业 App 关心的配置引导、状态保持、生命周期管理都覆盖到位了。对一个中小型 Flutter 项目来说,这种克制反而是更可维护的工程选择。