每年,iOS平台都会发生许多变化,此外,第三方库会定期与网络合作,缓存数据,通过JavaScript渲染UI等。 与所有这些趋势形成鲜明对比的是,
Pavel Gurov谈到了体系结构解决方案,无论您现在正在使用或未来几年将使用哪种技术,该解决方案都将是有意义的。
ApplicationCoordinator可用于在屏幕之间建立导航,同时解决许多问题。 在cat演示和说明下,可以最快地实现此方法。
关于演讲者: Pavel Gurov正在Avito中开发iOS应用程序。
导览

在屏幕之间导航是一项任务,无论您做什么,都要100%面对-社交网络,出租车呼叫或在线银行。 这是应用程序甚至在原型阶段就开始的时候,当您甚至不完全了解屏幕的外观,屏幕的动画形式,是否缓存数据时。 这些屏幕可能是空白图片,也可能是静态图片,但是
一旦这些屏幕中的一个以上,导航任务就会出现在应用程序中 。 即几乎立即。

构建iOS应用程序体系结构的最常见方法:MVc,MVVm和MVp,描述了如何构建单个屏幕模块。 它还说模块可以相互了解,相互通信等。 但是很少关注这些模块之间如何进行转换,由谁来决定这些转换以及如何传输数据的问题。
UlStoryboard + segues
开箱即用的iOS提供了几种显示以下屏幕场景的方法:
- 当我们在一个图元文件中指定屏幕之间的所有转换,然后调用它们时,众所周知的UlStoryboard + segues 。 一切都非常方便而且很棒。
- 容器-如UINavigationController。 UITabBarController,UIPageController或可能是可自行编写的容器,可以通过编程方式以及与StoryBoards一起使用。
- 存在的方法(_:动画:完成:)。 这只是UIController类的方法。
这些工具本身没有问题。 问题在于它们通常如何使用。 UINavigationController,performSegue,prepareForSegue,presentViewController方法都是UIViewController类的属性方法。 苹果建议在UIViewController内部使用这些工具。

证明如下。

如果使用标准模板创建UIViewController的新子类,则这些注释将出现在项目中。 它是直接编写的-如果您使用segues,并且需要根据场景将数据传输到下一个屏幕,则应该: 知道它将是哪种类型; 将其转换为这种类型,然后将数据传递到该类型。
这种解决建筑物导航问题的方法。
1.屏幕的刚性连接这意味着屏幕1知道屏幕2的存在。他不仅知道屏幕2的存在,还潜在地创建了屏幕2,或者从序列中获取了屏幕2,知道了屏幕2的类型,并将一些数据传输到屏幕2。
在某些情况下,如果我们需要显示屏幕3而不是屏幕2,那么我们将必须以相同的方式了解新屏幕3才能拼接到屏幕控制器1中。如果可以从多个位置调用控制器2和3,则一切都会变得更加困难。从屏幕1开始。事实证明,必须在这些位置中的每一个地方都缝制屏幕2和3的知识。
要做到这一点又是麻烦的一半,主要问题将在需要更改这些转换或支持所有这些转换时开始。
2.重新排序脚本控制器由于连接,这也不是那么简单。 要交换两个ViewController,仅进入UlStoryboard并交换2张图片是不够的。 您将必须打开每个屏幕的代码,将其转移到下一个屏幕的设置,然后更改其位置,这不是很方便。
3.根据情况传输数据例如,当选择屏幕3上的某个内容时,我们需要更新屏幕1上的视图。由于最初除了ViewController之外什么都没有,所以我们必须以某种方式连接两个ViewController-无关紧要-通过委托或某种方式还没 如果根据屏幕3上的操作,如果不是必须更新一个屏幕,而是一次更新多个屏幕(例如第一个和第二个屏幕),则将变得更加困难。

在这种情况下,不能放弃委托,因为委托是一对一的关系。 有人会说,让我们使用通知-通过共享状态。 所有这些使我们难以调试和跟踪应用程序中的数据流。
正如他们所说,看一次比听100次更好。 让我们看一下该Avito Services Pro应用程序中的特定示例。 该应用程序适合服务行业的专业人员,在其中可以方便地跟踪您的订单,与客户沟通,寻找新订单。
场景-在编辑用户个人资料时选择城市。

这是配置文件编辑屏幕,在许多应用程序中都是这样。 我们对选择城市感兴趣。
这是怎么回事
- 用户单击带有城市的单元格,然后第一个屏幕确定是时候将以下屏幕添加到导航堆栈中了。 此屏幕显示联邦城市列表(莫斯科和圣彼得堡)和地区列表。
- 如果用户在第二个屏幕上选择了一个联邦城市,则第二个屏幕将了解脚本已完成,将所选城市转发到第一个屏幕,并且导航堆栈会回滚到第一个屏幕。 该脚本被认为是完整的。
- 如果用户在第二个屏幕上选择一个区域,则第二个屏幕决定需要准备第三个屏幕,我们将在其中看到该区域中的城市列表。 如果用户选择了一个城市,则该城市将被发送到第一个屏幕,滚动导航堆栈,脚本将被视为完成。
在此图中,我前面提到的连接性问题显示为ViewController之间的箭头。 我们现在将摆脱这些问题。
我们该怎么做?- 我们禁止自己在UIViewController内部访问容器 ,即访问 self.navigationController,self.tabBarController或您作为属性扩展创建的其他一些自定义容器。 现在,我们无法从屏幕代码中取出容器并要求其执行操作。

- 我们禁止自己在UIViewController内部调用performSegue方法,并在prepareForSegue方法中编写代码,这将带走脚本之后的屏幕并对其进行配置。 也就是说,我们不再在UIViewController中使用segue(在屏幕之间进行转换)。

- 我们还禁止在特定控制器内提及其他控制器 :请勿进行初始化,进行数据传输,仅此而已。

协调员
由于我们从UIViewController中删除了所有这些职责,因此我们需要一个新的实体来执行它们。 创建一个新的对象类,并将其称为协调器。

协调器只是一个普通对象,我们在NavigationController的开头将其传递给该对象,并调用Start方法。 现在,不考虑它的实现方式,只看这种情况下选择城市的方案将如何变化。
现在,我们并没有开始准备过渡到任何特定NavigationController屏幕的事实,而是在协调器中调用Start方法,然后在NavigationController初始化程序中将其传递给它。 协调员了解到,现在该让NavigationController启动第一个屏幕了,他这样做了。
此外,当用户选择带有城市的小区时,该事件将传递给协调器。 也就是说,屏幕本身什么都不知道-正如他们所说的那样,至少在洪水之后。 他将此消息发送给协调器,然后协调器对此做出响应(因为他有一个NavigationController),该控制器向其发送下一步-这是区域选择。
接下来,用户单击“区域”(完全相同的图片),屏幕本身无法解决任何问题,仅告诉协调员下一个屏幕将打开。
当用户在第三屏幕上选择特定城市时,该城市也通过协调器转移到第一屏幕。 即,向协调员发送一条消息,告知已选择城市。 协调器将此消息发送到第一个屏幕,并将“导航”堆栈滚动到第一个屏幕。
请注意,
控制器不再相互通信 ,从而确定下一个将是谁,并且不会相互传输任何数据。 而且,他们对周围的环境一无所知。

如果我们在三层体系结构的框架内考虑应用程序,那么ViewController应该理想地完全适合于Presentation层,并尽可能少地携带应用程序逻辑。
在这种情况下,我们使用协调器提取到上一层的转换逻辑,并从ViewController中删除此知识。
演示版
Github上有一个演示和演示
项目 ,下面是演讲期间的演示。
这是相同的情况:编辑配置文件并在其中选择城市。
第一个屏幕是用户编辑屏幕。 它显示有关当前用户的信息:名称和所选城市。 有一个按钮“选择城市”。 当我们点击它时,我们进入带有城市列表的屏幕。 如果我们选择一个城市,则第一个屏幕将显示该城市。
现在让我们看看它在代码中如何工作。 让我们从模型开始。
struct City { let name: String } struct User { let name: String var city: City? }
这些模型很简单:
- 具有字段名称,字符串的城市结构;
- 也具有名称和财产城市的用户。
接下来是
StoryBoard 。 它从NavigationController开始。 原则上,这里是模拟器中的屏幕:带有标签和按钮的用户编辑屏幕,以及带有城市列表的屏幕,其中显示了带有城市的平板电脑。
用户编辑屏幕
import UIKit final class UserEditViewController: UIViewController, UpdateableWithUser { // MARK: - Input - var user: User? { didSet { updateView() } } // MARK: - Output - var onSelectCity: (() -> Void)? @IBOutlet private weak var userLabel: UILabel? @IBAction private func selectCityTap(_ sender: UIButton) { onSelectCity?() } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) updateView() } private func updateView() { userLabel?.text = "User: \(user?.name ?? ""), \n" + "City: \(user?.city?.name ?? "")" } }
这里有一个属性User-这是传输到外部的用户-我们将编辑的用户。 此处的Set用户会导致didSet块被调用,从而导致对本地updateView()方法的调用。 该方法所做的只是将有关该用户的信息放在标签上,即显示其姓名和该用户所居住城市的名称。
同样的事情发生在viewWillAppear()方法中。
最有趣的地方是单击城市选择按钮selectCityTap()的处理程序。
在这里,控制器本身无法解决任何问题 :它不会创建任何控制器,也不会调用任何segue。 他所做的只是回调-这是ViewController的第二个属性。 onSelectCity回调没有参数。 当用户单击按钮时,这将导致调用此回调。
城市选择画面
import UIKit final class CitiesViewController: UITableViewController { // MARK: - Output - var onCitySelected: ((City) -> Void)? // MARK: - Private variables - private let cities: [City] = [City(name: "Moscow"), City(name: "Ulyanovsk"), City(name: "New York"), City(name: "Tokyo")] // MARK: - Table - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return cities.count } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) cell.textLabel?.text = cities[indexPath.row].name return cell } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { onCitySelected?(cities[indexPath.row]) } }
此屏幕是UITableViewController。 这里的城市列表是固定的,但可能来自其他地方。 进一步的(// MARK:-表-)是一个相当琐碎的表代码,它显示单元格中的城市列表。
这里最有趣的地方是didSelectRowAt IndexPath处理程序,这是众所周知的方法。 屏幕本身再次无法解决任何问题。 选择城市后会发生什么? 它仅使用单个参数“ city”调用回调。
这样就结束了屏幕本身的代码。 我们看到,他们对环境一无所知。
协调员
让我们转到这些屏幕之间的链接。
import UIKit protocol UpdateableWithUser: class { var user: User? { get set } } final class UserEditCoordinator { // MARK: - Properties private var user: User { didSet { updateInterfaces() } } private weak var navigationController: UINavigationController? // MARK: - Init init(user: User, navigationController: UINavigationController) { self.user = user self.navigationController = navigationController } func start() { showUserEditScreen() } // MARK: - Private implementation private func showUserEditScreen() { let controller = UIStoryboard.makeUserEditController() controller.user = user controller.onSelectCity = { [weak self] in self?.showCitiesScreen() } navigationController?.pushViewController(controller, animated: false) } private func showCitiesScreen() { let controller = UIStoryboard.makeCitiesController() controller.onCitySelected = { [weak self] city in self?.user.city = city _ = self?.navigationController?.popViewController(animated: true) } navigationController?.pushViewController(controller, animated: true) } private func updateInterfaces() { navigationController?.viewControllers.forEach { ($0 as? UpdateableWithUser)?.user = user } } }
协调器具有两个属性:
- 用户-我们将编辑的用户;
- 启动时要传递给的NavigationController。
有一个简单的init()填充这些属性。
接下来是start()方法,该方法将导致
ShowUserEditScreen()方法被
调用 。 让我们更详细地讨论它。 此方法将控制器从UIStoryboard中移出,并将其传递给我们的本地用户。 然后,他放置onSelectCity回调并将该控制器推入Navigation堆栈。
用户单击按钮后,将触发onSelectCity回调,这将导致调用以下私有
ShowCitiesScreen()方法。
实际上,它几乎完成了相同的工作-抬起了与UIStoryboard略有不同的控制器,将onCitySelected回调放到了它,并将其推入了导航堆栈-这就是所有的事情。 当用户选择一个特定的城市时,会触发此回调,协调器将更新我们本地用户的“城市”字段,并将导航堆栈滚动到第一个屏幕。
由于User是一种结构,因此更新其中的字段“ city”会导致以下事实:分别调用了didSet块,因此调用了私有方法
updateInterfaces() 。 此方法遍历整个Navigation堆栈,并尝试将每个ViewController部署为UpdateableWithUser协议。 这是最简单的协议,只有一个属性-用户。 如果成功,则将其扔给更新的用户。 因此,事实证明,我们在第二个屏幕上选择的用户会自动跳到第一个屏幕。
协调员一切都清楚了,这里唯一要显示的是我们应用程序的入口点。 这就是一切的开始。 在这种情况下,这是AppDelegate的didFinishLaunchingWithOptions方法。
import UIKit @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? var coordinator: UserEditCoordinator! func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { guard let navigationController = window?.rootViewController as? UINavigationController else { return true } let user = User(name: "Pavel Gurov", city: City(name: "Moscow")) coordinator = UserEditCoordinator(user: user, navigationController: navigationController) coordinator.start() return true } }
这里的navigationController取自UIStoryboard,创建了一个用户,我们将对其进行编辑,并带有名称和特定的城市。 接下来,我们使用User和navigationController创建协调器。 它调用start()方法。 协调员将转移到本地财产-基本上就是全部。 该方案非常简单。
输入和输出
我想更详细地阐述几点。 您可能已经注意到,userEditViewController中的属性带有注释标记为Input,而这些控制器的回调标记为Output。
输入是随时间变化的任何数据,以及可以从外部调用的某些ViewController方法。 例如,在UserEditViewController中,这是一个User属性-用户本身或其City参数可以更改。
退出是控制器要与外界进行通信的任何事件。 在UserEditViewController中,这是单击onSelectCity按钮,在城市选择屏幕上,这是单击具有特定城市的单元格。 我再说一次,这里的主要思想是,控制器对这些事件一无所知,也不做任何事。 他委托他人决定做什么。
在Objective-C中,我不喜欢编写保存回调,因为它们的语法很糟糕。 但是在Swift中,这要简单得多。 在这种情况下,使用回调是iOS中众所周知的委托模式的替代方法。 仅在这里,我们无需在协议中指定方法并说协调器与该协议相对应,然后将这些方法分别写在某个地方,我们可以立即非常方便地在一个位置创建一个实体,对其进行回调并完成所有操作。
的确,与委托不同,使用这种方法,协调者的本质和屏幕之间存在紧密的联系,因为协调者知道屏幕具有特定的本质。
您可以使用协议,以与委派相同的方式来消除此问题。

为了避免连接,我们可以
使用protocol来
关闭 控制器 的 输入和 输出 。
上面是CitiesOutput协议,它只有一个要求-onCitySelected回调。 左侧是Swift上此方案的类似物。 我们的控制器遵守此协议,确定必要的回调。 我们这样做是为了使协调器不知道CitiesViewController类的存在。 但是在某个时候,他将需要配置该控制器的输出。 为了提高效率,我们向协调员添加了一个工厂。

工厂有一个cityOutput()方法。 事实证明,我们的协调员不会创建控制器,也不会从某个地方获取它。 工厂将它扔给他,它返回该方法中的协议关闭的对象,并且他对该对象是什么类一无所知。
现在最重要的事情-为什么要做所有这一切?
当没有问题时,为什么我们需要在另一个层次上进行构建?可以想象这种情况:一位经理会来找我们,要求您对A / B测试这一事实,即我们可以选择城市而不是城市列表。 如果在我们的应用程序中选择的城市不在一个地方,而是在不同的协调员中,在不同的情况下,我们必须在每个地方缝制一个旗帜,扔到外面,在这个旗帜上抬一个或另一个ViewController。 这不是很方便。
我们想从协调员中删除这些知识。 因此,可以在一处做到这一点。 在工厂本身中,我们将创建一个参数,工厂通过该参数返回协议关闭的一个或另一个控制器。 它们都将有一个onCitySelected回调,并且协调器原则上将不在乎要使用哪个屏幕-地图或列表。
组成VS继承
我想讲的下一点是反对继承的构成。

- 完成协调器的第一种方法是在将NavigationController从外部传递给组合并作为属性存储在本地时进行组合 。 这就像一个合成-我们为其添加了NavigationController作为属性。
- 另一方面,有一种观点认为UI套件中包含了所有内容,因此我们不需要重新发明轮子。 您只需获取并继承UI NavigationController即可 。
每个选项都有其优点和缺点,但就我个人而言,
这种情况下的
组合比继承
更合适 。 继承通常是不太灵活的方案。 例如,如果需要将Navigation更改为UIPageController,则在第一种情况下,我们可以简单地使用通用协议(例如“显示下一个屏幕”)将其关闭,并方便地替换所需的容器。
从我的角度来看,最重要的论据是您在合成中向最终用户隐藏了所有不必要的方法。 事实证明,他跌倒的可能性较小。 您
只剩下 所需 的 API (例如Start方法),
仅此而已 。 他无法调用PushViewController,PopViewController方法,也就是说,以某种方式干扰了协调器的活动。 父类的所有方法都是隐藏的。
故事板
我认为,与segues一起应特别注意它们。 就个人而言,
我支持 segues ,因为它们可以使您快速熟悉脚本。 当新开发人员到来时,他不需要攀登代码,情节提要可以帮助您。 即使使用代码创建接口,也可以保留空的ViewController,并使用代码组成接口,但至少要保留过渡和整个要点。 情节提要的全部本质在于过渡本身,而不是UI的布局。
幸运的是,
协调器方法并不限制工具的选择 。 我们可以安全地与Segue一起使用协调器。 但是我们必须记住,现在我们不能在UIViewController中使用segues。

因此,我们必须在类中重写onPrepareForSegue方法。 我们将通过回调将这些任务再次委派给协调器,而不是在控制器内部做任何事情。 调用onPrepareForSegue方法时,您自己不会做任何事情-您不知道它是什么样的segue,它是什么目标控制器-对您而言无关紧要。 您只需将其全部放入回调中,协调器就会弄清楚。 他有此知识,您不需要此知识。
为了简化一切,您可以在特定的Base类中执行此操作,以免在单独使用的每个控制器中覆盖它。 在这种情况下,协调员处理您的任务将更加方便。
我对Storyboard感到方便的另一件事是,坚持
一个 Storyboard等于一个协调员的规则。 然后,您可以大大简化所有内容,大致上讲一个类-StoryboardCoordinator,并在其中生成RootType参数,在Storyboard中创建初始Navigation控制器,然后将整个脚本包装在其中。

如您所见,这里的协调器具有2个属性:navigationController; 我们的RootType的rootViewController是通用的。 在初始化期间,我们将其传递给我们的根导航及其第一个控制器,而不是特定的navigationController,而是一个Storyboard。 这样,我们甚至不必调用任何Start方法。 也就是说,您创建了一个协调器,他立即具有“导航”,并且立即具有“根”。 您可以模态显示“导航”,也可以使用“根目录”推入现有导航并继续工作。
在这种情况下,我们的UserEditCoordinator可以简单地变成typealias,用通用参数替换其RootViewController的类型。
脚本数据传回
让我们谈谈我在开始时概述的最后一个问题。 这是将数据传输回脚本。

考虑选择城市的相同方案,但是现在可以选择一个城市而不是一个城市。 为了向用户显示他已经选择了同一区域内的多个城市,我们将在屏幕上显示区域列表,该区域列表旁边的区域名称旁边会显示一个小数字,显示该区域内选择的城市数量。
事实证明,对一个控制器(对第三个控制器)的操作应立即导致其他几个控制器的外观发生变化。 也就是说,首先,我们必须在带有城市的单元格中显示,而在第二步中,我们必须更新所选区域中的所有数字。
协调器通过将数据传输回脚本简化了此任务-现在,这就像根据脚本向前传输数据一样简单。
这是怎么回事 用户选择一个城市。 该消息被发送到协调器。 正如我在演示中已展示的那样,协调器遍历整个导航堆栈,并将更新的数据发送给所有感兴趣的各方。 因此,ViewController可以使用此数据更新其View。
重构现有代码
如果要将这种方法嵌入到具有MVc,MVVm或MVp的现有应用程序中,该如何重构现有代码?

您有一堆ViewController。 要做的第一件事是将它们分为参与方案。 在我们的示例中,有3种情况:授权,配置文件编辑,磁带。

现在,我们将每个方案包装在协调器中。 实际上,我们应该能够从应用程序中的任何位置启动这些脚本。 这应该是灵活的-
协调者必须完全自给自足 。
这种开发方法提供了更多的便利。 它包含以下事实:如果您当前正在使用特定方案,则无需在每次启动时都单击它。 您可以从头开始快速启动,编辑其中的内容,然后删除此临时开始。
在确定了协调员之后,我们需要确定哪个方案可以导致另一个方案的开始,并根据这些方案组成一棵树。

在我们的例子中,树很简单:LoginCoordinator可以启动配置文件编辑协调器。 在这里,几乎所有内容都准备就绪,但仍有一个非常重要的细节-我们的计划缺乏切入点。

该入口点将是一个特殊的协调器
-ApplicationCoordinator 。 它是由
AppDelegate创建和启动的,然后它已经在应用程序级别控制逻辑,即该协调器现在开始。
我们只是看了一个非常相似的电路,只有它有ViewController而不是协调器,并且做到了这一点,以便ViewController不了解彼此,也不相互传递数据。 原则上,协调员也可以这样做。 我们可以在其中指定某个输入(开始方法)和输出(onFinish回调)。
协调器变得独立,可重用且易于测试 。 协调器不再相互了解,并且仅与ApplicationCoordinator通信。
您需要小心,因为如果您的应用程序具有足够的这些脚本,那么ApplicationCoordinator可以变成一个巨大的上帝对象,它将知道所有现有的脚本-这也不是很酷。 在这里,我们必须已经可以看到-也许将协调器划分为子协调器,也就是说,考虑这样的体系结构,以使这些对象不会增长到令人难以置信的大小。
尽管大小并非始终是重构的原因 。
从哪里开始
我建议从头开始-首先实施单个脚本。

解决方法是,可以在UIViewController内部启动它们。 也就是说,只要您没有Root或其他协调器,就可以创建一个协调器,作为临时解决方案,可以从UIViewController启动它,并将其本地保存在属性中(如上nextCoordinator所示)。 当发生事件时,正如我在演示中所展示的,您将创建一个本地属性,在其中放置协调器,并在其上调用Start方法。 一切都非常简单。
然后,当所有这些协调器都已完成时,一个在另一个内部的开始看起来完全相同。 您是否具有本地属性或某种类型的依赖项数组(例如,协调器),请将所有这些内容放在那里,以免其失控,然后调用Start方法。
总结
- 不了解彼此的独立屏幕和脚本不会相互通信。 我们试图实现这一目标。
- 在不更改屏幕代码的情况下,可以轻松更改应用程序中屏幕的顺序 。 如果一切都按计划进行,则脚本更改时应用程序中唯一应更改的不是屏幕代码,而是协调程序代码。
- 屏幕之间的简化数据传输以及其他暗示屏幕之间连接的任务。
- — , .
AppsConf 2018 8 9 — ! ( ) . — iOS Android, , , , .