14: 模块化的依赖关系¶
将一个应用程序分割成模块,无论是框架、静态库还是结构隔离的代码,都是清洁编码的一个重要部分。将相关的文件放在同一抽象层次上,使你的代码更容易维护和跨项目重复使用。
在本章中,你将继续上一章的工作,进一步将MyBiz分解成模块,这样你就可以重复使用登录功能。你将学习如何在代码中定义清晰的边界以创建逻辑单元。通过使用测试,你将确保新的架构能够工作,应用程序能够继续运作。
为代码留出一个位置¶
有几种方法可以使一个应用程序模块化。在本教程中,你将使用最常见和最简单的方法。一个新的动态框架。你可以在许多iOS项目中重复使用一个框架,并通过Cocoapods、Carthage或Swift Package Manager等工具来分发它。
即使你完成了上一章的挑战,也要从本章的启动项目开始。这样,你就不会在文件或测试名称上有任何不一致。
让我们从创建新框架开始:
- 在项目编辑器中,创建一个新的目标。选择框架模板来创建一个动态
Framework,然后点击下一步。 - 将产品名称设为
Login。 - 确保你选中了"包括单元测试"。这样你就可以马上添加测试了!

- 点击完成。
- 选择新创建的
LoginTests目标,并将Host Application改为None,如果它还不是的话。 - 选择
Build Phases,确保Login是唯一的依赖关系。把MyBiz作为一个依赖项移除。
移动文件¶
围绕LoginViewController的依赖关系图已经没有循环了,所以现在你终于可以移动一些文件了。

抓住两个"绿色"文件LoginViewController.swift和Validators.swift,把它们从MyBiz目标拖到Login目标上。
仔细检查你是否已经把这些文件的目标成员从MyBiz改为Login。
构建并运行你的应用程序,你会看到很多红色的错误。LoginViewController现在可能已经摆脱了不良的依赖关系,但它并没有完全摆脱依赖关系。你会从很多问题中看到,这并不像最初看起来那么容易。
首先,像Skin和ErrorViewController这样的类是LoginViewController和MyBiz中其他类的依赖。为了防止复制或引入循环依赖关系,你需要创建另一个框架。
使用与上述相同的步骤创建一个新的框架,命名为UIHelpers。请确保也包括单元测试。
将以下文件移到新的目标中:
UIViewController+Alert.swiftErrorViewController.swiftSkin.swiftStyler.swiftColors.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中:
- 打开
Main.storyboard,选择Error View Controller Scene。 - 现在,你可以使用
Xcode工具来帮助。选择Editor ▸ Refactor to Storyboard.... - 将其命名为
UIHelpers.storyboard。 - 将组别改为
UIHelpers。 - 取消对
MyBiz目标的检查,改为对UIHelpers目标的检查。 - 点击保存。
- 在
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。完成后,它应该是这样的:

在LoginViewController.swift的顶部添加这个导入:
import UIHelpers
接下来,你将不得不修复UIHelpers中的一些访问级别。当所有的文件都在同一个目标中时,默认的内部访问是没有问题的,但是现在你需要把一些东西公开。
在UIHelpers中,将以下内容公开:
Skin。Skin.Styler中所有的静态让步常数。Styler。- 在
Styler中:class、shared、configuration和所有的style方法。 - 在
UIViewController+Alert.swift:showAlert。 UIConfiguration。- 在
Colors.swift中所有的类var。 ErrorViewController和它的SecondaryAction和viewDidLoad()。
你还需要在SecondaryAction中添加这个初始化器:
public init(title: String, action: @escaping () -> Void) {
self.title = title
self.action = action
}
现在,这为其他模块提供了这些类型和功能,供其使用。在这种情况下,这些模块将是Login和MyBiz。
进一步隔离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:
- 将
api的类型改为LoginAPI!. - 在
viewDidLoad()中,删除api.delegate = self这一行。 - 将
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)
}
}
}
- 在扩展中,删除
APIDelegate类型,并删除loginFailed(error:)以外的所有方法。
啊,干净多了! 我不能低估这一变化的力量。为了直观地看到结果,请看这个更新的依赖关系图,现在API已经不在图中了:

别忘了测试¶
接下来,你要给你的协议添加测试,以验证你刚刚做的改变。
首先,抓起ValidatorsTests.swift并把它拖到LoginTests目标上,确保目标也改为LoginTests。
接下来,删除LoginTests.swift,因为你不需要这个文件。最后,打开ValidatorsTests.swift,并替换:
@testable import MyBiz
为:
@testable import Login
构建并运行Login目标和测试,以验证一切都在按计划工作。与UIHelpersTests适用的条件相同。确保没有主机应用程序,并且目标和方案不会试图构建MyBiz。
修复MyBiz¶
现在你有了两个新的框架,其中包含以前可用的代码,你需要修复它们使用项目中的依赖关系。切换回MyBiz方案,你会开始看到各种构建错误。
别担心,你可以一次解决这些问题,项目会在短时间内恢复正常(或者说是Giffy?:] )。
首先,在以下文件中添加以下导入语句:
import UIHelpers
DateSelectingViewController.swiftAnnouncementsTableViewController.swiftCreatePurachaseOrderTableViewController.swiftPurchasesTableViewController.swiftOrgTableViewController.swiftAddToOrderTableViewController.swiftConfiguration.swift
接下来,在Configuration.swift中,替换:
let ui: UI
为以下内容:
let ui: UIConfiguration
这就照顾到了UIHelpers框架,但你还需要使用Login框架。
打开AppDelegate.swift,在下面添加导入UIKit:
import Login
为了解决这些错误,打开LoginViewController.swift,使LoginViewController、viewDidLoad()和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.swiftCalendarModel.swiftOrgTableViewController.swiftPurchasesTableViewController.swiftCreatePurachaseOrderTableViewController.swiftSettingsTableViewController.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)
}
这段代码添加了你在替换上面的login和handleToken方法时引入的完成参数。
在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的变化:

这是一个漂亮、干净、有层次的图。没有循环,你也没有引入任何不相干的数据类型或无关的功能。好样的!
挑战¶
这一章让你完成了将Login功能干净地拉入其自身框架的最小工作量。然而,还有(很多!)改进的余地。通过完成以下任何一项来修复这个项目。
LoginViewController仍然依赖于MyBiz模块中的Main.storyboard,这使得它更难被重用。把它拉出来放到自己的Storyboard中,住在框架内。-
通过添加和改进登录测试。
-
将
LoginViewControllerTests特性化测试拉到LoginTests目标中。 - 通过创建一个
mock的LoginAPI,将这些测试用例重用为单元测试,这样你就不必通过API和本地服务器了。 -
创建一个
AppDelegateTests,测试用户状态流。 -
通过将其带入
UIHelpers并像Styler一样传递其配置,或者通过创建一个日志协议并将其附加到框架上,来修复日志器的问题。
关键点¶
- 框架有助于组织代码,并保持依赖关系的分离干净。
- 使用协议来提供来自调用者的实现,而不产生循环依赖。
- 在大型重构之前、期间和之后都要写测试。
从这里开始,该怎么走?¶
天哪,那是一个很大的工作,但你真的清理了代码。有几个领域值得在未来进行研究,以改善你的架构卫生。其中一些是在挑战赛中建议的,但你可以通过一个专门的用户状态管理器,以及使用Router或FlowController等模式来处理显示错误和登录屏幕,而不是依赖AppDelegate,实现更多的改进。
其他伟大的资源是最初的《设计模式》一书(Gamma等人),尽管它非常面向对象,但包含了很多有用的模式,用于逐步分离依赖关系和分解功能。更直接有用的是这些架构书,网址是https://www.raywenderlich.com/books。
- 设计模式的教程
Combine:用Swift进行异步编程或RxSwift:用Swift进行反应性编程- 高级iOS应用架构