Swift中依赖注入的解耦策略

原文地址:Dependency Injection Strategies in Swift

今天我们将深入研究Swift中的依赖注入,这是软件开发中最重要的技术之一,也是许多编程语言中使用频繁的概念。 具体来说,我们将探索可以使用的策略/模式,包括Swift中的Service Locator模式。

依赖注入背后的意图是通过让一个对象提供另一个对象的依赖关系来解耦。它用于为模块提供不同的配置,尤其对于为(单元)测试模块和/或应用程序提供模拟依赖性非常有用。我们将在本文中使用术语依赖注入仅作为描述一个对象如何为其他对象提供依赖关系的设计模式。 不要将依赖注入与帮助你注入依赖项的框架或库混淆。

Why should I use it?

依赖注入有助于我们在不同的环境中使我们的组件更少耦合和更可重用。总的来说,它是分离关注的一种形式,因为它使用从初始化和配置的依赖性来分离。为实现这一目标,我们可以使用不同的技术将依赖项注入到我们的模块中。

如上所述,依赖注入的一个非常重要的方面是它使我们的代码更易于测试。 我们可以为我们想要测试的类/模块的依赖项注入模拟实例。这使我们可以将测试集中在模块中的单元测试代码上,并确保这部分按预期工作,而不会产生导致测试失败不明确的模糊副作用,因为其中一个依赖项不符合预期。这些依赖项应该自行测试,以便更容易地发现真正的错误并加快开发工作流程。

我们在之前的一篇文章中已经描述了我们的测试策略。 如果您想了解有关我们测试设置的更多信息,请务必阅读该文章Testing Mobile Apps

此外,依赖注入允许我们绕过软件开发中最常见的错误之一:在代码库中滥用单例。 如果你想更多地了解为什么单例不好,请看看Are Singletons BadSingletons Are Evil

Different strategies to do Dependency Injection in Swift

在Swift中我们有很多方式使用依赖注入,大多数原则也适用于其他编程语言,即使在大多数其他环境中(特别是在Java社区中),人们倾向于使用特殊的依赖注入框架来为它们做繁重的工作。

是的,Swift中也有Dependency Injection框架。 最受欢迎的是Swinject,具有丰富的功能和大型社区。但今天我们将向你展示一些注入依赖项的简单技巧,而不会引入另一个巨大的第三方框架。

要看看在实际中如何使用改技术,我们可以看一下简短的使用一个service使用repository对象获取数据的案例。

class BasketService {
    private let repository: Repository<Article>

    init(repository: Repository<Article>) {
        self.repository = repository
    }

    func addAllArticles(to basket: Basket) {
        let allArticles = repository.getAll()
        basket.articles.append(contentsOf: allArticles)
    }
}

我们为BasketService注入了一个repository,这样我们的service就不需要知道如何提供所用的商品了。它们可以来自repository,该repository从本地JSON文件获取数据,或从本地数据库检索,甚至从服务器获取。

这允许我们在不同的环境中使用我们的BasketService,如果我们想为这个类编写单元测试,我们可以注入我们的模拟的repository,通过使用始终相同的测试数据使我们的测试更加可预测。


class BasketServiceTests: XCTestCase {
    func testAddAllArticles() {
        let expectedArticle = Article(title: "Article 1")
        let mockRepository = MockRepository<Article>(objects: [expectedArticle])
        let basketService = BasketService(repository: mockRepository)
        let basket = Basket()

        basketService.addAllArticles(to: basket)

        XCTAssertEqual(basket.articles.count, 1)
        XCTAssertEqual(basket.articles[0], expectedArticle)
    }
}

好了,我们可以向模拟repository中放入模拟商品,再向service注入这个模拟repository来测试service是否按与其工作,并将测试商品添加到购物袋中。

Property-based Dependency Injection

xBildschirmfoto-2018-12-07-um-10.31.31-10.png.pagespeed.ic.yAytv_X3m0.png

好吧,initializer-based dependency injection 似乎是一个很好的解决方案,但有些情况下它不适合,例如在ViewControllers中,使用初始化程序并不是那么容易,特别是如果你使用XIB或storyboard文件。

我们都知道这个错误消息和Xcode提供的烦人的解决方案。 但是如何在不覆盖所有默认初始值设定项的情况下使用依赖注入?

这就是property-based Dependency Injection发挥作用的地方。我们在初始化后分配模块的属性。

让我们看一下我们的BasketViewController,它将我们的BasketService类作为依赖。

class BasketViewController: UIViewController {
    var basketService: BasketService! = nil
}

let basketViewController = BasketViewController()
basketViewController.basketService = BasketService()


我们被迫在这里强制解包一个optional的属性,以确保在之前未正确注入basketService属性时程序崩溃。

如果我们想要摆脱对optional属性的强制解包,可以在声明属性时提供默认值。

class BasketViewController: UIViewController {
    var basketService: BasketService = BasketService()
}


property-based Dependency Injection也有一些缺点:首先,我们的类需要处理依赖项的动态更改;其次,我们需要使属性可以从外部访问和变化,并且不能再将它们定义为私有。

Factory Classes

到目前为止,我们看到的两种解决方案都将注入依赖关系的责任转移到创建新模块的类。这可能比将依赖项硬编码到模块中更好,但将此责任转移到自己的类型通常是更好的解决方案。它还确保我们不需要在代码中为初始化模块写重复代码。

这些类型处理类的创建并设置其所有依赖项。这些所谓的Factory类还解决了传递依赖关系的问题。我们之前必须使用所有其他解决方案执行此操作,如果您的类具有大量依赖项,或者您具有多个依赖项层级(例如上面的示例),它可能会变得混乱:BasketViewController - > BasketService - > Repository。

让我们看一下Basket的Factory

protocol BasketFactory {
    func makeBasketService() -> BasketService
    func makeBasketViewController() -> BasketViewController
}

通过让工厂成为协议,我们可以有多个实现,例如测试用例的特殊工厂。

Factory-based Dependency Injection与我们之前看到的解决方案密切配合,允许我们混合使用不同的技术,但是我们如何保持创建类的实例接口清晰。

除了向你展示一个例子,没有更好的方法来解释它:

class DefaultBasketFactory: BasketFactory {

    func makeBasketService() -> BasketService {
        let repository = makeArticleRepository()
        return BasketService(repository: repository)
    }

    func makeBasketViewController() -> BasketViewController {
        let basketViewController = BasketViewController()
        basketViewController.basketService = makeBasketService()
        return basketViewController
    }

    // MARK: Private factory methods
    private func makeArticleRepository() -> Repository<Article> {
        return DatabaseRepository()
    }

}

我们的DefaultBasketFactory实现了上面定义的协议,并具有公共工厂方法和私有方法。 工厂方法可以而且应该使用类中的其他工厂方法来创建较低的依赖项。

上面的例子很好地展示了我们如何组合initializer-based and property-based Dependency Injection,同时具有优雅和简单的接口来创建依赖关系的优势。

要初始化我们的BasketViewController实例,我们只需编写一行单一且自解释的代码。

let basketViewController = factory.makeBasketViewController()

The Service Locator Pattern

根据我们目前看到的解决方案,我们将使用所谓的Service Locator设计模式构建更通用,更灵活的解决方案。 让我们从定义Service Locator的相关实体开始:

  • Container:存储用来创建已注册类型实例的配置。
  • Resolver:通过使用Container的配置创建类的实例,解决一个类型的实际实现。
  • ServiceFactory:用于创建通用类型实例的通用工厂。

Resolver

我们首先为Service Locator Pattern定义一个Resolver协议。它是一个简单的协议,只有一种方法可用于创建符合传递的ServiceType类型的实例。

protocol Resolver {
    func resolve<ServiceType>(_ type: ServiceType.Type) -> ServiceType
}


我们可以通过以下方式使用符合该协议的对象:

let resolver: Resolver = ...
let instance = resolver.resolve(SomeProtocol.self)

ServiceFactory

接下来,我们使用关联类型ServiceType定义ServiceFactory协议。 我们的工厂将创建符合ServiceType协议的类型实例。

protocol ServiceFactory {
    associatedtype ServiceType
    func resolve(_ resolver: Resolver) -> ServiceType
}


这看起来与我们之前看到的Resolver协议非常相似,但它引入了额外的关联类型,以便为我们的实现添加更多类型安全性。

让我们定义符合这个协议的第一个类型BasicServiceFactory。此工厂类使用注入的工厂方法生成ServiceType类型的类/结构的实例。 通过将Resolver作为参数传递给工厂闭包,我们可以使用它来创建创建该类型实例所需的更低级别的依赖关系。

struct BasicServiceFactory<ServiceType>: ServiceFactory {
    private let factory: (Resolver) -> ServiceType

    init(_ type: ServiceType.Type, factory: @escaping (Resolver) -> ServiceType) {
        self.factory = factory
    }

    func resolve(_ resolver: Resolver) -> ServiceType {
        return factory(resolver)
    }
}

这个BasicServiceFactory结构体可以独立使用,比我们上面看到的Factory类更通用。但我们还没有完成。我们在Swift中实现Service Locator Pattern所需的最后一件事是Container

Container

在我们开始写Container类之前 让我们重复一下它应该为我们做些什么:

  • 它应该允许我们为某种类型注册新工厂
  • 它应该存储ServiceFactory实例
  • 它应该被用作任何存储类型的Resolver

为了能够以类型安全的方式存储ServiceFactory类的实例,我们需要能够在Swift中实现可变参数化泛型。这在Swift中尚不可能,但是它是GenericsManifesto的一部分,将在未来版本中添加到Swift中。与此同时,我们需要使用名为AnyServiceFactory的类型擦除版本来消除泛型类型。

为了简单起见,我们不会向你展示它的实现,但如果您对它感兴趣,请查看下面链接。

struct Container: Resolver {

    let factories: [AnyServiceFactory]

    init() {
        self.factories = []
    }

    private init(factories: [AnyServiceFactory]) {
        self.factories = factories
    }

    ...

我们将Container定义为充当resolver解析器的结构体并存储已擦除类型的工厂。接下来,我们将添加用于在工厂中注册新类型的代码。

// MARK: Register
    func register<T>(_ type: T.Type, instance: T) -> Container {
        return register(type) { _ in instance }
    }

    func register<ServiceType>(_ type: ServiceType.Type, _ factory: @escaping (Resolver) -> ServiceType) -> Container {
        assert(!factories.contains(where: { $0.supports(type) }))

        let newFactory = BasicServiceFactory<ServiceType>(type, factory: { resolver in
            factory(resolver)
        })
        return .init(factories: factories + [AnyServiceFactory(newFactory)])
    }

    .

第一种方法允许我们为ServiceTyp注册一个类的某个实例。这对于注入Singleton(类似)类(如UserDefaultsBundle)特别有用。

第二个甚至更重要的方法是创建一个新factory(工厂)并返回一个新的不可container(容器),包括该新factory

最后一个缺失的部分是实际符合我们的Resolver协议并使用我们存储的工厂解析实例。

// MARK: Resolver
    func resolve<ServiceType>(_ type: ServiceType.Type) -> ServiceType {
        guard let factory = factories.first(where: { $0.supports(type) }) else {
            fatalError("No suitable factory found")
        }
        return factory.resolve(self)
    }

我们使用一个guard语句来检查它是否包含一个能够解决依赖关系的工厂,否则会抛出一个fatal error。最后,我们返回第一个支持此类型的工厂创建的实例。

Usage of Service Locators

让我们从之前开始我们的basket示例,并为所有basket相关类定义一个容器:

let basketContainer = Container()
    .register(Bundle.self, instance: Bundle.main)
    .register(Repository<Article>.self) { _ in DatabaseRepository() }
    .register(BasketService.self) { resolver in
        let repository = resolver.resolve(Repository<Article>.self)
        return BasketService(repository: repository)
    }
    .register(BasketViewController.self) { resolver in
        let basketViewController = BasketViewController()
        basketViewController.basketService = resolver.resolve(BasketService.self)
        return basketViewController
    }


这显示了我们超级简单解决方案的强大和优雅。我们可以使用链式register方法存储所有工厂,同时混合使用我们之前看到的所有不同的依赖注入技术。

最后,但同样重要的是,我们用于创建实例的接口保持简单和优雅。


let basketViewController = basketContainer.resolve(BasketViewController.self)

Conclusion

我们已经看到了在Swift中使用依赖注入的不同技术。更重要的是,我们已经看到你不需要决定一个单一的解决方案。它们可以混合以获得每种技术的综合优势。为了将所有内容提升到新的水平,我们在Swift中引入了Factory``类和更通用的ServiceLocator模式解决方案。这可以通过添加对多个参数的额外支持或通过在Swift引入可变参数泛型时添加更多类型安全性来改进。

为简单起见,我们忽略了诸如范围,动态依赖和循环依赖之类的东西 所有这些问题都是可以解决的,但超出了本文的范围。 你可以在DependencyInjectionPlayground查看在此展示的所有内容。

最后个人补充

依赖注入OC的比较不错的库有:
objectiontyphoon
Swift版本的有:
TyphoonSwiftSwinjectCleanseneedle
比较不错的中文文章:
使用objection来模块化开发iOS项目
Objection源码分析
iOS 组件通信方案
Swinject源码解析

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,547评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,399评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,428评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,599评论 1 274
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,612评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,577评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,941评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,603评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,852评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,605评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,693评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,375评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,955评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,936评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,172评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,970评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,414评论 2 342

推荐阅读更多精彩内容