跳转至

14: 模块化的依赖关系

将一个应用程序分割成模块,无论是框架、静态库还是结构隔离的代码,都是清洁编码的一个重要部分。将相关的文件放在同一抽象层次上,使你的代码更容易维护和跨项目重复使用。

在本章中,你将继续上一章的工作,进一步将MyBiz分解成模块,这样你就可以重复使用登录功能。你将学习如何在代码中定义清晰的边界以创建逻辑单元。通过使用测试,你将确保新的架构能够工作,应用程序能够继续运作。

为代码留出一个位置

有几种方法可以使一个应用程序模块化。在本教程中,你将使用最常见和最简单的方法。一个新的动态框架。你可以在许多iOS项目中重复使用一个框架,并通过CocoapodsCarthageSwift Package Manager等工具来分发它。

即使你完成了上一章的挑战,也要从本章的启动项目开始。这样,你就不会在文件或测试名称上有任何不一致。

让我们从创建新框架开始:

  1. 在项目编辑器中,创建一个新的目标。选择框架模板来创建一个动态Framework,然后点击下一步。
  2. 将产品名称设为Login
  3. 确保你选中了"包括单元测试"。这样你就可以马上添加测试了!

image

  1. 点击完成。
  2. 选择新创建的LoginTests目标,并将Host Application改为None,如果它还不是的话。
  3. 选择Build Phases,确保Login是唯一的依赖关系。把MyBiz作为一个依赖项移除。

移动文件

围绕LoginViewController的依赖关系图已经没有循环了,所以现在你终于可以移动一些文件了。

image

抓住两个"绿色"文件LoginViewController.swiftValidators.swift,把它们从MyBiz目标拖到Login目标上。

仔细检查你是否已经把这些文件的目标成员从MyBiz改为Login

构建并运行你的应用程序,你会看到很多红色的错误。LoginViewController现在可能已经摆脱了不良的依赖关系,但它并没有完全摆脱依赖关系。你会从很多问题中看到,这并不像最初看起来那么容易。

首先,像SkinErrorViewController这样的类是LoginViewControllerMyBiz中其他类的依赖。为了防止复制或引入循环依赖关系,你需要创建另一个框架。

使用与上述相同的步骤创建一个新的框架,命名为UIHelpers。请确保也包括单元测试。

将以下文件移到新的目标中:

  • UIViewController+Alert.swift
  • ErrorViewController.swift
  • Skin.swift
  • Styler.swift
  • Colors.swift

为了简化这一重构过程,将方案切换到UIHelpers的自动创建方案。这样一来,只有这个库可以构建,这将减少其他构建错误的噪音。

打破了Styler的依赖关系

你可能注意到的第一个错误是在Styler.swift中。Styler依赖于AppDelegate的配置。在这个辅助框架中引用App delegate会破坏封装,所以你需要用另一种方式来设置配置。

配置本身也是一个问题,因为除了UI风格之外,它还包含业务逻辑和服务器设置等内容。最简单的方法就是从底层开始,一路向上。

UIHelpers目标中创建一个新的Swift文件。UIConfiguration.swift。将Configuration.swift中的UI子结构移到这个新文件中,并重命名为UIConfiguration

struct UIConfiguration: Codable {
    struct Button: Codable {
        let cornerRadius: Double
        let borderWidth: Double
    }
    let button: Button
}

接下来,在Styler.swift中,将let配置行改为:

var configuration: UIConfiguration?

在你使用它之前,这必须被设置;它不再被保证被设置。

style(button:skin:)中,替换:

button.layer.cornerRadius = CGFloat(configuration.ui.button.cornerRadius)
button.layer.borderWidth = CGFloat(configuration.ui.button.borderWidth)

为以下内容:

button.layer.cornerRadius = CGFloat(configuration?.button.cornerRadius ?? 0)
button.layer.borderWidth = CGFloat(configuration?.button.borderWidth ?? 0)

最后,在ErrorViewController.swift中,你会看到一个对Logger的依赖。你将在本章的挑战部分重新审视这个依赖关系。现在,注释掉这行代码。

现在,该框架将成功构建。

Note

对这些文件进行与LoginViewController相同的依赖关系图练习是合理的。这将涉及到检查ErrorViewController的依赖关系,找到有问题的关系并加以纠正。我们在这里绕过了这个步骤,因为它很简单,而且这个教程也可以写成一本书。

Storyboard的模块化

在应用程序中,你通过Storyboard创建一个ErrorViewController。你在UIViewController+Alert.swift中通过Main.storyboard明确地做了这件事。由于这个Storyboard存在于一个应用程序模块中,所以它对这个框架是不可用的。

为了解决这个问题,请按照以下步骤将视图控制器移到UIHelpers框架的一个新的Storyboard中:

  1. 打开Main.storyboard,选择Error View Controller Scene
  2. 现在,你可以使用Xcode工具来帮助。选择Editor ▸ Refactor to Storyboard....
  3. 将其命名为UIHelpers.storyboard
  4. 将组别改为UIHelpers
  5. 取消对MyBiz目标的检查,改为对UIHelpers目标的检查。
  6. 点击保存。
  7. Main.storyboard中,删除error Scene的引用。

接下来,在UIViewController+Alert.swift中,将let alertController = ...替换为以下内容:

let thisBundle = Bundle(for: ErrorViewController.self)
let storyboard = UIStoryboard(name: "UIHelpers",
                              bundle: thisBundle)
let alertController = storyboard.instantiateViewController(withIdentifier: "error") as! ErrorViewController

现在加载相同的场景,但是从一个新的Storyboard上加载,这个Storyboard住在框架内。

移动测试

那么测试呢?你已经有了一些涵盖ErrorViewController的测试案例。你也可以移动这些测试。

UIHelpersTests中删除UIHelpersTests.swift

接下来,在UIHelpersTests中创建一个Cases组,把MyBizTests中的ErrorViewControllerTests.swift移到该组中。验证目标成员资格是否改变为UIHelpersTests。将@testable导入行改为:

@testable import UIHelpers

接下来,用以下内容替换setUpWithError

override func setUpWithError() throws {
    try super.setUpWithError()
    sut = UIStoryboard(
        name: "UIHelpers",
        bundle: Bundle(for: ErrorViewController.self))
    .instantiateViewController(withIdentifier: "error")
    as? ErrorViewController
}

这个新的setUpWithError使用你创建的新的UIHelpers.storyboard

确保你已经启用了这个目标中的测试。要检查,打开测试导航器,右击UIHelpersTests并选择启用UIHelperTests

现在,你可以只构建和测试UIHelpers方案,并对这一重大重构的工作感到更有信心。

Note

对于一些特性化测试,你需要一个与后台的实时连接。设置和启动说明见第11章,"遗留问题"。

使用新的框架与登录

现在你已经给了UI助手自己的框架,你需要告诉Login框架它的情况。

在项目编辑器中,选择Login。在框架和库下,添加UIHelpers。完成后,它应该是这样的:

image

LoginViewController.swift的顶部添加这个导入:

import UIHelpers

接下来,你将不得不修复UIHelpers中的一些访问级别。当所有的文件都在同一个目标中时,默认的内部访问是没有问题的,但是现在你需要把一些东西公开。

UIHelpers中,将以下内容公开:

  • Skin
  • Skin.Styler中所有的静态让步常数。
  • Styler
  • Styler中:classsharedconfiguration和所有的style方法。
  • UIViewController+Alert.swift:showAlert
  • UIConfiguration
  • Colors.swift中所有的类var
  • ErrorViewController和它的SecondaryActionviewDidLoad()

你还需要在SecondaryAction中添加这个初始化器:

public init(title: String, action: @escaping () -> Void) {
    self.title = title
    self.action = action
}

现在,这为其他模块提供了这些类型和功能,供其使用。在这种情况下,这些模块将是LoginMyBiz

进一步隔离LoginViewController

现在将构建方案改为Login,构建并运行。你仍然会得到大量的编译器错误。

清理LoginViewController需要修复一个长期困扰的问题。API类太宽泛了,依赖于有很多额外方法的奇怪委托。你可以通过创建一个只包含与Login有关的部分的新协议来界定API的范围。

Login下创建一个名为LoginAPI的新Swift文件,并将其内容替换为以下内容:

public protocol LoginAPI {
    func login(
        username: String,
        password: String,
        completion: @escaping (Result<String, Error>) -> Void
    )
}

这段代码完成了两个改变生活的代码和架构的清理工作。首先,LoginAPI只有一个关于Login的方法。其次,它用一个简单的completion闭包取代了令人厌恶的全能委托,该completion闭包使用一个结果。从概念上讲,增加Logout也是有意义的,但你可以把它留到将来改进。

为了使用新的协议,回到LoginViewController

  1. api的类型改为LoginAPI!.
  2. viewDidLoad()中,删除api.delegate = self这一行。
  3. signIn(_:)改为:
@IBAction func signIn(_ sender: Any) {
    guard let username = emailField.text,
          let password = passwordField.text else { return }

    guard username.isEmail && password.isValidPassword else {
        // a little client-side validation ;)
        showAlert(
            title: "Invalid Username or Password",
            subtitle: "Check the username or password")
        return
    }

    api.login(username: username, password: password) { result in
        if case .failure(let error) = result {
            self.loginFailed(error: error)
        }
    }
}
  1. 在扩展中,删除APIDelegate类型,并删除loginFailed(error:)以外的所有方法。

啊,干净多了! 我不能低估这一变化的力量。为了直观地看到结果,请看这个更新的依赖关系图,现在API已经不在图中了:

image

别忘了测试

接下来,你要给你的协议添加测试,以验证你刚刚做的改变。

首先,抓起ValidatorsTests.swift并把它拖到LoginTests目标上,确保目标也改为LoginTests

接下来,删除LoginTests.swift,因为你不需要这个文件。最后,打开ValidatorsTests.swift,并替换:

@testable import MyBiz

为:

@testable import Login

构建并运行Login目标和测试,以验证一切都在按计划工作。与UIHelpersTests适用的条件相同。确保没有主机应用程序,并且目标和方案不会试图构建MyBiz

修复MyBiz

现在你有了两个新的框架,其中包含以前可用的代码,你需要修复它们使用项目中的依赖关系。切换回MyBiz方案,你会开始看到各种构建错误。

别担心,你可以一次解决这些问题,项目会在短时间内恢复正常(或者说是Giffy?:] )。

首先,在以下文件中添加以下导入语句:

import UIHelpers
  • DateSelectingViewController.swift
  • AnnouncementsTableViewController.swift
  • CreatePurachaseOrderTableViewController.swift
  • PurchasesTableViewController.swift
  • OrgTableViewController.swift
  • AddToOrderTableViewController.swift
  • Configuration.swift

接下来,在Configuration.swift中,替换:

let ui: UI

为以下内容:

let ui: UIConfiguration

这就照顾到了UIHelpers框架,但你还需要使用Login框架。

打开AppDelegate.swift,在下面添加导入UIKit

import Login

为了解决这些错误,打开LoginViewController.swift,使LoginViewControllerviewDidLoad()api成为公共的。

同时在SceneDelegate.swift中也添加导入Login的内容。

现在是时候解决最棘手的部分了:API

打开API.swift,并在下面添加import Foundation

import Login

接下来,你要替换现有的登录。还是在API.swift中,用以下内容替换现有的login(username:password:)handleToken(token:)

func login(
    username: String,
    password: String,
    completion: @escaping (Result<String, Error>) -> Void
) {
    let eventsEndpoint = server + "api/users/login"
    let eventsURL = URL(string: eventsEndpoint)!
    var urlRequest = URLRequest(url: eventsURL)
    urlRequest.httpMethod = "POST"
    let data = "\(username):\(password)".data(using: .utf8)!
    let basic = "Basic \(data.base64EncodedString())"
    urlRequest.addValue(
        basic,
        forHTTPHeaderField: "Authorization")
    let task = session.dataTask(with: urlRequest) {
        data, _, error in
        guard let data = data else {
            if error != nil {
                DispatchQueue.main.async {
                    completion(.failure(error!))
                }
            }
            return
        }
        let decoder = JSONDecoder()
        if let token = try? decoder.decode(Token.self, from: data) {
            self.handleToken(token: token, completion: completion)
        } else {
            do {
                let error = try decoder.decode(
                    APIError.self,
                    from: data)
                DispatchQueue.main.async {
                    completion(.failure(error))
                }
            } catch {
                DispatchQueue.main.async {
                    completion(.failure(error))
                }
            }
        }
    }

    task.resume()
}

func handleToken(
    token: Token,
    completion: @escaping (Result<String, Error>) -> Void
) {
    self.token = token
    Logger.logDebug("user \(token.user.id)")
    DispatchQueue.main.async {
        let note = Notification(
            name: userLoggedInNotification,
            object: self,
            userInfo: [
                UserNotificationKey.userId: token.user.id.uuidString
            ])
        NotificationCenter.default.post(note)
        completion(.success(token.user.id.uuidString))
    }
}

这段代码与之前的代码基本相同,只是现在不是调用委托,而是调用传入的完成块。最后,将类的定义改为:

class API: LoginAPI

接下来,你可以清理APIDelegate,从协议定义中删除loginFailed(error:)loginSucceeded(userId:)

最后,将loginFailed(error:)loginSucceeded(userId:)从以下文件的APIDelegate一致性扩展中删除:

  • AnnouncementsTableViewController.swift
  • CalendarModel.swift
  • OrgTableViewController.swift
  • PurchasesTableViewController.swift
  • CreatePurachaseOrderTableViewController.swift
  • SettingsTableViewController.swift

建立并运行MyBiz,你会发现它现在已经建立了。如果这就是你需要做的一切......

修复Storyboard

尽管应用程序已经构建完毕,但它还没有运行或通过测试。重构列车的下一站是在Storyboard上工作。

打开Main.storyboard。选择Login View Controller场景。在身份检查器中,将模块改为Login。你也可以为登录框架提取一个单独的Storyboard,这也是本章挑战的一部分,你会在后面提到。

现在,应用程序将建立和运行,并和以前一样工作。

修复测试

在测试中,有几个构建问题需要修复。

MockAPI.swift中,将现有的login覆盖替换为:

override func login(username: String,
                    password: String,
                    completion: @escaping (Result<String, Error>) -> Void){
    let token = Token(token: username, userID: UUID())
    handleToken(token: token, completion: completion)
}

这段代码添加了你在替换上面的loginhandleToken方法时引入的完成参数。

SpyAPI.swift中,通过将login的覆盖部分替换为以下内容来添加你之前创建的完成参数:

override func login(username: String,
                    password: String,
                    completion: @escaping (Result<String, Error>) -> Void) {
    loginCalled = true
    super.login(
        username: username,
        password: password,
        completion: completion)
}

接下来,打开APITests.swift,找到testAPI_whenLogin_generatesANotification(),并将when一行替换为以下内容:

sut.login(username: "test", password: "test") { _ in }

打开LoginViewControllerTests.swift,并在下面添加以下内容@testable import MyBiz

@testable import Login
@testable import UIHelpers

接下来,在特征化测试ErrorViewControllerTests.swift中,添加以下内容@testable import MyBiz

@testable import UIHelpers

最后,为了使用正确的Storyboard,用以下内容替换setUpWithError

override func setUpWithError() throws {
    try super.setUpWithError()
    sut = UIStoryboard(
        name: "UIHelpers",
        bundle: Bundle(for: ErrorViewController.self))
    .instantiateViewController(withIdentifier: "error")
    as? ErrorViewController
}

现在,所有的测试将再次通过,你可以深深地松一口气了。这次重构没有破坏任何东西!

总结

拍拍自己的肩膀。现在Login已经在它自己的框架中,并准备在另一个项目中重新使用。你必须同时发布Login框架和UIHelpers框架,但框架有自己的依赖关系是很正常的。

看一下最终的依赖关系图,已经更新了,以反映API的变化:

image

这是一个漂亮、干净、有层次的图。没有循环,你也没有引入任何不相干的数据类型或无关的功能。好样的!

挑战

这一章让你完成了将Login功能干净地拉入其自身框架的最小工作量。然而,还有(很多!)改进的余地。通过完成以下任何一项来修复这个项目。

  1. LoginViewController仍然依赖于MyBiz模块中的Main.storyboard,这使得它更难被重用。把它拉出来放到自己的Storyboard中,住在框架内。
  2. 通过添加和改进登录测试。

  3. LoginViewControllerTests特性化测试拉到LoginTests目标中。

  4. 通过创建一个mockLoginAPI,将这些测试用例重用为单元测试,这样你就不必通过API和本地服务器了。
  5. 创建一个AppDelegateTests,测试用户状态流。

  6. 通过将其带入UIHelpers并像Styler一样传递其配置,或者通过创建一个日志协议并将其附加到框架上,来修复日志器的问题。

关键点

  • 框架有助于组织代码,并保持依赖关系的分离干净。
  • 使用协议来提供来自调用者的实现,而不产生循环依赖。
  • 在大型重构之前、期间和之后都要写测试。

从这里开始,该怎么走?

天哪,那是一个很大的工作,但你真的清理了代码。有几个领域值得在未来进行研究,以改善你的架构卫生。其中一些是在挑战赛中建议的,但你可以通过一个专门的用户状态管理器,以及使用RouterFlowController等模式来处理显示错误和登录屏幕,而不是依赖AppDelegate,实现更多的改进。

其他伟大的资源是最初的《设计模式》一书(Gamma等人),尽管它非常面向对象,但包含了很多有用的模式,用于逐步分离依赖关系和分解功能。更直接有用的是这些架构书,网址是https://www.raywenderlich.com/books

  • 设计模式的教程
  • Combine:用Swift进行异步编程或RxSwift:用Swift进行反应性编程
  • 高级iOS应用架构