1. Swift 5.1 Takes Dependency Injection to the Next Level

1.1. Some Background

现代软件开发是一种管理复杂性的练习,而我们试图做到这一点的方法之一就是通过架构。反过来,体系结构实际上只是一个术语,用来描述我们如何将复杂的软件分解成容易消化的层和组件。

avatar

因此,我们遵循这些规则,并将软件分解为易于编写、只做一件事(SRP)并易于测试的简化组件。

然而,一旦我们有了一堆部件,就必须将所有部件重新连接起来,形成一个可工作的应用程序。

Figures.

将事物以正确的方式连接在一起,我们就得到了由一组松散耦合的组件组成的整洁架构。

如果使用错误的方法,我们就会陷入一个紧密耦合的泥沼,在那里,我们的许多部件都有太多关于它们的子组件是如何构造的以及它们如何在内部运行的信息。

这可能会使组件共享几乎不可能,并且会使从一个组件层换到另一个组件层同样困难。

所以我们有了第22条军规。我们在试图简化代码时使用的工具和技术最终可能使我们的生活更加复杂。

幸运的是,我们还可以使用另一种技术来管理这一额外的复杂层,它叫做依赖注入,它基于控制反转的原理。

1.2. Dependency Injection

对依赖注入的完整而彻底的解释超出了本文的范围,所以让我们假设依赖注入让一个给定的组件向系统请求与它完成工作所需的所有部件的连接。

这些依赖项被返回给组件,这些依赖项被完全形成并准备使用。

例如,一个视图控制器可能需要一个视图模型。ViewModel可能需要一个API组件来获取一些数据,而这些数据又需要访问我们的身份验证系统和当前的会话管理器。ViewModel还需要一个数据转换服务,该服务有它的依赖关系。

ViewController不关心所有这些,它也不应该关心。它只是想与它需要完成工作的组件对话。

为了演示所涉及的技术,本文将使用一个轻量级但功能强大的Swift依赖注入系统,称为Resolver。如果你用另一个,别担心。可以使用任何DI框架。

如果你想了解更多,有一个关于Resolver GitHub库的Gentle Introduction to Dependency Injection指南,以及相当多关于Resolver本身的文档。


1.3. A Quick Example

一个使用依赖注入的非常基本的视图模型可能是这样的:

class XYZViewModel {
    private var fetcher: XYZFetching
    private var service: XYZService
    init(fetcher: XYZFetching, service: XYZService) {
        self.fetcher = fetcher
        self.service = service
    }
    func load() -> Image {
        let data = fetcher.getData(token)
        return service.decompress(data)
   }
}

列出的是我们的视图模型需要的组件,加上一个初始化函数,其作用基本上是将传递给模型的任何组件分配给模型的实例变量。

这就是所谓的构造函数注入,使用它可以确保我们不能在没有提供所有需要的东西的情况下实例化给定的组件。

现在我们有了视图模型,但是视图控制器如何获得它呢?

Resolver可以在多种模式下自动解决这个问题,但这里展示的最简单的方法使用了一个称为Service Locator的模式,它基本上是一些知道如何定位请求服务的代码。

class XYZViewController: UIViewController {
    private let viewModel: XYZViewModel = Resolver.resolve()
    override func viewDidLoad() {
       ...
    }
}

因此视图控制器要求resolver“resolve”依赖性。resolver使用提供的类型信息来查找用于创建请求类型对象实例的工厂。

请注意,我们的viewModel需要一个获取器和一个提供给它的服务,但是视图控制器完全忽略了这个事实,让DI系统处理所有这些混乱的小细节。

还有其他一些好处。例如,我们可以运行一个“模拟”方案,将数据层替换为应用中嵌入的JSON文件中的模拟数据。这在开发、调试和测试时非常方便。

依赖系统可以很容易地完全在幕后处理这类事情,我们所有的视图控制器都知道它仍然得到了它需要的视图模型。Resolver文档显示了一个例子。

最后,请注意,在依赖注入术语中,我们的依赖通常被称为services。

1.4. Registration

为了让一个典型的依赖注入系统工作,我们的服务必须被注册。这意味着我们需要提供一个与系统可能被调用来创建的每个类型相关联的工厂方法。

在某些系统中,依赖项被命名,而在其他系统中,依赖项类型必须被指定。然而,Resolver通常可以推断出所需的类型信息。

Resolver中典型的注册块可能是这样的。

func setupMyRegistrations {
    register { XYZViewModel(fetcher: resolve(), service: resolve()) }
    register { XYZFetcher(session: resolve()) as XYZFetching }
    register { XYZService() }
    register { XYZSessionManager()}
}

注意,第一个注册函数注册了XYZViewModel,并提供了一个工厂函数来创建一个新的实例。注册的类型由工厂的返回类型自动推断。

XYZViewModel的初始化函数所需要的每个参数也通过再次推断类型签名并依次解析它们。

第二个函数注册xyzfetch协议,它通过构建XYZFetcher的实例来满足它自己的依赖关系。

这个过程递归地重复,直到所有部分都拥有初始化自身所需的所有部分,并完成它们需要完成的工作。


2. The Problem

然而,现实生活中的大多数程序都很复杂,因此我们的初始化函数可能会开始失控。

class MyViewModel {
    var userStateMachine: UserStateMachine
    var keyValueStore: KeyValueStore
    var bundle: BundleProviding
    var touchIdService: TouchIDManaging
    var status: SystemStatusProviding
    init(userStateMachine: UserStateMachine,
         bundle: BundleProviding,
         touchID: TouchIDManaging,
         status: SystemStatusProviding,
         keyValueStore: KeyValueStore) {
        self.userStateMachine = userStateMachine
        self.bundle = bundle
        self.touchIdService = touchID
        self.status = status
        self.keyValueStore = keyValueStore
    }
    ...
}

初始化函数中有相当多的代码。这是必要的,但都是样板文件。如何摆脱它?


2.1. Swift 5.1 and Property Wrappers

幸运的是,Swift 5.1为我们提供了一个名为属性包装器的新工具(正式名称为“属性代表”),作为SE-0258提案的一部分在Swift论坛上提出,并添加到了Swift 5.1和Xcode 11中。

这个新特性允许使用自定义的get/set实现自动包装属性值,因此有了这个名称。

请注意,您可以使用属性值上的自定义getter和setter来实现某些功能,但缺点是必须为每个属性编写几乎相同的代码。(样板)。如果每个属性都需要某种内部支持变量,情况就更糟了。(更多的样板)。

2.2. The @Injected Property Wrapper

所以在get/set对中自动包装属性听起来并不令人兴奋,但属性包装将对我们的Swift代码产生重大影响。

为了演示,我将创建一个名为@ inject的属性包装器,并将其添加到我们的代码库中。(实现将在下一节中介绍。)

现在,让我们回到我们的“失控”示例,看看我们的全新属性包装器给我们带来了什么。

class MyViewModel {
    @Injected var userStateMachine: UserStateMachine
    @Injected var keyValueStore: KeyValueStore
    @Injected var bundle: BundleProviding
    @Injected var touchIdService: TouchIDManaging
    @Injected var status: SystemStatusProviding
    ...
}

就是这样。只需将属性标记为@inject,每个属性都会根据需要自动解析(注入)。

初始化函数中的所有样板代码都没有了!

此外,现在从@inject注释中可以清楚地看到依赖注入系统提供了哪些服务。

这种特殊类型的注释模式在其他语言中也被使用,尤其是在Android上用Kotlin编程和使用Dagger 2依赖注入框架时。

2.3. Implementation

Our property wrapper implementation is straightforward. We define a generic struct with a Service type and mark it as a @propertyWrapper.

@propertyWrapper
struct Injected<Service> {
    private var service: Service!
    public var container: Resolver?
    public var name: String?
    public init() {}
    public init(name: String? = nil, container: Resolver? = nil) {
        self.name = name
        self.container = container
    }
    public var wrappedValue: Service {
        mutating get {
            if self.service == nil {
                self.service = container?.resolve(Service.self, name: name) ?? Resolver.resolve(Service.self, name: name)
            }
            return service
        }
        mutating set { service = newValue  }
    }
    public var projectedValue: Injected<Service> {
        get { return self }
        mutating set { self = newValue }
    }
}

所有属性包装器都必须实现一个名为wrappedValue的变量。

WrappedValue提供属性包装器在从变量请求值或分配值时使用的getter和setter实现。

在这种情况下,当我们的服务被请求时,我们的值“getter”将检查是否这是它第一次被调用。如果是,在访问时,包装器代码会要求Resolver基于泛型类型解析所需服务的实例,将结果存储到一个私有变量中以供以后使用,并返回服务。

当我们想要手动分配服务时,我们还提供了一个setter。在一些情况下,这可以派上用场,尤其是在进行单元测试时。

该实现还公开了一些额外的参数,如name和container,稍后会详细介绍。

更新:上述实现在Xcode Beta 6中更新,并支持在Beta 5中propertyWrapper语法的wrappedValue和projectedValue更改。

更新:通过添加公共初始化器,上述实现也被更改为支持Xcode 11和Resolver的发布版本。

2.4. More examples

Our orignal view controller code is now…

class XYZViewController: UIViewController {
    @Injected private var viewModel: XYZViewModel
    override func viewDidLoad() {
       ...
    }
}

Our ViewModel trims down to the bare essentials…

class XYZViewModel {
    @Injected private var fetcher: XYZFetching
    @Injected private var service: XYZService
    func load() -> Image {
        let data = fetcher.getData(token)
        return service.decompress(data)
   }
}

And even our registration code is simplified as constructor arguments are dropped left and right…

func setupMyRegistrations {
    register { XYZViewModel() }
    register { XYZFetcher() as XYZFetching }
    register { XYZService() }
    register { XYZSessionManager()
}

2.5. Resover and Immediate Injection

上面显示的最初为本文编写的注入代码执行的是惰性注入。换句话说,直到第一次访问包装器的值时,服务才被解析。

这在很多情况下都是可行的,但是在不可变结构(例如swifttui)中使用注入时就失败了。

在Resolver中,基本注入属性包装器的当前实现如下…

@propertyWrapper
public struct Injected<Service> {
    private var service: Service
    public init() {
        self.service = Resolver.resolve(Service.self)
    }
    public init(name: String? = nil, container: Resolver? = nil) {
        self.service = container?.resolve(Service.self, name: name) ?? Resolver.resolve(Service.self, name: name)
    }
    public var wrappedValue: Service {
        get { return service }
        mutating set { service = newValue }
    }
    public var projectedValue: Injected<Service> {
        get { return self }
        mutating set { self = newValue }
    }
}

所需服务的立即解析确保我们的对象拥有它所需要的一切,并准备好进行初始化。


2.6. Named Service Types

Resolver支持命名类型,它允许程序区分相同类型的服务或协议。

这也让我们展示了属性包装器的一个有趣的属性[抱歉],所以让我们来研究一下。

一个常见的用例可能是一个视图控制器需要两个不同的视图模型之一,选择取决于它是否被传递数据,因此应该在“添加”或“编辑”模式下操作。

注册可能如下所示,两个模型都符合XYZViewModel协议或基类。

func setupMyRegistrations {
    register(name: "add") { NewXYZViewModel() as XYZViewModel }
    register(name: "edit") { EditXYZViewModel() as XYZViewModel }
}

Then in the view controller…

class XYZViewController: UIViewController {
    @Injected private var viewModel: XYZViewModel
    var myData: MyData?
    override func viewDidLoad() {
        $viewModel.name = myData == nil ? "add" : "edit"
        viewModel.configure(myData)
        ...
    }
}

注意viewDidLoad中引用的$viewModel.name。

在大多数情况下,我们希望Swift假装包装的值是财产的实际价值。但是,在属性包装器前面加上美元符号可以让我们引用属性包装器本身,从而获得对可能在其上公开的任何公共变量或函数的访问权。

在本例中,我们设置了name参数,当我们第一次尝试使用我们的视图模型,以及当Resolver解析我们的依赖关系时,它将被传递给Resolver。

注意,propertyWrapper实现中的projectedValue为我们提供了使用$前缀访问属性时使用的值(投射的)。

长话短说,在属性包装器上使用$前缀可以让我们操作和/或引用包装器本身。在swifttui中你会看到很多这样的东西。

Update: The above code will work in the lastest version of Resolver using the new @LazyInjected property wrapper. As mentioned earlier, using Resolver’s version of @Injected will resolve the dependency immediately

@LazyInjected private var viewModel: XYZViewModel

2.7. Why Injected?

我向一些人展示了这段代码,他们总是问:为什么要使用“注入”这个术语?

我的意思是,既然代码使用Resolver为什么不把它标记为@Resolve?

理性很简单。我现在使用Resolver,主要是因为它是我写的。但我可能希望在另一个应用程序中共享或使用我的一些模型或服务代码,而该应用程序可能使用不同的系统来管理依赖注入。说,Swinject故事板。

Injected then becomes a more neutral term, and all I need to do is provide a new version of the @Injected property wrapper that uses Swinject as a backend. Once done, I’m all set.


2.8. ther Use Cases

我们将看到Swift的一些属性包装器的用途。

我们提到了依赖注入和swifttui广泛使用包装器(@State, @Binding等),但如果看到Cocoa和UIKit中的标准类提供了一些额外的包装器,我也不会感到惊讶。

我想到了关于User Defaults和Keychain Access的常见包装器。想象一下用…

@Keychain(key: "username") var username: String?

并得到自动存储您的数据从钥匙链!


2.9. Overuse

然而,就像任何一把很酷的新锤子一样,我们也冒着过度使用的风险,因为每个问题都开始看起来像钉子。

在某一时刻,一切都需要一个协议,记得吗?然后我们开始理解协议最适合在哪里使用(比如数据层代码),然后我们放弃了。单一实现类上的太多协议只会使我们的代码混乱,实际上增加了协议和实现之间的耦合,并使我们的代码更加严格和难以更改。

在此之前,c++添加了自定义操作符,然后我们突然想弄清楚user1 + user2的结果可能是什么?

我认为使用属性包装器时的关键问题是问自己:我是否会在所有代码库中广泛地使用包装器?如果是这样,那么属性包装器可能是一个很好的选择。

或者,您至少可以考虑减少它的占用空间。如果您要如上所示制作一个@Keychain包装器,您可以在与KeychainManager类相同的文件中以fileprivate的形式实现它,这样就可以避免在代码中到处散布它的诱惑。

After all, using it now is as simple as…

@Injected var keychain: KeychainManager

What we don’t want is to get to the state were every model looks like some variant of…

class MyModel {
    @Injected private var fetcher: XYZFetching
    @Injected private var service: XYZService
    @Error private var error: String
    @Constrain private var myInt: Int
    @Status private var x = 0
    @Status private var y = 0
}

然后留给下一个查看代码的开发人员去弄清楚每个包装器是做什么的。


2.10. Completion Block

属性包装器只是Swift 5.1和Xcode 11中引入的众多特性之一,它们承诺将彻底改变我们编写iOS应用程序的方式。

SwiftUI和Combine受到了媒体的广泛关注,但我认为属性包装器将极大地减少我们在日常编程中编写的样板代码数量,特别是在我们真正开始使用SwiftUI和Combine之前。

与swifttui和Combine不同的是,属性包装器可以在早期版本的iOS上使用!不仅仅是iOS 13。