依赖注入:管理SDK模块的初始化顺序
你想想看,咱们平时写 SDK,模块少的时候还好办——手动一行一行初始化就完了。可一旦模块上了三四十个,依赖关系像蜘蛛网一样乱,一不小心就踩坑。我早期就栽过跟头:以为先初始化 A 再初始化 B 就行了,结果 A 内部偷偷依赖了 B 的某个服务,直接空指针崩溃。后来我才意识到,初始化顺序不是靠人脑排的,得靠机制。
说白了,依赖注入(DI)就是帮我们自动搞定这件事的。你只要告诉容器“我想要什么”,容器就会把依赖的模块按正确的顺序准备好,再交给你。
为什么手动排顺序不靠谱?
我们来看一段反面教材(我曾经就写过这样的代码):
// ❌ 糟糕的硬编码顺序
public class SDKInitializer {
public void init() {
LoggerModule.init(); // 1
NetworkModule.init(); // 2
DatabaseModule.init(); // 3
SyncModule.init(); // 4
UIModule.init(); // 5
}
}
表面上看着挺清晰。但过两周产品加了个新模块,需要依赖 Network 和 Database 都准备完毕才能初始化。你怎么办?你只能回去改 init() 方法,插入新模块,再仔细核对所有依赖。久而久之,这坨代码就成了一张脆弱的时间序列表——谁都不敢动,一动就崩。
依赖注入的三种注入方式
常见的 DI 实现有三种方式。我列个表,你一眼就能看明白:
| 注入方式 | 实现手段 | 适用场景 |
|---|---|---|
| 构造函数注入 | 通过构造参数传入依赖 | 必须依赖,不可为空 |
| 属性注入 | 通过 setter 或字段赋值 | 可选依赖,或需要延迟绑定 |
| 接口注入 | 依赖通过接口方法传入 | 框架级调用,较少用于业务模块 |
我个人最喜欢构造函数注入——因为它把依赖关系直接写进了类型签名里,不给你偷偷省略的机会。你想想,如果某个模块用了属性注入,你很容易忘了 set 它,运行时才炸。而构造函数注入在编译期就能强制你提供依赖。
用一个 DI 容器管理初始化顺序
咱们用伪代码示意一个简单的 DI 容器。你注意看依赖解析这一步——它会自动拓扑排序:
// 一个简化的 DI 容器
class DIKit {
private Map<Class, Object> singletons = new HashMap<>();
private Map<Class, List<Class>> depGraph = new HashMap<>();
public void register(Class type, Class implType) {
// 记录依赖关系(通过读取 implType 的构造函数参数)
depGraph.put(implType, getDependencies(implType));
}
public <T> T resolve(Class<T> type) {
if (singletons.containsKey(type)) {
return (T) singletons.get(type);
}
// 先递归解析所有依赖
resolveDependencies(type);
// 创建实例
T instance = createInstance(type);
singletons.put(type, instance);
return instance;
}
private void resolveDependencies(Class type) {
List<Class> deps = depGraph.get(type);
for (Class dep : deps) {
if (!singletons.containsKey(dep)) {
resolve(dep); // 递归,保证依赖先被初始化
}
}
}
}
你看,容器自己会沿着依赖链往下走,先创建最底层的依赖,再一层层往上返。这就是 DI 解决初始化顺序的核心原理——根本不是手工排的,而是由依赖图自动计算出来的。
生命周期管理:单例 vs 每次新建
除了初始化顺序,DI 容器还要管对象的生命周期。咱们做 SDK 的,很多模块都是全局唯一的,比如日志、网络、数据库。这种情况下应该用单例。但有些工具类模块,每次调用都应该全新创建,用瞬态。
我一般这样规划:
- 单例(Singleton):数据库连接、配置管理器、网络客户端、事件总线——全局只有一个实例,由容器创建一次后一直复用。
- 瞬态(Transient):请求上下文、临时计算器——每次注入都新建实例。
- 作用域(Scoped):比如每个业务场景的生命周期内共享同一个实例——在 SDK 里比较少见,但搞插件化时会用到。
管理好生命周期,你就能避免「鬼打墙」式的问题——比如日志模块在初始化时被创建了两次,导致日志丢失。我踩过这个坑,后来强制给核心模块加上了单例注册验证,如果同一个类型被注册两次,直接报错。
实战:基于 DI 重构 SDK 初始化
好了,咱们把前面的知识串起来。假设你的 SDK 有四个核心模块:Config、Logger、Network、ImageLoader。依赖关系是:
- Logger 依赖 Config
- Network 依赖 Logger
- ImageLoader 依赖 Network 和 Config
用 DI 容器注册后,你只需要入口处调用 container.resolve(ImageLoader.class),容器就会自动按Config → Logger → Network → ImageLoader 的顺序创建并初始化。
// 入口代码
public class SDKEntry {
public static void init(Context context) {
DIKit container = new DIKit();
// 注册配置(无依赖)
container.register(Config.class, AndroidConfig.class);
// 注册日志(依赖 Config)
container.register(Logger.class, LoggerImpl.class);
// 注册网络(依赖 Logger)
container.register(Network.class, OkHttpNetwork.class);
// 注册图片加载器(依赖 Network 和 Config)
container.register(ImageLoader.class, GlideImageLoader.class);
// 解析图片加载器——此时容器自动触发了所有依赖链的初始化
ImageLoader imageLoader = container.resolve(ImageLoader.class);
// 把 imageLoader 暴露给全局使用
SDKContext.setImageLoader(imageLoader);
}
}
你看,是不是清爽多了?以后加新模块,只需要在 register 里声明它依赖谁就行,不用改任何初始化顺序代码。这就是 DI 的魅力——它把「顺序」这个隐式约束变成了显式的依赖声明。
本章知识体系图
看完这张图,你应该能理解 DI 的核心链路:从注册映射到依赖图解析,再到生命周期管理,最后自动生成正确的初始化顺序。实际项目中,我会在 DI 容器初始化阶段就做依赖完整性校验——如果发现某个类型注册了但依赖没有被注册,直接抛出配置异常,不让应用带着残缺的依赖启动。
好了,这一篇我们就聊到这。记住,依赖注入不是银弹,但它能解决 80% 的初始化顺序乱象。剩下的 20% ——比如跨进程依赖、异步资源等待——就需要结合其他模式来处理了。那些我们后面章节再展开。