依赖注入(dependency injection, DI)是软件工程的一种设计模式,也是实现控制反转的一种技术,即设计模式 SOLID 原则中的依赖倒置原则。在软件开发中,DI 经成为了构建可维护、可扩展和可测试应用程序的关键技术。不同的框架在实现依赖注入时,展现了各自独特的设计思路和实现方式,各有其优点和适用场景。
本文将从理解 DI 的理念讲起,到深入分析 Factory 框架的源代码。 Factory 是 Swift 和 SwiftUI 下基于容器的 DI 框架,具有普适性、高性能、安全、灵活等优势,这些特性会在后文进行讲解。
但需要强调的是,当我们探讨 DI 框架时,实际上是在探讨一种思想、一种解决问题的方法,而非唯一或最优的解决方案。在实际项目中,我们应该根据项目的具体需求、团队的技术储备来选择合适的框架和工具。
一、基本的依赖项消费
最基本的依赖项消费可以总结为两种:
- 初始化实例时,为实例推送依赖项;
- 使用服务定位模式模式,实例请求依赖项。
对于第一种方式,可以以“登录账号后刷新私信内容”场景为例,写出以下伪码:
struct LogInView: View {
// 1. 验证服务
private let authService: AuthService
// 2. 私信服务
private let imService: IMService
// 3. 构造函数
init(authService: AuthService, imService: IMService) {
// ...
}
var body: some View {
// 4. 登录按钮,触发`verify`方法
Button {
verify()
} label: {
Text("Login")
}
}
private func verify() {
Task {
do {
// 5. 获取 token
let token = try await authService.fetchToken()
// 6. 更新私信服务
imService.update(token)
} catch let error {
// ...
}
}
}
}
推送依赖的方式明确了实例需要的所有依赖。但随着业务代码的增长,依赖关系的传递可能会变得十分复杂:若开发者需要一个在较深的分支节点中消费新的依赖,则需要从根节点开始传递,业务代码会不断复杂化。这违反了单一职责原则,中间节点被迫依赖了自身不直接使用的依赖项。至此建造者模式、工厂模式开始被引入降低复杂性。
对于第二种方式,实例将在运行时询问注册表来获取依赖项。可以以修改上述场景,写出以下伪码:
struct LogInView: View {
// 1. 验证服务
@EnviromentObject var authService: AuthService
// 2. 私信服务
@EnviromentObject var imService: IMService
// ...
}
上述两种方式,解决方案的本质是相同的:全局可变状态。
二、依赖项的方式分发
依赖项的方式分发一般分为两种:基于树的方式、基于单例的方式。
基于树的方式是“送依赖项”的一种形式,其意味着所需的依赖项在节点中传递,贯穿应用的生命周期和视图层次结构。以下图为例,依赖项都显式地沿着树传递,直到到达需要它的节点:
可以将这些依赖项捆绑到一个类或结构中,传递这些类或结构比传递单个依赖项更容易。但中间节点须处理仅与远端节点相关的依赖关系项,如图中间节点现在需要传递对自身无用的依赖项 A。实际应用中的树都比这复杂的多,节点也会变得紧密耦合。
基于单例的方式(或者是静态声明),是实例请求依赖项一种形式。依赖项以某种方式被静态定义和访问。但并不意味着依赖项会立即创建,有的在第一次被访问时会被延迟初始化,通常有可选的规则来定义着依赖项的生命周期。
如下图所示,当使用基于单例方式的依赖项时,依赖项完全分布在节点树之外,消除了节点创建和传递依赖项的责任:
这种方式消除了组件的紧密耦合,且使新增、更改依赖项变得更容易,且非树形的结构更容易实现。但是它引入了与服务定位模式相同的问题:隐藏了类的依赖关系,导致可能会发生运行时错误而不是编译时错误。以上图依赖项 A 为例,当应用启动且未构建依赖项 A 时,节点消费依赖项 A 时,如何保证依赖项 A 已经被创建?在正确的时刻创建(解锁)静态声明的依赖项是一个很难的问题。有一些观点例如Service Locator violates SOLID,认为服务定位模式违反了 SOLID 的接口隔离原则,每一个节点都可以自由的使用任何依赖项。
三、DI 的评价体系
如何分发依赖项值得权衡和深思的,需要理解和评估一些权衡项。
评价体系没有固定的标准,这里参考了来自 Lucas van Dongen 对于 DI 的评价体系:
- 可用性:开发者理清需依赖结构并正确使用 DI 的成本;
- 可测试性:开发者可以容易的将真实的依赖项替换为测试版本,无需处理上下文中不关心的依赖项的复杂性;
- 可扩展和可缩放:可扩展表示支持增加新的依赖项,但随着依赖项数量和复杂性的增加,应用启动时间等相关指标就会劣化,此时需要支持减少依赖项,可扩展和可缩放意味着对于任何规模的应用都可以提供可行的解决方案。
- 安全性:包括编译安全、运行时安全。编译器可以验证程序正确性,且DI 解决方案永远不应该成为运行时崩溃或意外行为的原因。
四、使用 DI:手动维护的树结构
手动传递依赖项即不使用任何框架方法,也是最简单的依赖注入。对之前的伪码稍作修改:
struct AccountView: View {
private let dependencies: DependenciesContainer
private let authService: AuthService
var body: some View {
switch authService.state {
case .logIn:
LogOutView(dependencies: dependencies(for: .logIn))
case .logOut:
LogOutView(dependencies: dependencies(for: .logOut))
}
}
}
现在我们有一个 AccountView,根据 authService 的 state 的不同展示不同的视图。它的原理简单且明确,在缺少依赖项时,会编译失败从而提醒开发者。但当业务变复杂后,部分节点需要管理与它们本身不直接相关的依赖项。
但上面代码其实存在一个问题,SwiftUI 的视图树会受到渲染树的状态属性的变化而重新构造 body,从而导致依赖项不断重复创建。视图树和渲染树的相关概念,可以从 SwiftConf.to 的 A Day in the Life of a SwiftUI View 入门。因此,子视图获取依赖项的时机需要调整,或者说这些依赖项的实现有持久化的能力等。
五、使用 DI:Apple 属性包装器
历来,Apple 对 DI 没有任何支持,直到为 SwiftUI 引入 @Environment 和 @EnvironmentObject 的依赖注入方式。
在 SwiftUI 中,@Environment 和 @EnvironmentObject 都是用于在视图之间传递数据的机制,但它们的使用场景和方式有所不同。@Environment 通常用于传递单个的值或常量,而 @EnvironmentObject 用于传递一个对象,这个对象可以包含多个属性和方法。
@Environment 是视图用于从环境中读取特定值的属性包装器,开发者可以通过自定义 EnvironmentKey 的方式来创建自定义环境值。@EnvironmentObject 是用于在视图中使用上层视图传递的 ObservableObject 实例的属性包装器。这里不做详细展开。
在下述伪码中,AccountView 获取了上层视图注入的 EnvironmentKey 为 tokenKey 的 token,并根据 token 的状态展示不同的视图。同时将变体的 token 注入 LogOutView。AccountView 还获取了上层视图注入的 @EnvironmentObject 属性包装器包装的 accountViewModel,同时将 userModel 注入 LogOutView:
struct AccountView: View {
@Environment(\.tokenKey) var token
@EnvironmentObject var accountViewModel: AccountViewModel
var body: some View {
if token.count {
LogOutView()
.environment(\.tokenKey, "Hello, \(token)")
} else {
LogOutView()
.environmentObject(accountViewModel.userModel)
}
}
}
首先,这是 Apple 最基本的 DI 方式,可以假定所有开发者默认会使用,有优异的可用性、且设置测试也相对容易。
对于使用 @Environment,是基于树的方式,且与静态声明集合。EnvironmentKey 协议强制开发者提供了 defaultValue。即使上层视图不注入,也会使用默认值,不会发生 Crash。若类型不匹配,也会通过编译失败进行提醒,使用起来相当安全。而 @EnvironmentObject 不同,若上层视图未注入,则子视图使用时,编译没有问题,但会发生运行时 Crash。
但由于上述俩属性包装器利用 SwiftUI 的渲染树来存储这些依赖项,因此只能通过视图访问它们,这种方式不具有普适性。且若为视图单独创建了 ViewModel,这些 ViewModel 是无法直接接触到被包装的属性。
六、使用 DI:Factory
6.1 如何使用 Factory
以下是一个简单使用 Factory 的示例:
// 1. 协议
protocol AServiceProtocol {
func whoAmI_a() -> String
}
// 2. 实现
class AServiceImpl: AServiceProtocol {
func whoAmI_a() -> String {
"AServiceImpl"
}
}
// 3. 注册
extension Container {
var aService: Factory<AServiceProtocol> {
self { AServiceImpl() }.singleton
}
}
// 4. 使用
print(Container.shared.aService().whoAmI_a())
我们将依赖项包装到 Factory 类型中,声明为 Container 的属性,这里只需使用 () 即可解析实例。上述 self { AServiceImpl() }.singleton 可能较为难以理解,其的工作原理是 Swift 5.2 中引入的callAsFunction。
允许用户将某些类型的实例作为函数调用:
struct CallableStruct { var value: Int func callAsFunction(_ number: Int, scale: Int) { print(scale * (number + value)) } } let callable = CallableStruct(value: 100) callable(4, scale: 2) callable.callAsFunction(4, scale: 2) // Both function calls print 208.
此外,也可以使用 Factory 的属性包装器 @Injected 、@LazyInjected 、@WeakLazyInjected 、@InjectedObject 等来请求所需的依赖项:
struct ContentView: View {
@Injected(\.aService) private var aService
var body: some View {
Text("Hello, world!")
.onAppear {
print(aService.whoAmI_a())
}
}
}
依赖项同时提供多种作用域,可见后文 Scope 部分。
6.2 使用 Factory 进行动态注册或 Mock
为什么不直接使用 let aService = AServiceImpl()?使用 DI 的优势之一是能够根据需要更改依赖项的行为。如果我们有一个 ContentViewModel,其内部使用 myService 的 API :
extension Container {
static var myService: MyServiceType { MyService() }
}
struct ContentView: View {
@StateObject var model = ContentViewModel()
var body: some View {
Text(model.text())
}
}
存在一种情况,当我们想预览我们的视图时,如何让依赖 ContentViewModel 不会在开发期间产生对 myService 的真实 API 调用?只替换 MyService 为也符合 MyServiceType 其他实例:
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
let _ = Container.shared.myService.register { MockService2() }
ContentView()
}
}
上述能力可以让开发者更深入了解依赖链,并根据需要改变依赖项的行为。示例也表明,Factory 提供动态注册一个新实现的能力。这个新的注册会覆盖旧依赖项,清除其关联的范围。下次解析此赖项时,将返回新注册的依赖项。
此外,也可以通过 Fatory 的上下文注入能力,提供不同的依赖项:
var myService: Factory<MyServiceProtocol> {
Factory(self) { MyService() }
.onPreview { MyServicePreview() }
.onSimulator { MyServiceeSimulator() }
.onText { ... }
更多 Factory 的功能,可以参考 Factoty 文档。
七、Factory 的设计
7.1 整体设计
简单的描述 DI,就是使用字典或其他数据结构来存储和管理依赖关系。当然, Factory 的核心逻辑也是如此:
上述图片以最简单的方式描述了 Factory 的核心逻辑:
- 业务提供服务协议、服务实现,业务其他角色通过 Factory 的 Container 使用这些服务;
- 服务实现被 Factory 中的 Factory 结构包装,被注册到 Container 中。
但是我们的目的是要分析 Factory 的代码设计,所以不能只以使用者视角来看待 Factory。展开 Factory 的结构,是一个庞大的关系网:
在这里先对 Factory 的角色进行了简单、快速的划分。业务可接触的部分用红色进行标注、协议使用蓝色进行标注、Factory 的结构使用绿色进行标注、注册相关使用紫色进行标注,其他内容使用灰色进行标注。
这张图放在这里主要是为了先揭示 Factory 内部的复杂性、对 Factory 有一个初步的认识。并不要求再这个环节熟知这些角色,在后文将会逐步拆解并深入分析源码设计。
在 Facory 源代码中,其目录结构如下:
+ Factory
+ Aliases
+ Containers
+ Contexts
+ Factory
+ Globals
+ Injections
+ Key
+ Locking
+ Modifiers
+ Registrations
+ Resolutions
+ Resolver
+ Scopes
这很精简,但这对源代码的解读并不友好。这里将调整其目录结构,并按照新结构进行 Factory 的源码设计分析:
Factory
├── 1 Base
│ ├── 1.1 Key
│ ├── 1.2 Locking
│ ├── 1.3 Scopes
│ └── 1.4 Contexts
├── 2 Container
│ ├── 2.1 Containers
│ └── 2.2 Resolver
├── 3 Factory
│ ├── 3.1 Factory
│ └── 3.2 Modifiers
├── 4 Registration
│ └── 4.1 Registrations
├── 5 Global
│ ├── 5.1 Resolutions
│ ├── 5.2 Globals
│ └── 5.3 Aliases
└── 6 Injection
└── 6.1 Injections
上述将目录划分成了 7 个部分,Base(基础)、Container(容器)、Factory(Factory 结构)、Registration(注册与解析)、Global(全局相关)、Injection(使用属性包装器)。
7.2 Base 部分设计
7.2.1 FactoryKey
FactoryKey 结构在 Factory 中,作为存储和管理依赖的键的角色。由 ObjectIdentifier 和 StaticString 两个属性组成:
// Key.swift
internal struct FactoryKey: Hashable {
let type: ObjectIdentifier
let key: StaticString
internal init(type: Any.Type, key: StaticString) {
self.type = ObjectIdentifier(type)
self.key = key
}
internal func hash(into hasher: inout Hasher) {
//...
}
static func == (lhs: FactoryKey, rhs: FactoryKey) -> Bool {
//...
}
}
ObjectIdentifier 和 StaticString,相比传统的 Stirng 作为键,有更好的性能。同时两者组合的方式,很好的支持了 Factory 服务的多实例能力:
protocol SomeServiceProtocol { ... }
class AService: SomeServiceProtocol { ... }
class BService: SomeServiceProtocol { ... }
extension Container {
var aService: Factory<SomeServiceProtocol> { self { AService() }
var bService: Factory<SomeServiceProtocol> { self { BService() }
}
在上述伪码中,aService、bService 均为符合 SomeServiceProtocol 的协议的服务实例,若只用 SomeServiceProtocol 作为键,存在注册冲突的问题。
7.2.1.1 ObjectIdentifier
元类型不能用作字典的键,因为元类型不符合 Hashable,且我们也无法扩展元类型,无法使它们符合 Hashable:
let map = [String.Type: String]()
// Error: Type 'String.Type' cannot conform to 'Hashable'
ObjectIdentifier 是 Swift 标准库中定义的类,能够为引用类型和元类型提供唯一标识符。ObjectIdentifier 的行为与指针相等运算符类似:
class Foo: Equatable {
let value: Int
init(_ value: Int) { self.value = value }
static func == (lhs: Foo, rhs: Foo) -> Bool { lhs.value == rhs.value }
}
let fooA = Foo(1)
let fooB = Foo(2)
let value1 = fooA == fooB // 比较 value 值
let value2 = fooA === fooB // 比较内存地址
let value3 = ObjectIdentifier(fooA) == ObjectIdentifier(fooB) // 比较内存地址
ObjectIdentifier 本质是它只是对象内存地址的包装:
public struct ObjectIdentifier {
internal let _value: Builtin.RawPointer
public init(_ x: AnyObject) {
self._value = Builtin.bridgeToRawPointer(x)
}
public init(_ x: Any.Type) {
self._value = unsafeBitCast(x, to: Builtin.RawPointer.self)
}
}
因为内存地址本质上也只是一个数字,所以我们可以通过简单地比较这个数字来给出 Equatable 和 Hashable 能力:
extension ObjectIdentifier: Equatable {
public static func == (x: ObjectIdentifier, y: ObjectIdentifier) -> Bool {
return Bool(Builtin.cmp_eq_RawPointer(x._value, y._value))
}
}
extension ObjectIdentifier: Hashable {
public func hash(into hasher: inout Hasher) {
hasher.combine(Int(Builtin.ptrtoint_Word(_value)))
}
}
元类型被视为对象类型的单个全局实例,因此使用 ObjectIdentifier 可以使元类型支持散列:
let dict = [ObjectIdentifier: String]()
let metaObject = ObjectIdentifier(UIViewController.self)
dict[metaObject] = "Hello World!"
我们可以使用 === 操作符和 ObjectIdentifier 类型来快速、唯一地识别对象,而不是要求实现者符合 Equatable,或暴露如 UUID 的唯一标识符。同时可以使用 ObjectIdentifier 让元类型支持散列。
7.2.1.2 StaticString
Key 中使用了 StaticString,这是一个相对小众的类型,是对内容的低级别的访问。StaticString 旨在表示编译时已知的文本,且运行时不会被修改。StaticString可以在 Swift 中引用源元数据,例如 #file、#function,也可以显式声明:
let path: StaticString = #file
let helloWorld: StaticString = "Hello World!"
String 包含整个数据结构,而 StaticString 只读取二进制文件中原始字符串的内存地址,非堆分配的字符串:
public struct StaticString: Sendable {
/// Either a pointer to the start of UTF-8 data, represented as an integer,
/// or an integer representation of a single Unicode scalar.
@usableFromInline
internal var _startPtrOrData: Builtin.Word
...
}
这是一个优化技巧,在内存大小和性能上都有提升。Factory 的作者在设计 Factory 时,于 Swift 论坛发起的有关 StaticString 的讨论,使用 StaticString 后,和使用普通字符串相比,FactoryKey 的哈希速度有 400% 的提升,这部分速度的提升,本质因素是把字符串的比较优化为指针的比较。
在使用 StaticString 的更多能力时,需要考虑 hasPointerRepresentation,指示静态字符串是否存储指向以 null 结尾的 UTF-8 code units 的指针。例如在 FactoryKey 中,其 hash(into:) 中根据 hasPointerRepresentation 做了差异化实现实现:
// Key.swift
internal func hash(into hasher: inout Hasher) {
hasher.combine(self.type)
if key.hasPointerRepresentation {
hasher.combine(bytes: UnsafeRawBufferPointer(
start: key.utf8Start,
count: key.utf8CodeUnitCount))
} else {
hasher.combine(key.unicodeScalar.value)
}
}
下面两段代码,更细致的解释了 hasPointerRepresentation 为 true 和 false 的差异:
let emoji: StaticString = "\u{1F600}"
emoji.hasPointerRepresentation //-> true
emoji.isASCII //-> false
emoji.unicodeScalar //-> Fatal error!
emoji.utf8CodeUnitCount //-> 4
emoji.utf8Start[0] //-> 0xF0
emoji.utf8Start[1] //-> 0x9F
emoji.utf8Start[2] //-> 0x98
emoji.utf8Start[3] //-> 0x80
emoji.utf8Start[4] //-> 0x00
struct MyStaticScalar: ExpressibleByUnicodeScalarLiteral {
typealias UnicodeScalarLiteralType = StaticString
let value: StaticString
init(unicodeScalarLiteral value: StaticString) {
self.value = value
}
}
let emoji: StaticString = MyStaticScalar("\u{1F600}").value
emoji.hasPointerRepresentation //-> false
emoji.isASCII //-> false
emoji.unicodeScalar.value //-> 0x1F600
emoji.utf8CodeUnitCount //-> Fatal error!
emoji.utf8Start //-> Fatal error!
此外需要注意,StaticString 不符合 Hashable 协议,Swift 社区也有相关的讨论 Make StaticString conform to Hashable。可能是 Apple 认为设计初衷是用于性能敏感的场景,运行时的哈希计算会引入不必要的开销,关于 StaticString 的该提案也没有推进。
7.2.2 Locking
Factory 中提供和使用了两种锁,RecursiveLock(递归锁)和 SpinLock(自旋锁),本质是对 pthread_mutex_t 和 os_unfair_lock 的封装:
internal var globalRecursiveLock = RecursiveLock()
internal let globalDebugLock = SpinLock()
internal struct RecursiveLock {
init() { ... }
@inline(__always) func lock() { ... }
@inline(__always) func unlock() { ... }
@usableFromInline let mutex: UnsafeMutablePointer<pthread_mutex_t>
}
internal struct SpinLock {
init() { ... }
@inline(__always) func lock() { ... }
@inline(__always) func unlock() { ... }
@usableFromInline let oslock: UnsafeMutablePointer<os_unfair_lock>
}
全局的递归锁是 Factory 注册、解析等使用的锁,适合 Factory 可能存在的递归调用场景,能够避免死锁问题。全局的自旋锁用于 Debug 编译条件下的日志记录、循环依赖检测等,这里不对这些锁详细展开描述。
7.2.2.1 @inline 内联函数
内联函数是一种编译器优化技术,通过直接用方法的内容替换对方法的调用,来消除调用这些方法的开销,从而提高性能,但也会增加二进制文件的大小。在 Swift 中,@inline 即内联的编译指令,用于指示编译器应在何处内联代码。
默认情况下,编译器可以自行做出内联决定,但可以在 Swift 中使用 @inline 来辅助其做出决定。@inline(__always) 如果可能的话内联该方法;@inline(never):永远不要内联该方法:
@inline(__always) func alwaysInline() {
print("This function is always inlined")
}
@inline(never) func neverInline() {
print("This function is never inlined")
}
由于内联函数会将函数调用展开为函数体,因此当编译器内联某一函数时,该函数本身将不再会被调用。所以在 Debug 模式下,内联不利于问题排查。因此在 Debug 模式下,编译器默认将不进行内联。
在 Xcode - TARGETS - Build Settings - Swift Compiler - Optimization Level 下,调整编译器优化等级(包括 [-Onone](无优化 Debug 模式默认)、[-O](速度优先,Release 模式默认)、[-Osize](体积优先))。[-O] 与 [-Osize]等级下均可以被优化。
但需要注意的是,其下划线的存在是有原因的,@inline 在 Swift Attributes 中未公开。也有 The Forbidden @inline Attribute in Swift 建议不应该使用 @inline。同时需要注意,dynamic和 @inline(__always) 无法同时使用。
7.2.2.2 @inlinable 跨模块内联
@inlinable 是 Swift 中较为少用的属性。与 @inline 不同,@inlinable 是 Swift Attributes 明确公开的。与其他同类属性一样,其目的是启用一组特定的微优化,来提高应用程序的性能。
编译器优化是因为编译器对正在编译的内容有全面的了解,但编译器不知道使用者如何对其进行操作。使用三方库时,链接已经编译的内容时,源文件上的可优化信息不存在了。
虽然框架的内部代码可能会被优化,但公共接口是保持完整。因此编译器对这个问题的解决方案,增强模块的公共接口以包含编译器可以在链接时使用的附加信息,以进一步优化三方库的代码片段。 SE-0193 提案提出了Cross-module inlining and specialization 跨模块内联。@inlinable 属性将函数主体作为模块接口的一部分导出,使其在从其他模块引用时可供优化器使用。
这个特性不能应用在内嵌函数或者 fileprivate 和 private 声明中,且如果某些东西是可内联的,它将成为模块 ABI 的一部分。
但是公共函数可能依赖于私有函数,这意味着可内联函数的每个传递依赖项也将成为 ABI 的一部分,即私有函数现在实际上已嵌入公共 ABI,可能会意外破坏 ABI,发生兼容性问题,除非要求库的用户在更新时重新编译。所以 Swift 不会这样做。
此时,@usableFromInline 出现,将原本属于模块内部的声明提升为 ABI 的一部分。@usableFromInline 属性只能应用于internal 声明,在内部声明中,@inlinable 隐含 @usableFromInline。
7.2.3 Scope
Scope 定义已解析依赖项的生命周期。Factory 提供了五种范围类型,包括 Singleton、Unique、Cached、Shared 和 Graph。
当 Scope 与 Factory 关联时,第一次解析依赖项时会根据设定缓存对该对象的引用。下次解析 Factory 时,将根据设定返回对原始缓存对象的引用。这里的设定就是上述五种范围类型。
每一个 Scope 都使用一个 Cache,这个 Cache 本质是 FactoryKey 和 AnyBox (依赖项的包装) 的字典:
extension Scope {
internal final class Cache {
typealias CacheMap = [FactoryKey:AnyBox]
@inlinable func value(forKey key: FactoryKey) -> AnyBox? { ... }
@inlinable func set(value: AnyBox, forKey key: FactoryKey) { ... }
// ...
}
}
注意这里是**“使用”而不是“拥有”**,通过继承和重写的方式,实现了上述五种范围类型。五种类型:
static let singleton: Scope.Singleton单例作用域,该作用域每一个依赖项都是一个单例,不受 Container 限制。static let unique: Scope.Unique短暂作用域,每次解析依赖项时都会返回一个全新的实例。static let cached: Scope.Cached容器作用域,Container 自身拥有一个Scope.Cache,此作用域将缓存在该 Cache 中。static let shared: Scope.Shared弱作用域,Container 自身拥有一个Scope.Cache,此作用域将缓存在该 Cache 中,但是是弱引用。static let graph: Scope.Graph图作用域,Graph 是一个小众的设计,它类似短暂作用域——总是会创建一个实例,但它持有一个单独的 Cache,但在一次解析周期中对于相同的服务只解析一次,当一次解析周期完毕后,自动清空 Cache。在 Swinject、Resolver 中都有这一概念,且被 Swinject、Resolver 作为默认的 Scope。
Scope 只干一件事——将类型 T 的依赖项放入 Cache,默认实现是:
public class Scope {
fileprivate func unboxed<T>(box: AnyBox?) -> T? { ... }
fileprivate func box<T>(_ instance: T) -> AnyBox? { ... }
internal func resolve<T>(
using cache: Cache,
key: FactoryKey,
factory: () -> T
) -> T {
if let box = cache.value(forKey: key),
let cached: T = unboxed(box: box) {
return cached
}
let instance = factory()
if let box = box(instance) {
cache.set(value: box, forKey: key)
}
return instance
}
}
通过提供静态变量的方式提供了上述五种 Scope:
extension Scope {
// Cached
public static let cached = Cached()
public final class Cached: Scope { ... }
// Unique
public static let unique = Unique()
public final class Unique: Scope {
internal override func resolve<T>(using cache: Cache, key: FactoryKey, factory: () -> T) -> T {
factory()
}
}
// Singleton
public static let singleton = Singleton()
public final class Singleton: Scope, InternalScopeCaching {
internal var cache = Cache()
internal override func resolve<T>(using cache: Cache, key: FactoryKey factory: () -> T) -> T {
return super.resolve(using: self.cache, key: key, ttl: ttl, factory: factory)
}
}
// Shared
public static let shared = Shared()
public final class Shared: Scope {
fileprivate override func box<T>(_ instance: T) -> AnyBox? {
// return WeakBox
}
}
}
7.2.3.1 弱引用集合
每一个 Scope 都使用一个 Cache,会将解析好的依赖项进行按需缓存。即上文 [FactoryKey:AnyBox],AnyBox 的相关实现如下:
internal protocol AnyBox {
var scopeID: UUID { get }
var timestamp: Double { get set }
}
internal struct StrongBox<T>: AnyBox {
let scopeID: UUID
var timestamp: Double
let boxed: T
}
internal struct WeakBox: AnyBox {
let scopeID: UUID
var timestamp: Double
weak var boxed: AnyObject?
}
因为不同的 Scope 可能使用同一个 Cache,所以为 AnyBox 添加了 ``scopeID 字段,在清理 Cache 时使用。Factory 有依赖项时间维度的声明周期设计,所以有timestamp字段来标识上次使用时间和设置过期逻辑。boxed` 为具体封装的依赖项。
上文提到,Cache 需要有对依赖项的强、弱引用,若不做上述结合两种类型的 AnyBox 的封装,则可能会为 Cache 添加强引用字典和弱引用字典。但是 Swift 是不提供弱引用字典的,弱引用随时可能变为 nil 值,这颠覆了 Swift 的设计理念——容器的值语义,并推翻了典型容器算法所做的假设,例如典型的排序算法假设两个元素之间的比较结果随时间保持一致,但如果排序过程中弱引用消失,会导致结果异常。而退化为 NSHashTable 则有损性能,所以业内更多采用了类似上述通过结构进行封装。
7.2.3.2 泛型
泛型简单来讲,比使用多种不同类型的声明更简洁,比使用 Any 类型更安全(编译时的类型检查)和有更高的性能(编译时类型推断和优化)。
弱引用涉及引用计数,所以 WeakBox 的 boxed 必须为类,AnyObject 是所有类都隐式遵守的协议,类似于 Objective-C 的id 类型。WeakBox 没有使用泛型,这里若使用了泛型,代码可以这样描述:
internal struct WeakBox<T: AnyObject>: AnyBox {
let scopeID: UUID
var timestamp: Double
weak var boxed: T?
}
但仓库暴露给开发者的接口,并没有指明依赖项必须是 AnyObject,所以在 Scope 中构造 WeakBox 时,必须对依赖项做类型判断:
if let unwrapped = optional.wrappedValue, type(of: unwrapped) is AnyObject.Type {
return WeakBox(
scopeID: scopeID,
timestamp: CFAbsoluteTimeGetCurrent(),
boxed: unwrapped as AnyObject)
}
这样的场景下泛型是没有任何意义的,所以 WeakBox 在这里不需要新增泛型了。而 StrongBox 相反,Scope 封装 AnyBox 的操作化简如下,泛型 T 被充分利用:
fileprivate func box<T>(_ instance: T) -> AnyBox? {
return StrongBox<T>(scopeID: scopeID, timestamp: CFAbsoluteTimeGetCurrent(), boxed: instance)
}
此外,综上可知道,Factory 的依赖项,在 StrongBox 的封装下,是支持使用结构、枚举实现的。
回到泛型,Swift 编译器是如何处理泛型的?使用两种方法来实现泛型:运行时 - 装箱(Boxing)、编译时 - 特化(Specialization)。
Boxing 考虑一个具有无限制参数的方法:
func test<T>(value: T) -> T {
let copy = value
print(copy)
return copy
}
swift 编译器会创建一个单独的代码块,编写 test(value: 1) 或 test(value: "Hello"),都将调用相同的代码,并且有关类型 <T> 的附加信息将传递给方法。要实现此方法,需要知道如何复制参数、大小以在运行时分配内存、如何销毁。而且 Swift 中的结构值类型,而类是按引用类型。Value Witness Table(VWT)用于存储此信息。VWT 在编译阶段为所有类型创建,编译器保证在运行时存在完全相同的对象布局。也就是说,调用一个方法 test 并传递一个结构最终将看起来像这样:
let myStruct = MyStruct()
test(value: myStruct, metadata: MyStruct.metadata)
当 MyStruct 本身又使用 MyStruct<T>泛型时,根据 MyStruct 内的 <T>,MyStruct<Int> 和 MyStruct<Bool> 类型的元数据和 VWT 将有所不同。它们在运行时是两种不同的类型。但是为 MyStruct 的每种可能的组合创建元数据的效率极低,因此 Swift 采用不同的路线,在运行时针对此类情况动态构造元数据。编译器为通用结构创建一种元数据模式,该模式可以与特定类型组合,从而在运行时使用正确的 VWT 获取有关该类型的完整信息:
func test<T>(value: MyStruct<T>, tMetadata: T.Type) {
let myStructMetadata = get_generic_metadata(MyStruct.metadataPattern, tMetadata)
}
当将协议形式的限制添加到泛型中时,例如比较两个参数:
func isEquals<T: Equatable>(first: T, second: T) -> Bool {
return first == second
}
为了使上述方法正常工作,必须有一个指向比较方法 static func ==(lhs:T, rhs:T) 的指针。如何获得?此时 VWT 不够了,为了解决这个问题,有 Protocol Witness Table(PWT)`,在协议的编译阶段创建的,用于描述这些协议:
isEquals(first: 1, second: 2)
isEquals(first: 1
second: 2,
metadata: Int.metadata,
intIsEquatable: Equatable.witnessTable)
使用 Boxing 来实现泛型可以灵活实现所需的功能,但有可以优化的开销:通过泛型调用的方法是动态派发(通过 VWT 或 PWT)。
Specialization 带来的性能影响可以通过特化来优化。即生成特定版本,将泛型转换为非泛型,参考 Swift 最佳实践之 Generics 的例子:
func swapTwoValues<T>(_ a: inout T, _ b: inout T) {
let temp = a
a = b
b = temp
}
var a = 1
var b = 2
swapTwoValues(&a, &b)
如上,通过 Int 型参数调用 swapTwoValues 时,编译器就会生成该方法的 Int 版本:
// specialized swapTwoValues<A>(_:_:)
sil shared [noinline] @$s4main13swapTwoValuesyyxz_xztlFSi_Tg5 : $@convention(thin) (@inout Int, @inout Int) -> () {
// %0 "a" // users: %6, %4, %2
// %1 "b" // users: %7, %5, %3
bb0(%0 : $*Int, %1 : $*Int):
debug_value_addr %0 : $*Int, var, name "a", argno 1 // id: %2
debug_value_addr %1 : $*Int, var, name "b", argno 2 // id: %3
%4 = load %0 : $*Int // user: %7
%5 = load %1 : $*Int // user: %6
store %5 to %0 : $*Int // id: %6
store %4 to %1 : $*Int // id: %7
%8 = tuple () // user: %9
return %8 : $() // id: %9
} // end sil function '$s4main13swapTwoValuesyyxz_xztlFSi_Tg5'
对于如何特化,可以启用优化 ,而且仅当泛型声明的定义在当前模块中可见时,优化器才能执行专门化。
7.2.4 Contexts
FactoryContext是提供全局通用的 current 静态属性,提供当前程序构建的一些信息,如是否在 Preview、在 XCTest、在模拟器或者 Debug 以及一些自定义参数环境下:
public enum FactoryContextType: Equatable {
case arg(String)
case args([String])
case preview
case test
case debug
case simulator
case device
}
extension FactoryContext {
public static var current = FactoryContext()
}
public struct FactoryContext {
public var arguments: [String] = ProcessInfo.processInfo.arguments
public var runtimeArguments: [String:String] = [:]
public var isPreview: Bool = ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
public var isTest: Bool = NSClassFromString("XCTest") != nil
public var isSimulator: Bool = ProcessInfo.processInfo.environment["SIMULATOR_UDID"] != nil
#if DEBUG
public var isDebug: Bool = true
#else
public var isDebug: Bool = false
#endif
}
在 Factory 声明部分,通过对 Factory 的链式调用,在不同的环境来使用不同的依赖性:
extension FactoryModifying {
@discardableResult
public func onPreview(factory: @escaping (P) -> T) -> Self {
context(.preview, factory: factory)
}
}
// For Example:
var numberService: Factory<NumberServiceProtocol> {
Factory(self) { NumberService() }
.onPreview { PreviewNumberService() }
}
这部分无需更多扩展介绍。
7.3 Container 部分设计
Container 是注册依赖项的容器,这里按照三方库使用者,对 Container 相关设计做了划分,分为了三大类:
- 需要使用者使用的部分,包括 Container 类、自动注册(AutoRegistering)协议,和一些可自定的默认配置。
- 可供使用者感知的部分,单例(SharedContainer)协议、持有 Manager(ManagedContainer)的协议、运行时注册解析(Resolving)协议、提供注册持有的 ManagedContainer 类。
- 使用者不感知的部分,包括对通过 Contray 注册的依赖项 Block 和配置。
我们来看其中部分协议:
public protocol AutoRegistering {
func autoRegister()
}
public final class Container: SharedContainer {
public static let shared = Container()
public let manager: ContainerManager = ContainerManager()
public init() {}
}
public protocol SharedContainer: ManagedContainer {
static var shared: Self { get }
}
public protocol ManagedContainer: AnyObject {
var manager: ContainerManager { get }
}
extension ManagedContainer {
@inline(__always) public func callAsFunction<T>(...) -> Factory<T> { ... }
public func promised<P,T>(...) -> ParameterFactory<P,T?> { ... }
public func with(_ transform: (Self) -> Void) -> Self { transform(self); return self }
}
AutoRegistering是需要接入方自行实现的协议,在ContainerManager内部,会在 DI 服务启动时调用,可以用来加载一些必须启动的依赖项;Container是 Factory 提供的默认容器,接入方提供 Extension 来注册依赖项;SharedContainer要求容器提供单例方法,使全局可以方便的使用同一个容器;ManagedContainer要求容器有ContainerManager实例,并且提供了注册、承诺、转换方法以及默认实现;
Resolving 协议为 ManagedContainer 提供了注册、解析方法,并且提供了默认实现:
public protocol Resolving: ManagedContainer {
func register<T>(_ type: T.Type, factory: @escaping () -> T) -> Factory<T>
func factory<T>(_ type: T.Type) -> Factory<T>?
func resolve<T>(_ type: T.Type) -> T?
}
extension Resolving {
@discardableResult
public func register<T>(_ type: T.Type = T.self, factory: @escaping () -> T) -> Factory<T> { ... }
public func factory<T>(_ type: T.Type = T.self) -> Factory<T>? { ... }
public func resolve<T>(_ type: T.Type = T.self) -> T? { ... }
}
而 ContainerManager 主要处理了核心的注册、解析逻辑:
public final class ContainerManager {
internal typealias FactoryMap = [FactoryKey:AnyFactory]
internal typealias FactoryOptionsMap = [FactoryKey:FactoryOptions]
public func reset(scope: Scope) { ... }
...
}
Container 的每一类能力都拆分为单独的一个协议,并按需提供默认默认实现,模块化的设计,对适用方的唯一目的是为了“重用”,即:
public final class MyContainer: SharedContainer {
public static let shared = Container()
public let manager: ContainerManager = ContainerManager()
public init() {}
}
开发者可以以最简单的方式,声明一个自己的 MyContainer。这种设计方式充分体现了面向协议编程的理念。
7.3.1 面向协议编程
面向协议编程(Protocol-Oriented Programming,POP)是 Swift 2.0 引入的一种新编程范式,Swift 对值类型的重视与 POP 非常契合,它提倡不变性,避免共享可变状态。在 Swift Standard Library 的 sample playground 的“Understanding Value Semantics”中,Apple 提到:
标准库中的序列和集合使用值语义,这使得推理代码变得容易。每个变量都有一个独立的值,并且对该值的引用不会被共享。例如,当您将数组传递给函数时,该函数不能意外修改调用者的数组副本。
对于协议,Swift 文档指出:
协议是定义了特定功能的方法、属性和其他要求的蓝图。然后,类、结构或枚举可以采用该协议来提供这些要求的实际实现。任何满足协议要求的类型都被视为符合该协议。除了指定符合类型必须实现的要求之外,还可以扩展协议来实现其中一些要求或实现符合类型可以利用的附加功能。
POP 鼓励将思维方式从类和继承转变为行为和组合。通过协议、扩展、组合、泛型、继承原则,生成模块化的代码。有助于提升可重用性、灵活性、可测试性等。
Array继承 10 个协议、Bool 继承7 个协议 、还有 Comparable 和 Equatable 等都充分体现了 Apple 对 POP 的态度:
以一个 Image 类为例,将所有功能都放置到 Image 类中,可以直接创建 Image 类的实例来满足需求:
class Image {
fileprivate var imageName: String
fileprivate var imageData: Data
init(name: String, data: Data) { ... }
convenience init(name: String, contentsOf url: URL) throws { ... }
/// compression
convenience init?(named name: String, data: Data, compressionQuality: Double) { ... }
var name: String { ... }
/// BASE64 encoding
var base64Encoded: String { ... }
/// persistence
func save(to url: URL) throws { ... }
}
但如果我们不需要部分功能怎么办?如果将 Image 类子类化,并不能完全摆脱不需要的公共方法和属性。此时使用 POP 来调整此设计:
protocol NamedImageData {
var name: String { get }
var data: Data { get }
init(name: String, data: Data)
}
/// persistence
protocol ImageDataPersisting: NamedImageData {
init(name: String, contentsOf url: URL) throws
func save(to url: URL) throws
}
extension ImageDataPersisting {
init(name: String, contentsOf url: URL) throws { ... }
func save(to url: URL) throws { ... }
}
/// compression
protocol ImageDataCompressing: NamedImageData {
func compress(withQuality compressionQuality: Double) -> Self?
}
extension ImageDataCompressing {
func compress(withQuality compressionQuality: Double) -> Self? { ... }
}
/// BASE64 encoding
protocol ImageDataEncoding: NamedImageData {
var base64Encoded: String { get }
}
extension ImageDataEncoding {
var base64Encoded: String { ... }
}
通过这种方式,我们可以创建更细粒度的设计,组合一个需要自身所需协议的类型。POP 避免不了拿来与 OOP 对比:
- POP 强调基于协议的组合,定义行为协议并采用这些协议来提供实现;OOP 强调基于类的继承,子类从其父类继承属性和行为;
- POP 支持多种协议组合;OOP 通常是单继承的;
- POP 适用于值类型(结构和枚举)和引用类型;OOP 通常涉及使用引用类型;
- POP 引入了默认实现和协议扩展,可以实现更大的代码重用;OOP 继承是代码重用的主要机制,使用子类来扩展或覆盖超类的行为;
- POP 可以使用动态和静态调用,协议方法可以动态调用,在使用值类型或使用关键字时
final,Swift 可以应用静态调用来提高性能。而动态调用是 OOP 中实现多态性的重要手段,当调用一个方法时,实际执行的是该对象所属类的版本的方法。 - POP 与泛型配合良好,允许编写对多种类型进行操作的通用协议和函数;OOP 泛型存在但不那么普遍或无缝集成。
在 Protocol and Value Oriented Programming in UIKit Apps 中,Apple 工程师说“应该使用值类型和协议来让你的应用变得更好”。当然,POP 和 OOP 没有绝对好的一方,需要根据开发环境和情况适当选择使用。但是由于 Apple、Swift 采用了 POP,因此 iOS 开发人员应该熟悉 POP。并且需要在编码时充分利用 POP。
7.4 Factory 部分设计
Factory 结构同样也可以划分为使用方使用的、使用方不感知的两部分:
使用方通过构造 Factory、ParameterFactory (需要初始化参数的 Factory)结构,来声明依赖项。同时提供动态注入、解析的方法:
public struct Factory<T>: FactoryModifying {
public var registration: FactoryRegistration<Void,T>
public init(
_ container: ManagedContainer,
key: StaticString = #function,
_ factory: @escaping () -> T
) {
self.registration = ...
}
// 动态注入
@discardableResult
public func register(factory: @escaping () -> T) -> Self { registration.register(factory); return self }
// 解析
public func callAsFunction() -> T { registration.resolve(with: ()) }
public func resolve() -> T { registration.resolve(with: ()) }
}
public struct ParameterFactory<P,T>: FactoryModifying { Same as Factory }
实际的解析行为在 FactoryRegistration 里发生:
public struct FactoryRegistration<P,T> {
internal func register(_ factory: @escaping (P) -> T) { ... }
internal func resolve(with parameters: P) -> T {
var current: (P) -> T
if let found = options?.factoryForCurrentContext() as? TypedFactory<P,T> {
current = found.factory
} else if let found = manager.registrations[key] as? TypedFactory<P,T> {
current = found.factory
} else {
current = factory
}
}
// 调整 Scope
internal func scope(_ scope: Scope?) {}
// 根据 Context 进行解析
internal func context(_ context: FactoryContextType, key: FactoryKey, factory: @escaping (P) -> T) {}
// ...
}
而 FactoryModifying,则为 Factory 定义了链式调用的函数,方便接入方使用:
public protocol FactoryModifying {
associatedtype P
associatedtype T
var registration: FactoryRegistration<P,T> { get set }
}
// Scope
extension FactoryModifying {
@discardableResult
public func scope(_ scope: Scope) -> Self { registration.scope(scope); return self }
public var cached: Self { registration.scope(.cached); return self }
public var singleton: Self { registration.scope(.singleton); return self }
// ...
}
// Context
extension FactoryModifying {
@discardableResult
public func context(_ contexts: FactoryContextType..., factory: @escaping (P) -> T) -> Self { ... }
public func onArg(_ argument: String, factory: @escaping (P) -> T) -> Self {
context(.arg(argument), factory: factory)
}
public func onArgs(_ args: [String], factory: @escaping (P) -> T) -> Self {
context(.args(args), factory: factory)
}
// ...
}
7.5 Global、Injection 部分
Global 部分是一些全局的配置、全局函数,这里不考虑再展开描述。
Injection 部分为 Factory 提供了属性包装器,使在 Swift、SwiftUI 中更便捷的使用依赖项:
@propertyWrapper public struct Injected<T> {
private var dependency: T
public init(_ keyPath: KeyPath<Container, Factory<T>>) {
self.dependency = Container.shared[keyPath: keyPath]()
}
public init<C:SharedContainer>(_ keyPath: KeyPath<C, Factory<T>>) {
self.dependency = C.shared[keyPath: keyPath]()
}
public var wrappedValue: T {
get { return dependency }
mutating set { dependency = newValue }
}
// ...
}
@propertyWrapper public struct LazyInjected<T> {}
@propertyWrapper public struct WeakLazyInjected<T> {}
@propertyWrapper public struct InjectedType<T> {}
@frozen @propertyWrapper public struct InjectedObject<T>: DynamicProperty where T: ObservableObject {}
7.6 Factory 中的一些问题
7.6.1 循环依赖的检查与不完备
Graph 将解析时的依赖关系绘制成一副图,配合解析时的循环依赖检测机制,在 Debug 环境下对明确有循环依赖的依赖项,抛出运行时异常。以以下代码为例:
protocol GraphAProtocol {}
protocol GraphBProtocol {}
protocol GraphCProtocol {}
class GraphAService: GraphAProtocol {
init(bService: GraphBProtocol) {}
}
class GraphBService: GraphBProtocol {
init(cService: GraphCProtocol) {}
}
class GraphCService: GraphCProtocol {
init(aService: GraphAProtocol) {}
}
extension Container {
var aService: Factory<GraphAProtocol> {
self { GraphAService(bService: Container.shared.bService()) }
.graph
}
var bService: Factory<GraphBProtocol> {
self { GraphBService(cService: Container.shared.cService()) }
.graph
}
var cService: Factory<GraphCProtocol> {
self { GraphCService(aService: Container.shared.aService()) }
.graph
}
}
上述代码依赖关系绘制成一副图,将存在循环依赖:
在运行时获取依赖项 aService,将提示 "FACTORY: Circular dependency chain - Factory.GraphAProtocol > Factory.GraphBProtocol > Factory.GraphCProtocol > Factory.GraphAProtocol":
但是根据上文,Factory 可以声明同一协议的多个依赖项。若依赖链中,出现了符合同一协议的依赖项,即便不是同一个实例,也会计算为依赖。没有像 FactoryKey 部分,考虑结合协议、属性名称。遇到这类 Case,使用方只能关闭循环依赖检查了。
7.7 总结
Factory 通过不到 800 行可执行代码完成了支持了 Container、Scope、Context、单元测试、SwiftUI 预览等等。泛型、协议等 POP 的设计,给出了巨大的灵活性。依靠明确的类型且存在性判断,使其有编译时的安全性。在可测试性的支持也非常完备。整体的架构设计值得推敲学习。文章中隐去了部分设计细节,比如对依赖项存活时间的控制、单元测试下 Container 的栈形态设计,感兴趣的读者可以继续深入阅读 Factory 源代码。
回到本文的开头,本文探讨 DI 框架时,实际上是在探讨一种思想、一种解决问题的方法,而非唯一或最优的解决方案,希望通过本文来探讨 Swift DI 的架构设计。在实际项目中,我们应该根据项目的具体需求、团队的技术储备来选择合适的框架和工具。
八、参考
- Github - Factory
- iOS Development with Factory: The Power of Dependency Injection
- Managing Dependencies in the Age of SwiftUI
- Comparing four different approaches towards Dependency Injection
- StaticString, and how it works internally in Swift
- The Forbidden @inline Attribute in Swift
- Understanding
@inlinablein Swift - DI in iOS: Complete guide
- Swift under the hood: Generic implementation
- Protocol Oriented Programming in Swift: An Introduction
- Protocol Oriented Programming in Swift
- Protocol Oriented Programming in Swift: Is it better than Object Oriented Programming?
- Swift 最佳实践之 Generics
- Swift 最佳实践之 Protocol