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;
}
}
这段代码有几个结构性问题:
UserRepository同时决定了依赖的具体实现。- 测试时无法方便地替换网络客户端和数据库。
ApiClient和AppDatabase的实例数量由调用链隐式决定。- 如果依赖需要异步初始化,构造器无法直接等待异步操作。
- 关闭数据库、取消订阅等生命周期责任不清楚。
依赖注入把创建关系移到外部:
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 的类型系统可以保证 GoodController 的 repository 是非空的,但它不能自动保证传入的实现满足业务语义。例如,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 ─┘
创建顺序必须满足依赖顺序:
- 先创建
HttpUserApi和SqliteUserStore。 - 再创建需要它们的
UserRepository。 - 最后创建需要
UserRepository的ProfileController。 - 将控制器传给 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(),
),
);
}
执行流程是:
main()调用configureDependencies()。- GetIt 记录三个注册规则,但
MemoryUserApi、UserRepository、ProfileController都尚未因lazy或factory规则而提前创建。 ProfilePage进入initState()。getIt<ProfileController>()创建一个控制器。- 创建控制器时读取
UserRepository。 - 创建仓库时读取
UserApi。 - 返回控制器并开始加载数据。
FutureBuilder根据 Future 状态重建界面。
在真实应用中,MemoryUserApi 可以替换成基于 http、dio 或平台 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 不能凭名称猜测它们的关系。注册类型必须与读取类型匹配,或者使用命名实例。
生命周期:实例何时创建,何时销毁
依赖注入中的生命周期至少包含三个问题:
- 何时创建实例?
- 多次获取是否返回同一个实例?
- 何时释放实例持有的资源?
可以把常见注册方式表示为:
| 注册方式 | 创建时机 | 多次获取 | 常见用途 |
|---|---|---|---|
registerSingleton |
注册时 | 同一实例 | 已准备好的应用级对象 |
registerLazySingleton |
首次获取 | 同一实例 | 延迟创建的共享服务 |
registerFactory |
每次获取 | 新实例 | 短生命周期对象 |
生命周期不是“是否单例”的同义词。一个实例即使是单例,也可能需要释放,例如:
StreamSubscriptionTimerChangeNotifier- 数据库连接
- 文件句柄
- 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();
}
}
这里假设 SearchController 是 registerFactory 注册的,并且页面拥有它。若控制器是全局共享单例,则页面不应在 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,并通过容器的 reset、resetScope 或 unregister 触发容器管理的销毁流程。
应用级重置常用于测试:
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));
});
}
测试步骤是:
- 用
FakeUserApi替换真实网络实现。 - 用
MemoryUserStore替换真实数据库。 - 直接构造
UserRepository。 - 执行真实业务方法。
- 断言返回值和副作用。
这个测试只验证仓库逻辑,不受 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');
});
}
setUp 和 tearDown 的因果关系不能省略:
- 测试前
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 树传递依赖的方式,例如 InheritedWidget、InheritedNotifier、Theme,以及生态中的 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(),页面立即获取尚未完成的异步服务,就可能得到“服务未准备好”或空状态。诊断时应检查:
WidgetsFlutterBinding.ensureInitialized()是否在插件调用前执行;- 异步注册是否已经完成;
- 是否在调用
allReady()前注册了所有异步依赖; - 依赖之间是否声明了
dependsOn; - 是否误把需要页面显示后加载的数据放入启动阻塞流程。
多次配置容器
热重载、测试或重复启动代码可能再次调用:
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。
最小自查标准
一个依赖注入设计至少应能回答以下问题:
- 每个核心类需要哪些依赖?
- 依赖是构造器参数,还是隐藏在全局查找中?
- 该依赖是每次新建、延迟单例,还是启动时单例?
- 谁拥有它,谁负责调用
dispose或close? - 页面销毁、用户登出和应用重置时,哪些对象必须释放?
- 异步依赖是否已经准备好,失败后界面如何恢复?
- 测试如何替换真实网络、数据库和平台服务?
- 当前作用域是否可能残留旧用户或旧测试的注册?
- Web、移动端和桌面端是否提供了对应的平台实现?
- 是否因为使用 GetIt 而误以为对象会自动响应 Flutter 重建?
依赖注入的核心不是“把所有对象放进 GetIt”,而是明确创建关系、所有权和生命周期。构造器注入提供最强的依赖可见性;GetIt 提供集中组装、延迟创建和作用域管理。将二者放在合适的边界上,才能同时获得可维护性、可测试性和对运行时资源的控制。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Flutter BLoC:Event、State、转换、并发、持久化和测试
- 下一篇:Flutter Dio 网络层:拦截器、取消、重试、上传和错误模型
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论