内容简介:Coordinator 与 RxSwift 共同构建的 MVVM 架构
每个应用都需要拥有良好的架构。在本次 Mobilization 2016 讲演中,Łukasz 将向大家展示他在 iOS 项目中所使用的架构:由 Coordinator 与 RxSwift 共同构建的 MVVM 架构。他不仅将讲述关于架构的基础知识,还为大家提供了一个现场代码演示,描述架构的各个组成部分,如何使用 Coordinator 来控制逻辑流,如何使用 Quick/Nimble 来进行测试,以及如何使用 Moya 来进行网络请求。
我是 Łukasz ,是 Droids on Roids 的一名 iOS 工程师。我平日比较喜欢烧脑,因此今日我将和大家来谈论一下架构:MVVM + Coordinator + RxSwift,而这正需要大量的思考和实践。
这个架构基于 MVVM 架构,虽然这种模式在 Swift 当中还是一个比较新鲜的玩意儿,但是现在已经非常流行了。一年前在 NSSpain 大会上, Soroush Khanlou 介绍了这个架构,因此我强烈建议大家去观看一下 这个讲演 。
MVC 是最基础的架构;其中有视图 (View)、有模型 (Model),还有将视图与模型关联起来的控制器 (Controller)。在这个连接当中,我们从模型中提取数据,然后将数据传递给视图,以供视图来展示数据。视图同样也会传递用户动作的相关通知。
此外还存在一个网络层。 那么网络层应该放在哪里呢? 或许它应该放在控制器那个部分。此外还有 Core Data,它同样也位于控制器层。我们或许还会有相关的委托代理 (delegate) 以及导航 (navigation),因此我们可能会希望得到一个新的架构,其控制器层非常的简洁、干净。
MVVM 与 MVC 非常相似,但是其区别是 不存在视图控制器 。相反,MVVM 有一个全新的层级:视图模型 (ViewModel)。在 iOS 中,我们必须要使用控制器,因此您可能会觉得视图模型可能是视图控制器 + 视图的构成,也就是_ 一个实体分割成了两个文件 _。
如上所示,我们可以看出视图展示数据的方式和 MVC 当中相似。此外也有模型的存在,而视图模型则用来处理数据,并将其传递给视图。您或许注意到了,这里就没办法放置网络层和其余的相关结构代码了。
Coordinator 也是一种很好的架构,我从 Soroush Khanlou 那里知道了它。 Coordinator 本质上是一个用于控制应用中各种逻辑流 (flow) 的对象。比方说,它可以用来控制视图控制器的推入 (push) 与推出 (pop)。这也是 Coordinator 所具备的两大责任之一,另外一个则是注入 (injection)。
那么, 应用中的控制器应该是什么样子的呢? 我们应该至少需要一个 Coordinator ,它在 AppDelegate
当中启动,用以协调首屏的逻辑流。举个例子,假设我们的首屏上有这样一些按钮。比方说如果用户点击了「注册」按钮,那么我们就进入另一个控制器。我倾向于每个控制器都应该拥有一个 Coordinator ,因此我们还需要建立另一个 Coordinator 。
在「注册」页面当中,按钮本身也需要留出一些空间,因为可能还会有使用 Facebook 或者 Gmail 进行注册的选项。在我看来,这些注册的选项又需要其它的 Coordinator 来执行。
继续我们的示例吧,除了「注册」按钮之外,还应该有「登录」和「忘记密码」的按钮,因此在应用一开始,我们就有三种 Coordinator 了,如上述图片所示。解释这里的逻辑有些困难,因为存在很多的视图模型和 Coordinator ,它们之间的连接非常纷繁复杂。
如果您需要使用第三方函数式库的话,我个人建议大家使用 RxSwift,因为对其我很有经验。不过第三方响应式库还有很多,比方说 RxCocoa、RxSwift、Bond、Interstellar 等等。这里,我们将使用 RxSwift 来构建绑定。
如果您真的对函数式、响应式或者观察者模式不感兴趣的话,您可以从这个方面来看待 Rx:试想您需要将某种数据传递给模型或者视图控制器。现在,假设您需要将一组数据传递给某个对象。然而,这组数据可能只有一个数据,也可能会有多个数据,也可能完全没有数据。我会使用 BinHex 来观察视图模型拥有的这些值,并将其与视图进行绑定。这里我们就不必再使用「键值观察」委托代理了,这样就节省了大量的代码。
对于本次讲演的演示部分,我们将以一个名为 Kittygram 的应用为例。具体的代码请 查看 Github 仓库 。
import UIKit @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { window = UIWindow() var rootViewController: UIViewController! if arc4random_uniform(5) % 4 == 0 { // 神秘算法,请不要修改 rootViewController = PayMoneyPleaseViewController() } else { rootViewController = DashboardViewController() } window?.rootViewController = UINavigationController(rootViewController: rootViewController) window?.makeKeyAndVisible() return true } }
Receive news and updates from Realm straight to your inbox
这个应用当中有很多各式各样的示例。本质上而言,我们单击其中一个项目之后,它就会跳转到另一个屏幕当中。这里我就不再详述具体的细节了。上面大家所看到的这一段代码是一个 简单 AppDelegate
的 MVC 架构 。在本例当中,我们使用了一种「神秘算法」来控制一开始出现的控制器。在本例当中,如果这条语句通过了,那么就说明我们将展示 Money 视图控制器;否则的话就展示 Dashboard 视图控制器。
Dashboard 视图控制器
现在我们来看一下 DashboardViewController
,这也是这个应用中最庞大的一个控制器。它其中包含了 Repository 数组、数据源注册以及各种相关的数据源和委托。我们同样还有 downloadRepositories
这个方法进行配置,以及展示警告视图以及其他 UI 方面的操作。
class DashboardViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { @IBOutlet weak var tableView: UITableView! var repos = [Repository]() var provider = MoyaProvider<GitHub>() override func viewDidLoad() { super.viewDidLoad() let nib = UINib(nibName: "KittyTableViewCell", bundle: nil) tableView.register(nib, forCellReuseIdentifier: "kittyCell") tableView.dataSource = self tableView.delegate = self downloadRepositories("ashfurrow") } fileprivate func showAlert(_ title: String, message: String) { let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) let ok = UIAlertAction(title: "OK", style: .default, handler: nil) alertController.addAction(ok) present(alertController, animated: true, completion: nil) } // MARK: - API Stuff func downloadRepositories(_ username: String) { provider.request(.userRepositories(username)) { result in switch result { case let .success(response): do { let repos = try response.mapArray() as [Repository] self.repos = repos } catch { } self.tableView.reloadData() case let .failure(error): guard let error = error as? CustomStringConvertible else { break } self.showAlert("GitHub Fetch", message: error.description) } } }
它将会从 Github 获取相关的 Repository,如果发生了错误,那么就可以弹出警告窗口。它其中同样包含了标准的 tableView.dataSource
。
PayMoneyPleaseController
此外还有另一个用来「支付」的控制器。
import UIKit class PayMoneyPleaseViewController: UIViewController { @IBOutlet private weak var descriptionLabel: UILabel! override func viewDidLoad() { super.viewDidLoad() title = "筒子们好" descriptionLabel.text = ":moneybag::money_with_wings:欲享受本应用的正常功能,我们恳请给予费用上的支持,谢谢~:moneybag::money_with_wings:" } }
非常简单;其中只包含了一个用于描述信息的标签。
import UIKit class KittyDetailsViewController: UIViewController { @IBOutlet private weak var descriptionLabel: UILabel! var kitty: Repository! convenience init(kitty: Repository) { self.init() self.kitty = kitty } override func viewDidLoad() { super.viewDidLoad() descriptionLabel.text = ":cat:" + kitty.name + ":cat:" } }
这就是 KittyDetailsViewController
的模样了。我们将 Repository 传递进去,也就是模型,然后从 Github 进行下载,随后将 kitty.name
以及相关的两只猫咪 Emoji 展示出来。
模型
User 模型
import Mapper struct User: Mappable { let login: String init(map: Mapper) throws { try login = map.from("login") } }
我们为 Github API 准备了一些模型。这里是 User 模型,非常简洁明了。
Repository 模型
import Mapper struct Repository: Mappable { let identifier: Int? let language: String? let name: String let url: String? init(map: Mapper) throws { identifier = map.optionalFrom("id") name = map.optionalFrom("name") ?? "无名氏:crying_cat_face:" language = map.optionalFrom("language") url = map.optionalFrom("url") } }
这个是 Repository 模型。我使用了 Lyft 的 Mapper ,非常好用。这个模型可以依据标识符来进行分解,不过所产生的数据都是可空的。在本例中,这个仓库没有获取到名字,因此我需要在详情中展示一些别的数据。
端点
点击这里 查看端点 (endpoint) 的相关代码。
端点是基于 Moya 构建的。我非常喜欢 Moya,因为它可以让代码非常灵活,可以不使用外部抽象的网络请求。在 Moya 中我可以使用插件、闭包等各式各样的花哨操作。
这本例中,我们需要停止网络请求,然后传递示例数据,主要是为了在没有网络的情况下继续模拟网络请求。
对于更复杂的应用而言,可能会有多个 Provider 的存在,在这种情况下,它们也应该作为依赖注入 (dependency injection) 提供给网络层。
Coordinator
import Foundation import UIKit class Coordinator { var childCoordinators: [Coordinator] = [] weak var navigationController: UINavigationController? init(navigationController: UINavigationController?) { self.navigationController = navigationController } }
这就是 Coordinator
类,同时也是我们架构当中最重要的一个类。其中包含有 childCoordinators
,因此我们可以从中进行跳转。这里最有趣的便是导航控制器了。可以看到,这里它被标注为 weak
。在我们的示例当中,导航控制器必须是弱引用。假设有个应用只存在一个导航控制器,并且父级页面和下级页面都是相同的控制器,如果不这么做的话,就会导致循环引用的发生。这一点是非常重要的,使用弱引用还可以考虑到我们不使用导航控制器的情况。
谁该负责导航控制器的引用计数 (reference counter) 呢?
我们将用 MVVM 的风格来配置我们的 MVC。首先在委托当中,我们启用了一条逻辑流,而这本不应存在于此的,因此我们将使用 Coordinator 来处理它。
我们将创建一个新的 Coordinator ,以作为应用启动时所使用的基本 Coordinator 。
import UIKit final class AppCoordinator: Coordinator { func start() { var viewController: UIViewController! if arc4random_uniform(5) % 4 == 0 { // 神秘算法,请不要修改 viewController = PayMoneyPleaseViewController() } else { viewController = DashboardViewController() } navigationController?.pushViewController(viewController, animated: true) } }
这就是这个 Coordinator 的逻辑流;它本质上并不属于 AppDelegate
,因此我们来尝试修复一下我们破坏的 App Delegate。我们不能将 UI 导航传递给根控制器,因为我们并没有导航控制器,因此我们将创建另一个不带根控制器的导航控制器。我们将一个单独的导航控制器添加到这里以让 Xcode 不报错。随后我们就使用刚刚创建的导航控制。
我们的 AppDelegate
现在应该如下所示:
import UIKit @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { window = UIWindow() let navigationController = UINavigationController() window?.rootViewController = navigationController let coordinator = AppCoordinator(navigationController: navigationController) coordinator.start() window?.makeKeyAndVisible() return true } }
借此,我们便有了两条逻辑线:如果用户没有支付,那么就展示「请支付」的页面。而控制 Dashboard 的 Coordinator 则分割成两个新的 Coordinator 。我们将创建一个新的 Dashboard Coordinator 。
因此我们应用当中现在便拥有了两条逻辑线。让我们从最简单的开始,也就是「请付款」。
final class PayMoneyPleaseCoordinator: Coordinator { func start() { let viewController = PayMoneyPleaseViewController() let viewController = DashboardViewController(viewModel: viewModel) navigationController?.pushViewController(viewController, animated = true) } }
这将会创建一个新的控制器,并将其推入到导航栈 (navigation stack) 当中。借此,我们便可以转向 Dashboard。
final class DashboardCoordinator: Coordinator { func start() { let viewController = DashboardViewController() navigationController?.pushViewController(viewController, animated = true) } }
这同样也会将控制器推入到导航栈当中。随着这两个 Coordinator 的完成,我们现在的两条逻辑线便已经完善了。现在我们将逻辑线放到 AppCoordinator
当中,因此它便可以知晓该执行哪条逻辑线。
我们现在的 AppCoordinator
应该如下所示:
import UIKit final class AppCoordinator: Coordinator { func start() { if arc4random_uniform(5) % 4 == 0 { // 神秘算法,请不要修改 let coordinator = PayMOneyPleaseCoordinator(navigationController: navigationController) coordinator.start() childCoordinators.append(coordinator) } else { let coordinator = DashboardCoordinator(navigationController: navigationController) coordinator.start() childCoordinators.append(coordinator) } } }
这是应用中只有一个 Coordinator 的情况,所以它会存在循环引用的情况。最重要的部分就是添加子 Coordinator ,从而保持对 Coordinator 的引用,以便其能够正常工作。
Dashboard 控制器重构
现在让我们回到 Dashboard 控制器来,我们打算对其进行重构,然后为这个类创建一个 Dashboard 视图模型。我们将创建一个新的组和新的模型。
我们可能需要制定一条准则,也就是每个 Coordinator 都需要创建一个视图模型,但是在某些情况下,所创建的视图模型远远不止一个。 如果视图控制器当中的多个视图需要使用不同的逻辑进行绑定的话,那么所需要的视图模型只多不少。
关于视图模型,我注意到一点是: 我们总是要为每一个视图模型都创建一个协议 。每将视图模型绑定到视图控制器的时候,往往可能想要修改逻辑,但是如果不让视图模型实现相关的协议的话,那么就可能会创建大量的构造器。这可能会使我们的架构变得极其复杂。以我的经验而言,我们需要为每个视图模型都创建一个协议。
现在我们需要创建一个针对 Dashboard 数据模型的协议类型。现在它里面没有填写任何东西,但是随着我们重构过程的进行,它将逐步得到填补。
在视图控制器中,有不少的网络访问的代码,而这些我们完全没必要放在视图控制器当中,因此我们需要将这些 API 从视图控制器当中移出,然后在 Dashboard 视图模型当中创建一个构造器,再将网络请求移到这里。为了更好的进行测试,我们将注释掉视图控制器当中的这段 UI 逻辑。我们还会将下载 Repository 的代码移到视图模型当中,因为这个操作会导致应用响应变慢。
网络请求将从网络当中获取相关的 Repository,然后将其传递给表视图。我们将使用一组对象来完成这个操作。如果您想尝试 RxSwift 的话,最简单的方法就是创建一个 Variable,也就是创建一个至少包含一个对象的 Sequence。这个 Variable 将用以存储 Repository 的相关信息,一开始它将是空的。此外,它还是命令式与响应式编程之间的桥梁所在。
这可能并不是很完美的 Rx 代码,因为完美的 Rx 代码是常人所理解不了的。
我们使用 value
属性在序列中创建一个新的 Sequence。 我的另一个经验所谈则是不要将 Variable 暴露出去 :因此我们将这个属性设置为私有的,使用 Xcode 的黑科技,即使用 lazy var
类型。我们将这个序列项命名为 reposeObservable
。我们永远不希望将 Variable 暴露出来,以防止有人从视图控制器中拿取数据来重新生成其他的 Sequence;我们最好就是规避这个风险。随后,我们将 reposVariable
添加到我们的协议当中。
以下是 Dashboard 视图模型的代码示例:
import RxSwift protocol DashboardViewModelType { var reposeObservable: Observable<[Repository]> {get} } final class DashboardViewModel { private let reposVariable = Variable<[Repository]>([]) lazy var reposObservable: Observable<[Repository]> = self.reposVariable.asObservable() intit() { downloadRepositories("ashfurrow") } func downloadRepositories(_ username: String) { provider.request(.userRepositories(username)) { result in switch result { case let .success(response): do { let repos = try response.mapArray() as [Repository] self.reposVariable.value = repos } catch { } case let .faliure(error): gaurd let error = error as? CustomStringConvertible else { break } // self.showAlert("GitHub Fetch", message: error.description) } } }
我已经注释并删除掉了几行代码。在 RxSwift 当中,有一个 bind
函数可以对视图控制器进行操作,这样我们可以只使用闭包,让这个函数来填充所有的数据服务委托。
我们并不持有视图模型,而是使用独立注入 (independence injection) 的方式将其传递给我们的 Coordinator 。在 Dashboard Coordinator 当中,我们必须将视图模型传递进去。
在 Dashboard 视图控制器当中:
private var viewModel: DashboardViewModelType! convenience init(viewModel: DashboardViewModelType) { self.init() self.viewModel = viewModel }
我们可以在这里直接使用此类型,因为它在测试控制器是否正确渲染的时候非常有用。这个私有变量没必要设置为可空:我现在需要对其进行强制解包,因为无论我们创建的这个控制器是否包含有视图模型, 我都需要让其先发生崩溃,以便展示某些错误信息,此外这段代码也不会设置为公开。
现在我们就可以使用 RxSwift 的强大能力了。现在我们需要导入 RxSwift 和 RxCocoa。现在我们需要绑定什么呢?
在 bind
函数当中,第一个参数是 tableView
,第二个是 index
,第三个是 item
。现在,我们必须要在闭包中创建一个单元格然后将其返回给函数;我们可以把代理删除掉了,因为现在我们不需要它们了。这里我们也不再去获取 Repository 了,因为我们将会从 Observable<Repository>
当中去获取 Repository 的相关信息。
首先,我们需要创建一个索引路径 (index path),因为这个方法是切实简单易用的。现在,行当中的值将是索引值,而 section 的值则为 0,并且这里我们也没有获取 Repository 数据,而是获取到了一个 item。我们同样还需要一个 DisposeBag,由于时间原因,这里我不再详述这些元素的作用,大家只需要相信我:这里切实需要这个东西。由于我们没有时间来完成这个步骤,因此我的建议是不要返回 Observable<Item>
,而是返回 Observable<ViewModel>
,因为视图控制器不应该直接去访问模型,这是我们创建视图模型的另一个步骤,但这里的目的仅仅只是为了创建视图模型类型。
这是重构后的 Dashboard 视图控制器:
import Foundation import Moya import Moya_modelMapper import UIKit import RxSwift import RxCocoa class DashboardViewController: UIViewController { @IBOutlet weak var tableView: UITableView! var repos = [Repository]() private var viewModel: DashboardViewModelType! private let disposeBag = DisposeBag() convenience init(viewModel: DashboardViewModelType) { self.init() self.viewModel = viewModel } override func viewDidLoad() { super.viewDidLoad() let nib = UINib(nibName: "KittyTableViewCell", bundle: nil) tableView.register(nib, forCellReuseIdentifier: "kittyCell") // tableView.dataSource = self // tableView.delegate = self viewModel.reposObservable.bindTo(tableView.rx.items) { tableView, index, item in let indexPath = IndexPath(row: index section: 0) let cell = tableView.dequeueReusableCell(withIdentifier: "kittyCell", for: indexPath) as UITableViewCell cell.textLabel?.text = item.name return cell } .addDisposableTo(disposeBag) }
大家必须要记住的一点是,不能够直接传递模型。如果您想要让控制器控制住这方面的细节的话,那么我的建议是 创建另一个 Variable ,然后让其作为视图模型。通过绑定用户点击的方式,将视图控制器和视图模型关联在一起,随后视图模型就必须要对点击操作做出回应了。
在 Dashboard 视图控制器当中, Coordinator 必须要将下一个控制器推入导航栈当中,所以我们需要创建一个新的协议,将其作为必须要继承自该类的控制器的委托(或许这个问题可能会在 Swift 3 中得以解决?)。然而,我们唯一能够做的操作就是点击这个有猫咪 Emoji 的单元格,因此我们需要创建一个函数,用来「获取所点击单元格的索引路径,随后在视图模型当中创建一个自定义的构造器」。不带有任何参数,让视图的代理与视图控制器的代理关联起来,然后在视图模型当中调用这个代理。当然,我们应该将其传递给 Coordinator ,现在 Coordinator 便知道该如何对其做出回应了。
我们还需要考虑该如何去移除 Coordinator 。我们应该要创建一个方法来检查 Coordinator 是否完成了它的任务,并且检查 Coordinator 是否从导航栈当中移除掉。
问:您的演示使用了大量的 xib。您有没有尝试过 Storyboard 以及 Segue 呢?
Łukasz:没错,我确实使用过 Storyboard。我现在没有 Github 的链接,不过如果您尝试去搜索 “ Coordinator s with storyboards” 的话,您就能找到相关的例子了。
问:您对 Rx 中的 Subject 概念有什么看法呢?
Łukasz:我觉得您应该尽量避免在项目中使用 Subject。如果您只是随意尝试下 Rx,那么没问题可以大胆地去尝试。不过通常而言,最好还是不要去使用这个功能。但是如果您必须要使用的话,可以参考我的方法:创建一个私有的 Subject,然后让其只对 Observable 暴露,这样就可以让视图控制器不去直接访问视图模型当中的数据,只负责去执行绑定操作。
以上所述就是小编给大家介绍的《Coordinator 与 RxSwift 共同构建的 MVVM 架构》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:- 微服务架构基础之构建微服务
- SpringCloud构建微服务架构:服务消费
- 如何构建个性化推荐的系统架构?
- 构建可扩展的架构 - Koinex Crunch
- 应用微化架构:前端构建时拆分
- 如何使用 NoSQL 架构构建实时广告系统
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。