Flutter 基础体系 · 第 55/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter 依赖注入:构造器、GetIt、作用域、生命周期和测试

依赖注入(Dependency Injection,DI)解决的问题是:一个对象需要另一个对象才能完成工作时,不由它自己决定如何创建依赖,而由外部把依赖传入。

例如,一个页面需要加载用户资料:

class UserPage {
  final UserRepository repository;

  UserPage(this.repository);
}

这里的 UserPage 依赖 UserRepository,但它没有执行 UserRepository(),也没有读取全局变量。创建者负责提供依赖:

final repository = UserRepository();
final page = UserPage(repository);

这一区分很重要:

  • 依赖:对象工作所需要的另一个对象、配置或能力。
  • 注入:把依赖从对象外部传入。
  • 依赖容器:保存依赖的创建规则,并在需要时返回实例。
  • 服务定位器(Service Locator):对象主动从容器中查找依赖,例如 GetIt.I.get<ApiClient>()

构造器注入和 GetIt 都能实现依赖注入,但对象获得依赖的方式不同。构造器注入把依赖写在类型的构造器上;GetIt 把依赖查找隐藏在容器访问中。这个差异会影响可读性、生命周期、作用域和测试方式。


依赖注入先要解决什么问题

假设业务类直接创建所有依赖:

class UserRepository {
  final ApiClient client = ApiClient();
  final AppDatabase database = AppDatabase();

  Future<User> loadUser(String id) async {
    final response = await client.get('/users/$id');
    await database.save(response);
    return response;
  }
}

这段代码有几个结构性问题:

  1. UserRepository 同时决定了依赖的具体实现。
  2. 测试时无法方便地替换网络客户端和数据库。
  3. ApiClientAppDatabase 的实例数量由调用链隐式决定。
  4. 如果依赖需要异步初始化,构造器无法直接等待异步操作。
  5. 关闭数据库、取消订阅等生命周期责任不清楚。

依赖注入把创建关系移到外部:

abstract interface class UserApi {
  Future<User> fetchUser(String id);
}

abstract interface class UserStore {
  Future<void> save(User user);
}

class UserRepository {
  UserRepository(this.api, this.store);

  final UserApi api;
  final UserStore store;

  Future<User> loadUser(String id) async {
    final user = await api.fetchUser(id);
    await store.save(user);
    return user;
  }
}

现在 UserRepository 只描述业务流程:

调用 UserApi 获取用户
        ↓
调用 UserStore 保存用户
        ↓
返回用户

具体实现、实例数量和初始化顺序由应用组合根(composition root)决定。Flutter 应用中,main() 或应用启动函数通常就是组合根。


构造器注入:最直接、最透明的方案

构造器注入的基本形式

构造器注入就是把必需依赖声明为构造器参数:

class ProfileController {
  ProfileController({
    required UserRepository repository,
    required Analytics analytics,
  })  : _repository = repository,
        _analytics = analytics;

  final UserRepository _repository;
  final Analytics _analytics;
}

创建对象时,依赖关系显式可见:

final controller = ProfileController(
  repository: userRepository,
  analytics: analytics,
);

如果依赖是对象成立的必要条件,应优先放在构造器中,而不是声明为可空字段后再延迟赋值:

// 不推荐:对象可能处于未完成初始化状态。
class BadController {
  UserRepository? repository;
}

// 推荐:构造完成后,依赖一定存在。
class GoodController {
  GoodController(this.repository);

  final UserRepository repository;
}

Dart 的类型系统可以保证 GoodControllerrepository 是非空的,但它不能自动保证传入的实现满足业务语义。例如,FakeUserRepository 仍然可能返回错误数据;这属于运行时行为,不是构造器注入能解决的问题。

main() 组装一棵依赖树

下面是一组可以独立运行的核心类型:

abstract interface class UserApi {
  Future<User> fetchUser(String id);
}

abstract interface class UserStore {
  Future<void> save(User user);
}

class User {
  const User({
    required this.id,
    required this.name,
  });

  final String id;
  final String name;
}

class UserRepository {
  UserRepository({
    required UserApi api,
    required UserStore store,
  })  : _api = api,
        _store = store;

  final UserApi _api;
  final UserStore _store;

  Future<User> load(String id) async {
    final user = await _api.fetchUser(id);
    await _store.save(user);
    return user;
  }
}

class ProfileController {
  ProfileController(this.repository);

  final UserRepository repository;

  Future<User> loadProfile() {
    return repository.load('u-1');
  }
}

组合根负责从底向上创建:

Future<void> main() async {
  final api = HttpUserApi();
  final store = SqliteUserStore();

  final repository = UserRepository(
    api: api,
    store: store,
  );

  final controller = ProfileController(repository);

  // runApp(ProfileApp(controller: controller));
}

这里的依赖图是:

HttpUserApi ─┐
             ├─> UserRepository ─> ProfileController ─> ProfileApp
SqliteStore ─┘

创建顺序必须满足依赖顺序:

  1. 先创建 HttpUserApiSqliteUserStore
  2. 再创建需要它们的 UserRepository
  3. 最后创建需要 UserRepositoryProfileController
  4. 将控制器传给 Flutter Widget。

如果依赖图中存在环,例如:

A → B → C → A

那么构造器注入无法直接完成,因为创建 A 需要先有 B,创建 B 需要先有 C,而创建 C 又需要 A。通常应拆分职责、引入较小的接口,或把某个反向依赖改为事件、回调或延迟查询。单纯把字段改成可空并在之后赋值,只是把构造期错误推迟成运行时错误。

Flutter Widget 中的构造器注入

Widget 本身是不可变配置对象,适合接收依赖:

import 'package:flutter/material.dart';

class ProfilePage extends StatelessWidget {
  const ProfilePage({
    super.key,
    required this.controller,
  });

  final ProfileController controller;

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<User>(
      future: controller.loadProfile(),
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return const CircularProgressIndicator();
        }

        if (snapshot.hasError) {
          return Text('加载失败:${snapshot.error}');
        }

        final user = snapshot.data;
        if (user == null) {
          return const Text('没有用户数据');
        }

        return Text(user.name);
      },
    );
  }
}

应用入口可以这样组装:

class ProfileApp extends StatelessWidget {
  const ProfileApp({
    super.key,
    required this.controller,
  });

  final ProfileController controller;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('Profile')),
        body: Center(
          child: ProfilePage(controller: controller),
        ),
      ),
    );
  }
}

这种方式的优点是依赖路径完全可见。缺点是依赖层次较深时,所有中间对象都需要逐层传递:

App
 └─ Page
     └─ Controller
         └─ UseCase
             └─ Repository
                 └─ ApiClient

当应用规模增大,组合根可能需要创建大量对象并把它们传递到许多页面。这是引入依赖容器的主要动机之一,但不是使用容器的充分理由。


GetIt:运行时依赖容器和服务定位器

GetIt 是 Dart/Flutter 生态中常用的服务定位器。它维护一个类型到注册对象的映射,调用方通过类型或实例名获取对象。

先添加依赖:

flutter pub add get_it

然后定义全局容器:

import 'package:get_it/get_it.dart';

final getIt = GetIt.instance;

GetIt.instance 是一个进程内单例入口。它不是业务服务本身,而是访问容器的入口。

注册和读取对象

getIt.registerSingleton<Analytics>(Analytics());
getIt.registerLazySingleton<UserApi>(() => HttpUserApi());
getIt.registerFactory<UserSession>(
  () => UserSession(getIt<UserApi>()),
);

读取时:

final analytics = getIt<Analytics>();
final api = getIt<UserApi>();
final session = getIt<UserSession>();

这几种注册方式的行为不同。

registerSingleton

getIt.registerSingleton<Analytics>(Analytics());

调用 registerSingleton 时,实例已经创建。之后每次读取都返回相同实例:

注册时创建一次
第一次 get  ─┐
第二次 get  ─┼─> 同一个 Analytics 实例
第三次 get  ─┘

适合已经准备好的、应用级共享对象,例如日志器、配置对象或已初始化的数据库。但如果创建对象很重,启动时就会承担这部分成本。

registerLazySingleton

getIt.registerLazySingleton<UserApi>(() => HttpUserApi());

注册时只保存工厂函数,第一次读取时才创建,之后复用同一实例:

注册 ──> 尚未创建
第一次 get ──> 创建并缓存
后续 get ──> 返回缓存实例

它适合不一定会被当前运行路径使用,但一旦使用就希望共享的服务。

registerFactory

getIt.registerFactory<UserSession>(
  () => UserSession(getIt<UserApi>()),
);

每次读取都会执行工厂函数,返回新对象:

get 第一次 ─> UserSession A
get 第二次 ─> UserSession B

适合短生命周期对象,例如页面控制器、一次性的用例对象,前提是调用方明确负责对象使用结束后的清理。

registerFactoryParam

当对象创建时需要运行时参数,而参数不是容器中的固定依赖,可以使用参数工厂:

getIt.registerFactoryParam<DetailsController, String, void>(
  (id, _) => DetailsController(
    id: id,
    repository: getIt<UserRepository>(),
  ),
);

final controller = getIt<DetailsController>(param1: 'u-1');

这表达的是:

  • UserRepository 是容器管理的依赖;
  • id 是某次导航或页面创建时才知道的参数;
  • DetailsController 每次获取都重新创建。

不要把用户 ID、搜索关键词等请求级数据注册成全局单例,否则不同页面或并发请求可能互相覆盖状态。


用 GetIt 组装 Flutter 应用

下面给出一个可运行结构的简化示例。网络和数据库实现使用内存版本,以便示例无需额外插件即可运行。

import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';

final getIt = GetIt.instance;

class User {
  const User({
    required this.id,
    required this.name,
  });

  final String id;
  final String name;
}

abstract interface class UserApi {
  Future<User> fetchUser(String id);
}

class MemoryUserApi implements UserApi {
  @override
  Future<User> fetchUser(String id) async {
    await Future<void>.delayed(const Duration(milliseconds: 50));
    return User(id: id, name: 'User $id');
  }
}

class UserRepository {
  UserRepository(this.api);

  final UserApi api;

  Future<User> load(String id) {
    return api.fetchUser(id);
  }
}

class ProfileController {
  ProfileController(this.repository);

  final UserRepository repository;

  Future<User> loadProfile() {
    return repository.load('u-1');
  }
}

void configureDependencies() {
  getIt.registerLazySingleton<UserApi>(() => MemoryUserApi());

  getIt.registerLazySingleton<UserRepository>(
    () => UserRepository(getIt<UserApi>()),
  );

  getIt.registerFactory<ProfileController>(
    () => ProfileController(getIt<UserRepository>()),
  );
}

class ProfilePage extends StatefulWidget {
  const ProfilePage({super.key});

  @override
  State<ProfilePage> createState() => _ProfilePageState();
}

class _ProfilePageState extends State<ProfilePage> {
  late final ProfileController controller;
  late final Future<User> profileFuture;

  @override
  void initState() {
    super.initState();
    controller = getIt<ProfileController>();
    profileFuture = controller.loadProfile();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Profile')),
      body: Center(
        child: FutureBuilder<User>(
          future: profileFuture,
          builder: (context, snapshot) {
            if (snapshot.connectionState == ConnectionState.waiting) {
              return const CircularProgressIndicator();
            }
            if (snapshot.hasError) {
              return Text('加载失败:${snapshot.error}');
            }
            return Text(snapshot.data?.name ?? '无数据');
          },
        ),
      ),
    );
  }
}

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  configureDependencies();

  runApp(
    const MaterialApp(
      home: ProfilePage(),
    ),
  );
}

执行流程是:

  1. main() 调用 configureDependencies()
  2. GetIt 记录三个注册规则,但 MemoryUserApiUserRepositoryProfileController 都尚未因 lazyfactory 规则而提前创建。
  3. ProfilePage 进入 initState()
  4. getIt<ProfileController>() 创建一个控制器。
  5. 创建控制器时读取 UserRepository
  6. 创建仓库时读取 UserApi
  7. 返回控制器并开始加载数据。
  8. FutureBuilder 根据 Future 状态重建界面。

在真实应用中,MemoryUserApi 可以替换成基于 httpdio 或平台 SDK 的实现;ProfilePage 不需要知道实现细节。

不过,页面直接调用 getIt 仍然是服务定位。它隐藏了页面的依赖。若页面改为:

class ProfilePage extends StatefulWidget {
  const ProfilePage({
    super.key,
    required this.controller,
  });

  final ProfileController controller;

  @override
  State<ProfilePage> createState() => _ProfilePageState();
}

再由外部写:

ProfilePage(
  controller: getIt<ProfileController>(),
)

就形成了混合方案:

  • GetIt 位于组合边界,负责组装;
  • Widget 使用构造器接收依赖;
  • Widget 本身不依赖全局容器。

这通常比让每个 Widget 都直接调用 getIt 更容易测试和重构。


依赖容器不是自动注入器

GetIt 不会分析构造器并自动推导完整依赖图。下面的代码不会因为声明了构造器就自动注册:

class UserRepository {
  UserRepository(this.api);

  final UserApi api;
}

仍然需要显式注册:

getIt.registerLazySingleton<UserApi>(() => HttpUserApi());
getIt.registerLazySingleton<UserRepository>(
  () => UserRepository(getIt<UserApi>()),
);

如果忘记注册,运行时获取会失败:

final repository = getIt<UserRepository>();

典型结果是 GetIt 抛出“未注册该类型”的错误。这个错误不是编译期错误,因为 Dart 编译器不知道运行时容器中有哪些注册项。

因此:

  • 构造器注入把依赖关系暴露给编译器和调用者;
  • GetIt 把依赖解析推迟到运行时;
  • GetIt 减少了传递对象的代码,但增加了注册完整性和运行时诊断的责任。

注册接口而不是具体实现,可以避免业务层绑定实现:

getIt.registerLazySingleton<UserApi>(
  () => HttpUserApi(
    baseUrl: getIt<AppConfig>().apiBaseUrl,
  ),
);

业务类只依赖 UserApi

class UserRepository {
  UserRepository(this.api);

  final UserApi api;
}

如果注册的是 HttpUserApi 而读取的是 UserApi,两者没有继承或实现关系,GetIt 不能凭名称猜测它们的关系。注册类型必须与读取类型匹配,或者使用命名实例。


生命周期:实例何时创建,何时销毁

依赖注入中的生命周期至少包含三个问题:

  1. 何时创建实例?
  2. 多次获取是否返回同一个实例?
  3. 何时释放实例持有的资源?

可以把常见注册方式表示为:

注册方式 创建时机 多次获取 常见用途
registerSingleton 注册时 同一实例 已准备好的应用级对象
registerLazySingleton 首次获取 同一实例 延迟创建的共享服务
registerFactory 每次获取 新实例 短生命周期对象

生命周期不是“是否单例”的同义词。一个实例即使是单例,也可能需要释放,例如:

  • StreamSubscription
  • Timer
  • ChangeNotifier
  • 数据库连接
  • 文件句柄
  • WebSocket
  • 原生插件资源

注册销毁函数

GetIt 支持在注册时声明销毁函数:

getIt.registerSingleton<CacheStore>(
  CacheStore(),
  dispose: (store) async {
    await store.close();
  },
);

如果对象的关闭方法是同步的:

getIt.registerLazySingleton<RefreshController>(
  () => RefreshController(),
  dispose: (controller) {
    controller.dispose();
  },
);

销毁函数表达的是容器对该实例的所有权。只有当容器负责创建并管理对象时,才应把关闭责任交给容器。若对象由 Flutter 的 State 创建并由 State.dispose() 管理,就不应再让 GetIt 重复销毁。

例如,下面的所有权关系是清晰的:

class PollingController {
  PollingController() {
    _timer = Timer.periodic(
      const Duration(seconds: 5),
      (_) {},
    );
  }

  late final Timer _timer;

  void dispose() {
    _timer.cancel();
  }
}

void registerControllers() {
  getIt.registerFactory<PollingController>(
    () => PollingController(),
  );
}

registerFactory 每次返回新控制器,GetIt 不会自动知道何时某个页面不再使用它。若页面通过 getIt<PollingController>() 获取对象,就需要在页面退出时显式调用 dispose(),或者改用作用域统一管理。

Flutter State 生命周期与依赖生命周期

Flutter 的 State 生命周期通常是:

createState
  ↓
initState
  ↓
didChangeDependencies
  ↓
build(可能多次)
  ↓
deactivate
  ↓
dispose

如果一个依赖与页面状态绑定,应在 initState() 获取,在 dispose() 释放:

class SearchPage extends StatefulWidget {
  const SearchPage({super.key});

  @override
  State<SearchPage> createState() => _SearchPageState();
}

class _SearchPageState extends State<SearchPage> {
  late final SearchController controller;

  @override
  void initState() {
    super.initState();
    controller = getIt<SearchController>();
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return const SizedBox();
  }
}

这里假设 SearchControllerregisterFactory 注册的,并且页面拥有它。若控制器是全局共享单例,则页面不应在 dispose() 中把它关闭,因为其他页面可能仍在使用。

build() 可能频繁执行,因此不应在其中注册或创建需要稳定身份的对象:

@override
Widget build(BuildContext context) {
  // 错误倾向:每次重建都可能触发新的注册或对象创建。
  final controller = SearchController();
  return const SizedBox();
}

异步初始化和启动顺序

Flutter 应用常见的异步初始化包括:

  • 打开数据库;
  • 读取安全存储;
  • 加载远程或本地配置;
  • 初始化 Firebase 等 SDK;
  • 创建需要平台通道的插件对象。

插件调用通常应在绑定初始化后进行:

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  final database = await AppDatabase.open();

  runApp(MyApp(database: database));
}

这是构造器注入的直接写法。若使用 GetIt,可以注册异步单例:

final getIt = GetIt.instance;

class AppConfig {
  AppConfig(this.apiBaseUrl);

  final String apiBaseUrl;
}

Future<AppConfig> loadConfig() async {
  return AppConfig('https://example.com');
}

Future<AppDatabase> openDatabase() async {
  return AppDatabase.open();
}

void configureAsyncDependencies() {
  getIt.registerSingletonAsync<AppConfig>(
    loadConfig,
  );

  getIt.registerSingletonAsync<AppDatabase>(
    openDatabase,
  );
}

应用启动前等待容器准备完成:

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  configureAsyncDependencies();
  await getIt.allReady();

  runApp(const MyApp());
}

但是,如果 AppDatabase 依赖 AppConfig,应声明依赖关系:

void configureAsyncDependencies() {
  getIt.registerSingletonAsync<AppConfig>(
    loadConfig,
  );

  getIt.registerSingletonAsync<AppDatabase>(
    () async {
      final config = getIt<AppConfig>();
      return AppDatabase.open(path: config.apiBaseUrl);
    },
    dependsOn: <Type>[AppConfig],
  );
}

其因果关系是:

注册 AppConfig 异步任务
        ↓
AppConfig 完成
        ↓
注册函数读取 AppConfig
        ↓
AppDatabase 完成
        ↓
allReady() 返回
        ↓
runApp()

allReady() 只适合确实需要在界面启动前准备好的依赖。如果把所有网络请求都放进启动阶段,网络故障会阻塞整个应用进入主界面。更合理的划分通常是:

  • 必须存在才能启动的本地配置:启动前等待;
  • 可在页面显示后加载的远程数据:由控制器或页面处理加载状态;
  • 可选的预热任务:异步执行,但不阻塞 runApp()

异步初始化失败时,allReady() 会失败,应用不能无条件继续启动:

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  try {
    configureAsyncDependencies();
    await getIt.allReady();
    runApp(const MyApp());
  } catch (error, stackTrace) {
    runApp(BootstrapErrorApp(
      error: error,
      stackTrace: stackTrace,
    ));
  }
}

生产应用可以把错误展示为可重试的启动页,而不是只打印异常后继续使用未初始化的对象。


作用域:让依赖随业务边界生存

作用域(scope)是依赖注册的嵌套层次。GetIt 允许在当前容器上创建新作用域:

全局作用域
 ├─ AppConfig
 ├─ AuthService
 └─ 用户作用域
     ├─ CurrentUser
     ├─ DraftRepository
     └─ SessionController

子作用域可以读取父作用域的对象,也可以用同一类型注册一个新对象来遮蔽父作用域。

getIt.registerSingleton<Logger>(Logger());

getIt.pushNewScope(
  scopeName: 'user-session',
);

getIt.registerSingleton<CurrentUser>(
  CurrentUser(id: 'u-1'),
);

在用户作用域中:

final logger = getIt<Logger>();
final user = getIt<CurrentUser>();

Logger 来自父作用域,CurrentUser 来自当前作用域。

作用域的查找路径可以理解为:

当前作用域
  ↓ 找不到
父作用域
  ↓ 找不到
更外层作用域
  ↓ 找不到
抛出未注册错误

登录、登出和作用域销毁

用户登录后创建会话作用域:

Future<void> signIn(String userId) async {
  getIt.pushNewScope(
    scopeName: 'user-session',
  );

  getIt.registerSingleton<CurrentUser>(
    CurrentUser(id: userId),
  );

  getIt.registerLazySingleton<DraftRepository>(
    () => DraftRepository(
      user: getIt<CurrentUser>(),
    ),
    dispose: (repository) async {
      await repository.close();
    },
  );
}

登出时弹出作用域:

Future<void> signOut() async {
  await getIt.popScope();
}

弹出作用域会移除该作用域中的注册,并执行由容器管理的销毁回调。这样,用户数据、草稿缓存和会话级控制器不会意外泄露到下一个用户。

作用域不是路由栈的自动映射。打开一个 Flutter route 不会自动创建 GetIt scope,关闭 route 也不会自动调用 popScope()。二者必须由应用代码明确关联:

登录成功
  └─ pushNewScope()

用户会话期间
  └─ 读取会话级依赖

登出或会话失效
  └─ await popScope()

如果在旧作用域销毁前就创建新作用域,可能出现旧订阅、旧 WebSocket 或旧缓存仍然工作的问题。登出流程应先停止使用旧对象,再等待作用域销毁完成。

作用域遮蔽的风险

父作用域已有:

getIt.registerSingleton<ApiClient>(ProductionApiClient());

测试作用域又注册:

getIt.pushNewScope();
getIt.registerSingleton<ApiClient>(FakeApiClient());

此时当前作用域读取到 FakeApiClient。如果忘记 popScope(),后续测试或业务代码可能继续读取假实现。作用域适合隔离边界,但它也增加了查找上下文;调试时需要确认当前处于哪一层作用域。


生命周期状态与资源关闭的完整路径

一个数据库依赖的生命周期可以写成:

未注册
  ↓ registerSingletonAsync
初始化中
  ↓ 初始化成功
可用
  ↓ reset / unregister / popScope
关闭中
  ↓ close 完成
已移除

失败路径则是:

初始化中
  ↓ 打开数据库失败
初始化失败
  ↓ 重试或退出启动

注意“从容器移除”和“资源已关闭”不是同一个概念。如果只删除注册:

await getIt.unregister<AppDatabase>();

是否关闭资源取决于注册时是否提供了销毁函数以及具体版本 API 的行为配置。更明确的做法是注册时声明 dispose,并通过容器的 resetresetScopeunregister 触发容器管理的销毁流程。

应用级重置常用于测试:

await getIt.reset();

reset() 会清除注册并执行销毁回调。它不是普通业务流程中“重新初始化应用”的低成本操作,因为全局服务可能被多个页面持有。生产环境中更常见的是针对作用域执行清理,而不是随意重置整个容器。

如果对象内部还有异步任务,dispose 应先阻止新任务,再等待正在运行的任务结束。例如:

class SyncService {
  SyncService(this.client);

  final ApiClient client;
  bool _closed = false;

  Future<void> sync() async {
    if (_closed) {
      throw StateError('SyncService 已关闭');
    }
    await client.sync();
  }

  Future<void> close() async {
    _closed = true;
    await client.close();
  }
}

否则,关闭后仍在执行的 Future 可能访问已释放的数据库或平台通道,产生间歇性错误。


测试:构造器注入和 GetIt 的差异

构造器注入的单元测试

构造器注入测试时不需要初始化 Flutter,也不需要配置全局容器:

import 'package:test/test.dart';

class FakeUserApi implements UserApi {
  FakeUserApi(this.user);

  final User user;

  @override
  Future<User> fetchUser(String id) async {
    return user;
  }
}

class MemoryUserStore implements UserStore {
  final saved = <User>[];

  @override
  Future<void> save(User user) async {
    saved.add(user);
  }
}

void main() {
  test('UserRepository 读取用户并保存用户', () async {
    final expected = User(id: 'u-1', name: 'Alice');
    final store = MemoryUserStore();

    final repository = UserRepository(
      api: FakeUserApi(expected),
      store: store,
    );

    final actual = await repository.load('u-1');

    expect(actual.name, 'Alice');
    expect(store.saved, contains(expected));
  });
}

测试步骤是:

  1. FakeUserApi 替换真实网络实现。
  2. MemoryUserStore 替换真实数据库。
  3. 直接构造 UserRepository
  4. 执行真实业务方法。
  5. 断言返回值和副作用。

这个测试只验证仓库逻辑,不受 GetIt 注册状态、Flutter 引擎或平台插件影响,因此通常运行更快,失败原因也更直接。

GetIt 的测试替换

如果被测对象内部直接调用 GetIt:

class LocatorBasedController {
  Future<User> load() {
    return getIt<UserRepository>().load('u-1');
  }
}

测试必须替换容器中的注册:

class FakeUserRepository implements UserRepository {
  FakeUserRepository(this.user);

  final User user;

  @override
  Future<User> load(String id) async {
    return user;
  }
}

void main() {
  setUp(() async {
    await getIt.reset();

    getIt.registerSingleton<UserRepository>(
      FakeUserRepository(
        const User(id: 'u-1', name: 'Test User'),
      ),
    );
  });

  tearDown(() async {
    await getIt.reset();
  });

  test('通过 GetIt 取得测试仓库', () async {
    final controller = LocatorBasedController();

    final user = await controller.load();

    expect(user.name, 'Test User');
  });
}

setUptearDown 的因果关系不能省略:

  • 测试前 reset() 清除上一个测试留下的注册;
  • 测试中注册 fake;
  • 测试后再次 reset(),避免 fake 泄漏到后续测试;
  • 如果注册对象有资源,销毁回调也会在重置时执行。

对于注册冲突,也要注意 registerSingleton 默认不允许同一作用域中重复注册同一类型。测试中如果不重置就再次注册,通常会得到重复注册错误,而不是自动覆盖生产实现。

用作用域隔离测试替换

也可以为每个测试创建子作用域:

setUp(() async {
  getIt.pushNewScope();

  getIt.registerSingleton<UserRepository>(
    FakeUserRepository(
      const User(id: 'u-test', name: 'Fake'),
    ),
  );
});

tearDown(() async {
  await getIt.popScope();
});

这种方式适合保留全局基础设施、只替换局部服务。但测试必须保证 popScope() 一定执行。若测试中途抛出异常,测试框架的 tearDown 仍应负责清理。

Widget 测试中的替换

如果 Widget 使用构造器注入:

await tester.pumpWidget(
  MaterialApp(
    home: ProfilePage(
      controller: ProfileController(
        UserRepository(
          api: FakeUserApi(
            const User(id: 'u-1', name: 'Widget Test'),
          ),
          store: MemoryUserStore(),
        ),
      ),
    ),
  ),
);

测试树的依赖完全由测试提供。若 Widget 内部直接使用 GetIt,则测试还必须先配置全局容器:

setUp(() async {
  await getIt.reset();
  getIt.registerFactory<ProfileController>(
    () => ProfileController(
      UserRepository(
        api: FakeUserApi(
          const User(id: 'u-1', name: 'Widget Test'),
        ),
        store: MemoryUserStore(),
      ),
    ),
  );
});

两种方式都可以工作,但构造器注入的测试依赖更明显,测试文件不会依赖隐藏的全局注册状态。


构造器注入、GetIt 和 Flutter 上下文的边界

Flutter 还提供基于 Widget 树传递依赖的方式,例如 InheritedWidgetInheritedNotifierTheme,以及生态中的 Provider、Riverpod 等方案。它们与 GetIt 的作用域不同:

  • Widget 树依赖通常随 BuildContext 和 Widget 子树生存;
  • GetIt 作用域是容器注册层次,不自动感知 Widget 树;
  • Widget 树依赖可以触发依赖它的 Widget 重建;
  • GetIt 返回对象本身不会自动触发 Widget 重建。

例如,把可变状态放进 GetIt:

getIt.registerSingleton<ValueNotifier<int>>(ValueNotifier<int>(0));

调用:

getIt<ValueNotifier<int>>().value++;

不会因为 GetIt 而自动刷新 Widget。界面必须显式监听:

ValueListenableBuilder<int>(
  valueListenable: getIt<ValueNotifier<int>>(),
  builder: (context, value, child) {
    return Text('$value');
  },
)

因此,GetIt 适合解决“对象在哪里创建、如何共享、何时释放”;它不是状态管理器,也不是响应式重建机制。

BuildContext、Widget、State 或页面级对象注册成应用单例通常风险较高,因为它们有自己的 Flutter 生命周期,且 BuildContext 只在相应元素仍然挂载时有效。依赖容器不应替代 Flutter 的树生命周期。


并发、异步和隔离边界

Flutter 的 Dart 代码通常运行在一个 isolate 中。GetIt 的注册表也是 isolate 内的内存状态:

主 isolate 的 GetIt
    ≠
后台 isolate 的 GetIt

如果通过 Isolate.spawn 创建后台 isolate,后台 isolate 不会自动共享主 isolate 的 GetIt 注册。需要通过消息传递传输可发送的数据,或在后台 isolate 内部重新初始化它需要的依赖。

异步请求还会带来“旧结果覆盖新结果”的问题。依赖注入只能提供服务,不能自动处理请求竞态。例如:

final first = repository.load('u-1');
final second = repository.load('u-2');

final result = await Future.wait([first, second]);

两个 Future 可能以任意顺序完成。控制器若把结果写入同一个页面状态,就必须使用请求标识、取消机制或版本检查,确保旧请求不会覆盖新请求。

同样,退出作用域时,如果仍有异步操作使用其中的依赖,销毁顺序必须经过设计:

停止接受新请求
  ↓
等待或取消正在执行的请求
  ↓
关闭 WebSocket / 数据库 / 订阅
  ↓
popScope()

直接 popScope() 并不能自动取消外部持有的 Future。应用仍需保证异步任务不会在对象关闭后继续访问资源。


平台差异:Android、iOS、桌面和 Web

依赖注入本身是 Dart 层机制,Android、iOS、Windows、macOS、Linux 和 Web 都可以使用构造器注入与 GetIt。但被注入的具体实现可能受平台能力影响。

平台插件初始化

依赖原生插件时,启动阶段通常需要:

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // 在这里调用需要 Flutter binding 的插件初始化。
  // 例如读取平台存储、初始化数据库插件等。

  runApp(const MyApp());
}

WidgetsFlutterBinding.ensureInitialized() 确保 Flutter binding 已建立,但它不会保证任意插件已经初始化,也不会让不支持的平台 API 变得可用。

Web

Web 平台没有 Android/iOS 的所有原生能力。例如:

  • 文件系统路径模型不同;
  • 后台执行限制不同;
  • 部分 SQLite、蓝牙、推送或原生存储实现不可用或行为不同;
  • 浏览器刷新会重建整个 Dart 应用上下文。

因此可定义平台无关接口:

abstract interface class SecureStorage {
  Future<void> write(String key, String value);
  Future<String?> read(String key);
}

再按平台注册实现:

void configurePlatformDependencies() {
  if (kIsWeb) {
    getIt.registerLazySingleton<SecureStorage>(
      () => WebStorage(),
    );
  } else {
    getIt.registerLazySingleton<SecureStorage>(
      () => NativeSecureStorage(),
    );
  }
}

更复杂的项目可以使用 Dart 条件导入,让平台选择位于编译期,而不是在业务代码中大量出现 kIsWeb。无论采用哪种方式,业务层都应依赖 SecureStorage 接口,而不是直接依赖某个平台类。

Android、iOS 和桌面

Android、iOS、桌面平台可能具有不同的:

  • 应用暂停、恢复和终止时机;
  • 文件目录和权限模型;
  • 后台任务能力;
  • 原生 SDK 初始化要求;
  • 窗口或多实例行为。

不要把“应用进程结束时一定调用 dispose”作为平台保证。操作系统可能直接终止进程,来不及执行 Dart 清理逻辑。因此重要数据应在状态变化时及时持久化,而不是只依赖退出时的销毁回调。


常见错误与诊断路径

在全局变量中直接创建所有对象

final repository = UserRepository(
  HttpUserApi(),
  SqliteUserStore(),
);

这比容器清晰,但如果该全局对象在导入时就被创建,可能早于 Flutter binding 或平台插件初始化。需要平台资源的对象应在启动流程中显式创建,或者通过延迟注册避免导入时副作用。

每次请求都创建本应共享的客户端

class UserRepository {
  Future<User> load(String id) {
    final client = HttpClient();
    return client.getUser(id);
  }
}

如果客户端包含连接池、认证拦截器或缓存,这会导致资源重复创建。通常可以把客户端注册为 lazy singleton,再把仓库注册为 singleton 或 lazy singleton。但是否共享仍取决于实现是否线程安全、是否带有请求级状态,不能仅因为“客户端”这个名字就强制单例。

把请求状态注册为单例

getIt.registerSingleton<SearchQuery>(SearchQuery());

如果多个页面共用它,页面 A 的输入可能改变页面 B 的查询。请求状态通常应由页面、控制器或路由作用域拥有,而不是应用全局作用域。

忘记销毁订阅

class NotificationService {
  NotificationService() {
    stream.listen(_onEvent);
  }

  void _onEvent(Event event) {}
}

即使服务从页面消失,订阅仍可能持有服务实例。应保存订阅并关闭:

class NotificationService {
  StreamSubscription<Event>? _subscription;

  void start(Stream<Event> stream) {
    _subscription = stream.listen(_onEvent);
  }

  Future<void> dispose() async {
    await _subscription?.cancel();
    _subscription = null;
  }

  void _onEvent(Event event) {}
}

注册时绑定销毁函数:

getIt.registerLazySingleton<NotificationService>(
  () => NotificationService(),
  dispose: (service) => service.dispose(),
);

初始化顺序错误

如果启动代码先 runApp(),页面立即获取尚未完成的异步服务,就可能得到“服务未准备好”或空状态。诊断时应检查:

  1. WidgetsFlutterBinding.ensureInitialized() 是否在插件调用前执行;
  2. 异步注册是否已经完成;
  3. 是否在调用 allReady() 前注册了所有异步依赖;
  4. 依赖之间是否声明了 dependsOn
  5. 是否误把需要页面显示后加载的数据放入启动阻塞流程。

多次配置容器

热重载、测试或重复启动代码可能再次调用:

configureDependencies();

如果同一作用域中重复注册,GetIt 通常会报告类型已注册。配置函数应只在明确的启动位置调用;测试中要使用 reset(),而不是依赖注册覆盖。


如何选择注入方式

可以按依赖可见性和生命周期边界选择。

优先使用构造器注入的情况

适合:

  • 业务核心类;
  • 用例、仓库和控制器;
  • 希望单元测试不依赖全局状态的代码;
  • 依赖是对象成立的必要条件;
  • 类型关系需要在调用点清楚表达。

示例:

class CheckoutUseCase {
  CheckoutUseCase({
    required PaymentGateway payment,
    required OrderRepository orders,
  })  : _payment = payment,
        _orders = orders;

  final PaymentGateway _payment;
  final OrderRepository _orders;
}

适合使用 GetIt 的情况

适合:

  • 应用级基础设施;
  • 平台适配器;
  • 日志、配置、数据库、网络客户端;
  • 组合根中需要集中管理的共享依赖;
  • 需要作用域和统一销毁的对象。

但应尽量把 GetIt 限制在组合边界。业务类如果直接到处调用 getIt<T>(),依赖关系就会变成隐式运行时约定。

一个实际的分层组合

void configureDependencies() {
  // 基础设施层
  getIt.registerSingleton<AppConfig>(loadLocalConfig());
  getIt.registerLazySingleton<UserApi>(
    () => HttpUserApi(config: getIt<AppConfig>()),
  );

  // 数据层
  getIt.registerLazySingleton<UserRepository>(
    () => UserRepository(getIt<UserApi>()),
  );

  // 表现层对象
  getIt.registerFactory<ProfileController>(
    () => ProfileController(getIt<UserRepository>()),
  );
}

然后在边界处定位:

class AppRoutes {
  static Route<void> profile() {
    return MaterialPageRoute<void>(
      builder: (_) => ProfilePage(
        controller: getIt<ProfileController>(),
      ),
    );
  }
}

页面和业务类仍然使用构造器,只有路由或应用组装代码知道 GetIt。


最小自查标准

一个依赖注入设计至少应能回答以下问题:

  1. 每个核心类需要哪些依赖?
  2. 依赖是构造器参数,还是隐藏在全局查找中?
  3. 该依赖是每次新建、延迟单例,还是启动时单例?
  4. 谁拥有它,谁负责调用 disposeclose
  5. 页面销毁、用户登出和应用重置时,哪些对象必须释放?
  6. 异步依赖是否已经准备好,失败后界面如何恢复?
  7. 测试如何替换真实网络、数据库和平台服务?
  8. 当前作用域是否可能残留旧用户或旧测试的注册?
  9. Web、移动端和桌面端是否提供了对应的平台实现?
  10. 是否因为使用 GetIt 而误以为对象会自动响应 Flutter 重建?

依赖注入的核心不是“把所有对象放进 GetIt”,而是明确创建关系、所有权和生命周期。构造器注入提供最强的依赖可见性;GetIt 提供集中组装、延迟创建和作用域管理。将二者放在合适的边界上,才能同时获得可维护性、可测试性和对运行时资源的控制。


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。