本文由 Yison 發表在 ScalaCool 團隊部落格。
前文 探討了 ReSwift,它是基於「單向資料流」的架構方案,來解決 Massive View Controller 災難。
Soroush Khanlou 寫過一篇《8 Patterns to Help You Destroy Massive View Controller》,就多方面來改善工程的維護性和可測試性。
今天要討論的是其中之一,即在解決「資料流問題」之後,再對檢視層的 Navigator 進行解耦,所謂的「Flow Coordinators」。
什麼是 Coordinator
Coordinator 是 Soroush Khanlou 在一次演講中提出的模式,啟發自 Application Controller Pattern。
先來看看傳統的作法到底存在什麼問題。
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let item = self.dataSource[indexPath.row]
let vc = DetailViewController(item.id)
self.navigationController.pushViewController(vc, animated: true, completion: nil)
}
複製程式碼
再熟悉不過的場景:點選 ListViewController
中的 table 列表元素,之後跳轉到具體的 DetailViewController
。
實現思路即在 UITableViewDelegate
的代理方法中實現兩個 view 之間的跳轉。
傳統的耦合問題
看似很和諧。
好,現在我們的業務發展了,需要適配 iPad,互動發生了變化,我們打算使用 popover 來顯示 detail 資訊。
於是,程式碼又變成了這個樣子:
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let item = self.dataSource[indexPath.row]
let vc = DetailViewController(item.id)
if (! Device.isIPad()) {
self.navigationController.pushViewController(vc, animated: true, completion: nil)
} else {
var nc = UINavigationController(rootViewController: vc)
nc.modalPresentationStyle = UIModalPresentationStyle.Popover
var popover = nc.popoverPresentationController
popoverContent.preferredContentSize = CGSizeMake(500, 600)
popover.delegate = self
popover.sourceView = self.view
popover.sourceRect = CGRectMake(100, 100, 0, 0)
presentViewController(nc, animated: true, completion: nil)
}
}
複製程式碼
很快我們感覺到不對勁,經過理性分析,發現以下問題:
- view controller 之間高耦合
- ListViewController 沒有良好的複用性
- 過多 if 控制流程式碼
- 副作用導致難以測試
Coordinator 如何改進
顯然,問題的關鍵在於「解耦」,看看所謂的 Coordinator 到底起到了什麼作用。
先來看看 Coordinator 主要的職責:
- 為每個 ViewController 配置一個 Coordinator 物件
- Coordinator 負責建立配置 ViewController 以及處理檢視間的跳轉
- 每個應用程式至少包含一個 Coordinator,可叫做 AppCoordinator 作為所有 Flow 的啟動入口
瞭解了具體概念之後,我們用程式碼來實現一下吧。
不難看出,Coordinator 是一個簡單的概念。因此,它並沒有特別嚴格的實現標準,不同的人或 App 架構,在實現細節上也存在差別。
但主流的方式,最多是這兩種:
- 通過抽象一個 BaseViewController 來內建 Coordinator 物件
- 通過 protocol 和 delegate 來建立 Coordinator 和 ViewController 之間的聯絡,前者對後者的「事件方法」進行實現
由於個人更傾向於低耦合的方案,所以接下來我們會採用第二種方案。
事實上 BaseViewController 在複雜的專案中,也未必是一種優秀的設計,不少文章採用 AOP 的思路進行過改良。
好了,首先我們定義一個 Coordinator 協議。
protocol Coordinator: class {
func start()
var childCoordinators: [Coordinator] { get set }
}
複製程式碼
Coordinator 儲存了「子 Coordinators」 的引用列表,防止它們被回收,實現相應的列表增減方法。
extension Coordinator {
func addChildCoordinator(childCoordinator: Coordinator) {
self.childCoordinators.append(childCoordinator)
}
func removeChildCoordinator(childCoordinator: Coordinator) {
self.childCoordinators = self.childCoordinators.filter { $0 !== childCoordinator }
}
}
複製程式碼
我們說過,每個程式的 Flow 入口是由 AppCoordinator 物件來啟動的,在 AppDelegate.swift
寫入啟動的程式碼.
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
self.window = UIWindow(frame: UIScreen.main.bounds)
self.window?.rootViewController = UINavigationController()
self.appCoordinator = AppCoordinator(with: window?.rootViewController as! UINavigationController)
self.appCoordinator.start()
return true
}
複製程式碼
回到我們之前 ListViewController
的例子,我們重新梳理下,看看如何結合 Coordinator。假設需求如下:
- 如果使用者未登入狀態,顯示登入檢視
- 如果使用者登入了,則顯示主檢視列表
定義 AppCoordinator
如下:
final class AppCoordinator: Coordinator {
fileprivate let navigationController: UINavigationController
init(with navigationController: UINavigationController) {
self.navigationController = navigationController
}
override func start() {
if (isLogined) {
showList()
} else {
showLogin()
}
}
}
複製程式碼
那麼如何在 AppCoordinator 中建立和配置 view controller 呢?拿 LoginViewController
為例。
private func showLogin() {
let loginCoordinator = LoginCoordinator(navigationController: self.navigationController)
loginCoordinator.delegate = self
loginCoordinator.start()
self.childCoordinators.append(loginCoordinator)
}
extension AppCoordinator: LoginCoordinatorDelegate {
func didLogin(coordinator: AuthenticationCoordinator) {
self.removeCoordinator(coordinator: coordinator)
self.showList()
}
}
複製程式碼
再來看看如何定義 LoginCoordinator
:
import UIKit
protocol LoginCoordinatorDelegate: class {
func didLogin(coordinator: LoginCoordinator)
}
final class LoginCoordinator: Coordinator {
weak var delegate:LoginCoordinatorDelegate?
let navigationController: UINavigationController
let loginViewController: LoginViewController
init(navigationController: UINavigationController) {
self.navigationController = navigationController
self.loginViewController = LoginViewController()
}
override func start() {
self.showLogin()
}
func showLogin() {
self.loginViewController.delegate = self
self.navigationController.show(self.loginViewController, sender: self)
}
}
extension LoginCoordinator: LoginViewControllerDelegate {
func didLogin() {
self.delegate?.didLogin(coordinator: self)
}
}
複製程式碼
正如 UIKit
基於 delegate 的設計,我們靠這種方式真正實現了對 view controller 進行了解耦。
同理 LoginViewController
也存在相應的 LoginViewControllerDelegate
協議。
import UIKit
protocol LoginViewControllerDelegate: class {
func didLogin()
}
final class LoginViewController: UIViewController {
weak var delegate:LoginViewControllerDelegate?
……
}
複製程式碼
這樣,一套基本的 Coordinator 方案就出爐了。當然,目前還是非常基礎的功能子集,我們完全可以在這個基礎上擴充套件得更加強大。
適配多入口
顯然,一個成熟的 App 會存在多樣化的入口。除了我們一直在討論的 App 內跳轉之外,我們還會遇到以下的路由問題:
- Deeplink
- Push Notifications
- Force Touch
常見的,我們很可能需要在手機上點選一個連結之後,直接連結到 app 內部的某個檢視,而不是 app 正常開啟時顯示的主檢視。
AndreyPanov 的方案解決了這個問題,我們需要對 Coordinator
再進行擴充。
protocol Coordinator: class {
func start()
func start(with option: DeepLinkOption?)
var childCoordinators: [Coordinator] { get set }
}
複製程式碼
增加了一個 DeepLinkOption?
型別的引數。這個有什麼用呢?
我們可以在 AppDelegate
中針對不同的程式喚起方式都用 Coordinator 進行啟動。
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
let notification = launchOptions?[.remoteNotification] as? [String: AnyObject]
let deepLink = buildDeepLink(with: notification)
self.applicationCoordinator.start(with: deepLink)
return true
}
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any]) {
let dict = userInfo as? [String: AnyObject]
let deepLink = buildDeepLink(with: dict)
self.applicationCoordinator.start(with: deepLink)
}
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([Any]?) -> Void) -> Bool {
let deepLink = buildDeepLink(with: userActivity)
self.applicationCoordinator.start(with: deepLink)
return true
}
複製程式碼
利用 buildDeepLink
方法對不同的入口方式判斷輸出相應的 flow 型別。
我們對之前的業務需求進行相應的擴充套件,假設存在以下三種不同的 flow 型別:
enum DeepLinkOption {
case login // 登入
case help // 幫助中心
case main // 主檢視
}
複製程式碼
我們來實現下 AppCoordinator
中的新 start
方法:
override func start(with option: DeepLinkOption?) {
//通過 deeplink 啟動
if let option = option {
switch option {
case .login: runLoginFlow()
case .help: runHelpFlow()
default: childCoordinators.forEach { coordinator in
coordinator.start(with: option)
}
}
//預設啟動
} else {
……
}
}
複製程式碼
總結
本文專門介紹了 Coordinator 模式來對 iOS 開發中的 navigator 進行了深度的解耦。然而當今仍沒有權威標準的解決方案,感興趣的同學建議去 github 參考下其他更優秀的實踐方案。
接下來的第三篇文章計劃就 Swift 語言的 extension 語法進行深入的介紹和分析,它是構建「類 Vue + Vuex」打法的核心之一。