在过去的四篇文章中,我们从DI的基础理论聊到手写容器,再到Swinject框架实战,最后还探讨了它在声明式UI中的应用。至此,我们对"使用DI容器"已经有了非常深入的了解。
但是,如果我们将目光仅仅局限在Swinject这样的"DI容器"上,就可能会陷入"手里拿着锤子,看什么都是钉子"的思维定式。
今天,我们要回归本源,再次强调一个核心观点:依赖注入(DI)是一种设计思想,而不是某一个特定的工具或框架。 理解了这一点,我们就能在不同的场景下,选择最恰当的方式来实现解耦,而不是一味地追求"上容器"。
1. DI思想的多种实现形态
DI思想的本质是将依赖的创建和管理责任从使用者内部转移到外部。实现这一目标,我们有多种武器可选。
a. 手动DI (Manual DI)
这其实是我们最开始接触,也是最朴素的DI形式。它不借助任何框架,直接在代码中通过构造函数或属性来传递依赖。
swift
// 在某个负责组装的类(比如一个Factory或者Coordinator)中
func makeProfileScene() -> UIViewController {
// 手动创建和注入依赖
let apiService = APIService()
let userCache = UserCache()
let userManager = UserManager(api: apiService, cache: userCache)
let viewModel = ProfileViewModel(manager: userManager)
let viewController = ProfileViewController(viewModel: viewModel)
return viewController
}
优点:
- 零依赖: 不需要引入任何第三方框架。
- 极其直观: 代码如何执行一目了然,没有"黑魔法"。
- 编译时安全: 所有依赖关系都在编译时确定。
缺点:
- 代码冗长: 当依赖链很长或很复杂时,组装代码会变得非常繁琐。
适用场景:
- 小型项目或独立模块。
- 在应用的"组合根"(后面会详谈)进行最高层的对象组装。
- 当你希望对依赖的创建有最细粒度的控制时。
b. 工厂模式 (Factory Pattern)
工厂模式是面向对象设计中的经典模式,它本身就是DI思想的一种体现。工厂类封装了创建对象的复杂逻辑,调用者只需向工厂请求一个对象,而无需关心其内部是如何被创建和组装的。
swift
// 一个专门负责创建ViewController的工厂
class ViewControllerFactory {
// 工厂自身也可能有依赖,通过构造函数注入
private let apiService: APIService
private let userManager: UserManager
init(apiService: APIService, userManager: UserManager) {
self.apiService = apiService
self.userManager = userManager
}
func makeLoginViewController() -> LoginViewController {
let viewModel = LoginViewModel(apiService: self.apiService)
return LoginViewController(viewModel: viewModel)
}
func makeProfileViewController() -> ProfileViewController {
let viewModel = ProfileViewModel(manager: self.userManager)
return ProfileViewController(viewModel: viewModel)
}
}
优点:
- 封装创建逻辑: 将复杂的创建过程从业务代码中分离出来。
- 职责单一: 工厂的职责就是创建,非常清晰。
适用场景:
- 当对象的创建过程比较复杂,包含一些配置或判断逻辑时。
- 作为DI容器的一种轻量级替代方案,用于管理某一类特定对象(如所有ViewController)的创建。
c. 服务协议 (Service Protocol) 与"协议式服务发现"的辨析
我们一直在强调面向协议编程是DI的基石。在Swift的生态中,协议(Protocol)是一个极其强大的工具,它也被用于一些组件化方案中,实现所谓的"协议式服务发现"。我们必须清晰地辨析它与我们所提倡的依赖注入模式的区别。
"协议式服务发现"模式剖析
这种模式通常会有一个全局的管理者(比如叫RouterService
或ComponentManager
),并结合泛型来实现服务的注册和发现。
swift
// ---- 这种模式的典型实现 ----
// 1. 公共模块定义协议
public protocol ProfileServiceProvider {
func getProfileViewController(for userId: String) -> UIViewController
}
// 2. 在Profile组件中实现并"注册"
class ProfileModule: ProfileServiceProvider { /*...*/ }
// 通过某种机制,比如启动时扫描或手动编码,将 `ProfileServiceProvider.self` 和 `ProfileModule.new` 关联起来
RouterService.shared.register(service: ProfileServiceProvider.self, implementation: ProfileModule.init)
// 3. 在使用方,通过泛型协议"发现"服务
// 这个 `rs` 属性可能是一个通过 @dynamicMemberLookup 实现的语法糖
let profileVC = RouterService.rs.profileServiceProvider.getProfileViewController(for: "123")
navigationController.push(profileVC)
// 泛型解析的背后逻辑
extension RouterService {
func service<T>(for protocolType: T.Type) -> T? {
// ... 从注册表中查找并返回实现类的实例 ...
}
}
// 所以 RouterService.rs.profileServiceProvider 实际上是调用了 service(for: ProfileServiceProvider.self)
为什么我们要警惕并避免这种模式?
尽管这种方式利用了协议和泛型,看起来很"Swift-y",并且实现了模块间的解耦,但它从根本上违反了依赖注入的核心原则,并退化为了**服务定位器(Service Locator)**模式,其危害我们在第一篇中已经深入讨论过:
- 隐藏依赖(幽灵依赖): 任何一个需要
ProfileServiceProvider
的类,在其公开接口(如init
)上完全看不出来。依赖关系深埋在实现细节中,你需要通读代码才能发现它调用了RouterService
。这严重破坏了代码的可读性和可维护性。 - 耦合到全局定位器: 所有业务代码都与
RouterService.shared
这个全局单例紧密耦合。这使得代码单元测试变得极其困难。为了测试一个ViewModel,你必须去处理或模拟这个全局的RouterService
,而不是简单地在创建ViewModel时传入一个Mock对象。 - 职责不清: ViewModel或ViewController的职责应该是处理业务逻辑和UI状态,而不应该关心它的依赖是从哪里来的。让它自己去"定位"服务,是典型的职责不清。
我们的选择:明确的依赖注入
依赖注入坚持:一个类需要什么,就必须在它的构造函数里明确声明。
swift
// 依赖注入的方式
class SomeCoordinator {
private let profileServiceProvider: ProfileServiceProvider // 依赖是明确的成员变量
init(profileServiceProvider: ProfileServiceProvider) { // 通过构造函数注入
self.profileServiceProvider = profileServiceProvider
}
func showProfile() {
let vc = profileServiceProvider.getProfileViewController(for: "123")
// ...
}
}
结论: "协议式服务发现"是一种伪装成现代模样的服务定位器 。它解决了模块解耦,却以牺牲代码清晰度、可测试性和职责单一性为代价。因此,在我们的实践中,应当明确拒绝这种模式,始终选择通过构造函数进行显式依赖注入。
2. 架构的关键节点:"组合根" (Composition Root)
我们一直在说"依赖由外部提供",那么这个"外部"的尽头是哪里?应用程序总得有一个地方,负责创建所有这些依赖,并将它们"粘合"在一起。这个地方,就叫做组合根 (Composition Root)。
组合根是应用中唯一一个可以引用具体实现,并将它们与业务代码中的抽象(协议)连接起来的地方。
在典型的iOS App中,组合根通常位于:
- UIKit App:
AppDelegate
或SceneDelegate
。 - SwiftUI App:
@main
入口的App
struct。
swift
// 在SceneDelegate中,这里是我们的组合根
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
var assembler: Assembler! // Assembler是组合根的一部分
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// --- 这是组合根的核心区域 ---
setupDIContainer()
let rootCoordinator = assembler.resolver.resolve(AppCoordinator.self)!
// --------------------------
guard let windowScene = (scene as? UIWindowScene) else { return }
window = UIWindow(windowScene: windowScene)
window?.rootViewController = rootCoordinator.start() // 启动应用
window?.makeKeyAndVisible()
}
private func setupDIContainer() {
assembler = Assembler([
ServiceAssembly(),
ViewModelAssembly(),
CoordinatorAssembly()
// ...所有模块的Assembly都在这里被组装
])
}
}
理解"组合根"的概念至关重要,因为它回答了"DI容器应该在哪里被创建和使用"的问题。答案是:DI容器本身(或其Assembler)应该只存在于组合根中。 其他所有业务代码(ViewModel、Service等)都应该是"纯洁"的,它们不应该知道DI容器的存在,只通过构造函数接收自己需要的依赖。
这可以防止我们将DI容器滥用为我们第一篇中批评的"服务发现器"。
3. 总结与思想升华
依赖注入是一种深刻影响我们代码组织方式的设计思想。它的目标是追求高内聚、低耦合。
- DI容器(如Swinject) 是实现这一思想的强大工具,特别适合管理复杂应用中的众多依赖。
- 手动DI和工厂模式 则是更轻量、更直接的实现方式,在简单场景下同样有效。
- 面向协议编程 是实现DI价值的基石。
- 组合根 是我们应用DI原则,同时又保持架构清晰的"圣地"。
作为架构师或资深开发者,我们需要具备根据不同项目规模和复杂度,选择最合适DI实现方式的能力。可能是一个全功能的DI容器,也可能只是一组精心设计的工厂类,甚至只是在组合根中的手动注入。
理解了DI是一种思想而非特定工具,我们就拥有了更大的架构自由度和灵活性。
在最后一篇中,我们将进行一场关键的辩证讨论:我们团队中已经存在的"路由解耦"方案,与我们现在提倡的DI思想,究竟是竞争关系还是可以协同作战的盟友?我们将给出最终的架构决策建议。
敬请期待!