依赖注入:管理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() 方法,插入新模块,再仔细核对所有依赖。久而久之,这坨代码就成了一张脆弱的时间序列表——谁都不敢动,一动就崩。

我个人的习惯:只要超过 5 个模块,我就直接上 DI 容器。别犹豫,手动排顺序的维护成本是指数增长的。

依赖注入的三种注入方式

常见的 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 解决初始化顺序的核心原理——根本不是手工排的,而是由依赖图自动计算出来的。

注意循环依赖!我曾经在一个项目中遇到 A 依赖 B,B 依赖 C,C 又依赖 A 的情况。DI 容器会直接死循环或者抛异常。解决办法:要么重构架构去掉循环,要么用属性注入 + 延迟加载绕过。我建议优先重构,循环依赖往往是设计耦合过紧的信号。

生命周期管理:单例 vs 每次新建

除了初始化顺序,DI 容器还要管对象的生命周期。咱们做 SDK 的,很多模块都是全局唯一的,比如日志、网络、数据库。这种情况下应该用单例。但有些工具类模块,每次调用都应该全新创建,用瞬态。

我一般这样规划:

  • 单例(Singleton):数据库连接、配置管理器、网络客户端、事件总线——全局只有一个实例,由容器创建一次后一直复用。
  • 瞬态(Transient):请求上下文、临时计算器——每次注入都新建实例。
  • 作用域(Scoped):比如每个业务场景的生命周期内共享同一个实例——在 SDK 里比较少见,但搞插件化时会用到。

管理好生命周期,你就能避免「鬼打墙」式的问题——比如日志模块在初始化时被创建了两次,导致日志丢失。我踩过这个坑,后来强制给核心模块加上了单例注册验证,如果同一个类型被注册两次,直接报错。

一个小技巧:在 DI 容器的注册阶段,使用一个 set 记录所有已注册的类型。发现重复注册时,输出警告并抛异常——宁可启动时炸,也不要运行时静默覆盖。

实战:基于 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 Container) ① 注册映射 类型 → 实现类 ② 依赖图解析 拓扑排序 → 确定顺序 ③ 生命周期管理 单例/瞬态/作用域 输出:自动按依赖顺序初始化 避坑提示 循环依赖检测 + 重复注册检查 + 生命周期一致性 最终:SDK 启动稳定、可扩展、可测试

看完这张图,你应该能理解 DI 的核心链路:从注册映射到依赖图解析,再到生命周期管理,最后自动生成正确的初始化顺序。实际项目中,我会在 DI 容器初始化阶段就做依赖完整性校验——如果发现某个类型注册了但依赖没有被注册,直接抛出配置异常,不让应用带着残缺的依赖启动。

补充一个经验:如果你的 SDK 需要支持热插拔(比如插件化),可以考虑用作用域容器来隔离插件间的依赖。每个插件拥有自己的子容器,父容器提供公共服务。这样可以避免插件 A 的实例污染插件 B 的命名空间。

好了,这一篇我们就聊到这。记住,依赖注入不是银弹,但它能解决 80% 的初始化顺序乱象。剩下的 20% ——比如跨进程依赖、异步资源等待——就需要结合其他模式来处理了。那些我们后面章节再展开。