您的客户要求估算以修复错误。你告诉他也许几个小时。但是你内心深处知道你只是不确定。你只需要给出一个数字。
如果它花费的时间比你的估计长,它就会回来咬你。“我以为你说过会在几个小时内完成。”
承诺破灭。失去信任。怨恨情绪发展。客户解雇。钱丢了。
更糟糕的是,作为开发人员,我们倾向于低估某件事需要多长时间。
也许问题出在客户端?他们只是不明白开发是如何运作的。
他们不知道这件事有多复杂。
他们需要停止不断变化的需求。
他们应该首先关注功能,而不是 UI。
如果他们在 2 个月前在代码还很新鲜的时候要求我这样做,那将花费更少的时间。
在此处添加您自己喜欢的台词
你必须理解代码。浏览 50 个类、协议和方法。跟踪各种条件和循环。确定相关线路。重现错误。进行更改以查看其行为方式有何不同。冲洗并重复,直到您修复错误。
在做所有这些的时候,你担心你可能会破坏其他东西。因为没有单元测试来防止回归。
每次您需要修复错误或添加新功能时,都会发生同样的恶性循环。感谢Massive View Controller。
解决 MVC 问题的尝试失败
如果这听起来很熟悉,那么您可能已经尝试过解决这种情况。您阅读了很多关于设计模式以及如何重构视图控制器的内容。提取您的数据源和委托。将您的业务逻辑放入模型中。这应该如何帮助您编写单元测试。
但你仍然没有这样做。现在是 2015 年。Swift 发布 2.0,下个月发布 iOS 9。
到目前为止,您阅读和尝试过的东西就是我所说的急救箱。损坏已经造成。你只是在伤口上贴绷带。您正在治疗症状,而不是攻击根本原因。
我们可以先预防伤害,而不是事后处理伤口吗?为什么它在重构中有“re” ?我们是否可以从一开始就编写已分解的代码,这样我们就不必重构了?
根本原因
大约 2 年前,我决定认真对待它。我想找出为什么我们仍在与大型视图控制器作斗争。尽管如此,重构和测试这些天都在引起轰动。是否真的可以编写分解代码而永远不必重构?
进入建筑。如果建筑物的地基不稳固,它最终会倒塌。如果你的代码库有一个不稳定的架构,它会把你活活吃掉。直到你够了。让我们重写它。我们都知道重写是多么昂贵。
我研究了各种 iOS 架构,例如 MVC、MVVM、ReactiveCocoa 和 VIPER。我还尝试了不同的测试和模拟框架。我会在以后的文章中写更多关于这些主题的文章。今天为了对大家有所帮助,我想重点介绍一下如何将Bob 大叔的 Clean Architecture应用到使用 Swift 进行的 iOS 开发中。我将称之为Clean Swift。阅读这篇文章后,您将学习如何:
更快、更轻松地查找和修复错误。
自信地改变现有行为。
轻松添加新功能。
编写具有单一职责的较短方法。
用已建立的边界解耦类依赖关系。
从视图控制器中提取业务逻辑到交互器中。
使用工作人员和服务对象构建可重用组件。
从一开始就编写因式分解代码。
编写快速且可维护的单元测试。
对您的测试有信心以捕捉回归。
将您学到的知识应用到任何规模的新项目和现有项目中。
如果您确切知道要打开哪个文件以及查看哪种方法怎么办?你能提高多少生产力?想象一下,您的测试在几秒钟内运行,而不是几分钟或几小时。而且您不需要学习任何测试或模拟框架。无需安装 CocoaPods。
您只需遵循一个简单的系统,您的代码就会在逻辑上到位。这就是当您在 3 个月后回顾您的代码时确切地知道在哪里查看的方式。
为了让您更容易上手,我创建了一组 Xcode 模板。您可以只使用 Xcode 的 New File 命令生成 Clean Swift 组件。当您订阅我下面的列表时,您将在收件箱中收到模板和更新。我还将向您发送有关升级架构的未来帖子。我们将检查交互器、工作器和服务对象的细节,看看路由如何与多个故事板一起工作(是的,它就像一个具有多个故事板的魅力)。在下面订阅,您将收到所有这些好东西。
介绍 Clean Swift – iOS 的干净架构
Clean Swift 架构源自 Bob 大叔提出的 Clean Architecture。它们共享许多共同的概念,例如组件、边界和模型。我将在 Bob 叔叔的一次演讲中实现相同的创建订单用例。在 Bob 大叔演示在 Web 应用程序中使用 Java 的 Clean Architecture 时,我将向您展示如何在 iOS 项目中使用 Swift 应用 Clean Architecture。
在我们开始之前,请确保您订阅了上面表格中的列表以获取我的 Xcode 模板。当您可以单击一个按钮来生成它们时,为什么还要手动编写所有样板代码呢?
如果您还没有准备好使用 Swift,我还有一个我的 Xcode 模板的 Objective-C 版本,并且正在寻找人们用更多的项目类型来测试它。如果这是你,请在订阅后给我发电子邮件。我会发给你Objective-C 版本。您的反馈将有助于指导组件的设计,因为语言存在差异。
“创建订单”用例
这个用例在鲍勃叔叔的为什么没有人能正确掌握 Web 架构?讲话。它源自 Ivar Jacobson 的《面向对象软件工程:用例驱动方法》一书。这是一个很好的例子,因为它包含了 Clean Swift 的大部分特性,除了路由。稍后我会写一篇关于路由的所有细节的帖子。
数据:
– 客户 ID
– 客户联系信息
– 发货目的地
– 发货机制
– 付款信息
初级课程:
1. 订单员用上述数据发出“创建订单”命令。
2. 系统验证所有数据。
3.系统创建订单并确定order-id。
4. 系统下发order-id 给店员。
异常课程: 验证错误
1. 系统将错误信息传递给店员。
我们将在模型层中对用例中的数据进行建模,并创建特殊的请求、响应和视图模型,以便在视图控制器、交互器和演示器组件之间的边界处传递。我们将在交互器中实现每个主要课程项目和验证作为业务逻辑,如果有必要,在工作人员中实现。
为避免迷路,我们先来看看我们在 Xcode 项目中是如何组织代码的。
在 Xcode 中组织你的代码
我们将创建一个新的 Xcode 项目并将其命名为CleanStore。为简单起见,只需选择Single View Application和iPhone 。确保语言设置为Swift。接下来,创建一个嵌套子组Scenes -> CreateOrder。当我们将来实现删除订单用例时,我们将创建一个新的子组Scenes -> DeleteOrder。
在典型的 Xcode 项目中,通常会看到文件被组织成模型、视图和控制器组。但是每个 iOS 开发者都知道 MVC。它没有告诉您有关该项目的任何具体信息。正如鲍勃叔叔指出的那样,组名和文件名应该揭示您对用例的意图。它不应反映底层框架结构。因此,我们将在嵌套在Scenes中的新组下组织每个用例。
在CreateOrder组中,您可以预期所有文件都与创建订单有关。同样,在DeleteOrder组下,您会找到处理删除订单的代码。如果您看到另一个开发人员创建的新ViewOrderHistory组,您已经知道会发生什么。
这个组织告诉你的远不止你习惯看到的模型、视图和控制器组。随着时间的推移,您将积累 15 个模型、27 个视图控制器和 17 个视图。他们在做什么?在检查每个文件之前,您根本不知道。
你可能会问。CreateOrder、DeleteOrder和ViewOrderHistory使用的共享类和协议怎么样?好吧,您可以将它们放在一个单独的组中,称为Common -> Order。为简单起见怎么样?
回到我们的用例。
在新的CreateOrder组下,我们将创建以下 Clean Swift 组件。在处理用例时,我们将向组件的输入和输出协议添加方法,然后在组件中实现它们。确保你加入了我的电子邮件列表,这样你就可以使用我的 Xcode 模板通过点击几下自动为你创建所有这些组件。
VIP 周期
视图控制器、交互器和演示器是 Clean Swift 的三个主要组件。它们作为彼此的输入和输出,如下图所示。

视图控制器的输出连接到交互器的输入。交互者的输出连接到演示者的输入。演示者的输出连接到视图控制器的输入。我们将创建特殊对象来通过组件之间的边界传递数据。这使我们能够将底层数据模型与组件分离。这些特殊对象仅包含基本类型,例如 Int、Double 和 String。我们可以创建结构、类或枚举来表示数据,但在这些包含实体中应该只有原始类型。
这很重要,因为当业务规则发生变化时,会导致底层数据模型发生变化。我们不需要更新整个代码库。这些组件在 Clean Swift 中充当插件。这意味着我们可以交换不同的组件,只要它们符合输入和输出协议。该应用程序仍按预期工作。
一个典型的场景是这样的。用户点击应用程序用户界面中的按钮。点击手势通过视图控制器中的 IBActions 进入。视图控制器构造一个请求对象并将其发送给交互器。交互器获取请求对象并执行一些工作。然后它将结果放入响应对象并将其发送给演示者。演示者获取响应对象并格式化结果。然后它将格式化的结果放入视图模型对象并将其发送回视图控制器。最后,视图控制器将结果显示给用户。
1.视图控制器
视图控制器在 iOS 应用程序中应该做什么?基类名称UITableViewController应该告诉你一些事情。你想把代码放在那里来控制UITableView和UIView子类。但是这个控制代码是什么样的呢?什么是控制代码,什么不是?
让我们潜入水中。
Swift
import UIKit
protocol CreateOrderViewControllerInput
{
func displaySomething(viewModel: CreateOrderViewModel)
}
protocol CreateOrderViewControllerOutput
{
func doSomething(request: CreateOrderRequest)
}
class CreateOrderViewController: UITableViewController, CreateOrderViewControllerInput
{
var output: CreateOrderViewControllerOutput!
var router: CreateOrderRouter!
// MARK: Object lifecycle
override func awakeFromNib()
{
super.awakeFromNib()
CreateOrderConfigurator.sharedInstance.configure(self)
}
// MARK: View lifecycle
override func viewDidLoad()
{
super.viewDidLoad()
doSomethingOnLoad()
}
// MARK: Event handling
func doSomethingOnLoad()
{
// NOTE: Ask the Interactor to do some work
let request = CreateOrderRequest()
output.doSomething(request)
}
// MARK: Display logic
func displaySomething(viewModel: CreateOrderViewModel)
{
// NOTE: Display the result from the Presenter
// nameTextField.text = viewModel.name
}
}
CreateOrderViewControllerInput和CreateOrderViewControllerOutput协议
CreateOrderViewControllerInput协议指定组件的输入(CreateOrderViewController它符合协议)。CreateOrderViewControllerOutput协议指定输出。稍后您将在交互器和演示器中看到相同的模式。
输出协议中有一种方法doSomething()。如果另一个组件要充当 的输出CreateOrderViewController,则需要doSomething()在其输入中提供支持。
从你之前看到的 VIP 循环中,我们知道这个输出将是交互器。但通知中CreateOrderViewController.swift,并没有提及CreateOrderInteractor。这意味着CreateOrderViewController只要它支持doSomething()其输入协议,我们就可以交换另一个组件作为输出。
to 的参数doSomething()是一个请求对象,它通过边界从视图控制器传递到交互器。这个请求对象是一个CreateOrderRequest结构。它由原始类型组成,而不是我们之前确定的整个订单数据。这意味着我们已经将底层订单数据模型与视图控制器和交互器分离。当我们将来对订单数据模型进行更改时(例如,添加一个内部订单 ID 字段),我们不需要更新 Clean Swift 组件中的任何内容。
等我们完成 VIP 循环后,我会回到displaySomething()输出协议中的方法。
output和router变量
output变量是符合CreateOrderViewControllerOutput协议的对象。虽然我们知道它将成为交互器,但它不需要。
该router变量是对 CreateOrderRouter 的引用,用于导航到不同的场景。
configure()方法
我们将调用CreateOrderConfigurator.sharedInstance.configure(self)inawakeFromNib()要求配置器设置 VIP 链。在Bob 叔叔的 Clean Architecture或VIPER中没有配置器(它在应用程序委托中完成所有设置)。我真的不想用这个无关的设置代码乱扔我的 VIP 代码,所以我将它提取到配置器中。我们稍后会看一下配置器。
控制流程
在viewDidLoad()中,我们有一些业务逻辑要运行,所以我们调用doSomethingOnLoad(). 在doSomethingOnLoad()中,我们创建一个对象并在输出(交互器)上CreateOrderRequest()调用。doSomething(request)而已。我们要求输出执行我们的业务逻辑。视图控制器不会也不应该关心谁以及如何完成。
2. 交互者
交互器包含您的应用程序的业务逻辑。用户在您的 UI 中点击和滑动以与您的应用交互。视图控制器从 UI 收集用户输入并将其传递给交互器。然后它检索一些模型并要求一些工人来做这项工作。
import UIKit
protocol CreateOrderInteractorInput
{
func doSomething(request: CreateOrderRequest)
}
protocol CreateOrderInteractorOutput
{
func presentSomething(response: CreateOrderResponse)
}
class CreateOrderInteractor: CreateOrderInteractorInput
{
var output: CreateOrderInteractorOutput!
var worker: CreateOrderWorker!
// MARK: Business logic
func doSomething(request: CreateOrderRequest)
{
// NOTE: Create some Worker to do the work
worker = CreateOrderWorker()
worker.doSomeWork()
// NOTE: Pass the result to the Presenter
let response = CreateOrderResponse()
output.presentSomething(response)
}
}
CreateOrderInteractorInput和CreateOrderInteractorOutput协议
CreateOrderInteractorInput协议指定组件的输入(CreateOrderInteractor它符合协议)。CreateOrderInteractorOutput协议指定输出。
我们在协议中看到与协议中相同的doSomething()方法。的输出连接到 的输入。CreateOrderInteractorInputCreateOrderViewControllerOutputCreateOrderViewControllerCreateOrderInteractor
输出协议有一种方法presentSomething()。输出CreateOrderInteractor需要支持presentSomething()才能充当输出。提示:输出将是 VIP 中的 P。
这里要注意的另一件事是 to 的参数doSomething()是 type 的请求对象CreateOrderRequest。CreateOrderViewControllerOutput这与协议中的方法签名相同。交互器在此请求对象内部窥视以检索任何必要的数据来完成其工作。
同样,参数 topresentSomething()是 CreateOrderResponse 类型的响应对象。
output和worker变量
output变量是符合CreateOrderInteractorOutput协议的对象。虽然我们知道它将成为演示者,但它不一定是。
worker类型变量是一个专门的CreateOrderWorker对象,它将实际创建新订单。因为创建订单可能涉及核心数据中的持久性和进行网络调用。交互者一个人做的工作量太大了。请记住,交互者还必须验证订单,这很可能会被提取到它自己的工作人员中。
控制流程
当CreateOrderInteractor(ie CreateOrderViewController) 的输入调用doSomething()时,它首先创建工作对象并通过调用来要求它做一些工作doSomeWork()。presentSomething()然后它构造一个响应对象并在输出上调用。
接下来让我们快速浏览一下worker。
3. 工人
个人资料视图可能需要从 Core Data 获取用户,下载个人资料照片,允许用户喜欢和关注,......等等。您不想让交互者忙于完成所有这些任务。相反,您可以将其分解为许多工人,每个工人做一件事。然后,您可以在其他地方重用相同的工作人员。
非常简单,CreateOrderWorker因为它只提供了一个接口和它可以对交互器执行的工作的实现。
Swift
import UIKit
class CreateOrderWorker
{
// MARK: Business Logic
func doSomeWork()
{
// NOTE: Do the work
}
}
4. 主持人
在交互器产生一些结果后,它将响应传递给演示者。演示者然后将响应编组到适合显示的视图模型中。然后它将视图模型传递回视图控制器以显示给用户。
Swift
import UIKit
protocol CreateOrderPresenterInput
{
func presentSomething(response: CreateOrderResponse)
}
protocol CreateOrderPresenterOutput: class
{
func displaySomething(viewModel: CreateOrderViewModel)
}
class CreateOrderPresenter: CreateOrderPresenterInput
{
weak var output: CreateOrderPresenterOutput!
// MARK: Presentation logic
func presentSomething(response: CreateOrderResponse)
{
// NOTE: Format the response from the Interactor and pass the result back to the View Controller
let viewModel = CreateOrderViewModel()
output.displaySomething(viewModel)
}
}
CreateOrderPresenterInput和CreateOrderPresenterOutput协议
CreateOrderPresenterInput协议指定组件的输入(CreateOrderPresenter它符合协议)。CreateOrderPresenterOutput协议指定输出。
至此,presentSomething()和displaySomething()方法就不用解释了。CreateOrderResponse参数通过交互者-演示者边界传递,而参数CreateOrderViewModel在 VIP 循环完成时通过演示者-视图控制器边界传递。
output多变的
output变量是符合CreateOrderPresenterOutput协议的对象。虽然我们知道它将成为视图控制器,但它不是必须的。这里的一个细微差别是我们创建output了一个弱变量以避免在不再需要此CreateOrder场景并且组件被释放时出现引用循环。
控制流程
由于 的输出CreateOrderInteractor连接到 的输入CreateOrderPresenter,因此presentSomething()将在交互器完成其工作后调用该方法。它只是构造视图模型对象并displaySomething()在输出上调用。
我答应过你,我们会回到displaySomething()视图控制器中的方法。这是 VIP 周期的最后一步。它获取视图模型对象中的任何数据并将其显示给用户。例如,我们可能希望在文本字段中显示客户的姓名:nameTextField.text = viewModel.name.
恭喜!您刚刚了解了 Clean Swift 的精髓。您现在应该能够从您的用户界面代码中提取业务和表示逻辑。但别担心。我不会给你一个例子。但让我们先说完其余的 Clean Swift 组件。
5.路由器
当用户点击下一个按钮导航到故事板中的下一个场景时,会触发一个 segue 并呈现一个新的视图控制器。路由器从视图控制器中提取此导航逻辑。它也是将任何数据传递到下一个场景的最佳位置。结果,视图控制器只剩下控制视图的任务。
Swift
import UIKit
protocol CreateOrderRouterInput
{
func navigateToSomewhere()
}
class CreateOrderRouter
{
weak var viewController: CreateOrderViewController!
// MARK: Navigation
func navigateToSomewhere()
{
// NOTE: Teach the router how to navigate to another scene. Some examples follow:
// 1. Trigger a storyboard segue
// viewController.performSegueWithIdentifier("ShowSomewhereScene", sender: nil)
// 2. Present another view controller programmatically
// viewController.presentViewController(someWhereViewController, animated: true, completion: nil)
// 3. Ask the navigation controller to push another view controller onto the stack
// viewController.navigationController?.pushViewController(someWhereViewController, animated: true)
// 4. Present a view controller from a different storyboard
// let storyboard = UIStoryboard(name: "OtherThanMain", bundle: nil)
// let someWhereViewController = storyboard.instantiateInitialViewController() as! SomeWhereViewController
// viewController.navigationController?.pushViewController(someWhereViewController, animated: true)
}
// MARK: Communication
func passDataToNextScene(segue: UIStoryboardSegue)
{
// NOTE: Teach the router which scenes it can communicate with
if segue.identifier == "ShowSomewhereScene" {
passDataToSomewhereScene(segue)
}
}
func passDataToSomewhereScene(segue: UIStoryboardSegue)
{
// NOTE: Teach the router how to pass data to the next scene
// let someWhereViewController = segue.destinationViewController as! SomeWhereViewController
// someWhereViewController.output.name = viewController.output.name
}
}
CreateOrderRouterInput协议
该CreateOrderRouterInput协议将其路由(它可以导航到的场景)指定到视图控制器。该方法navigateToSomewhere()告诉视图控制器:如果你使用我作为你的路由器,我知道如何导航到一个叫做某处的场景。
正如您在navigateToSomewhere()方法内部的注释中看到的那样,路由器在导航到另一个场景的方式上非常灵活。我将在另一篇文章中讨论路由器。
viewController多变的
该viewController变量只是对使用此路由器的视图控制器的引用。它是避免引用循环问题的弱变量,由配置器设置,您很快就会看到。Apple 在 segues 之间转换的方式将所有present*和push*方法添加到UIViewController类中。所以我们需要在viewController这里,以便我们可以在路由器中调用这些方法。
passDataToNextScene()和passDataToSomewhereScene()
passDataToNextScene()和passDataToSomewhereScene()方法为您提供了一种将数据传递到下一个场景的方法。您在那里放置的代码与通常为prepareForSegue(). passDataToNextScene()尝试匹配 segue 标识符以分派到更具体passDataToSomewhereScene()的实际数据传递的标识符。
6. 配置器
配置器的工作是连接上面所有的 Clean Swift 组件。我特意将所有设置代码提取到配置器中,以便您可以专注于编写应用程序代码。您根本不需要查看配置器。但我会为你揭开魔法。
Swift
import UIKit
// MARK: Connect View, Interactor, and Presenter
extension CreateOrderViewController: CreateOrderPresenterOutput
{
override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?)
{
router.passDataToNextScene(segue)
}
}
extension CreateOrderInteractor: CreateOrderViewControllerOutput
{
}
extension CreateOrderPresenter: CreateOrderInteractorOutput
{
}
class CreateOrderConfigurator
{
// MARK: Object lifecycle
class var sharedInstance: CreateOrderConfigurator
{
struct Static {
static var instance: CreateOrderConfigurator?
static var token: dispatch_once_t = 0
}
dispatch_once(&Static.token) {
Static.instance = CreateOrderConfigurator()
}
return Static.instance!
}
// MARK: Configuration
func configure(viewController: CreateOrderViewController)
{
let router = CreateOrderRouter()
router.viewController = viewController
let presenter = CreateOrderPresenter()
presenter.output = viewController
let interactor = CreateOrderInteractor()
interactor.output = presenter
viewController.output = interactor
viewController.router = router
}
}
扩展名
记得我告诉过你在视图控制器中没有提到交互器。该output变量被定义为类型CreateOrderViewControllerOutput。您还可以自由交换另一个对象来替换交互器作为视图控制器的输出。但不知何故,视图控制器需要连接到交互器,对吧?这正是一开始的三个扩展所做的。
该CreateOrderViewController扩展添加了CreateOrderPresenterOutput协议一致性。这意味着演示者的输出连接到视图控制器。同样,CreateOrderInteractor扩展添加了CreateOrderViewControllerOutput协议一致性。该CreateOrderPresenter扩展添加了CreateOrderInteractorOutput协议一致性。
辛格尔顿
每个场景应该只有一个配置器,连接设置代码应该只运行一次。我创建一个sharedInstance类变量并CreateOrderConfigurator.sharedInstance.configure(self)在视图控制器的awakeFromNib()方法中调用。这是从情节提要加载视图控制器对象后我必须立即做的第一次机会。除非我重写UIViewController基类,并强制你从它派生。但我不想那样做。
configure()方法
这是 VIP 周期中绘制箭头的地方。注意箭头是单向的。这种一致的控制流使事情变得非常清楚。这就是您在修复错误时确切知道要查找的文件和方法的原因。
只有视图控制器是从情节提要中加载的。我们需要手动创建交互器、演示器和路由器实例。该configure()方法执行此操作,然后将相应的引用分配给output、router和viewController变量。
这里要记住的重要一点是VIP 周期:
视图控制器的输出连接到交互器的输入。交互器的输出连接到演示器的输入。演示者的输出连接到视图控制器的输入。这意味着控制流始终是单向的。
当您实现功能和修复错误时,记住 VIP 周期将变得非常方便。您将确切知道要查找的文件和方法。
它还简化了您的依赖关系图。您不希望对象在任何时候都可以相互引用。视图控制器直接要求演示者格式化字符串似乎很方便。但随着时间的推移,你的依赖关系图会变得一团糟。始终牢记这一点,以避免任何不必要的耦合。
当我们实现创建订单用例时,这将变得非常清楚。
7. 模型
为了完全解耦 Clean Swift 组件,我们需要定义数据模型以通过它们之间的边界,而不仅仅是使用原始数据模型。有 3 种主要类型的模型:
请求——视图控制器构造一个请求模型并将其传递给交互器。请求模型主要包含用户输入,例如在文本字段中输入的文本和在选择器中选择的值。
响应——交互者完成请求的工作后,它将结果封装在响应模型中,然后将其传递给演示者。
视图模型——在演示者收到来自交互者的响应后,它将结果格式化为原始数据类型,例如 String 和 Int,并将它们填充到视图模型中。然后它将视图模型传递回视图控制器以进行显示。
Swift
import UIKit
struct CreateOrderRequest
{
}
struct CreateOrderResponse
{
}
struct CreateOrderViewModel
{
}
在模板生成的代码中,我们实际上并没有这些模型中的任何数据。所以他们只是空的。但是当我们实现创建订单用例时,您会看到实际数据。接下来让我们看看这些数据是什么样的。
厌倦了所有这些样板代码?在下面订阅以获取我的 Xcode 模板,以自动为您生成所有这些。
“创建订单”数据
让我们分解创建订单用例以提供创建新订单所需的数据。然后,我们将使用应用程序中的表格视图和文本字段创建一个表单,以从用户那里收集这些数据。
客户ID
整数
客户联系方式
名
姓
电话
电子邮件
发货目的地
收件地址
街道 1
街道 2
城市
状态
压缩
出货机制
邮寄方式
明天
3 到 5 天
地面
支付信息
信用卡号
截止日期
CVV
帐单地址
街道 1
街道 2
城市
状态
压缩
那是一个巨大的形式!在一个更现实的应用程序中,我们很可能在用户登录后已经拥有一些此类数据,例如姓名、电话、电子邮件、送货和账单地址,可能还有信用卡信息。因此,这种形式将大大缩小。
“创建订单”业务逻辑
现在,让我们看看我们可以从用例的需求中得出什么业务逻辑。这可以用作我们稍后将为其编写验收测试的伪代码。
订单员使用上述数据发出“创建订单”命令。
显示一个表单以从用户那里收集数据。
表单使用文本字段来收集文本数据。
表单使用选择器来收集运输方式和过期数据。
表单使用开关从送货地址自动填写账单地址。
表单使用一个按钮来发出“创建订单”命令。
系统验证所有数据。
确保除 Street 2 之外的所有字段都不为空。
如果有效,则在按钮下方显示“有效”消息。
如果无效,则在无效字段旁边显示错误消息。
系统创建订单并确定订单ID。
生成唯一的订单 ID。
在 Core Data 中创建并存储新订单。
系统将 order-id 发送给店员。
在屏幕上向用户显示订单 ID。
这是一组很好的初始特性,用于演示 Clean Swift 的工作原理及其优势。在以后的帖子中,我们将扩展此功能集,以展示您如何轻松适应变化。一些未来的要求可能是:
使用核心位置将当前纬度/经度反向地理编码到地址以预先填写送货地址和帐单地址。
集成 Stripe API 以收集信用卡信息。
在输入数据时验证字段,而不是在点击按钮后验证字段。
将国家/地区添加到送货地址和帐单地址以扩展海外业务。
格式化电话号码、信用卡号码和到期日期。
现在我们准备好实现创建订单用例了。
在 Storyboard 和 View Controller 中设计 Create Order Form
我们应该从哪里开始?
您希望首先在视图控制器中收集用户输入,例如文本和点击。然后视图控制器将输入传递给交互器以完成一些工作。接下来,交互者将输出传递给演示者进行格式化。最后,演示者要求视图控制器显示结果。
这是控制流,它总是在一个方向上。由于非常重要的人,VIP循环不被命名为VIP。因为事情是有秩序的。V 然后 I 然后 P。
让我们从创建创建订单表单开始。在情节提要中,CreateOrderViewController将UINavigationController. 添加一个标题Create Order来给它一些上下文。
使表格视图使用静态单元格。为每组数据添加一个新部分,为创建订单表单中所需的每条数据添加一个新单元格。对于每个单元格,添加一个UILabel和UITextField。
进行以下 IBOutlet 连接:
将所有文本字段连接到textFieldsIBOutlet 集合。还将他们的代表设置为我们的CreateOrderViewController.
将运输方式的文本字段连接到shippingMethodTextFieldIBOutlet。
将到期日期的文本字段连接到expirationDateTextFieldIBOutlet。
将 aUIPickerView和UIDatePicker从对象库拖到场景中。然后进行以下 IBOutlet 连接:
连接UIPickerView到shippingMethodPickerIBOutlet。还将数据源和委托设置为我们的CreateOrderViewController.
连接UIDatePicker到expirationDatePickerIBOutlet。还在助手编辑器中按住 Control 键将其从 拖动UIDatePicker到 ,CreateOrderViewController为值更改事件创建 IBAction 并将其命名为expirationDatePickerValueChanged()。
您的表单应如下所示:

它不会赢得任何设计奖项,但它满足我们的用例要求。Clean Swift 的美妙之处在于您可以稍后修改视图而不会影响应用程序的其他部分。例如,我们可以在送货地址和帐单地址中添加国家/地区,以扩展客户的海外业务。或者我们可以聘请专业设计师来设计表格。
设置 IBOutlets 和 IBActions 后,您CreateOrderViewController还应该包含以下代码:
Swift
// MARK: Text fields
@IBOutlet var textFields: [UITextField]!
// MARK: Shipping method
@IBOutlet weak var shippingMethodTextField: UITextField!
@IBOutlet var shippingMethodPicker: UIPickerView!
// MARK: Expiration date
@IBOutlet weak var expirationDateTextField: UITextField!
@IBOutlet var expirationDatePicker: UIDatePicker!
@IBAction func expirationDatePickerValueChanged(sender: AnyObject)
{
}
现在用户界面已经全部设置好了。接下来让我们处理用户交互。当用户点击键盘上的下一个按钮时,我们希望他能够为下一个文本字段输入文本。使CreateOrderViewController符合UITextFieldDelegate协议。然后添加textFieldShouldReturn()方法。
Swift
func textFieldShouldReturn(textField: UITextField) -> Bool
{
textField.resignFirstResponder()
if let index = textFields.indexOf(textField) {
if index < textFields.count - 1 {
let nextTextField = textFields[index + 1]
nextTextField.becomeFirstResponder()
}
}
return true
}
当用户点击表格视图单元格(而不是直接点击文本字段)时,我们仍然希望用户能够编辑文本字段。毕竟,使用UITextBorderStyleNone使得无法看到文本字段的边界。另外,能够点击标签并编辑字段只是感觉更好,并且是一种常见的行为。要实现此行为,请添加该tableView:didSelectRowAtIndexPath()方法。
Swift
override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath)
{
if let cell = tableView.cellForRowAtIndexPath(indexPath) {
for textField in textFields {
if textField.isDescendantOfView(cell) {
textField.becomeFirstResponder()
}
}
}
}
在交互器中实现运输方法业务逻辑
让运输方式和到期日期的选择器工作起来非常简单。但更重要的是,这是我们遇到的第一个业务逻辑。
我们先来看看运输方式。
随着时间的推移,随着客户与不同的托运人合作,可用的运输方式可能会在客户的业务中发生变化。我们不想把它留在视图控制器中。因此,我们将把这个业务逻辑提取到交互器中。
首先添加configurePickers()方法并在viewDidLoad(). 当用户点击 时shippingMethodTextField,将显示正确的选择器 UI 而不是标准键盘。
Swift
override func viewDidLoad()
{
super.viewDidLoad()
configurePickers()
}
func configurePickers()
{
shippingMethodTextField.inputView = shippingMethodPicker
}
接下来,使CreateOrderViewController符合UIPickerViewDataSource和UIPickerViewDelegate协议,并添加以下方法。
Swift
func numberOfComponentsInPickerView(pickerView: UIPickerView) -> Int
{
return 1
}
func pickerView(pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int
{
return output.shippingMethods.count
}
func pickerView(pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String?
{
return output.shippingMethods[row]
}
func pickerView(pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int)
{
shippingMethodTextField.text = output.shippingMethods[row]
}
由于运输方法业务逻辑被移动到交互器,我们可以output.shippingMethods在视图控制器中调用它。然后我们还需要将shippingMethods变量添加到CreateOrderViewControllerOutput和CreateOrderInteractorInput协议中。我们需要将它添加到两个协议中,因为配置器已将CreateOrderViewController输出连接到CreateOrderInteractor输入。现在,我们将通过假设它是一个固定的文字字符串数组来保持简单。将来,我们可以在 Core Data 中或通过网络动态检索可用的运输方式。
在CreateOrderViewController:
Swift
protocol CreateOrderViewControllerOutput
{
var shippingMethods: [String] { get }
}
在CreateOrderInteractor:
Swift
protocol CreateOrderInteractorInput
{
var shippingMethods: [String] { get }
}
class CreateOrderInteractor: CreateOrderInteractorInput
{
var output: CreateOrderInteractorOutput!
var worker: CreateOrderWorker!
var shippingMethods = [
"Standard Shipping",
"Two-Day Shipping ",
"One-Day Shipping "
]
}
在交互器中实现到期日期业务逻辑
到期日期的格式可以根据用户的语言和位置而有所不同。我们将把这个演示逻辑移到演示者。
让我们configurePickers()首先处理到期日期选择器。
Swift
func configurePickers()
{
shippingMethodTextField.inputView = shippingMethodPicker
expirationDateTextField.inputView = expirationDatePicker
}
接下来,将expirationDatePickerValueChanged()方法修改为如下所示:
Swift
@IBAction func expirationDatePickerValueChanged(sender: AnyObject)
{
let date = expirationDatePicker.date
let request = CreateOrder_FormatExpirationDate_Request(date: date)
output.formatExpirationDate(request)
}
在用户在选择器中选择到期日期后,将expirationDatePickerValueChanged()调用该方法。我们从选择器中检索日期,并将其填充到这个看起来很奇怪的东西中,称为CreateOrder_FormatExpirationDate_Request. 它只是我们在CreateOrderModels.swift. 它有一个名为typedate的数据成员。NSDate
Swift
struct CreateOrder_FormatExpirationDate_Request
{
var date: NSDate
}
你可能想知道为什么我在这里使用_。Objective-C 和 Swift 命名约定建议对类、结构和枚举等标记名称使用大写驼峰式。在你因为我破坏对流而对我大喊大叫之前。让我解释一下我为什么使用_。
CreateOrder是场景的名称。FormatExpirationDate是意图或商业规则。Request表示视图控制器和交互器之间的边界。这就是为什么你得到CreateOrder_FormatExpirationDate_Request.
对比。CreateOrder_FormatExpirationDate_Request_ CreateOrderFormatExpirationDateRequest哪一个更清楚地告诉你场景、意图和边界?所以我选择了清晰而不是教条。
现在,回到expirationDatePickerValueChanged()方法。
在我们创建请求对象并使用用户选择的日期对其进行初始化后,我们只需通过调用output.formatExpirationDate(request).
是的,你猜对了。我们确实需要将这个新方法添加到CreateOrderViewControllerOutput和CreateOrderInteractorInput协议中。
在CreateOrderViewController:
Swift
protocol CreateOrderViewControllerOutput
{
var shippingMethods: [String] { get }
func formatExpirationDate(request: CreateOrder_FormatExpirationDate_Request)
}
在CreateOrderInteractor:
Swift
protocol CreateOrderInteractorInput
{
var shippingMethods: [String] { get }
func formatExpirationDate(request: CreateOrder_FormatExpirationDate_Request)
}
class CreateOrderInteractor: CreateOrderInteractorInput
{
// MARK: Expiration date
func formatExpirationDate(request: CreateOrder_FormatExpirationDate_Request)
{
let response = CreateOrder_FormatExpirationDate_Response(date: request.date)
output.presentExpirationDate(response)
}
}
在 Presenter 中实现过期日期表示逻辑
在该formatExpirationDate()方法中,我们创建了CreateOrder_FormatExpirationDate_Response中定义的响应对象CreateOrderModels.swift。
Swift
struct CreateOrder_FormatExpirationDate_Response
{
var date: NSDate
}
我们使用从请求模型中获得的日期初始化响应对象。交互者对日期不做任何事情,只是通过调用直接传递output.presentExpirationDate(response)。当前没有与到期日期关联的业务逻辑。稍后,当我们添加验证以确保到期日期不会过去时,我们将在交互器中添加该业务规则。
让我们继续将presentExpirationDate()方法添加到CreateOrderInteractorOutput和CreateOrderPresenterInput协议中。
在CreateOrderInteractor:
Swift
protocol CreateOrderInteractorOutput
{
func presentExpirationDate(response: CreateOrder_FormatExpirationDate_Response)
}
在CreateOrderPresenter:
Swift
protocol CreateOrderPresenterInput
{
func presentExpirationDate(response: CreateOrder_FormatExpirationDate_Response)
}
class CreateOrderPresenter: CreateOrderPresenterInput
{
weak var output: CreateOrderPresenterOutput!
let dateFormatter: NSDateFormatter = {
let dateFormatter = NSDateFormatter()
dateFormatter.dateStyle = .ShortStyle
dateFormatter.timeStyle = NSDateFormatterStyle.NoStyle
return dateFormatter
}()
// MARK: Expiration date
func presentExpirationDate(response: CreateOrder_FormatExpirationDate_Response)
{
let date = dateFormatter.stringFromDate(response.date)
let viewModel = CreateOrder_FormatExpirationDate_ViewModel(date: date)
output.displayExpirationDate(viewModel)
}
}
在CreateOrderPresenter中,我们定义一个NSDateFormatter常数。该presentExpirationDate()方法只是要求此日期格式化程序对象将到期日期从 转换NSDate为String。然后,它将这个日期字符串表示CreateOrder_FormatExpirationDate_ViewModel填充到CreateOrderModels.swift.
请注意,视图模型中的日期是 a String,而不是NSDate。演示者的工作是将数据编组为适合向用户显示的格式。由于我们的 UI 在 a 中显示日期UITextField,因此我们将日期转换为字符串。事实上,大多数时候,视图模型中的数据要么是字符串,要么是数字,因为这是人类阅读的内容。
Swift
struct CreateOrder_FormatExpirationDate_ViewModel
{
var date: String
}
它最终要求视图控制器通过调用来显示它output.displayExpirationDate(viewModel)。
这也意味着我们需要在和协议中定义displayExpirationDate()方法。CreateOrderPresenterOutputCreateOrderViewControllerInput
在CreateOrderPresenter:
Swift
protocol CreateOrderPresenterOutput: class
{
func displayExpirationDate(viewModel: CreateOrder_FormatExpirationDate_ViewModel)
}
在CreateOrderViewController:
Swift
protocol CreateOrderViewControllerInput
{
func displayExpirationDate(viewModel: CreateOrder_FormatExpirationDate_ViewModel)
}
class CreateOrderViewController: UITableViewController, CreateOrderViewControllerInput, UITextFieldDelegate, UIPickerViewDataSource, UIPickerViewDelegate
{
// MARK: Expiration date
func displayExpirationDate(viewModel: CreateOrder_FormatExpirationDate_ViewModel)
{
let date = viewModel.date
expirationDateTextField.text = date
}
}
在该displayExpirationDate()方法中,我们只需要从视图模型中获取日期字符串并将其分配给 textField,如expirationDateTextField.text = date.
而已。这样就完成了 VIP 周期。
您可以在https://github.com/Clean-Swift/CleanStore找到完整的代码示例。
您有一个有效的创建订单表格。用户可以在文本字段中输入文本,并在选择器中选择运输方式和到期日期。您的业务和演示逻辑从您的视图控制器中提取到交互器和演示器中。自定义边界模型结构用于在其边界处解耦 Clean Swift 组件。
在这个例子中,我们还没有查看 worker 和 router。当您的业务逻辑更复杂时,稍微分工会有所帮助。交互者的工作可以分解为多个较小的任务,然后由单个工作人员执行。当您需要在创建新订单后显示不同的场景时,您需要使用路由器来提取导航逻辑。
让我们回顾一下。
到目前为止你做了什么?
在这篇文章中,您学到了:
大规模的视图控制器问题是真实存在的。
MVC 不是一个适合 iOS 应用的架构。
设计模式和重构只是技术,而不是架构。
只有在健全的架构下才能进行测试。
良好的架构使更改变得容易。
用意图组织你的代码。
Clean Swift 架构和 VIP 周期。
将创建订单用例分解为数据和业务逻辑。
使用 Clean Swift 来实现用例。
以及关于VIP 周期的提醒:
视图控制器接受用户事件,构造请求对象,将其发送给交互器。
交互者对请求做一些工作,构造一个响应对象,并将它发送给演示者。
演示者格式化响应中的数据,构造视图模型对象,并将其发送到视图控制器。
视图控制器向用户显示视图模型中包含的结果。
在以后的文章中,我们将继续介绍这个用例。您将学习:
如何实现从收货地址自动填写账单地址的开关。
如何使用工人来验证创建订单。
当用户点击完成按钮时,如何使用 Core Data 保存新订单。
如何使用路由器导航到多个故事板中的任何场景。
如何使用 TDD 来驱动功能。
最后,如果你认为这篇文章对你的 iOS 架构有了新的认识,我请求你将这篇文章分享给你的开发者朋友。
我希望你学到了一些新东西并从今天开始使用它!