用 Swift 模仿 Vue + Vuex 進行 iOS 開發(二):Coordinator

ScalaCool發表於2018-03-18

本文由 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」打法的核心之一。

用 Swift 模仿 Vue + Vuex 進行 iOS 開發(二):Coordinator

相關文章