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.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
中:
- 打开
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.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
,使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.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)
}
这段代码添加了你在替换上面的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应用架构