模块划分与解耦:功能模块的边界定义、依赖注入

说到模块划分,很多新手会把「拆文件」当成模块化。其实不然。模块划分的本质是职责的边界定义。我早年做支付SDK时,把签名、路由、日志全塞在一个类里——后来加个渠道动辄改半个月,那滋味不好受。

模块边界定义的原则

边界怎么划?我个人习惯用三个原则来判断:

  1. 单一职责——一个模块只做一件事,并且做好。
  2. 最小知识原则——模块只暴露必须的接口,内部细节全部隐藏。
  3. 依赖方向稳定——高层模块不依赖底层模块的具体实现,而是依赖抽象。

你想想看,如果一个模块既要做网络请求,又要解析数据,还要缓存结果,那它改动的触发点就太多了。我在项目中遇到过类似情况:一个“数据加载器”包了下载、解压、数据库写入,后来网络库升级,改一处牵动全局。说白了,边界不清是耦合的开始。

好的边界 坏的边界
模块内聚,职责单一 一个模块干好几件不相关的事
接口小而稳定 暴露内部字段或实现细节
依赖抽象(接口/协议) 直接依赖具体类或全局变量
模块可独立测试 测试时要mock一大片外部依赖
边界 = 接口 + 职责 + 依赖方向。三者缺一,模块迟早腐化。

依赖注入:让模块真正解耦

模块划好边界后,怎么让它们“连”起来又不产生强依赖?答案就是依赖注入(Dependency Injection,DI)。别被这个名字唬住,说白了就是一个模块不再自己new依赖对象,而是由外部把依赖“递”进来。

为什么要这样做?看个最简对比:

传统硬编码方式:

class OrderService {
    private let logger = FileLogger()  // 直接创建具体对象
    func createOrder() {
        logger.log("订单创建")
    }
}

这段代码里,OrderService 硬耦合了 FileLogger。想换成数据库日志?必须改 OrderService 的源码。这违反了开闭原则。

依赖注入方式:

class OrderService {
    private let logger: LoggerProtocol  // 依赖抽象

    init(logger: LoggerProtocol) {     // 构造注入
        self.logger = logger
    }

    func createOrder() {
        logger.log("订单创建")
    }
}

// 使用时由组装层注入具体实现
let service = OrderService(logger: FileLogger())

看到了吗?OrderService 不再关心 logger 是啥。你甚至可以在测试时注入一个 MockLogger。这就是解耦。

小提示:依赖注入不一定要用框架。手动构造注入在中小SDK里反而更清爽,避免引入不必要的容器依赖。

实践中的依赖注入容器

当模块数量多了(比如10个以上),手动构造会变得繁琐。这时候可以考虑一个轻量容器。我建议自己写一个极简容器,理解原理后再用三方库。

下面是一个简化版的容器示例(Swift 风格,原理通用):

class DIContainer {
    private var factories: [String: () -> Any] = [:]
    private var singletons: [String: Any] = [:]

    func register<T>(_ type: T.Type, factory: @escaping () -> T) {
        let key = "\(type)"
        factories[key] = factory
    }

    func resolve<T>(_ type: T.Type) -> T {
        let key = "\(type)"
        guard let factory = factories[key] else {
            fatalError("依赖未注册: \(type)")
        }
        return factory() as! T
    }
}

// 使用
let container = DIContainer()
container.register(LoggerProtocol.self) { FileLogger() }
container.register(OrderService.self) { OrderService(logger: container.resolve(LoggerProtocol.self)) }

容器的核心就三件事:注册、解析、生命周期管理。了解这个后,你用任何DI框架(如 Dagger、Swinject、Spring)都能快速上手。

警告:不要过度设计依赖注入。如果模块只有2-3个,手动注入完全够用。引入容器反而增加了理解成本。我曾经在一个小型工具SDK里强行套DI容器,结果代码量翻倍,反而更难维护。

避坑指南

  • 我曾经把网络模块的配置(baseURL、超时时间)硬编码在模块内部,导致测试环境切换需要改代码。后来改成通过初始化参数注入配置,一切清爽了。
  • 我曾经让模块A直接调用模块B的内部方法,理由是“方便”。结果模块B重构时,模块A跟着崩了。应该通过协议接口调用。
  • 避免循环依赖:A依赖B,B又依赖A。在容器里这会导致死循环。一个常用的解决方法是引入事件总线或中间层。

模块划分与依赖注入关系图

模块划分与依赖注入关系图 模块划分与依赖注入解耦关系 业务模块A (只依赖抽象接口) 业务模块B (只依赖抽象接口) 业务模块C (只依赖抽象接口) 抽象接口层 协议/接口定义 (如 LoggerProtocol) (如 ConfigProvider) (如 StorageProtocol) 不包含任何实现 依赖注入容器 (组装与提供实例) FileLogger 实现 DBConfig 实现 注入实现 核心逻辑 1. 业务模块只依赖抽象接口,不依赖具体实现 2. 依赖注入容器负责将抽象接口与具体实现绑定 3. 具体实现可以在运行时替换,无需修改业务代码 4. 模块间通过抽象层解耦,实现边界清晰、可测试

小结

模块划分与依赖注入是SDK架构的两大基石。边界定义好了,代码的“地盘”就清晰了;依赖注入用上了,模块之间的“协作”就不再是硬绑定的。记住:抽象接口是契约,依赖注入是物流,模块是独立的生产线。每一条生产线只关心自己的零件,物流把需要的零件送进来,整个系统就灵活了。