模块划分与解耦:功能模块的边界定义、依赖注入
说到模块划分,很多新手会把「拆文件」当成模块化。其实不然。模块划分的本质是职责的边界定义。我早年做支付SDK时,把签名、路由、日志全塞在一个类里——后来加个渠道动辄改半个月,那滋味不好受。
模块边界定义的原则
边界怎么划?我个人习惯用三个原则来判断:
- 单一职责——一个模块只做一件事,并且做好。
- 最小知识原则——模块只暴露必须的接口,内部细节全部隐藏。
- 依赖方向稳定——高层模块不依赖底层模块的具体实现,而是依赖抽象。
你想想看,如果一个模块既要做网络请求,又要解析数据,还要缓存结果,那它改动的触发点就太多了。我在项目中遇到过类似情况:一个“数据加载器”包了下载、解压、数据库写入,后来网络库升级,改一处牵动全局。说白了,边界不清是耦合的开始。
| 好的边界 | 坏的边界 |
|---|---|
| 模块内聚,职责单一 | 一个模块干好几件不相关的事 |
| 接口小而稳定 | 暴露内部字段或实现细节 |
| 依赖抽象(接口/协议) | 直接依赖具体类或全局变量 |
| 模块可独立测试 | 测试时要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。这就是解耦。
实践中的依赖注入容器
当模块数量多了(比如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)都能快速上手。
避坑指南
- 我曾经把网络模块的配置(baseURL、超时时间)硬编码在模块内部,导致测试环境切换需要改代码。后来改成通过初始化参数注入配置,一切清爽了。
- 我曾经让模块A直接调用模块B的内部方法,理由是“方便”。结果模块B重构时,模块A跟着崩了。应该通过协议接口调用。
- 避免循环依赖:A依赖B,B又依赖A。在容器里这会导致死循环。一个常用的解决方法是引入事件总线或中间层。
模块划分与依赖注入关系图
小结
模块划分与依赖注入是SDK架构的两大基石。边界定义好了,代码的“地盘”就清晰了;依赖注入用上了,模块之间的“协作”就不再是硬绑定的。记住:抽象接口是契约,依赖注入是物流,模块是独立的生产线。每一条生产线只关心自己的零件,物流把需要的零件送进来,整个系统就灵活了。